diff --git a/android_app/app/build.gradle b/android_app/app/build.gradle deleted file mode 100644 index 1899ca97..00000000 --- a/android_app/app/build.gradle +++ /dev/null @@ -1,157 +0,0 @@ -apply plugin: 'com.android.application' -apply plugin: "androidx.navigation.safeargs" - -android { - compileSdk 34 - defaultConfig { - applicationId "com.health.openscale" - testApplicationId "com.health.openscale.test" - minSdkVersion 23 - targetSdkVersion 34 - versionCode 66 - versionName "2.5.4" - - manifestPlaceholders = [ - appIcon: "@drawable/ic_launcher_openscale", - appIconRound: "@mipmap/ic_launcher_openscale_round" - ] - - javaCompileOptions { - annotationProcessorOptions { - arguments = ["room.schemaLocation": "$projectDir/schemas".toString()] - } - } - - buildFeatures { - buildConfig = true - } - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - sourceSets { - androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) - } - - signingConfigs { - release { - def keystorePropertiesFile = rootProject.file("../../openScale.keystore") - def keystoreProperties = new Properties() - try { - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) - } catch (FileNotFoundException e) { - keystoreProperties = null; - } - - if (keystoreProperties != null) { - storeFile file(rootDir.getCanonicalPath() + '/' + keystoreProperties['releaseKeyStore']) - keyAlias keystoreProperties['releaseKeyAlias'] - keyPassword keystoreProperties['releaseKeyPassword'] - storePassword keystoreProperties['releaseStorePassword'] - } - } - - oss { - def keystoreOSSPropertiesFile = rootProject.file("../../openScale_oss.keystore") - def keystoreOSSProperties = new Properties() - try { - keystoreOSSProperties.load(new FileInputStream(keystoreOSSPropertiesFile)) - } - catch (FileNotFoundException e) { - keystoreOSSProperties = null; - } - - if (keystoreOSSProperties != null) { - storeFile file(rootDir.getCanonicalPath() + '/' + keystoreOSSProperties['releaseKeyStore']) - keyAlias keystoreOSSProperties['releaseKeyAlias'] - keyPassword keystoreOSSProperties['releaseKeyPassword'] - storePassword keystoreOSSProperties['releaseStorePassword'] - } - } - } - - buildTypes { - debug { - // don't include version number into the apk filename for debug build type so Travis can find it - applicationVariants.all { variant -> - variant.outputs.all { output -> - if (variant.buildType.name == "debug") { - outputFileName = "openScale-debug.apk" - } - } - } - } - release { - archivesBaseName = "openScale-"+defaultConfig.versionName - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' - signingConfig signingConfigs.release - } - oss { - archivesBaseName = "openScale-"+defaultConfig.versionName - applicationIdSuffix ".oss" - versionNameSuffix "-oss" - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' - signingConfig signingConfigs.oss - } - } - compileOptions { - coreLibraryDesugaringEnabled true - - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - namespace 'com.health.openscale' - lint { - abortOnError false - } -} - -dependencies { - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.3' - - implementation 'com.google.android.material:material:1.12.0' - implementation 'androidx.legacy:legacy-support-v4:1.0.0' - implementation 'androidx.appcompat:appcompat:1.7.0' - implementation 'androidx.recyclerview:recyclerview:1.3.2' - implementation 'androidx.constraintlayout:constraintlayout:2.2.0' - implementation 'androidx.preference:preference:1.2.1' - implementation 'androidx.navigation:navigation-fragment:2.8.4' - implementation 'androidx.navigation:navigation-ui:2.8.4' - implementation "android.arch.lifecycle:extensions:1.1.1" - annotationProcessor "androidx.lifecycle:lifecycle-common-java8:2.8.7" - - // MPAndroidChart - implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0' - // Simple CSV - implementation 'com.j256.simplecsv:simplecsv:2.6' - // Blessed Android - implementation 'com.github.weliem:blessed-android:2.5.0' - // CustomActivityOnCrash - implementation 'cat.ereza:customactivityoncrash:2.3.0' - // AppIntro - implementation 'com.github.AppIntro:AppIntro:6.2.0' - // implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.20' - // Room - implementation 'androidx.room:room-runtime:2.6.1' - annotationProcessor 'androidx.room:room-compiler:2.6.1' - androidTestImplementation 'androidx.room:room-testing:2.6.1' - // Timber - implementation 'com.jakewharton.timber:timber:5.0.1' - // Local unit tests - testImplementation 'junit:junit:4.13.2' - // Instrumented unit tests - implementation 'androidx.annotation:annotation:1.9.1' - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test:rules:1.6.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' - androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.6.1' -} - -tasks.withType(Test) { - testLogging { - exceptionFormat "full" - events "started", "skipped", "passed", "failed" - showStandardStreams true - } -} diff --git a/android_app/app/build.gradle.kts b/android_app/app/build.gradle.kts new file mode 100644 index 00000000..e4081b60 --- /dev/null +++ b/android_app/app/build.gradle.kts @@ -0,0 +1,93 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + id("kotlin-kapt") +} + +android { + namespace = "com.health.openscale" + compileSdk = 36 + + defaultConfig { + applicationId = "com.health.openscale" + minSdk = 31 + targetSdk = 36 + versionCode = 67 + versionName = "3.0 beta" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + kapt { + arguments { + arg("room.schemaLocation", "$projectDir/schemas") + } + } + + buildFeatures { + compose = true + buildConfig = true + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.navigation.compose) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + + // Room + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + kapt(libs.androidx.room.compiler) + + implementation(libs.datastore.preferences) + + // ViewModel + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + + // Vico charts + implementation(libs.compose.charts) + implementation(libs.compose.charts.m3) + + // Compose reorderable + implementation(libs.compose.reorderable) + implementation(libs.compose.material.icons.extended) + + // Kotlin-CSV + implementation(libs.kotlin.csv.jvm) + + // Blessed Kotlin + // implementation(libs.blessed.kotlin) + implementation(libs.blessed.java) +} \ No newline at end of file diff --git a/android_app/app/proguard-rules.pro b/android_app/app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/android_app/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/1.json b/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/1.json deleted file mode 100644 index 7aab138f..00000000 --- a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/1.json +++ /dev/null @@ -1,182 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 1, - "identityHash": "f7147b87965bad6c8417519fa7d0f7d2", - "entities": [ - { - "tableName": "scaleMeasurements", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `datetime` INTEGER, `weight` REAL NOT NULL, `fat` REAL NOT NULL, `water` REAL NOT NULL, `muscle` REAL NOT NULL, `lbw` REAL NOT NULL, `waist` REAL NOT NULL, `hip` REAL NOT NULL, `bone` REAL NOT NULL, `comment` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "enabled", - "columnName": "enabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "dateTime", - "columnName": "datetime", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "weight", - "columnName": "weight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "fat", - "columnName": "fat", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "water", - "columnName": "water", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "muscle", - "columnName": "muscle", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "lbw", - "columnName": "lbw", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "waist", - "columnName": "waist", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "hip", - "columnName": "hip", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "bone", - "columnName": "bone", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "comment", - "columnName": "comment", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_scaleMeasurements_datetime", - "unique": true, - "columnNames": [ - "datetime" - ], - "createSql": "CREATE UNIQUE INDEX `index_scaleMeasurements_datetime` ON `${TABLE_NAME}` (`datetime`)" - } - ], - "foreignKeys": [] - }, - { - "tableName": "scaleUsers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `username` TEXT, `birthday` INTEGER, `bodyHeight` INTEGER NOT NULL, `scaleUnit` INTEGER NOT NULL, `gender` INTEGER NOT NULL, `initialWeight` REAL NOT NULL, `goalWeight` REAL NOT NULL, `goalDate` INTEGER)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userName", - "columnName": "username", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "birthday", - "columnName": "birthday", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "bodyHeight", - "columnName": "bodyHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "scaleUnit", - "columnName": "scaleUnit", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "gender", - "columnName": "gender", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "initialWeight", - "columnName": "initialWeight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "goalWeight", - "columnName": "goalWeight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "goalDate", - "columnName": "goalDate", - "affinity": "INTEGER", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - } - ], - "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, \"f7147b87965bad6c8417519fa7d0f7d2\")" - ] - } -} \ No newline at end of file diff --git a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/2.json b/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/2.json deleted file mode 100644 index 44f5adba..00000000 --- a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/2.json +++ /dev/null @@ -1,195 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 2, - "identityHash": "29790d4babbe129963d2c9282393c2d2", - "entities": [ - { - "tableName": "scaleMeasurements", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `datetime` INTEGER, `weight` REAL NOT NULL, `fat` REAL NOT NULL, `water` REAL NOT NULL, `muscle` REAL NOT NULL, `lbw` REAL NOT NULL, `waist` REAL NOT NULL, `hip` REAL NOT NULL, `bone` REAL NOT NULL, `comment` TEXT, FOREIGN KEY(`userId`) REFERENCES `scaleUsers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "enabled", - "columnName": "enabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "dateTime", - "columnName": "datetime", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "weight", - "columnName": "weight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "fat", - "columnName": "fat", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "water", - "columnName": "water", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "muscle", - "columnName": "muscle", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "lbm", - "columnName": "lbw", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "waist", - "columnName": "waist", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "hip", - "columnName": "hip", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "bone", - "columnName": "bone", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "comment", - "columnName": "comment", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_scaleMeasurements_userId_datetime", - "unique": true, - "columnNames": [ - "userId", - "datetime" - ], - "createSql": "CREATE UNIQUE INDEX `index_scaleMeasurements_userId_datetime` ON `${TABLE_NAME}` (`userId`, `datetime`)" - } - ], - "foreignKeys": [ - { - "table": "scaleUsers", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "userId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "scaleUsers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `username` TEXT, `birthday` INTEGER, `bodyHeight` INTEGER NOT NULL, `scaleUnit` INTEGER NOT NULL, `gender` INTEGER NOT NULL, `initialWeight` REAL NOT NULL, `goalWeight` REAL NOT NULL, `goalDate` INTEGER)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userName", - "columnName": "username", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "birthday", - "columnName": "birthday", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "bodyHeight", - "columnName": "bodyHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "scaleUnit", - "columnName": "scaleUnit", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "gender", - "columnName": "gender", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "initialWeight", - "columnName": "initialWeight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "goalWeight", - "columnName": "goalWeight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "goalDate", - "columnName": "goalDate", - "affinity": "INTEGER", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - } - ], - "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, \"29790d4babbe129963d2c9282393c2d2\")" - ] - } -} \ No newline at end of file diff --git a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/3.json b/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/3.json deleted file mode 100644 index 2b7e3e0d..00000000 --- a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/3.json +++ /dev/null @@ -1,255 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 3, - "identityHash": "974ad0a810bf389300cf67b40862bb75", - "entities": [ - { - "tableName": "scaleMeasurements", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `datetime` INTEGER, `weight` REAL NOT NULL, `fat` REAL NOT NULL, `water` REAL NOT NULL, `muscle` REAL NOT NULL, `visceralFat` REAL NOT NULL, `lbm` REAL NOT NULL, `waist` REAL NOT NULL, `hip` REAL NOT NULL, `bone` REAL NOT NULL, `chest` REAL NOT NULL, `thigh` REAL NOT NULL, `biceps` REAL NOT NULL, `neck` REAL NOT NULL, `caliper1` REAL NOT NULL, `caliper2` REAL NOT NULL, `caliper3` REAL NOT NULL, `comment` TEXT, FOREIGN KEY(`userId`) REFERENCES `scaleUsers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "enabled", - "columnName": "enabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "dateTime", - "columnName": "datetime", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "weight", - "columnName": "weight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "fat", - "columnName": "fat", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "water", - "columnName": "water", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "muscle", - "columnName": "muscle", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "visceralFat", - "columnName": "visceralFat", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "lbm", - "columnName": "lbm", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "waist", - "columnName": "waist", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "hip", - "columnName": "hip", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "bone", - "columnName": "bone", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "chest", - "columnName": "chest", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "thigh", - "columnName": "thigh", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "biceps", - "columnName": "biceps", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "neck", - "columnName": "neck", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "caliper1", - "columnName": "caliper1", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "caliper2", - "columnName": "caliper2", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "caliper3", - "columnName": "caliper3", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "comment", - "columnName": "comment", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_scaleMeasurements_userId_datetime", - "unique": true, - "columnNames": [ - "userId", - "datetime" - ], - "createSql": "CREATE UNIQUE INDEX `index_scaleMeasurements_userId_datetime` ON `${TABLE_NAME}` (`userId`, `datetime`)" - } - ], - "foreignKeys": [ - { - "table": "scaleUsers", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "userId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "scaleUsers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `username` TEXT NOT NULL, `birthday` INTEGER NOT NULL, `bodyHeight` REAL NOT NULL, `scaleUnit` INTEGER NOT NULL, `gender` INTEGER NOT NULL, `initialWeight` REAL NOT NULL, `goalWeight` REAL NOT NULL, `goalDate` INTEGER, `measureUnit` INTEGER NOT NULL, `activityLevel` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userName", - "columnName": "username", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "birthday", - "columnName": "birthday", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "bodyHeight", - "columnName": "bodyHeight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "scaleUnit", - "columnName": "scaleUnit", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "gender", - "columnName": "gender", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "initialWeight", - "columnName": "initialWeight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "goalWeight", - "columnName": "goalWeight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "goalDate", - "columnName": "goalDate", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "measureUnit", - "columnName": "measureUnit", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "activityLevel", - "columnName": "activityLevel", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - } - ], - "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, \"974ad0a810bf389300cf67b40862bb75\")" - ] - } -} \ No newline at end of file diff --git a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/4.json b/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/4.json deleted file mode 100644 index 3c1e634e..00000000 --- a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/4.json +++ /dev/null @@ -1,262 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 4, - "identityHash": "2db259b9e244ebad0c664f2c9fb36068", - "entities": [ - { - "tableName": "scaleMeasurements", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `datetime` INTEGER, `weight` REAL NOT NULL, `fat` REAL NOT NULL, `water` REAL NOT NULL, `muscle` REAL NOT NULL, `visceralFat` REAL NOT NULL, `lbm` REAL NOT NULL, `waist` REAL NOT NULL, `hip` REAL NOT NULL, `bone` REAL NOT NULL, `chest` REAL NOT NULL, `thigh` REAL NOT NULL, `biceps` REAL NOT NULL, `neck` REAL NOT NULL, `caliper1` REAL NOT NULL, `caliper2` REAL NOT NULL, `caliper3` REAL NOT NULL, `calories` REAL NOT NULL, `comment` TEXT, FOREIGN KEY(`userId`) REFERENCES `scaleUsers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "enabled", - "columnName": "enabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "dateTime", - "columnName": "datetime", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "weight", - "columnName": "weight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "fat", - "columnName": "fat", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "water", - "columnName": "water", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "muscle", - "columnName": "muscle", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "visceralFat", - "columnName": "visceralFat", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "lbm", - "columnName": "lbm", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "waist", - "columnName": "waist", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "hip", - "columnName": "hip", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "bone", - "columnName": "bone", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "chest", - "columnName": "chest", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "thigh", - "columnName": "thigh", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "biceps", - "columnName": "biceps", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "neck", - "columnName": "neck", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "caliper1", - "columnName": "caliper1", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "caliper2", - "columnName": "caliper2", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "caliper3", - "columnName": "caliper3", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "calories", - "columnName": "calories", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "comment", - "columnName": "comment", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_scaleMeasurements_userId_datetime", - "unique": true, - "columnNames": [ - "userId", - "datetime" - ], - "createSql": "CREATE UNIQUE INDEX `index_scaleMeasurements_userId_datetime` ON `${TABLE_NAME}` (`userId`, `datetime`)" - } - ], - "foreignKeys": [ - { - "table": "scaleUsers", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "userId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "scaleUsers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `username` TEXT NOT NULL, `birthday` INTEGER NOT NULL, `bodyHeight` REAL NOT NULL, `scaleUnit` INTEGER NOT NULL, `gender` INTEGER NOT NULL, `initialWeight` REAL NOT NULL, `goalWeight` REAL NOT NULL, `goalDate` INTEGER, `measureUnit` INTEGER NOT NULL, `activityLevel` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userName", - "columnName": "username", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "birthday", - "columnName": "birthday", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "bodyHeight", - "columnName": "bodyHeight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "scaleUnit", - "columnName": "scaleUnit", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "gender", - "columnName": "gender", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "initialWeight", - "columnName": "initialWeight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "goalWeight", - "columnName": "goalWeight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "goalDate", - "columnName": "goalDate", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "measureUnit", - "columnName": "measureUnit", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "activityLevel", - "columnName": "activityLevel", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "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, \"2db259b9e244ebad0c664f2c9fb36068\")" - ] - } -} \ No newline at end of file diff --git a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/5.json b/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/5.json deleted file mode 100644 index 02e1f958..00000000 --- a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/5.json +++ /dev/null @@ -1,280 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 5, - "identityHash": "d66fc1fc2752b2d6f41700fa2102492a", - "entities": [ - { - "tableName": "scaleMeasurements", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `datetime` INTEGER, `weight` REAL NOT NULL, `fat` REAL NOT NULL, `water` REAL NOT NULL, `muscle` REAL NOT NULL, `visceralFat` REAL NOT NULL, `lbm` REAL NOT NULL, `waist` REAL NOT NULL, `hip` REAL NOT NULL, `bone` REAL NOT NULL, `chest` REAL NOT NULL, `thigh` REAL NOT NULL, `biceps` REAL NOT NULL, `neck` REAL NOT NULL, `caliper1` REAL NOT NULL, `caliper2` REAL NOT NULL, `caliper3` REAL NOT NULL, `calories` REAL NOT NULL, `comment` TEXT, FOREIGN KEY(`userId`) REFERENCES `scaleUsers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "enabled", - "columnName": "enabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "dateTime", - "columnName": "datetime", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "weight", - "columnName": "weight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "fat", - "columnName": "fat", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "water", - "columnName": "water", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "muscle", - "columnName": "muscle", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "visceralFat", - "columnName": "visceralFat", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "lbm", - "columnName": "lbm", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "waist", - "columnName": "waist", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "hip", - "columnName": "hip", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "bone", - "columnName": "bone", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "chest", - "columnName": "chest", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "thigh", - "columnName": "thigh", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "biceps", - "columnName": "biceps", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "neck", - "columnName": "neck", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "caliper1", - "columnName": "caliper1", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "caliper2", - "columnName": "caliper2", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "caliper3", - "columnName": "caliper3", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "calories", - "columnName": "calories", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "comment", - "columnName": "comment", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_scaleMeasurements_userId_datetime", - "unique": true, - "columnNames": [ - "userId", - "datetime" - ], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_scaleMeasurements_userId_datetime` ON `${TABLE_NAME}` (`userId`, `datetime`)" - } - ], - "foreignKeys": [ - { - "table": "scaleUsers", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "userId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "scaleUsers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `username` TEXT NOT NULL, `birthday` INTEGER NOT NULL, `bodyHeight` REAL NOT NULL, `scaleUnit` INTEGER NOT NULL, `gender` INTEGER NOT NULL, `initialWeight` REAL NOT NULL, `goalWeight` REAL NOT NULL, `goalDate` INTEGER, `measureUnit` INTEGER NOT NULL, `activityLevel` INTEGER NOT NULL, `assistedWeighing` INTEGER NOT NULL, `leftAmputationLevel` INTEGER NOT NULL, `rightAmputationLevel` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userName", - "columnName": "username", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "birthday", - "columnName": "birthday", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "bodyHeight", - "columnName": "bodyHeight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "scaleUnit", - "columnName": "scaleUnit", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "gender", - "columnName": "gender", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "initialWeight", - "columnName": "initialWeight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "goalWeight", - "columnName": "goalWeight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "goalDate", - "columnName": "goalDate", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "measureUnit", - "columnName": "measureUnit", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "activityLevel", - "columnName": "activityLevel", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "assistedWeighing", - "columnName": "assistedWeighing", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "leftAmputationLevel", - "columnName": "leftAmputationLevel", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "rightAmputationLevel", - "columnName": "rightAmputationLevel", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "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, 'd66fc1fc2752b2d6f41700fa2102492a')" - ] - } -} \ No newline at end of file diff --git a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/6.json b/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/6.json deleted file mode 100644 index b7d0a2b5..00000000 --- a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/6.json +++ /dev/null @@ -1,287 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 6, - "identityHash": "363295f46fda89cfa9f94179971dc240", - "entities": [ - { - "tableName": "scaleMeasurements", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `datetime` INTEGER, `weight` REAL NOT NULL, `fat` REAL NOT NULL, `water` REAL NOT NULL, `muscle` REAL NOT NULL, `visceralFat` REAL NOT NULL, `lbm` REAL NOT NULL, `waist` REAL NOT NULL, `hip` REAL NOT NULL, `bone` REAL NOT NULL, `chest` REAL NOT NULL, `thigh` REAL NOT NULL, `biceps` REAL NOT NULL, `neck` REAL NOT NULL, `caliper1` REAL NOT NULL, `caliper2` REAL NOT NULL, `caliper3` REAL NOT NULL, `calories` REAL NOT NULL, `comment` TEXT, FOREIGN KEY(`userId`) REFERENCES `scaleUsers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "enabled", - "columnName": "enabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "dateTime", - "columnName": "datetime", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "weight", - "columnName": "weight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "fat", - "columnName": "fat", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "water", - "columnName": "water", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "muscle", - "columnName": "muscle", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "visceralFat", - "columnName": "visceralFat", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "lbm", - "columnName": "lbm", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "waist", - "columnName": "waist", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "hip", - "columnName": "hip", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "bone", - "columnName": "bone", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "chest", - "columnName": "chest", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "thigh", - "columnName": "thigh", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "biceps", - "columnName": "biceps", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "neck", - "columnName": "neck", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "caliper1", - "columnName": "caliper1", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "caliper2", - "columnName": "caliper2", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "caliper3", - "columnName": "caliper3", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "calories", - "columnName": "calories", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "comment", - "columnName": "comment", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_scaleMeasurements_userId_datetime", - "unique": true, - "columnNames": [ - "userId", - "datetime" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_scaleMeasurements_userId_datetime` ON `${TABLE_NAME}` (`userId`, `datetime`)" - } - ], - "foreignKeys": [ - { - "table": "scaleUsers", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "userId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "scaleUsers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `username` TEXT NOT NULL, `birthday` INTEGER NOT NULL, `bodyHeight` REAL NOT NULL, `scaleUnit` INTEGER NOT NULL, `gender` INTEGER NOT NULL, `goalEnabled` INTEGER NOT NULL, `initialWeight` REAL NOT NULL, `goalWeight` REAL NOT NULL, `goalDate` INTEGER, `measureUnit` INTEGER NOT NULL, `activityLevel` INTEGER NOT NULL, `assistedWeighing` INTEGER NOT NULL, `leftAmputationLevel` INTEGER NOT NULL, `rightAmputationLevel` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userName", - "columnName": "username", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "birthday", - "columnName": "birthday", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "bodyHeight", - "columnName": "bodyHeight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "scaleUnit", - "columnName": "scaleUnit", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "gender", - "columnName": "gender", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "goalEnabled", - "columnName": "goalEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "initialWeight", - "columnName": "initialWeight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "goalWeight", - "columnName": "goalWeight", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "goalDate", - "columnName": "goalDate", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "measureUnit", - "columnName": "measureUnit", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "activityLevel", - "columnName": "activityLevel", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "assistedWeighing", - "columnName": "assistedWeighing", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "leftAmputationLevel", - "columnName": "leftAmputationLevel", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "rightAmputationLevel", - "columnName": "rightAmputationLevel", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "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, '363295f46fda89cfa9f94179971dc240')" - ] - } -} \ No newline at end of file diff --git a/android_app/app/src/androidTest/java/com/health/openscale/DatabaseMigrationTest.java b/android_app/app/src/androidTest/java/com/health/openscale/DatabaseMigrationTest.java deleted file mode 100644 index 34937715..00000000 --- a/android_app/app/src/androidTest/java/com/health/openscale/DatabaseMigrationTest.java +++ /dev/null @@ -1,264 +0,0 @@ -/* Copyright (C) 2018 Erik Johansson -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale; - -import android.content.ContentValues; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; - -import com.health.openscale.core.database.AppDatabase; - -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import androidx.room.testing.MigrationTestHelper; -import androidx.sqlite.db.SupportSQLiteDatabase; -import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import static junit.framework.Assert.assertEquals; -import static junit.framework.Assert.assertNotSame; -import static junit.framework.Assert.assertTrue; - -@RunWith(AndroidJUnit4.class) -public class DatabaseMigrationTest { - private static final String TEST_DB = "migration-test"; - - @Rule - public MigrationTestHelper helper; - - public DatabaseMigrationTest() { - helper = new MigrationTestHelper( - InstrumentationRegistry.getInstrumentation(), - AppDatabase.class.getCanonicalName(), - new FrameworkSQLiteOpenHelperFactory()); - } - - @Test - public void migrate1To2() throws Exception { - SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1); - - ContentValues users = new ContentValues(); - for (int i = 1; i < 4; ++i) { - users.put("id", i); - users.put("username", String.format("test%d", i)); - users.put("bodyHeight", i * 50); - users.put("scaleUnit", 0); - users.put("gender", 0); - users.put("initialWeight", i * 25); - users.put("goalWeight", i * 20); - assertNotSame(-1, db.insert("scaleUsers", SQLiteDatabase.CONFLICT_ABORT, users)); - } - - ContentValues measurement = new ContentValues(); - for (int i = 2; i < 4; ++i) { - for (int j = 0; j < 2; ++j) { - measurement.put("userId", i); - measurement.put("enabled", j); - measurement.put("comment", "a string"); - for (String type : new String[]{"weight", "fat", "water", "muscle", "lbw", "waist", "hip", "bone"}) { - measurement.put(type, i * j + type.hashCode()); - } - - assertNotSame(-1, db.insert("scaleMeasurements", SQLiteDatabase.CONFLICT_ABORT, measurement)); - } - } - - // Prepare for the next version. - db.close(); - - // Re-open the database with version 2 and provide MIGRATION_1_2 as the migration process. - db = helper.runMigrationsAndValidate(TEST_DB, 2, true, AppDatabase.MIGRATION_1_2); - - // MigrationTestHelper automatically verifies the schema changes. - - Cursor cursor = db.query("SELECT * FROM scaleMeasurements ORDER BY id, userId"); - assertEquals(2 * 2, cursor.getCount()); - - cursor.moveToFirst(); - for (int i = 2; i < 4; ++i) { - for (int j = 0; j < 2; ++j) { - assertEquals(i, cursor.getInt(cursor.getColumnIndex("userId"))); - assertEquals(j, cursor.getInt(cursor.getColumnIndex("enabled"))); - assertEquals("a string", cursor.getString(cursor.getColumnIndex("comment"))); - for (String type : new String[]{"weight", "fat", "water", "muscle", "lbw", "waist", "hip", "bone"}) { - assertEquals((float) i * j + type.hashCode(), - cursor.getFloat(cursor.getColumnIndex(type))); - } - - cursor.moveToNext(); - } - } - - assertTrue(cursor.isAfterLast()); - } - - @Test - public void migrate2To3() throws Exception { - SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 2); - - ContentValues users = new ContentValues(); - for (int i = 1; i < 4; ++i) { - users.put("id", i); - users.put("username", String.format("test%d", i)); - users.put("birthday", i*100); - users.put("bodyHeight", i * 50); - users.put("scaleUnit", 0); - users.put("gender", 0); - users.put("initialWeight", i * 25); - users.put("goalWeight", i * 20); - assertNotSame(-1, db.insert("scaleUsers", SQLiteDatabase.CONFLICT_ABORT, users)); - } - - ContentValues measurement = new ContentValues(); - for (int i = 2; i < 4; ++i) { - for (int j = 0; j < 2; ++j) { - measurement.put("userId", i); - measurement.put("enabled", j); - measurement.put("comment", "a string"); - for (String type : new String[]{"weight", "fat", "water", "muscle", "lbw", "waist", "hip", "bone"}) { - measurement.put(type, i * j + type.hashCode()); - } - - assertNotSame(-1, db.insert("scaleMeasurements", SQLiteDatabase.CONFLICT_ABORT, measurement)); - } - } - - // Prepare for the next version. - db.close(); - - // Re-open the database with version 3 and provide MIGRATION_2_3 as the migration process. - db = helper.runMigrationsAndValidate(TEST_DB, 3, true, AppDatabase.MIGRATION_2_3); - - // MigrationTestHelper automatically verifies the schema changes. - - assertEquals(3, db.query("SELECT * FROM scaleUsers WHERE measureUnit = 0").getCount()); - assertEquals(3, db.query("SELECT * FROM scaleUsers WHERE activityLevel = 0").getCount()); - - Cursor cursor = db.query("SELECT * FROM scaleUsers ORDER BY id"); - - cursor.moveToFirst(); - for (int i = 1; i < 4; ++i) { - assertEquals(i, cursor.getInt(cursor.getColumnIndex("id"))); - assertEquals(i*100, cursor.getInt(cursor.getColumnIndex("birthday"))); - assertEquals(i*50, cursor.getInt(cursor.getColumnIndex("bodyHeight"))); - assertEquals(i*25, cursor.getInt(cursor.getColumnIndex("initialWeight"))); - assertEquals(i*20, cursor.getInt(cursor.getColumnIndex("goalWeight"))); - cursor.moveToNext(); - } - - cursor = db.query("SELECT * FROM scaleMeasurements ORDER BY id, userId"); - assertEquals(2 * 2, cursor.getCount()); - - cursor.moveToFirst(); - for (int i = 2; i < 4; ++i) { - for (int j = 0; j < 2; ++j) { - assertEquals(i, cursor.getInt(cursor.getColumnIndex("userId"))); - assertEquals(j, cursor.getInt(cursor.getColumnIndex("enabled"))); - assertEquals("a string", cursor.getString(cursor.getColumnIndex("comment"))); - for (String type : new String[]{"weight", "fat", "water", "muscle", "lbm", "waist", "hip", "bone"}) { - float value = i * j; - if (type.equals("lbm")) { - value += "lbw".hashCode(); - } - else { - value += type.hashCode(); - } - assertEquals(value, cursor.getFloat(cursor.getColumnIndex(type))); - } - for (String type : new String[]{"visceralFat", "chest", "thigh", "biceps", "neck", - "caliper1", "caliper2", "caliper3"}) { - assertEquals(0.0f, cursor.getFloat(cursor.getColumnIndex(type))); - } - - cursor.moveToNext(); - } - } - - assertTrue(cursor.isAfterLast()); - } - - @Test - public void migrate3To4() throws Exception { - SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 3); - - ContentValues users = new ContentValues(); - for (int i = 1; i < 4; ++i) { - users.put("id", i); - users.put("username", String.format("test%d", i)); - users.put("birthday", i*100); - users.put("bodyHeight", i * 50); - users.put("scaleUnit", 0); - users.put("gender", 0); - users.put("initialWeight", i * 25); - users.put("goalWeight", i * 20); - users.put("measureUnit", 0); - users.put("activityLevel", 0); - assertNotSame(-1, db.insert("scaleUsers", SQLiteDatabase.CONFLICT_ABORT, users)); - } - - ContentValues measurement = new ContentValues(); - for (int i = 2; i < 4; ++i) { - for (int j = 0; j < 2; ++j) { - measurement.put("userId", i); - measurement.put("enabled", j); - measurement.put("comment", "a string"); - for (String type : new String[]{"weight", "fat", "water", "muscle", "lbm", "waist", "hip", "bone", - "visceralFat", "chest", "thigh", "biceps", "neck", "caliper1", "caliper2", "caliper3"}) { - measurement.put(type, (float) i * j + type.hashCode()); - } - - assertNotSame(-1, db.insert("scaleMeasurements", SQLiteDatabase.CONFLICT_ABORT, measurement)); - } - } - - // Prepare for the next version. - db.close(); - - // Re-open the database with version 4 and provide MIGRATION_3_4 as the migration process. - db = helper.runMigrationsAndValidate(TEST_DB, 4, true, AppDatabase.MIGRATION_3_4); - - // MigrationTestHelper automatically verifies the schema changes. - - Cursor cursor = db.query("SELECT * FROM scaleMeasurements ORDER BY id, userId"); - assertEquals(2 * 2, cursor.getCount()); - - cursor.moveToFirst(); - for (int i = 2; i < 4; ++i) { - for (int j = 0; j < 2; ++j) { - assertEquals(i, cursor.getInt(cursor.getColumnIndex("userId"))); - assertEquals(j, cursor.getInt(cursor.getColumnIndex("enabled"))); - assertEquals("a string", cursor.getString(cursor.getColumnIndex("comment"))); - for (String type : new String[]{"weight", "fat", "water", "muscle", "lbm", "waist", "hip", "bone", - "visceralFat", "chest", "thigh", "biceps", "neck", "caliper1", "caliper2", "caliper3"}) { - assertEquals((float) i * j + type.hashCode(), - cursor.getFloat(cursor.getColumnIndex(type))); - } - - for (String type : new String[]{"calories"}) { - assertEquals(0.0f, cursor.getFloat(cursor.getColumnIndex(type))); - } - - cursor.moveToNext(); - } - } - - assertTrue(cursor.isAfterLast()); - } -} diff --git a/android_app/app/src/androidTest/java/com/health/openscale/DatabaseTest.java b/android_app/app/src/androidTest/java/com/health/openscale/DatabaseTest.java deleted file mode 100644 index 14b63873..00000000 --- a/android_app/app/src/androidTest/java/com/health/openscale/DatabaseTest.java +++ /dev/null @@ -1,243 +0,0 @@ -/* Copyright (C) 2018 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale; - -import android.content.Context; - -import com.health.openscale.core.database.AppDatabase; -import com.health.openscale.core.database.ScaleMeasurementDAO; -import com.health.openscale.core.database.ScaleUserDAO; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; - -import androidx.room.Room; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -// run this test as an Android instrumented test! -@RunWith(AndroidJUnit4.class) -public class DatabaseTest { - private static final double DELTA = 1e-15; - - private AppDatabase appDB; - private ScaleUserDAO userDao; - private ScaleMeasurementDAO measurementDAO; - - @Before - public void initDatabase() { - Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); - appDB = Room.inMemoryDatabaseBuilder(context, AppDatabase.class).build(); - userDao = appDB.userDAO(); - measurementDAO = appDB.measurementDAO(); - } - - @After - public void closeDatabase() throws IOException { - appDB.close(); - } - - @Test - public void userOperations() throws Exception { - ScaleUser user1 = new ScaleUser(); - ScaleUser user2 = new ScaleUser(); - - user1.setUserName("foo"); - user2.setUserName("bar"); - - // is user database empty on initialization - assertTrue(userDao.getAll().isEmpty()); - - userDao.insert(user1); - - // was the user successfully inserted - assertEquals(1, userDao.getAll().size()); - - assertEquals("foo", userDao.getAll().get(0).getUserName()); - - userDao.insert(user2); - assertEquals(2, userDao.getAll().size()); - - assertEquals("foo", userDao.getAll().get(0).getUserName()); - assertEquals("bar", userDao.getAll().get(1).getUserName()); - - // check if get(id) works - List scaleUserList = userDao.getAll(); - ScaleUser firstUser = scaleUserList.get(0); - ScaleUser secondUser = scaleUserList.get(1); - assertEquals(firstUser.getUserName(), userDao.get(firstUser.getId()).getUserName()); - - // check delete method - userDao.delete(firstUser); - assertEquals(1, userDao.getAll().size()); - assertEquals(secondUser.getUserName(), userDao.getAll().get(0).getUserName()); - - // check update method - secondUser.setUserName("foobar"); - userDao.update(secondUser); - assertEquals("foobar", userDao.get(secondUser.getId()).getUserName()); - - // clear database - userDao.delete(secondUser); - assertTrue(userDao.getAll().isEmpty()); - - // check insert user list - ScaleUser user3 = new ScaleUser(); - user3.setUserName("bob"); - - List myScaleUserList = new ArrayList<>(); - myScaleUserList.add(user1); - myScaleUserList.add(user2); - myScaleUserList.add(user3); - - userDao.insertAll(myScaleUserList); - assertEquals(3, userDao.getAll().size()); - } - - @Test - public void measurementOperations() throws Exception { - final ScaleUser scaleUser1 = new ScaleUser(); - final ScaleUser scaleUser2 = new ScaleUser(); - - scaleUser1.setId((int)userDao.insert(scaleUser1)); - scaleUser2.setId((int)userDao.insert(scaleUser2)); - - // User 1 data initialization - final int user1 = scaleUser1.getId(); - ScaleMeasurement measurement11 = new ScaleMeasurement(); - ScaleMeasurement measurement12 = new ScaleMeasurement(); - ScaleMeasurement measurement13 = new ScaleMeasurement(); - - measurement11.setUserId(user1); - measurement12.setUserId(user1); - measurement13.setUserId(user1); - - measurement11.setWeight(10.0f); - measurement12.setWeight(20.0f); - measurement13.setWeight(30.0f); - - measurement11.setDateTime(new Date(100)); - measurement12.setDateTime(new Date(200)); - measurement13.setDateTime(new Date(300)); - - // User 2 data initialization - final int user2 = scaleUser2.getId(); - ScaleMeasurement measurement21 = new ScaleMeasurement(); - ScaleMeasurement measurement22 = new ScaleMeasurement(); - - measurement21.setUserId(user2); - measurement22.setUserId(user2); - - measurement21.setWeight(15.0f); - measurement22.setWeight(25.0f); - - measurement21.setDateTime(new Date(150)); - measurement22.setDateTime(new Date(250)); - - // check if database is empty - assertTrue(measurementDAO.getAll(user1).isEmpty()); - assertTrue(measurementDAO.getAll(user2).isEmpty()); - - // insert measurement as list and single insertion - List scaleMeasurementList = new ArrayList<>(); - scaleMeasurementList.add(measurement11); - scaleMeasurementList.add(measurement13); - scaleMeasurementList.add(measurement12); - - measurementDAO.insertAll(scaleMeasurementList); - - assertEquals(3, measurementDAO.getAll(user1).size()); - - measurementDAO.insert(measurement22); - measurementDAO.insert(measurement21); - - assertEquals(2, measurementDAO.getAll(user2).size()); - - // check if sorted DESC by date correctly - assertEquals(30.0f, measurementDAO.getAll(user1).get(0).getWeight(), DELTA); - assertEquals(25.0f, measurementDAO.getAll(user2).get(0).getWeight(), DELTA); - - // don't allow insertion with the same date - long id = measurementDAO.insert(measurement11); - assertEquals(-1 , id); - assertEquals(3, measurementDAO.getAll(user1).size()); - - // test get(datetime) method - assertEquals(20.0f, measurementDAO.get(new Date(200), user1).getWeight(), DELTA); - - // test get(id) method - scaleMeasurementList = measurementDAO.getAll(user1); - - assertEquals(scaleMeasurementList.get(2).getWeight(), measurementDAO.get(scaleMeasurementList.get(2).getId()).getWeight(), DELTA); - - // test getPrevious(id) method - assertNull(measurementDAO.getPrevious(scaleMeasurementList.get(2).getId(), user1)); - assertEquals(scaleMeasurementList.get(2).getWeight(), measurementDAO.getPrevious(scaleMeasurementList.get(1).getId(), user1).getWeight(), DELTA); - assertEquals(scaleMeasurementList.get(1).getWeight(), measurementDAO.getPrevious(scaleMeasurementList.get(0).getId(), user1).getWeight(), DELTA); - - // test getNext(id) method - assertNull(measurementDAO.getNext(scaleMeasurementList.get(0).getId(), user1)); - assertEquals(scaleMeasurementList.get(0).getWeight(), measurementDAO.getNext(scaleMeasurementList.get(1).getId(), user1).getWeight(), DELTA); - assertEquals(scaleMeasurementList.get(1).getWeight(), measurementDAO.getNext(scaleMeasurementList.get(2).getId(), user1).getWeight(), DELTA); - - // test getAllInRange method - assertEquals(1, measurementDAO.getAllInRange(new Date(0), new Date(200), user1).size()); - assertEquals(0, measurementDAO.getAllInRange(new Date(0), new Date(50), user1).size()); - assertEquals(2, measurementDAO.getAllInRange(new Date(100), new Date(201), user1).size()); - assertEquals(1, measurementDAO.getAllInRange(new Date(0), new Date(200), user1).size()); - assertEquals(3, measurementDAO.getAllInRange(new Date(0), new Date(1000), user1).size()); - assertEquals(2, measurementDAO.getAllInRange(new Date(150), new Date(400), user1).size()); - - assertEquals(0, measurementDAO.getAllInRange(new Date(10), new Date(20), user2).size()); - assertEquals(1, measurementDAO.getAllInRange(new Date(70), new Date(200), user2).size()); - assertEquals(2, measurementDAO.getAllInRange(new Date(0), new Date(1000), user2).size()); - - // test update method - assertEquals(30.0f, measurementDAO.get(scaleMeasurementList.get(0).getId()).getWeight(), DELTA); - scaleMeasurementList.get(0).setWeight(42.0f); - measurementDAO.update(scaleMeasurementList.get(0)); - assertEquals(42.0f, measurementDAO.get(scaleMeasurementList.get(0).getId()).getWeight(), DELTA); - - // test delete method - assertEquals(3, measurementDAO.getAll(user1).size()); - measurementDAO.delete(scaleMeasurementList.get(0).getId()); - assertEquals(2, measurementDAO.getAll(user1).size()); - - // test delete all method - assertEquals(2, measurementDAO.getAll(user1).size()); - assertEquals(2, measurementDAO.getAll(user2).size()); - measurementDAO.deleteAll(user1); - measurementDAO.deleteAll(user2); - assertEquals(0, measurementDAO.getAll(user1).size()); - assertEquals(0, measurementDAO.getAll(user2).size()); - - assertTrue(measurementDAO.getAll(user1).isEmpty()); - assertTrue(measurementDAO.getAll(user2).isEmpty()); - } -} diff --git a/android_app/app/src/androidTest/java/com/health/openscale/TrisaBodyAnalyzeLibTest.java b/android_app/app/src/androidTest/java/com/health/openscale/TrisaBodyAnalyzeLibTest.java deleted file mode 100644 index e64bc87c..00000000 --- a/android_app/app/src/androidTest/java/com/health/openscale/TrisaBodyAnalyzeLibTest.java +++ /dev/null @@ -1,180 +0,0 @@ -package com.health.openscale; - -import com.health.openscale.core.bluetooth.BluetoothTrisaBodyAnalyze; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; -import com.health.openscale.gui.MainActivity; - -import junit.framework.Assert; - -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.Calendar; -import java.util.Date; -import java.util.GregorianCalendar; - -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.rule.ActivityTestRule; - -import static junit.framework.Assert.assertEquals; - -/** Unit tests for {@link com.health.openscale.core.bluetooth.lib.TrisaBodyAnalyzeLib}.*/ -@RunWith(AndroidJUnit4.class) -public class TrisaBodyAnalyzeLibTest { - - @Rule - public ActivityTestRule mActivityTestRule = new ActivityTestRule<>(MainActivity.class, false, false); - - - public BluetoothTrisaBodyAnalyze trisaBodyAnalyze; - - @Before - public void initTest() { - try { - mActivityTestRule.runOnUiThread(new Runnable() { - public void run() { - trisaBodyAnalyze =new BluetoothTrisaBodyAnalyze(InstrumentationRegistry.getInstrumentation().getTargetContext()); - } - }); - } catch (Throwable throwable) { - throwable.printStackTrace(); - } - } - - @Test - public void getBase10FloatTests() { - double eps = 1e-9; // margin of error for inexact floating point comparisons - - assertEquals(0.0f, trisaBodyAnalyze.getBase10Float(new byte[]{0, 0, 0, 0}, 0)); - assertEquals(0.0f, trisaBodyAnalyze.getBase10Float(new byte[]{0, 0, 0, -1}, 0)); - assertEquals(76.1f, trisaBodyAnalyze.getBase10Float(new byte[]{-70, 29, 0, -2}, 0), eps); - assertEquals(1234.5678f, trisaBodyAnalyze.getBase10Float(new byte[]{78, 97, -68, -4}, 0), eps); - assertEquals(12345678e20f, trisaBodyAnalyze.getBase10Float(new byte[]{78, 97, -68, 20}, 0)); - assertEquals(12345678e-20f, trisaBodyAnalyze.getBase10Float(new byte[]{78, 97, -68, -20}, 0), eps); - - byte[] data = new byte[]{1,2,3,4,5}; - assertEquals(0x030201*1e4f, trisaBodyAnalyze.getBase10Float(data, 0)); - assertEquals(0x040302*1e5f, trisaBodyAnalyze.getBase10Float(data, 1)); - - assertThrows(IndexOutOfBoundsException.class, getBase10FloatRunnable(data, -1)); - assertThrows(IndexOutOfBoundsException.class, getBase10FloatRunnable(data, 5)); - assertThrows(IndexOutOfBoundsException.class, getBase10FloatRunnable(new byte[]{1,2,3}, 0)); - } - - @Test - public void convertJavaTimestampToDeviceTests() { - assertEquals(275852082, trisaBodyAnalyze.convertJavaTimestampToDevice(1538156082000L)); - - // Rounds down. - assertEquals(275852082, trisaBodyAnalyze.convertJavaTimestampToDevice(1538156082499L)); - - // Rounds up. - assertEquals(275852083, trisaBodyAnalyze.convertJavaTimestampToDevice(1538156082500L)); - } - - @Test - public void convertDeviceTimestampToJavaTests() { - assertEquals(1538156082000L, trisaBodyAnalyze.convertDeviceTimestampToJava(275852082)); - } - - @Test - public void parseScaleMeasurementData_validUserData() { - long expected_timestamp_seconds = 1539205852L; // Wed Oct 10 21:10:52 UTC 2018 - byte[] bytes = hexToBytes("9f:b0:1d:00:fe:dc:2f:81:10:00:00:00:ff:0a:15:00:ff:00:09:00"); - - ScaleUser user = new ScaleUser(); - user.setGender(Converters.Gender.MALE); - user.setBirthday(ageToBirthday(36)); - user.setBodyHeight(186); - user.setMeasureUnit(Converters.MeasureUnit.CM); - - ScaleMeasurement measurement = trisaBodyAnalyze.parseScaleMeasurementData(bytes, user); - - float eps = 1e-3f; - assertEquals(76.0f, measurement.getWeight(), eps); - assertEquals(new Date(expected_timestamp_seconds * 1000), measurement.getDateTime()); - assertEquals(14.728368f, measurement.getFat(), eps); - assertEquals(64.37914f, measurement.getWater(), eps); - assertEquals(43.36414f, measurement.getMuscle(), eps); - assertEquals(4.525733f, measurement.getBone()); - } - - @Test - public void parseScaleMeasurementData_missingUserData() { - long expected_timestamp_seconds = 1538156082L; // Fri Sep 28 17:34:42 UTC 2018 - byte[] bytes = hexToBytes("9f:ba:1d:00:fe:32:2b:71:10:00:00:00:ff:8d:14:00:ff:00:09:00"); - - ScaleMeasurement measurement = trisaBodyAnalyze.parseScaleMeasurementData(bytes, null); - - assertEquals(76.1f, measurement.getWeight(), 1e-3f); - assertEquals(new Date(expected_timestamp_seconds * 1000), measurement.getDateTime()); - assertEquals(0f, measurement.getFat()); - } - - @Test - public void parseScaleMeasurementData_invalidUserData() { - long expected_timestamp_seconds = 1538156082L; // Fri Sep 28 17:34:42 UTC 2018 - byte[] bytes = hexToBytes("9f:ba:1d:00:fe:32:2b:71:10:00:00:00:ff:8d:14:00:ff:00:09:00"); - - ScaleMeasurement measurement = trisaBodyAnalyze.parseScaleMeasurementData(bytes, new ScaleUser()); - - assertEquals(76.1f, measurement.getWeight(), 1e-3f); - assertEquals(new Date(expected_timestamp_seconds * 1000), measurement.getDateTime()); - assertEquals(0f, measurement.getFat()); - } - - /** - * Creates a {@link Runnable} that will call getBase10Float(). In Java 8, this can be done more - * easily with a lambda expression at the call site, but we are using Java 7. - */ - private Runnable getBase10FloatRunnable(final byte[] data, final int offset) { - return new Runnable() { - @Override - public void run() { - trisaBodyAnalyze.getBase10Float(data, offset); - } - }; - } - - /** - * Runs the given {@link Runnable} and verifies that it throws an exception of class {@code - * exceptionClass}. If it does, the exception will be caught and returned. If it does not (i.e. - * the runnable throws no exception, or throws an exception of a different class), then {@link - * Assert#fail} is called to abort the test. - */ - private static T assertThrows(Class exceptionClass, Runnable run) { - try { - run.run(); - Assert.fail("Expected an exception to be thrown."); - } catch (Throwable t) { - if (exceptionClass.isInstance(t)) { - return exceptionClass.cast(t); - } - Assert.fail("Wrong kind of exception was thrown; expected " + exceptionClass + ", received " + t.getClass()); - } - return null; // unreachable, because Assert.fail() throws an exception - } - - /** Parses a colon-separated hex-encoded string like "aa:bb:cc:dd" into an array of bytes. */ - private static byte[] hexToBytes(String s) { - String[] parts = s.split(":"); - byte[] bytes = new byte[parts.length]; - for (int i = 0; i < bytes.length; ++i) { - if (parts[i].length() != 2) { - throw new IllegalArgumentException(); - } - bytes[i] = (byte)Integer.parseInt(parts[i], 16); - } - return bytes; - } - - private static Date ageToBirthday(int years) { - int currentYear = GregorianCalendar.getInstance().get(Calendar.YEAR); - return new GregorianCalendar(currentYear - years, Calendar.JANUARY, 1).getTime(); - } -} diff --git a/android_app/app/src/androidTest/java/com/health/openscale/gui/AddMeasurementTest.java b/android_app/app/src/androidTest/java/com/health/openscale/gui/AddMeasurementTest.java deleted file mode 100644 index 08565732..00000000 --- a/android_app/app/src/androidTest/java/com/health/openscale/gui/AddMeasurementTest.java +++ /dev/null @@ -1,218 +0,0 @@ -/* Copyright (C) 2018 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui; - -import android.content.Context; -import android.content.SharedPreferences; -import android.preference.PreferenceManager; -import android.widget.DatePicker; -import android.widget.EditText; -import android.widget.TimePicker; - -import androidx.test.espresso.contrib.PickerActions; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.filters.LargeTest; -import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.rule.ActivityTestRule; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; -import com.health.openscale.gui.measurement.BicepsMeasurementView; -import com.health.openscale.gui.measurement.BoneMeasurementView; -import com.health.openscale.gui.measurement.Caliper1MeasurementView; -import com.health.openscale.gui.measurement.Caliper2MeasurementView; -import com.health.openscale.gui.measurement.Caliper3MeasurementView; -import com.health.openscale.gui.measurement.ChestMeasurementView; -import com.health.openscale.gui.measurement.CommentMeasurementView; -import com.health.openscale.gui.measurement.DateMeasurementView; -import com.health.openscale.gui.measurement.FatMeasurementView; -import com.health.openscale.gui.measurement.HipMeasurementView; -import com.health.openscale.gui.measurement.LBMMeasurementView; -import com.health.openscale.gui.measurement.MuscleMeasurementView; -import com.health.openscale.gui.measurement.NeckMeasurementView; -import com.health.openscale.gui.measurement.ThighMeasurementView; -import com.health.openscale.gui.measurement.TimeMeasurementView; -import com.health.openscale.gui.measurement.VisceralFatMeasurementView; -import com.health.openscale.gui.measurement.WaistMeasurementView; -import com.health.openscale.gui.measurement.WaterMeasurementView; -import com.health.openscale.gui.measurement.WeightMeasurementView; - -import org.hamcrest.Matchers; -import org.junit.AfterClass; -import org.junit.BeforeClass; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.Calendar; -import java.util.List; - -import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.action.ViewActions.click; -import static androidx.test.espresso.action.ViewActions.replaceText; -import static androidx.test.espresso.action.ViewActions.scrollTo; -import static androidx.test.espresso.matcher.ViewMatchers.withClassName; -import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static org.junit.Assert.assertEquals; - -@LargeTest -@RunWith(AndroidJUnit4.class) -public class AddMeasurementTest { - private static Context context; - private static OpenScale openScale; - - private static final ScaleUser male = TestData.getMaleUser(); - private static final ScaleUser female = TestData.getFemaleUser(); - - @Rule - public ActivityTestRule mActivityTestRule = new ActivityTestRule<>(MainActivity.class, false, true); - - @BeforeClass - public static void initTest() { - context = InstrumentationRegistry.getInstrumentation().getTargetContext(); - - openScale = OpenScale.getInstance(); - - male.setId(openScale.addScaleUser(male)); - female.setId(openScale.addScaleUser(female)); - - // Set first start to true to get the user add dialog - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - prefs.edit() - .putBoolean("firstStart", false) - .putString(MainActivity.PREFERENCE_LANGUAGE, "en") - .putBoolean(VisceralFatMeasurementView.KEY + "Enable", true) - .putBoolean(LBMMeasurementView.KEY + "Enable", true) - .putBoolean(BoneMeasurementView.KEY + "Enable", true) - .putBoolean(WaistMeasurementView.KEY + "Enable", true) - .putBoolean(HipMeasurementView.KEY + "Enable", true) - .putBoolean(ChestMeasurementView.KEY + "Enable", true) - .putBoolean(BicepsMeasurementView.KEY + "Enable", true) - .putBoolean(ThighMeasurementView.KEY + "Enable", true) - .putBoolean(NeckMeasurementView.KEY + "Enable", true) - .putBoolean(Caliper1MeasurementView.KEY + "Enable", true) - .putBoolean(Caliper2MeasurementView.KEY + "Enable", true) - .putBoolean(Caliper3MeasurementView.KEY + "Enable", true) - .commit(); - } - - @AfterClass - public static void cleanup() { - openScale.deleteScaleUser(male.getId()); - openScale.deleteScaleUser(female.getId()); - } - - @Test - public void addMeasurementMaleTest() { - openScale.selectScaleUser(male.getId()); - - ScaleMeasurement measurement = TestData.getMeasurement(1); - onView(withId(R.id.action_add_measurement)).perform(click()); - - onView(withClassName(Matchers.equalTo(DateMeasurementView.class.getName()))).perform(scrollTo(), click()); - Calendar date = Calendar.getInstance(); - date.setTime(measurement.getDateTime()); - onView(withClassName(Matchers.equalTo(DatePicker.class.getName()))).perform(PickerActions.setDate(date.get(Calendar.YEAR), date.get(Calendar.MONTH)+1, date.get(Calendar.DAY_OF_MONTH))); - onView(withId(android.R.id.button1)).perform(click()); - - onView(withClassName(Matchers.equalTo(TimeMeasurementView.class.getName()))).perform(scrollTo(), click()); - onView(withClassName(Matchers.equalTo(TimePicker.class.getName()))).perform(PickerActions.setTime(date.get(Calendar.HOUR_OF_DAY), date.get(Calendar.MINUTE))); - onView(withId(android.R.id.button1)).perform(click()); - - setMeasuremntField(WeightMeasurementView.class.getName(), measurement.getWeight()); - setMeasuremntField(FatMeasurementView.class.getName(), measurement.getFat()); - setMeasuremntField(WaterMeasurementView.class.getName(), measurement.getWater()); - setMeasuremntField(MuscleMeasurementView.class.getName(), measurement.getMuscle()); - setMeasuremntField(LBMMeasurementView.class.getName(), measurement.getLbm()); - setMeasuremntField(BoneMeasurementView.class.getName(), measurement.getBone()); - setMeasuremntField(WaistMeasurementView.class.getName(), measurement.getWaist()); - setMeasuremntField(HipMeasurementView.class.getName(), measurement.getHip()); - setMeasuremntField(VisceralFatMeasurementView.class.getName(), measurement.getVisceralFat()); - setMeasuremntField(ChestMeasurementView.class.getName(), measurement.getChest()); - setMeasuremntField(ThighMeasurementView.class.getName(), measurement.getThigh()); - setMeasuremntField(BicepsMeasurementView.class.getName(), measurement.getBiceps()); - setMeasuremntField(NeckMeasurementView.class.getName(), measurement.getNeck()); - setMeasuremntField(Caliper1MeasurementView.class.getName(), measurement.getCaliper1()); - setMeasuremntField(Caliper2MeasurementView.class.getName(), measurement.getCaliper2()); - setMeasuremntField(Caliper3MeasurementView.class.getName(), measurement.getCaliper3()); - - onView(withClassName(Matchers.equalTo(CommentMeasurementView.class.getName()))).perform(scrollTo(), click()); - onView(withClassName(Matchers.equalTo(EditText.class.getName()))).perform(replaceText(measurement.getComment())); - onView(withId(android.R.id.button1)).perform(click()); - - onView(withId(R.id.saveButton)).perform(click()); - - List scaleMeasurementList = openScale.getScaleMeasurementList(); - assertEquals(1, scaleMeasurementList.size()); - - TestData.compareMeasurements(measurement, scaleMeasurementList.get(0)); - } - - @Test - public void addMeasurementFemaleTest() { - openScale.selectScaleUser(female.getId()); - - ScaleMeasurement measurement = TestData.getMeasurement(2); - onView(withId(R.id.action_add_measurement)).perform(click()); - - onView(withClassName(Matchers.equalTo(DateMeasurementView.class.getName()))).perform(scrollTo(), click()); - Calendar date = Calendar.getInstance(); - date.setTime(measurement.getDateTime()); - onView(withClassName(Matchers.equalTo(DatePicker.class.getName()))).perform(PickerActions.setDate(date.get(Calendar.YEAR), date.get(Calendar.MONTH)+1, date.get(Calendar.DAY_OF_MONTH))); - onView(withId(android.R.id.button1)).perform(click()); - - onView(withClassName(Matchers.equalTo(TimeMeasurementView.class.getName()))).perform(scrollTo(), click()); - onView(withClassName(Matchers.equalTo(TimePicker.class.getName()))).perform(PickerActions.setTime(date.get(Calendar.HOUR_OF_DAY), date.get(Calendar.MINUTE))); - onView(withId(android.R.id.button1)).perform(click()); - - setMeasuremntField(WeightMeasurementView.class.getName(), Converters.fromKilogram(measurement.getWeight(), Converters.WeightUnit.LB)); - setMeasuremntField(FatMeasurementView.class.getName(), measurement.getFat()); - setMeasuremntField(WaterMeasurementView.class.getName(), measurement.getWater()); - setMeasuremntField(MuscleMeasurementView.class.getName(), measurement.getMuscle()); - setMeasuremntField(LBMMeasurementView.class.getName(), Converters.fromKilogram(measurement.getLbm(), Converters.WeightUnit.LB)); - setMeasuremntField(BoneMeasurementView.class.getName(), Converters.fromKilogram(measurement.getBone(), Converters.WeightUnit.LB)); - setMeasuremntField(WaistMeasurementView.class.getName(), Converters.fromCentimeter(measurement.getWaist(), Converters.MeasureUnit.INCH)); - setMeasuremntField(HipMeasurementView.class.getName(), Converters.fromCentimeter(measurement.getHip(), Converters.MeasureUnit.INCH)); - setMeasuremntField(VisceralFatMeasurementView.class.getName(), measurement.getVisceralFat()); - setMeasuremntField(ChestMeasurementView.class.getName(), Converters.fromCentimeter(measurement.getChest(), Converters.MeasureUnit.INCH)); - setMeasuremntField(ThighMeasurementView.class.getName(), Converters.fromCentimeter(measurement.getThigh(), Converters.MeasureUnit.INCH)); - setMeasuremntField(BicepsMeasurementView.class.getName(), Converters.fromCentimeter(measurement.getBiceps(), Converters.MeasureUnit.INCH)); - setMeasuremntField(NeckMeasurementView.class.getName(), Converters.fromCentimeter(measurement.getNeck(), Converters.MeasureUnit.INCH)); - setMeasuremntField(Caliper1MeasurementView.class.getName(), Converters.fromCentimeter(measurement.getCaliper1(), Converters.MeasureUnit.INCH)); - setMeasuremntField(Caliper2MeasurementView.class.getName(), Converters.fromCentimeter(measurement.getCaliper2(), Converters.MeasureUnit.INCH)); - setMeasuremntField(Caliper3MeasurementView.class.getName(), Converters.fromCentimeter(measurement.getCaliper3(), Converters.MeasureUnit.INCH)); - - onView(withClassName(Matchers.equalTo(CommentMeasurementView.class.getName()))).perform(scrollTo(), click()); - onView(withClassName(Matchers.equalTo(EditText.class.getName()))).perform(replaceText(measurement.getComment())); - onView(withId(android.R.id.button1)).perform(click()); - - onView(withId(R.id.saveButton)).perform(click()); - - List scaleMeasurementList = openScale.getScaleMeasurementList(); - assertEquals(1, scaleMeasurementList.size()); - - TestData.compareMeasurements(measurement, scaleMeasurementList.get(0)); - } - - private void setMeasuremntField(String className, float value) { - onView(withClassName(Matchers.equalTo(className))).perform(scrollTo(), click()); - onView(withId(R.id.float_input)).perform(replaceText(String.valueOf(value))); - onView(withId(android.R.id.button1)).perform(click()); - } -} diff --git a/android_app/app/src/androidTest/java/com/health/openscale/gui/AddUserTest.java b/android_app/app/src/androidTest/java/com/health/openscale/gui/AddUserTest.java deleted file mode 100644 index 7ee87ccc..00000000 --- a/android_app/app/src/androidTest/java/com/health/openscale/gui/AddUserTest.java +++ /dev/null @@ -1,194 +0,0 @@ -/* Copyright (C) 2018 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui; - -import android.content.Context; -import android.content.SharedPreferences; -import android.preference.PreferenceManager; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewParent; -import android.widget.DatePicker; - -import androidx.test.espresso.ViewInteraction; -import androidx.test.espresso.contrib.PickerActions; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.filters.LargeTest; -import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.rule.ActivityTestRule; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleUser; - -import org.hamcrest.Description; -import org.hamcrest.Matcher; -import org.hamcrest.Matchers; -import org.hamcrest.TypeSafeMatcher; -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.Calendar; - -import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.action.ViewActions.click; -import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; -import static androidx.test.espresso.action.ViewActions.replaceText; -import static androidx.test.espresso.action.ViewActions.scrollTo; -import static androidx.test.espresso.matcher.ViewMatchers.withClassName; -import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static org.hamcrest.Matchers.allOf; -import static org.junit.Assert.assertEquals; - -@LargeTest -@RunWith(AndroidJUnit4.class) -public class AddUserTest { - private static final double DELTA = 1e-15; - - private Context context; - - @Rule - public ActivityTestRule mActivityTestRule = new ActivityTestRule<>(MainActivity.class, false, false); - - @Before - public void initTest() { - context = InstrumentationRegistry.getInstrumentation().getTargetContext(); - - // Set first start to true to get the user add dialog - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - prefs.edit() - .putBoolean("firstStart", true) - .putString(MainActivity.PREFERENCE_LANGUAGE, "en") - .commit(); - } - - @After - public void addUserVerification() { - ScaleUser user = OpenScale.getInstance().getSelectedScaleUser(); - - assertEquals("test", user.getUserName()); - assertEquals(180, user.getBodyHeight(), DELTA); - assertEquals(80, user.getInitialWeight(), DELTA); - assertEquals(60, user.getGoalWeight(), DELTA); - - Calendar birthday = Calendar.getInstance(); - birthday.setTimeInMillis(0); - birthday.set(Calendar.YEAR, 1990); - birthday.set(Calendar.MONTH, Calendar.JANUARY); - birthday.set(Calendar.DAY_OF_MONTH, 19); - birthday.set(Calendar.HOUR_OF_DAY, 0); - - assertEquals(birthday.getTime().getTime(), user.getBirthday().getTime()); - - Calendar goalDate = Calendar.getInstance(); - goalDate.setTimeInMillis(0); - goalDate.set(Calendar.YEAR, 2018); - goalDate.set(Calendar.MONTH, Calendar.JANUARY); - goalDate.set(Calendar.DAY_OF_MONTH, 31); - goalDate.set(Calendar.HOUR_OF_DAY, 0); - - assertEquals(goalDate.getTime().getTime(), user.getGoalDate().getTime()); - - OpenScale.getInstance().deleteScaleUser(user.getId()); - } - - @Test - public void addUserTest() { - mActivityTestRule.launchActivity(null); - - ViewInteraction editText = onView( - allOf(withId(R.id.txtUserName), - childAtPosition( - allOf(withId(R.id.rowUserName), - childAtPosition( - withId(R.id.tableUserData), - 0)), - 1))); - editText.perform(scrollTo(), click()); - - ViewInteraction editText2 = onView( - allOf(withId(R.id.txtUserName), - childAtPosition( - allOf(withId(R.id.rowUserName), - childAtPosition( - withId(R.id.tableUserData), - 0)), - 1))); - editText2.perform(scrollTo(), replaceText("test"), closeSoftKeyboard()); - - ViewInteraction editText3 = onView( - allOf(withId(R.id.txtBodyHeight), - childAtPosition( - allOf(withId(R.id.rowBodyHeight), - childAtPosition( - withId(R.id.tableUserData), - 6)), - 1))); - editText3.perform(scrollTo(), replaceText("180"), closeSoftKeyboard()); - - onView(withId(R.id.txtBirthday)).perform(click()); - onView(withClassName(Matchers.equalTo(DatePicker.class.getName()))).perform(PickerActions.setDate(1990, 1, 19)); - onView(withId(android.R.id.button1)).perform(click()); - - ViewInteraction editText5 = onView( - allOf(withId(R.id.txtInitialWeight), - childAtPosition( - allOf(withId(R.id.tableRowInitialWeight), - childAtPosition( - withId(R.id.tableUserData), - 7)), - 1))); - editText5.perform(scrollTo(), replaceText("80"), closeSoftKeyboard()); - - ViewInteraction editText6 = onView( - allOf(withId(R.id.txtGoalWeight), - childAtPosition( - allOf(withId(R.id.rowGoalWeight), - childAtPosition( - withId(R.id.tableUserData), - 8)), - 1))); - editText6.perform(scrollTo(), replaceText("60"), closeSoftKeyboard()); - - onView(withId(R.id.txtGoalDate)).perform(click()); - onView(withClassName(Matchers.equalTo(DatePicker.class.getName()))).perform(PickerActions.setDate(2018, 1, 31)); - onView(withId(android.R.id.button1)).perform(click()); - - onView(withId(R.id.saveButton)).perform(click()); - } - - private static Matcher childAtPosition( - final Matcher parentMatcher, final int position) { - - return new TypeSafeMatcher() { - @Override - public void describeTo(Description description) { - description.appendText("Child at position " + position + " in parent "); - parentMatcher.describeTo(description); - } - - @Override - public boolean matchesSafely(View view) { - ViewParent parent = view.getParent(); - return parent instanceof ViewGroup && parentMatcher.matches(parent) - && view.equals(((ViewGroup) parent).getChildAt(position)); - } - }; - } -} diff --git a/android_app/app/src/androidTest/java/com/health/openscale/gui/ScreenshotRecorder.java b/android_app/app/src/androidTest/java/com/health/openscale/gui/ScreenshotRecorder.java deleted file mode 100644 index a287912b..00000000 --- a/android_app/app/src/androidTest/java/com/health/openscale/gui/ScreenshotRecorder.java +++ /dev/null @@ -1,319 +0,0 @@ -/* Copyright (C) 2018 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui; - -import static android.os.Environment.DIRECTORY_PICTURES; -import static android.os.Environment.getExternalStoragePublicDirectory; -import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.Espresso.pressBack; -import static androidx.test.espresso.action.ViewActions.click; -import static androidx.test.espresso.assertion.ViewAssertions.matches; -import static androidx.test.espresso.contrib.DrawerActions.close; -import static androidx.test.espresso.contrib.DrawerActions.open; -import static androidx.test.espresso.contrib.DrawerMatchers.isClosed; -import static androidx.test.espresso.contrib.NavigationViewActions.navigateTo; -import static androidx.test.espresso.matcher.ViewMatchers.withId; - -import android.content.Context; -import android.content.SharedPreferences; -import android.graphics.Bitmap; -import android.preference.PreferenceManager; -import android.view.Gravity; - -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.filters.LargeTest; -import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.rule.ActivityTestRule; -import androidx.test.runner.screenshot.BasicScreenCaptureProcessor; -import androidx.test.runner.screenshot.ScreenCapture; -import androidx.test.runner.screenshot.Screenshot; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.CsvHelper; - -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.io.BufferedReader; -import java.io.File; -import java.io.IOException; -import java.io.StringReader; -import java.text.ParseException; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.List; -import java.util.Locale; - -import timber.log.Timber; - -@LargeTest -@RunWith(AndroidJUnit4.class) -public class ScreenshotRecorder { - private Context context; - private OpenScale openScale; - private final int WAIT_MS = 500; - - @Rule - public ActivityTestRule mActivityTestRule = new ActivityTestRule<>(MainActivity.class, false , false); - - @Before - public void initRecorder() { - context = InstrumentationRegistry.getInstrumentation().getTargetContext(); - openScale = OpenScale.getInstance(); - - // Set first start to true to get the user add dialog - PreferenceManager.getDefaultSharedPreferences(context).edit() - .putBoolean("firstStart", false) - .putBoolean("waistEnable", true) - .putBoolean("hipEnable", true) - .putBoolean("boneEnable", true) - .commit(); - } - - @Test - public void captureScreenshots() { - try { - mActivityTestRule.runOnUiThread(new Runnable() { - public void run() { - prepareData(); - } - }); - } catch (Throwable throwable) { - throwable.printStackTrace(); - } - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - String language = prefs.getString(MainActivity.PREFERENCE_LANGUAGE, "default"); - - prefs.edit() - .remove("lastFragmentId") - .putString(MainActivity.PREFERENCE_LANGUAGE, "en") - .commit(); - screenshotRecorder(); - - prefs.edit() - .remove("lastFragmentId") - .putString(MainActivity.PREFERENCE_LANGUAGE, "de") - .commit(); - screenshotRecorder(); - - // Restore language setting - prefs.edit() - .putString(MainActivity.PREFERENCE_LANGUAGE, language) - .commit(); - } - - private ScaleUser getTestUser() { - ScaleUser user = new ScaleUser(); - user.setUserName("Test"); - user.setBodyHeight(180); - user.setInitialWeight(80.0f); - user.setGoalWeight(60.0f); - - Calendar birthday = Calendar.getInstance(); - birthday.add(Calendar.YEAR, -28); - birthday.set(birthday.get(Calendar.YEAR), Calendar.JANUARY, 19, 0, 0, 0); - birthday.set(Calendar.MILLISECOND, 0); - - user.setBirthday(birthday.getTime()); - - Calendar goalDate = Calendar.getInstance(); - goalDate.add(Calendar.YEAR, 1); - goalDate.set(goalDate.get(Calendar.YEAR), Calendar.JANUARY, 31, 0, 0, 0); - goalDate.set(Calendar.MILLISECOND, 0); - - user.setGoalDate(goalDate.getTime()); - - return user; - } - - private List getTestMeasurements() { - List scaleMeasurementList = new ArrayList<>(); - - String data = "\"dateTime\",\"weight\",\"fat\",\"water\",\"muscle\",\"lbm\",\"bone\",\"waist\",\"hip\",\"comment\"\n" + - "04.08.2023 08:08,89.7,21.2,58.0,41.5\n" + - "03.08.2023 05:17,89.0,26.4,54.6,41.6\n" + - "02.08.2023 07:32,88.8,25.0,55.6,41.7\n" + - "31.07.2023 04:39,89.1,29.2,52.8,41.6\n" + - "18.07.2023 07:54,91.3,22.1,57.4,41.2\n" + - "12.07.2023 07:14,91.1,21.9,57.6,41.3\n" + - "16.06.2023 05:16,89.5,25.3,55.4,41.5\n" + - "15.06.2023 05:34,90.1,26.3,54.7,41.4\n" + - "12.06.2023 05:36,90.3,26.4,54.6,41.4\n" + - "10.06.2023 04:22,90.8,22.3,57.3,41.3\n" + - "07.06.2023 10:17,90.0,22.6,57.1,41.4\n" + - "06.06.2023 06:36,91.0,21.6,57.8,41.3\n" + - "05.06.2023 06:57,91.6,21.7,57.7,41.2\n" + - "04.06.2023 06:35,90.4,23.5,56.5,41.4\n" + - "25.05.2023 10:25,89.5,21.6,57.8,41.5\n" + - "17.05.2023 09:55,92.5,21.9,57.6,41.0\n" + - "09.05.2023 09:30,89.0,21.6,57.8,41.6\n" + - "29.04.2023 08:25,89.2,21.0,58.2,41.4\n" + - "13.04.2023 04:54,87.6,32.7,50.6,41.9\n" + - "11.04.2023 07:41,86.8,20.9,58.3,42.0\n" + - "10.04.2023 05:27,86.4,24.0,56.3,42.1\n" + - "06.04.2023 06:45,87.6,24.4,56.0,41.9\n" + - "01.04.2023 05:03,88.6,25.6,55.2,41.7\n" + - "28.03.2023 07:06,87.1,23.5,56.6,42.2\n" + - "21.03.2023 18:21,88.1,20.7,58.5,42.0\n" + - "15.03.2023 20:56,90.3,22.6,57.1,41.6\n" + - "14.03.2023 07:37,87.2,25.3,55.5,42.1\n" + - "13.03.2023 06:11,85.6,27.4,54.1,42.4\n" + - "17.02.2023 10:32,86.6,20.6,58.5,42.2\n" + - "16.02.2023 07:59,87.5,27.6,53.9,42.1\n" + - "15.02.2023 10:38,86.4,23.4,56.7,42.3\n" + - "14.02.2023 09:18,87.5,20.5,58.6,42.1\n" + - "08.02.2023 07:05,85.5,26.6,54.6,42.4\n" + - "06.02.2023 06:09,85.8,30.3,52.2,42.4\n" + - "05.02.2023 06:16,86.5,31.2,51.6,42.3\n" + - "04.02.2023 06:10,86.7,28.3,53.5,42.2\n" + - "01.02.2023 08:59,87.4,22.2,57.5,42.1\n" + - "24.01.2023 09:55,85.1,24.1,56.2,42.5\n" + - "18.01.2023 11:11,86.1,20.1,58.9,42.3\n" + - "14.01.2023 06:11,86.9,26.3,54.8,42.2\n" + - "07.01.2023 07:08,85.6,20.3,58.7,42.4\n" + - "06.01.2023 10:34,85.5,19.7,59.1,42.4\n" + - "05.01.2023 08:25,85.6,26.1,54.9,42.4\n" + - "02.01.2023 18:06,86.3,19.8,59.1,42.3\n" + - "13.12.2022 13:16,85.2,19.3,59.4,42.5\n" + - "09.12.2022 19:36,86.9,20.3,58.7,42.2\n" + - "08.12.2022 20:28,86.8,19.9,59.0,42.2\n" + - "05.12.2022 18:21,86.7,20.3,58.7,42.2\n"; - - try { - scaleMeasurementList = CsvHelper.importFrom(new BufferedReader(new StringReader(data))); - } catch (IOException | ParseException e) { - Timber.e(e); - } - - // set current year to the measurement data - Calendar measurementDate = Calendar.getInstance(); - int year = measurementDate.get(Calendar.YEAR); - - for (ScaleMeasurement measurement : scaleMeasurementList) { - measurementDate.setTime(measurement.getDateTime()); - measurementDate.set(Calendar.YEAR, year); - measurement.setDateTime(measurementDate.getTime()); - } - - return scaleMeasurementList; - } - - private void prepareData() { - int userId = openScale.addScaleUser(getTestUser()); - openScale.selectScaleUser(userId); - - List scaleMeasurementList = getTestMeasurements(); - - for (ScaleMeasurement measurement : scaleMeasurementList) { - openScale.addScaleMeasurement(measurement, true); - } - } - - private void screenshotRecorder() { - try { - mActivityTestRule.launchActivity(null); - - Thread.sleep(WAIT_MS); - captureScreenshot("overview"); - - onView(withId(R.id.action_add_measurement)).perform(click()); - - Thread.sleep(WAIT_MS); - captureScreenshot("dataentry"); - - pressBack(); - - onView(withId(R.id.drawer_layout)) - .perform(open()); // Open Drawer - - onView(withId(R.id.navigation_view)) - .perform(navigateTo(R.id.nav_graph)); - - onView(withId(R.id.drawer_layout)) - .perform(close()); // Close Drawer - - onView(withId(R.id.drawer_layout)) - .check(matches(isClosed(Gravity.LEFT))); - - Thread.sleep(WAIT_MS); - captureScreenshot("graph"); - - onView(withId(R.id.drawer_layout)) - .perform(open()); // Open Drawer - - onView(withId(R.id.navigation_view)) - .perform(navigateTo(R.id.nav_table)); - - onView(withId(R.id.drawer_layout)) - .perform(close()); // Close Drawer - - onView(withId(R.id.drawer_layout)) - .check(matches(isClosed(Gravity.LEFT))); - - Thread.sleep(WAIT_MS); - captureScreenshot("table"); - - onView(withId(R.id.drawer_layout)) - .perform(open()); // Open Drawer - - onView(withId(R.id.navigation_view)) - .perform(navigateTo(R.id.nav_statistic)); - - onView(withId(R.id.drawer_layout)) - .perform(close()); // Close Drawer - - onView(withId(R.id.drawer_layout)) - .check(matches(isClosed(Gravity.LEFT))); - - Thread.sleep(WAIT_MS); - captureScreenshot("statistics"); - - mActivityTestRule.finishActivity(); - } catch (InterruptedException e) { - Timber.e(e); - } - } - - private void captureScreenshot(String name) { - BasicScreenCaptureProcessor processor = new BasicScreenCaptureProcessor(); - - ScreenCapture capture = Screenshot.capture(); - capture.setFormat(Bitmap.CompressFormat.PNG); - capture.setName(name); - try { - String filename = processor.process(capture); - - // rename file to remove UUID suffix - File folder = new File(getExternalStoragePublicDirectory(DIRECTORY_PICTURES) + "/screenshots/openScale_" + Locale.getDefault().getLanguage()); - if (!folder.exists()) { - folder.mkdir(); - } - Timber.d("Saved to " + getExternalStoragePublicDirectory(DIRECTORY_PICTURES) + "/screenshots/" + filename); - File from = new File(getExternalStoragePublicDirectory(DIRECTORY_PICTURES) + "/screenshots/" + filename); - File to = new File(getExternalStoragePublicDirectory(DIRECTORY_PICTURES) + "/screenshots/openScale_" + Locale.getDefault().getLanguage() + "/screen_" + name + ".png"); - from.renameTo(to); - } catch (IOException ex) { - throw new IllegalStateException(ex); - } - } -} diff --git a/android_app/app/src/androidTest/java/com/health/openscale/gui/TestData.java b/android_app/app/src/androidTest/java/com/health/openscale/gui/TestData.java deleted file mode 100644 index 16c1d924..00000000 --- a/android_app/app/src/androidTest/java/com/health/openscale/gui/TestData.java +++ /dev/null @@ -1,141 +0,0 @@ -/* Copyright (C) 2018 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.gui; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; - -import java.util.Calendar; -import java.util.Date; -import java.util.Random; - -import static org.junit.Assert.assertEquals; - -public class TestData { - private static Random rand = new Random(); - private static final double DELTA = 1e-4; - - public static ScaleUser getMaleUser() { - ScaleUser male = new ScaleUser(); - - male.setUserName("Bob"); - male.setGender(Converters.Gender.MALE); - male.setInitialWeight(80.0f); - male.setScaleUnit(Converters.WeightUnit.KG); - male.setActivityLevel(Converters.ActivityLevel.MILD); - male.setBodyHeight(180.0f); - male.setGoalWeight(60.0f); - male.setMeasureUnit(Converters.MeasureUnit.CM); - male.setBirthday(getDateFromYears(-20)); - male.setGoalDate(getDateFromYears(2)); - - return male; - } - - public static ScaleUser getFemaleUser() { - ScaleUser female = new ScaleUser(); - - female.setUserName("Alice"); - female.setGender(Converters.Gender.FEMALE); - female.setInitialWeight(70.0f); - female.setScaleUnit(Converters.WeightUnit.LB); - female.setActivityLevel(Converters.ActivityLevel.EXTREME); - female.setBodyHeight(160.0f); - female.setGoalWeight(50.0f); - female.setMeasureUnit(Converters.MeasureUnit.INCH); - female.setBirthday(getDateFromYears(-25)); - female.setGoalDate(getDateFromYears(1)); - - return female; - } - - public static ScaleMeasurement getMeasurement(int nr) { - ScaleMeasurement measurement = new ScaleMeasurement(); - - rand.setSeed(nr); - - measurement.setDateTime(getDateFromDays(nr)); - measurement.setWeight(100.0f + getRandNumberInRange(0,50)); - measurement.setFat(30.0f + getRandNumberInRange(0,30)); - measurement.setWater(50.0f + getRandNumberInRange(0,20)); - measurement.setMuscle(40.0f + getRandNumberInRange(0,15)); - measurement.setLbm(20.0f + getRandNumberInRange(0,10)); - measurement.setBone(8.0f + getRandNumberInRange(0,50)); - measurement.setWaist(50.0f + getRandNumberInRange(0,50)); - measurement.setHip(60.0f + getRandNumberInRange(0,50)); - measurement.setChest(80.0f + getRandNumberInRange(0,50)); - measurement.setThigh(40.0f + getRandNumberInRange(0,50)); - measurement.setVisceralFat(10 + getRandNumberInRange(0,5)); - measurement.setBiceps(30.0f + getRandNumberInRange(0,50)); - measurement.setNeck(15.0f + getRandNumberInRange(0,50)); - measurement.setCaliper1(5.0f + getRandNumberInRange(0,10)); - measurement.setCaliper2(10.0f + getRandNumberInRange(0,10)); - measurement.setCaliper3(7.0f + getRandNumberInRange(0,10)); - measurement.setComment("my comment " + nr); - - return measurement; - } - - public static void compareMeasurements(ScaleMeasurement measurementA, ScaleMeasurement measurementB) { - assertEquals(measurementA.getDateTime().getTime(), measurementB.getDateTime().getTime(), DELTA); - assertEquals(measurementA.getWeight(), measurementB.getWeight(), DELTA); - assertEquals(measurementA.getFat(), measurementB.getFat(), DELTA); - assertEquals(measurementA.getWater(), measurementB.getWater(), DELTA); - assertEquals(measurementA.getMuscle(), measurementB.getMuscle(), DELTA); - assertEquals(measurementA.getLbm(), measurementB.getLbm(), DELTA); - assertEquals(measurementA.getBone(), measurementB.getBone(), DELTA); - assertEquals(measurementA.getWaist(), measurementB.getWaist(), DELTA); - assertEquals(measurementA.getHip(), measurementB.getHip(), DELTA); - assertEquals(measurementA.getChest(), measurementB.getChest(), DELTA); - assertEquals(measurementA.getThigh(), measurementB.getThigh(), DELTA); - assertEquals(measurementA.getVisceralFat(), measurementB.getVisceralFat(), DELTA); - assertEquals(measurementA.getBiceps(), measurementB.getBiceps(), DELTA); - assertEquals(measurementA.getNeck(), measurementB.getNeck(), DELTA); - assertEquals(measurementA.getCaliper1(), measurementB.getCaliper1(), DELTA); - assertEquals(measurementA.getCaliper2(), measurementB.getCaliper2(), DELTA); - assertEquals(measurementA.getCaliper3(), measurementB.getCaliper3(), DELTA); - assertEquals(measurementA.getComment(), measurementB.getComment()); - } - - private static Date getDateFromYears(int years) { - Calendar currentTime = Calendar.getInstance(); - - currentTime.add(Calendar.YEAR, years); - currentTime.set(Calendar.HOUR_OF_DAY, 8); - currentTime.set(Calendar.MINUTE, 0); - currentTime.set(Calendar.MILLISECOND, 0); - currentTime.set(Calendar.SECOND, 0); - - return currentTime.getTime(); - } - - private static Date getDateFromDays(int days) { - Calendar currentTime = Calendar.getInstance(); - - currentTime.add(Calendar.DAY_OF_YEAR, days); - currentTime.set(Calendar.HOUR_OF_DAY, 8); - currentTime.set(Calendar.MINUTE, 0); - currentTime.set(Calendar.MILLISECOND, 0); - currentTime.set(Calendar.SECOND, 0); - - return currentTime.getTime(); - } - - private static float getRandNumberInRange(int min, int max) { - return (float)(rand.nextInt(max*10 - min*10) + min*10) / 10.0f; - } -} diff --git a/android_app/app/src/main/AndroidManifest.xml b/android_app/app/src/main/AndroidManifest.xml index 96d07d99..81ae31d0 100644 --- a/android_app/app/src/main/AndroidManifest.xml +++ b/android_app/app/src/main/AndroidManifest.xml @@ -1,84 +1,40 @@ - + - - - - - - - - - - - - - + + android:roundIcon="@mipmap/ic_launcher_round" + android:supportsRtl="true" + android:theme="@style/Theme.OpenScale"> + android:name=".MainActivity" + android:exported="true" + android:label="@string/app_name" + android:theme="@style/Theme.OpenScale"> - - - - - - - - - - - - - - - - - - - - - - - - - + android:exported="false" + android:grantUriPermissions="true"> + + - + \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/MainActivity.kt b/android_app/app/src/main/java/com/health/openscale/MainActivity.kt new file mode 100644 index 00000000..2b8624ad --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/MainActivity.kt @@ -0,0 +1,207 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale + +import android.content.Context +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.health.openscale.core.data.InputFieldType +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.SupportedLanguage +import com.health.openscale.core.data.UnitType +import com.health.openscale.core.database.AppDatabase +import com.health.openscale.core.database.DatabaseRepository +import com.health.openscale.core.utils.LanguageUtil +import com.health.openscale.core.utils.LogManager +import com.health.openscale.core.database.UserSettingsRepository +import com.health.openscale.core.database.provideUserSettingsRepository +import com.health.openscale.ui.navigation.AppNavigation +import com.health.openscale.ui.screen.SharedViewModel +import com.health.openscale.ui.theme.OpenScaleTheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** + * Generates a default list of measurement types available in the application, + * resolving names from string resources. + * These types are intended for insertion into the database on the first app start. + * + * @param context The context used to access string resources. + * @return A list of [MeasurementType] objects. + */ +fun getDefaultMeasurementTypes(context: Context): List { + return listOf( + MeasurementType(key = MeasurementTypeKey.WEIGHT, unit = UnitType.KG, color = 0xFFEF2929.toInt(), icon = "ic_weight", isPinned = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.BMI, color = 0xFFF57900.toInt(), icon = "ic_bmi", isDerived = true, isPinned = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.BODY_FAT, color = 0xFFFFCE44.toInt(), icon = "ic_fat", isPinned = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.WATER, color = 0xFF8AE234.toInt(), icon = "ic_water", isPinned = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.MUSCLE, color = 0xFF729FCF.toInt(), icon = "ic_muscle", isPinned = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.LBM, color = 0xFFAD7FA8.toInt(), icon = "ic_lbm", isEnabled = true), + MeasurementType(key = MeasurementTypeKey.BONE, color = 0xFFE9B96E.toInt(), icon = "ic_bone", isEnabled = true), + MeasurementType(key = MeasurementTypeKey.WAIST, color = 0xFF888A85.toInt(), icon = "ic_waist", isEnabled = true), + MeasurementType(key = MeasurementTypeKey.WHR, color = 0xFF204A87.toInt(), icon = "ic_whr", isDerived = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.WHTR, color = 0xFF204A87.toInt(), icon = "ic_whtr", isDerived = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.HIPS, color = 0xFF3465A4.toInt(), icon = "ic_hip", isEnabled = true), + MeasurementType(key = MeasurementTypeKey.VISCERAL_FAT, color = 0xFF4E9A06.toInt(), icon = "ic_visceral_fat", isEnabled = true), + MeasurementType(key = MeasurementTypeKey.CHEST, color = 0xFF5C3566.toInt(), icon = "ic_chest", isEnabled = true), + MeasurementType(key = MeasurementTypeKey.THIGH, color = 0xFFC17D11.toInt(), icon = "ic_thigh", isEnabled = true), + MeasurementType(key = MeasurementTypeKey.BICEPS, color = 0xFFA40000.toInt(), icon = "ic_biceps", isEnabled = true), + MeasurementType(key = MeasurementTypeKey.NECK, color = 0xFFCE5C00.toInt(), icon = "ic_neck", isEnabled = true), + MeasurementType(key = MeasurementTypeKey.CALIPER_1, color = 0xFFEDD400.toInt(), icon = "ic_caliper1", isEnabled = true), + MeasurementType(key = MeasurementTypeKey.CALIPER_2, color = 0xFF73D216.toInt(), icon = "ic_caliper2", isEnabled = true), + MeasurementType(key = MeasurementTypeKey.CALIPER_3, color = 0xFF11A879.toInt(), icon = "ic_caliper3", isEnabled = true), + MeasurementType(key = MeasurementTypeKey.CALIPER, color = 0xFF555753.toInt(), icon = "ic_fat_caliper", isDerived = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.BMR, color = 0xFFBABDB6.toInt(), icon = "ic_bmr", isDerived = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.TDEE, color = 0xFFD3D7CF.toInt(), icon = "ic_tdee", isDerived = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.CALORIES, color = 0xFF2E3436.toInt(), icon = "ic_calories", isEnabled = true), + MeasurementType(key = MeasurementTypeKey.COMMENT, inputType = InputFieldType.TEXT, unit = UnitType.NONE, color = 0xFF729FCF.toInt(), icon = "ic_comment", isPinned = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.DATE, inputType = InputFieldType.DATE, unit = UnitType.NONE, color = 0xFFA40000.toInt(), icon = "ic_date", isEnabled = true), + MeasurementType(key = MeasurementTypeKey.TIME, inputType = InputFieldType.TIME, unit = UnitType.NONE, color = 0xFF73D216.toInt(), icon = "ic_time", isEnabled = true) + ) +} + +/** + * The main entry point of the application. + * This activity hosts the Jetpack Compose UI and initializes essential components + * like the database, repositories, and ViewModels. + */ +class MainActivity : ComponentActivity() { + companion object { + private const val TAG = "MainActivity" + } + + private lateinit var userSettingsRepository: UserSettingsRepository // Machen Sie es zur Property + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + userSettingsRepository = provideUserSettingsRepository(applicationContext) + + // --- LogManager initializing --- + lifecycleScope.launch { + val isFileLoggingEnabled = userSettingsRepository.isFileLoggingEnabled.first() + LogManager.init(applicationContext, isFileLoggingEnabled) + LogManager.d(TAG, "LogManager initialized. File logging enabled: $isFileLoggingEnabled") + } + + // --- Language initializing --- + lifecycleScope.launch { + userSettingsRepository.appLanguageCode.collectLatest { languageCode -> + val currentActivityLocale = resources.configuration.locales.get(0).language + val targetLanguage = languageCode ?: SupportedLanguage.getDefault().code + + LogManager.d(TAG, "Observed language code: $languageCode, Current activity locale: $currentActivityLocale, Target: $targetLanguage") + + if (currentActivityLocale != targetLanguage) { + LogManager.i(TAG, "Language changed or first load. Applying locale: $targetLanguage and recreating activity.") + LanguageUtil.updateAppLocale(this@MainActivity, targetLanguage) + + if (!isFinishing) { + recreate() + } + } else { + if (!isFinishing && !isChangingConfigurations) { + initializeAndSetContent() + } + } + } + } + } + + private fun initializeAndSetContent() { + val db = AppDatabase.getInstance(applicationContext) + val databaseRepository = DatabaseRepository( + database = db, + userDao = db.userDao(), + measurementDao = db.measurementDao(), + measurementValueDao = db.measurementValueDao(), + measurementTypeDao = db.measurementTypeDao() + ) + + // --- Measurement Types initializing --- + CoroutineScope(Dispatchers.IO).launch { + val isActuallyFirstStart = userSettingsRepository.isFirstAppStart.first() + LogManager.d(TAG, "Checking for first app start. isFirstAppStart: $isActuallyFirstStart") + if (isActuallyFirstStart) { + LogManager.i(TAG, "First app start detected. Inserting default measurement types...") + val defaultTypesToInsert = getDefaultMeasurementTypes(this@MainActivity) + db.measurementTypeDao().insertAll(defaultTypesToInsert) + userSettingsRepository.setFirstAppStartCompleted(false) + LogManager.i(TAG, "Default measurement types inserted and first start marked as completed.") + } else { + LogManager.d(TAG, "Not the first app start. Default data should already exist.") + } + } + + enableEdgeToEdge() + + setContent { + OpenScaleTheme { + val sharedViewModel: SharedViewModel = viewModel( + factory = provideSharedViewModelFactory(databaseRepository, userSettingsRepository) + ) + + val view = LocalView.current + if (!view.isInEditMode) { + DisposableEffect(Unit) { + val window = this@MainActivity.window + val insetsController = WindowCompat.getInsetsController(window, view) + insetsController.isAppearanceLightStatusBars = false + insetsController.isAppearanceLightNavigationBars = false + onDispose { } + } + } + AppNavigation(sharedViewModel) + } + } + } +} + +/** + * Provides a [ViewModelProvider.Factory] for creating [SharedViewModel] instances. + * This allows for dependency injection into the ViewModel. + * + * @param databaseRepository The repository for accessing database operations. + * @param userSettingsRepository The repository for accessing user preferences. + * @return A [ViewModelProvider.Factory] for [SharedViewModel]. + */ +private fun provideSharedViewModelFactory( + databaseRepository: DatabaseRepository, + userSettingsRepository: UserSettingsRepository +): ViewModelProvider.Factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(SharedViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return SharedViewModel(databaseRepository, userSettingsRepository) as T + } + throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") + } +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/Application.java b/android_app/app/src/main/java/com/health/openscale/core/Application.java deleted file mode 100644 index cb006941..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/Application.java +++ /dev/null @@ -1,48 +0,0 @@ -/* Copyright (C) 2018 Erik Johansson - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - -package com.health.openscale.core; - -import com.health.openscale.BuildConfig; - -import timber.log.Timber; - -public class Application extends android.app.Application { - OpenScale openScale; - - private class TimberLogAdapter extends Timber.DebugTree { - @Override - protected boolean isLoggable(String tag, int priority) { - if (BuildConfig.DEBUG || OpenScale.DEBUG_MODE) { - return super.isLoggable(tag, priority); - } - return false; - } - } - - @Override - public void onCreate() { - super.onCreate(); - - Timber.plant(new TimberLogAdapter()); - - // Create OpenScale instance - OpenScale.createInstance(getApplicationContext()); - - // Hold on to the instance for as long as the application exists - openScale = OpenScale.getInstance(); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/OpenScale.java b/android_app/app/src/main/java/com/health/openscale/core/OpenScale.java deleted file mode 100644 index 581d2f2b..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/OpenScale.java +++ /dev/null @@ -1,774 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core; - -import android.appwidget.AppWidgetManager; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabaseCorruptException; -import android.net.Uri; -import android.os.Handler; -import android.os.Looper; -import android.preference.PreferenceManager; -import android.provider.OpenableColumns; -import android.text.format.DateFormat; -import android.widget.Toast; - -import androidx.core.content.ContextCompat; -import androidx.lifecycle.LiveData; -import androidx.room.Room; -import androidx.room.RoomDatabase; -import androidx.sqlite.db.SupportSQLiteDatabase; - -import com.health.openscale.R; -import com.health.openscale.core.alarm.AlarmHandler; -import com.health.openscale.core.bluetooth.BluetoothCommunication; -import com.health.openscale.core.bluetooth.BluetoothFactory; -import com.health.openscale.core.bodymetric.EstimatedFatMetric; -import com.health.openscale.core.bodymetric.EstimatedLBMMetric; -import com.health.openscale.core.bodymetric.EstimatedWaterMetric; -import com.health.openscale.core.database.AppDatabase; -import com.health.openscale.core.database.ScaleMeasurementDAO; -import com.health.openscale.core.database.ScaleUserDAO; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; -import com.health.openscale.core.utils.CsvHelper; -import com.health.openscale.gui.measurement.FatMeasurementView; -import com.health.openscale.gui.measurement.LBMMeasurementView; -import com.health.openscale.gui.measurement.MeasurementViewSettings; -import com.health.openscale.gui.measurement.WaterMeasurementView; -import com.health.openscale.gui.widget.WidgetProvider; - -import java.io.BufferedReader; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.text.ParseException; -import java.util.Calendar; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.TreeMap; - -import timber.log.Timber; - -public class OpenScale { - public static boolean DEBUG_MODE = false; - - public static final String DATABASE_NAME = "openScale.db"; - public static final float SMART_USER_ASSIGN_DEFAULT_RANGE = 15.0F; - - private static OpenScale instance; - - private AppDatabase appDB; - private ScaleMeasurementDAO measurementDAO; - private ScaleUserDAO userDAO; - - private ScaleUser selectedScaleUser; - - private BluetoothCommunication btDeviceDriver; - private AlarmHandler alarmHandler; - - private Context context; - - private OpenScale(Context context) { - this.context = context; - alarmHandler = new AlarmHandler(); - btDeviceDriver = null; - - reopenDatabase(false); - } - - public static void createInstance(Context context) { - if (instance != null) { - return; - } - - instance = new OpenScale(context); - } - - public static OpenScale getInstance() { - if (instance == null) { - throw new RuntimeException("No OpenScale instance created"); - } - - return instance; - } - - public void reopenDatabase(boolean truncate) throws SQLiteDatabaseCorruptException { - if (appDB != null) { - appDB.close(); - } - - appDB = Room.databaseBuilder(context, AppDatabase.class, DATABASE_NAME) - .allowMainThreadQueries() - .setJournalMode(truncate == true ? RoomDatabase.JournalMode.TRUNCATE : RoomDatabase.JournalMode.AUTOMATIC) // in truncate mode no sql cache files (-shm, -wal) are generated - .addCallback(new RoomDatabase.Callback() { - @Override - public void onOpen(SupportSQLiteDatabase db) { - super.onOpen(db); - db.setForeignKeyConstraintsEnabled(true); - } - }) - .addMigrations(AppDatabase.MIGRATION_1_2, AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, AppDatabase.MIGRATION_5_6) - .build(); - measurementDAO = appDB.measurementDAO(); - userDAO = appDB.userDAO(); - } - - public void triggerWidgetUpdate() { - int[] ids = AppWidgetManager.getInstance(context).getAppWidgetIds( - new ComponentName(context, WidgetProvider.class)); - if (ids.length > 0) { - Intent intent = new Intent(context, WidgetProvider.class); - intent.setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE); - intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids); - context.sendBroadcast(intent); - } - } - - public int addScaleUser(final ScaleUser user) { - return (int)userDAO.insert(user); - } - - public void selectScaleUser(int userId) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - prefs.edit().putInt("selectedUserId", userId).apply(); - - selectedScaleUser = getScaleUser(userId); - } - - public int getSelectedScaleUserId() { - if (selectedScaleUser != null) { - return selectedScaleUser.getId(); - } - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - return prefs.getInt("selectedUserId", -1); - } - - public List getScaleUserList() { - return userDAO.getAll(); - } - - public ScaleUser getScaleUser(int userId) { - if (selectedScaleUser != null && selectedScaleUser.getId() == userId) { - return selectedScaleUser; - } - return userDAO.get(userId); - } - - public ScaleUser getSelectedScaleUser() { - if (selectedScaleUser != null) { - return selectedScaleUser; - } - - try { - final int selectedUserId = getSelectedScaleUserId(); - if (selectedUserId != -1) { - selectedScaleUser = userDAO.get(selectedUserId); - if (selectedScaleUser == null) { - selectScaleUser(-1); - throw new Exception("could not find the selected user"); - } - - return selectedScaleUser; - } - } catch (Exception e) { - Timber.e(e); - runUiToastMsg("Error: " + e.getMessage()); - } - - return new ScaleUser(); - } - - public void deleteScaleUser(int id) { - Timber.d("Delete user " + getScaleUser(id)); - userDAO.delete(userDAO.get(id)); - selectedScaleUser = null; - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - // Remove user specific settings - SharedPreferences.Editor editor = prefs.edit(); - final String prefix = ScaleUser.getPreferenceKey(id, ""); - for (String key : prefs.getAll().keySet()) { - if (key.startsWith(prefix)) { - editor.remove(key); - } - } - editor.apply(); - } - - public void updateScaleUser(ScaleUser user) { - userDAO.update(user); - selectedScaleUser = null; - } - - public boolean isScaleMeasurementListEmpty() { - if (measurementDAO.getCount(getSelectedScaleUserId()) == 0) { - return true; - } - - return false; - } - - public ScaleMeasurement getLastScaleMeasurement() { - return measurementDAO.getLatest(getSelectedScaleUserId()); - } - - public ScaleMeasurement getLastScaleMeasurement(int userId) { - return measurementDAO.getLatest(userId); - } - - public ScaleMeasurement getFirstScaleMeasurement() { - return measurementDAO.getFirst(getSelectedScaleUserId()); - } - - public List getScaleMeasurementList() { - return measurementDAO.getAll(getSelectedScaleUserId()); - } - - public ScaleMeasurement[] getTupleOfScaleMeasurement(int id) - { - ScaleMeasurement[] tupleScaleMeasurement = new ScaleMeasurement[3]; - - tupleScaleMeasurement[0] = null; - tupleScaleMeasurement[1] = measurementDAO.get(id); - tupleScaleMeasurement[2] = null; - - if (tupleScaleMeasurement[1] != null) { - tupleScaleMeasurement[0] = measurementDAO.getPrevious(id, tupleScaleMeasurement[1].getUserId()); - tupleScaleMeasurement[2] = measurementDAO.getNext(id, tupleScaleMeasurement[1].getUserId()); - } - - return tupleScaleMeasurement; - } - - public int addScaleMeasurement(final ScaleMeasurement scaleMeasurement) { - return addScaleMeasurement(scaleMeasurement, false); - } - - public int addScaleMeasurement(final ScaleMeasurement scaleMeasurement, boolean silent) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - // Check user id and do a smart user assign if option is enabled - if (scaleMeasurement.getUserId() == -1) { - scaleMeasurement.setUserId(getAssignableUser(scaleMeasurement.getWeight())); - - // don't add scale data if no user is selected - if (scaleMeasurement.getUserId() == -1) { - Timber.e("to be added measurement are thrown away because no user is selected"); - return -1; - } - } - - // Assisted weighing - if (getScaleUser(scaleMeasurement.getUserId()).isAssistedWeighing()) { - int assistedWeighingRefUserId = prefs.getInt("assistedWeighingRefUserId", -1); - if (assistedWeighingRefUserId != -1) { - ScaleMeasurement lastRefScaleMeasurement = getLastScaleMeasurement(assistedWeighingRefUserId); - - if (lastRefScaleMeasurement != null) { - float refWeight = lastRefScaleMeasurement.getWeight(); - float diffToRef = scaleMeasurement.getWeight() - refWeight; - scaleMeasurement.setWeight(diffToRef); - } - } else { - Timber.e("assisted weighing reference user id is -1"); - } - } - - // Calculate the amputation correction factor for the weight, if available - scaleMeasurement.setWeight((scaleMeasurement.getWeight() * 100.0f) / getScaleUser(scaleMeasurement.getUserId()).getAmputationCorrectionFactor()); - - // If option is enabled then calculate body measurements from generic formulas - MeasurementViewSettings settings = new MeasurementViewSettings(prefs, WaterMeasurementView.KEY); - if (settings.isEnabled() && settings.isEstimationEnabled()) { - EstimatedWaterMetric waterMetric = EstimatedWaterMetric.getEstimatedMetric( - EstimatedWaterMetric.FORMULA.valueOf(settings.getEstimationFormula())); - scaleMeasurement.setWater(waterMetric.getWater(getScaleUser(scaleMeasurement.getUserId()), scaleMeasurement)); - } - - settings = new MeasurementViewSettings(prefs, FatMeasurementView.KEY); - if (settings.isEnabled() && settings.isEstimationEnabled()) { - EstimatedFatMetric fatMetric = EstimatedFatMetric.getEstimatedMetric( - EstimatedFatMetric.FORMULA.valueOf(settings.getEstimationFormula())); - scaleMeasurement.setFat(fatMetric.getFat(getScaleUser(scaleMeasurement.getUserId()), scaleMeasurement)); - } - - // Must be after fat estimation as one formula is based on fat - settings = new MeasurementViewSettings(prefs, LBMMeasurementView.KEY); - if (settings.isEnabled() && settings.isEstimationEnabled()) { - EstimatedLBMMetric lbmMetric = EstimatedLBMMetric.getEstimatedMetric( - EstimatedLBMMetric.FORMULA.valueOf(settings.getEstimationFormula())); - scaleMeasurement.setLbm(lbmMetric.getLBM(getScaleUser(scaleMeasurement.getUserId()), scaleMeasurement)); - } - - // Insert measurement into the database, check return if it was successful inserted - if (measurementDAO.insert(scaleMeasurement) != -1) { - Timber.d("Added measurement: %s", scaleMeasurement); - if (!silent) { - ScaleUser scaleUser = getScaleUser(scaleMeasurement.getUserId()); - - final java.text.DateFormat dateFormat = DateFormat.getDateFormat(context); - final java.text.DateFormat timeFormat = DateFormat.getTimeFormat(context); - final Date dateTime = scaleMeasurement.getDateTime(); - - final Converters.WeightUnit unit = scaleUser.getScaleUnit(); - - String infoText = String.format(context.getString(R.string.info_new_data_added), - Converters.fromKilogram(scaleMeasurement.getWeight(), unit), unit.toString(), - dateFormat.format(dateTime) + " " + timeFormat.format(dateTime), - scaleUser.getUserName()); - runUiToastMsg(infoText); - } - - syncInsertMeasurement(scaleMeasurement, "com.health.openscale.sync"); - syncInsertMeasurement(scaleMeasurement, "com.health.openscale.sync.oss"); - alarmHandler.entryChanged(context, scaleMeasurement); - triggerWidgetUpdate(); - } else { - Timber.d("to be added measurement is thrown away because measurement with the same date and time already exist"); - if (!silent) { - runUiToastMsg(context.getString(R.string.info_new_data_duplicated)); - } - } - - return scaleMeasurement.getUserId(); - } - - - public int getAssignableUser(float weight){ - // Not the best function name - // Returns smart user assignment, if options allow it - // Otherwise it returns the selected user - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - if (prefs.getBoolean("smartUserAssign", false)) { - return getSmartUserAssignment(weight, SMART_USER_ASSIGN_DEFAULT_RANGE); - } else { - return getSelectedScaleUser().getId(); - } - } - - private int getSmartUserAssignment(float weight, float range) { - List scaleUsers = getScaleUserList(); - Map inRangeWeights = new TreeMap<>(); - - for (int i = 0; i < scaleUsers.size(); i++) { - List scaleUserData = measurementDAO.getAll(scaleUsers.get(i).getId()); - - float lastWeight; - - if (scaleUserData.size() > 0) { - lastWeight = scaleUserData.get(0).getWeight(); - } else { - lastWeight = scaleUsers.get(i).getInitialWeight(); - } - - if ((lastWeight - range) <= weight && (lastWeight + range) >= weight) { - inRangeWeights.put(Math.abs(lastWeight - weight), scaleUsers.get(i).getId()); - } - } - - if (inRangeWeights.size() > 0) { - // return the user id which is nearest to the weight (first element of the tree map) - int userId = inRangeWeights.entrySet().iterator().next().getValue(); - Timber.d("assign measurement to the nearest measurement with the user " + getScaleUser(userId).getUserName() + " (smartUserAssignment=on)"); - return userId; - } - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - // if ignore out of range preference is true don't add this data - if (prefs.getBoolean("ignoreOutOfRange", false)) { - Timber.d("to be added measurement is thrown away because measurement is out of range (smartUserAssignment=on;ignoreOutOfRange=on)"); - return -1; - } - - // return selected scale user id if not out of range preference is checked and weight is out of range of any user - Timber.d("assign measurement to the selected user (smartUserAssignment=on;ignoreOutOfRange=off)"); - return getSelectedScaleUser().getId(); - } - - public void updateScaleMeasurement(ScaleMeasurement scaleMeasurement) { - Timber.d("Update measurement: %s", scaleMeasurement); - measurementDAO.update(scaleMeasurement); - alarmHandler.entryChanged(context, scaleMeasurement); - syncUpdateMeasurement(scaleMeasurement, "com.health.openscale.sync"); - syncUpdateMeasurement(scaleMeasurement, "com.health.openscale.sync.oss"); - - triggerWidgetUpdate(); - } - - public void deleteScaleMeasurement(int id) { - syncDeleteMeasurement(measurementDAO.get(id).getDateTime(), "com.health.openscale.sync"); - syncDeleteMeasurement(measurementDAO.get(id).getDateTime(), "com.health.openscale.sync.oss"); - - measurementDAO.delete(id); - } - - public String getFilenameFromUriMayThrow(Uri uri) { - Cursor cursor = context.getContentResolver().query( - uri, null, null, null, null); - try { - cursor.moveToFirst(); - return cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)); - } - finally { - if (cursor != null) { - cursor.close(); - } - } - } - - public String getFilenameFromUri(Uri uri) { - try { - return getFilenameFromUriMayThrow(uri); - } - catch (Exception e) { - String name = uri.getLastPathSegment(); - if (name != null) { - return name; - } - name = uri.getPath(); - if (name != null) { - return name; - } - return uri.toString(); - } - } - - public void importDatabase(Uri importFile) throws IOException { - File exportFile = context.getApplicationContext().getDatabasePath("openScale.db"); - File tmpExportFile = context.getApplicationContext().getDatabasePath("openScale_tmp.db"); - - try { - if (appDB != null) { - appDB.close(); - } - - copyFile(Uri.fromFile(exportFile), Uri.fromFile(tmpExportFile)); - copyFile(importFile, Uri.fromFile(exportFile)); - - reopenDatabase(false); - - getScaleUserList().get(0); // call it to test if the imported database works otherwise a runtime exception is thrown - - if (!getScaleUserList().isEmpty()) { - selectScaleUser(getScaleUserList().get(0).getId()); - } - } catch (RuntimeException e) { - Timber.d("import database corrupted, restore old database"); - File restoreExportFile = context.getApplicationContext().getDatabasePath("openScale_restore.db"); - copyFile(Uri.fromFile(tmpExportFile), Uri.fromFile(restoreExportFile)); - importDatabase(Uri.fromFile(restoreExportFile)); - restoreExportFile.delete(); - throw new IOException(e.getMessage()); - } finally { - tmpExportFile.delete(); - } - } - - public void exportDatabase(Uri exportFile) throws IOException { - File dbFile = context.getApplicationContext().getDatabasePath("openScale.db"); - reopenDatabase(true); // re-open database without caching sql -shm, -wal files - - copyFile(Uri.fromFile(dbFile), exportFile); - } - - private void copyFile(Uri src, Uri dst) throws IOException { - InputStream input = context.getContentResolver().openInputStream(src); - OutputStream output = context.getContentResolver().openOutputStream(dst); - - try { - byte[] bytes = new byte[4096]; - int count; - - while ((count = input.read(bytes)) != -1){ - output.write(bytes, 0, count); - } - } finally { - if (input != null) { - input.close(); - } - if (output != null) { - output.flush(); - output.close(); - } - } - } - - public void importData(Uri uri) { - try { - final String filename = getFilenameFromUri(uri); - - InputStream input = context.getContentResolver().openInputStream(uri); - List csvScaleMeasurementList = - CsvHelper.importFrom(new BufferedReader(new InputStreamReader(input))); - - final int userId = getSelectedScaleUser().getId(); - for (ScaleMeasurement measurement : csvScaleMeasurementList) { - measurement.setUserId(userId); - } - - measurementDAO.insertAll(csvScaleMeasurementList); - runUiToastMsg(context.getString(R.string.info_data_imported) + " " + filename); - } catch (IOException | ParseException e) { - runUiToastMsg(context.getString(R.string.error_importing) + ": " + e.getMessage()); - } - } - - public boolean exportData(Uri uri) { - try { - List scaleMeasurementList = getScaleMeasurementList(); - OutputStream output = context.getContentResolver().openOutputStream(uri); - CsvHelper.exportTo(new OutputStreamWriter(output), scaleMeasurementList); - return true; - } catch (IOException e) { - runUiToastMsg(context.getResources().getString(R.string.error_exporting) + " " + e.getMessage()); - } - - return false; - } - - public void clearScaleMeasurements(int userId) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - prefs.edit().putInt("uniqueNumber", 0x00).apply(); - syncClearMeasurements("com.health.openscale.sync"); - syncClearMeasurements("com.health.openscale.sync.oss"); - - measurementDAO.deleteAll(userId); - } - - public int[] getCountsOfMonth(int year) { - int selectedUserId = getSelectedScaleUserId(); - - int [] numOfMonth = new int[12]; - - Calendar startCalender = Calendar.getInstance(); - Calendar endCalender = Calendar.getInstance(); - - for (int i=0; i<12; i++) { - startCalender.set(year, i, 1, 0, 0, 0); - endCalender.set(year, i, 1, 0, 0, 0); - endCalender.add(Calendar.MONTH, 1); - - numOfMonth[i] = measurementDAO.getAllInRange(startCalender.getTime(), endCalender.getTime(), selectedUserId).size(); - } - - return numOfMonth; - } - - public List getScaleMeasurementOfStartDate(int year, int month, int day) { - int selectedUserId = getSelectedScaleUserId(); - - Calendar startCalender = Calendar.getInstance(); - Calendar endCalender = Calendar.getInstance(); - - startCalender.set(year, month, day, 0, 0, 0); - - return measurementDAO.getAllInRange(startCalender.getTime(), endCalender.getTime(), selectedUserId); - } - - public List getScaleMeasurementOfRangeDates(int startYear, int startMonth, int startDay, int endYear, int endMonth, int endDay) { - int selectedUserId = getSelectedScaleUserId(); - - Calendar startCalender = Calendar.getInstance(); - Calendar endCalender = Calendar.getInstance(); - - startCalender.set(startYear, startMonth, startDay, 0, 0, 0); - endCalender.set(endYear, endMonth, endDay, 0, 0, 0); - endCalender.add(Calendar.DAY_OF_MONTH, 1); - - return measurementDAO.getAllInRange(startCalender.getTime(), endCalender.getTime(), selectedUserId); - } - - public List getScaleMeasurementOfDay(int year, int month, int day) { - int selectedUserId = getSelectedScaleUserId(); - - Calendar startCalender = Calendar.getInstance(); - Calendar endCalender = Calendar.getInstance(); - - startCalender.set(year, month, day, 0, 0, 0); - endCalender.set(year, month, day, 0, 0, 0); - endCalender.add(Calendar.DAY_OF_MONTH, 1); - - return measurementDAO.getAllInRange(startCalender.getTime(), endCalender.getTime(), selectedUserId); - } - - public List getScaleMeasurementOfMonth(int year, int month) { - int selectedUserId = getSelectedScaleUserId(); - - Calendar startCalender = Calendar.getInstance(); - Calendar endCalender = Calendar.getInstance(); - - startCalender.set(year, month, 1, 0, 0, 0); - endCalender.set(year, month, 1, 0, 0, 0); - endCalender.add(Calendar.MONTH, 1); - - return measurementDAO.getAllInRange(startCalender.getTime(), endCalender.getTime(), selectedUserId); - } - - public List getScaleMeasurementOfYear(int year) { - int selectedUserId = getSelectedScaleUserId(); - - Calendar startCalender = Calendar.getInstance(); - Calendar endCalender = Calendar.getInstance(); - - startCalender.set(year, Calendar.JANUARY, 1, 0, 0, 0); - endCalender.set(year+1, Calendar.JANUARY, 1, 0, 0, 0); - - return measurementDAO.getAllInRange(startCalender.getTime(), endCalender.getTime(), selectedUserId); - } - - public void connectToBluetoothDeviceDebugMode(String hwAddress, Handler callbackBtHandler) { - Timber.d("Trying to connect to bluetooth device [%s] in debug mode", hwAddress); - - disconnectFromBluetoothDevice(); - - btDeviceDriver = BluetoothFactory.createDebugDriver(context); - btDeviceDriver.registerCallbackHandler(callbackBtHandler); - btDeviceDriver.connect(hwAddress); - } - - public boolean connectToBluetoothDevice(String deviceName, String hwAddress, Handler callbackBtHandler) { - Timber.d("Trying to connect to bluetooth device [%s] (%s)", hwAddress, deviceName); - - disconnectFromBluetoothDevice(); - - btDeviceDriver = BluetoothFactory.createDeviceDriver(context, deviceName); - if (btDeviceDriver == null) { - return false; - } - - btDeviceDriver.registerCallbackHandler(callbackBtHandler); - btDeviceDriver.connect(hwAddress); - - return true; - } - - public boolean disconnectFromBluetoothDevice() { - if (btDeviceDriver == null) { - return false; - } - - Timber.d("Disconnecting from bluetooth device"); - btDeviceDriver.disconnect(); - btDeviceDriver = null; - - return true; - } - - public boolean setBluetoothDeviceUserIndex(int appUserId, int scaleUserIndex, Handler uiHandler) { - if (btDeviceDriver == null) { - return false; - } - btDeviceDriver.selectScaleUserIndexForAppUserId(appUserId, scaleUserIndex, uiHandler); - return true; - } - - public boolean setBluetoothDeviceUserConsent(int appUserId, int scaleUserConsent, Handler uiHandler) { - if (btDeviceDriver == null) { - return false; - } - btDeviceDriver.setScaleUserConsent(appUserId, scaleUserConsent, uiHandler); - return true; - } - - public LiveData> getScaleMeasurementsLiveData() { - int selectedUserId = getSelectedScaleUserId(); - - return measurementDAO.getAllAsLiveData(selectedUserId); - } - - // As getScaleUserList(), but as a Cursor for export via a Content Provider. - public Cursor getScaleUserListCursor() { - return userDAO.selectAll(); - } - - // As getScaleMeasurementList(), but as a Cursor for export via a Content Provider. - public Cursor getScaleMeasurementListCursor(long userId) { - return measurementDAO.selectAll(userId); - } - - private void runUiToastMsg(String text) { - Handler handler = new Handler(Looper.getMainLooper()); - handler.post(new Runnable() { - @Override - public void run() { - Toast.makeText(context, text, Toast.LENGTH_SHORT).show(); - } - }); - } - - private void syncInsertMeasurement(ScaleMeasurement scaleMeasurement, String pkgName) { - Intent intent = new Intent(); - intent.setComponent(new ComponentName(pkgName, pkgName + ".core.service.SyncService")); - intent.putExtra("mode", "insert"); - intent.putExtra("userId", scaleMeasurement.getUserId()); - intent.putExtra("weight", scaleMeasurement.getWeight()); - intent.putExtra("fat", scaleMeasurement.getFat()); - intent.putExtra("water", scaleMeasurement.getWater()); - intent.putExtra("muscle", scaleMeasurement.getMuscle()); - intent.putExtra("date", scaleMeasurement.getDateTime().getTime()); - ContextCompat.startForegroundService(context, intent); - } - - private void syncUpdateMeasurement(ScaleMeasurement scaleMeasurement, String pkgName) { - Intent intent = new Intent(); - intent.setComponent(new ComponentName(pkgName, pkgName + ".core.service.SyncService")); - intent.putExtra("mode", "update"); - intent.putExtra("userId", scaleMeasurement.getUserId()); - intent.putExtra("weight", scaleMeasurement.getWeight()); - intent.putExtra("fat", scaleMeasurement.getFat()); - intent.putExtra("water", scaleMeasurement.getWater()); - intent.putExtra("muscle", scaleMeasurement.getMuscle()); - intent.putExtra("date", scaleMeasurement.getDateTime().getTime()); - ContextCompat.startForegroundService(context, intent); - } - - private void syncDeleteMeasurement(Date date, String pkgName) { - Intent intent = new Intent(); - intent.setComponent(new ComponentName(pkgName, pkgName + ".core.service.SyncService")); - intent.putExtra("mode", "delete"); - intent.putExtra("date", date.getTime()); - ContextCompat.startForegroundService(context, intent); - } - - private void syncClearMeasurements(String pkgName) { - Intent intent = new Intent(); - intent.setComponent(new ComponentName(pkgName, pkgName + ".core.service.SyncService")); - intent.putExtra("mode", "clear"); - ContextCompat.startForegroundService(context, intent); - } - - public ScaleMeasurementDAO getScaleMeasurementDAO() { - return measurementDAO; - } - - public ScaleUserDAO getScaleUserDAO() { - return userDAO; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/alarm/AlarmBackupHandler.java b/android_app/app/src/main/java/com/health/openscale/core/alarm/AlarmBackupHandler.java deleted file mode 100644 index d9c64a82..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/alarm/AlarmBackupHandler.java +++ /dev/null @@ -1,127 +0,0 @@ -/* Copyright (C) 2018 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.core.alarm; - -import android.app.AlarmManager; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.net.Uri; -import android.preference.PreferenceManager; - -import androidx.documentfile.provider.DocumentFile; - -import com.health.openscale.core.OpenScale; - -import java.io.IOException; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; - -import timber.log.Timber; - -public class AlarmBackupHandler -{ - public static final String INTENT_EXTRA_BACKUP_ALARM = "alarmBackupIntent"; - private static final int ALARM_NOTIFICATION_ID = 0x02; - - public void scheduleAlarms(Context context) - { - disableAlarm(context); - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - if (prefs.getBoolean("autoBackup", true)) { - - String backupSchedule = prefs.getString("autoBackup_Schedule", "Monthly"); - - long intervalDayMultiplicator = 0; - - switch (backupSchedule) { - case "Daily": - intervalDayMultiplicator = 1; - break; - case "Weekly": - intervalDayMultiplicator = 7; - break; - case "Monthly": - intervalDayMultiplicator = 30; - break; - } - - PendingIntent alarmPendingIntent = getPendingAlarmIntent(context); - AlarmManager alarmMgr = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - alarmMgr.setInexactRepeating(AlarmManager.RTC_WAKEUP, System.currentTimeMillis(), - AlarmManager.INTERVAL_DAY * intervalDayMultiplicator, alarmPendingIntent); - } - } - - private PendingIntent getPendingAlarmIntent(Context context) - { - Intent alarmIntent = new Intent(context, ReminderBootReceiver.class); - alarmIntent.putExtra(INTENT_EXTRA_BACKUP_ALARM, true); - - return PendingIntent.getBroadcast(context, ALARM_NOTIFICATION_ID, alarmIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); - } - - public void disableAlarm(Context context) { - AlarmManager alarmMgr = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - - alarmMgr.cancel(getPendingAlarmIntent(context)); - } - - public void executeBackup(Context context) { - OpenScale openScale = OpenScale.getInstance(); - - String databaseName = "openScale.db"; - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - // Extra check that there is a backupDir saved in settings - String backupDirString = prefs.getString("backupDir", null); - if (backupDirString == null) { - return; - } - - DocumentFile backupDir = DocumentFile.fromTreeUri(context, Uri.parse(backupDirString)); - // Check if it is possible to read and write to auto export dir - // If it is not possible auto backup function will be disabled - if (!backupDir.canRead() || !backupDir.canWrite()) { - SharedPreferences.Editor editor = prefs.edit(); - editor.putBoolean("autoBackup", false); - editor.apply(); - return; - } - - if (!prefs.getBoolean("overwriteBackup", false)) { - SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.US); - databaseName = dateFormat.format(new Date()) + "_" + databaseName; - } - - DocumentFile exportFile = backupDir.findFile(databaseName); - if (exportFile == null) { - exportFile = backupDir.createFile("application/x-sqlite3", databaseName); - } - - try { - openScale.exportDatabase(exportFile.getUri()); - Timber.d("openScale Auto Backup to %s", exportFile); - } catch (IOException e) { - Timber.e(e, "Error while exporting database"); - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/alarm/AlarmEntry.java b/android_app/app/src/main/java/com/health/openscale/core/alarm/AlarmEntry.java deleted file mode 100644 index bbeebcea..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/alarm/AlarmEntry.java +++ /dev/null @@ -1,90 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.core.alarm; - -import java.util.Calendar; - -public class AlarmEntry implements Comparable -{ - private final int dayOfWeek; - private final long timeInMillis; - - public AlarmEntry(int dayOfWeek, long timeInMillis) - { - this.dayOfWeek = dayOfWeek; - this.timeInMillis = timeInMillis; - } - - public int getDayOfWeek() - { - return dayOfWeek; - } - - private long getTimeInMillis() - { - return timeInMillis; - } - - public Calendar getNextTimestamp() - { - // We just want the time *not* the date - Calendar nextAlarmTimestamp = Calendar.getInstance(); - nextAlarmTimestamp.setTimeInMillis(getTimeInMillis()); - - Calendar alarmCal = Calendar.getInstance(); - alarmCal.set(Calendar.HOUR_OF_DAY, nextAlarmTimestamp.get(Calendar.HOUR_OF_DAY)); - alarmCal.set(Calendar.MINUTE, nextAlarmTimestamp.get(Calendar.MINUTE)); - alarmCal.set(Calendar.SECOND, 0); - alarmCal.set(Calendar.DAY_OF_WEEK, getDayOfWeek()); - - // Check we aren't setting it in the past which would trigger it to fire instantly - if (alarmCal.before(Calendar.getInstance())) alarmCal.add(Calendar.DAY_OF_YEAR, 7); - return alarmCal; - } - - @Override - public boolean equals(Object o) - { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - AlarmEntry that = (AlarmEntry) o; - - if (dayOfWeek != that.dayOfWeek) return false; - return timeInMillis == that.timeInMillis; - } - - @Override - public int hashCode() - { - int result = dayOfWeek; - result = 31 * result + (int) (timeInMillis ^ (timeInMillis >>> 32)); - return result; - } - - @Override - public int compareTo(AlarmEntry o) - { - int rc = compare(dayOfWeek, o.dayOfWeek); - if (rc == 0) rc = compare(timeInMillis, o.timeInMillis); - return rc; - } - - private int compare(long x, long y) - { - return (x < y) ? -1 : ((x == y) ? 0 : 1); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/alarm/AlarmEntryReader.java b/android_app/app/src/main/java/com/health/openscale/core/alarm/AlarmEntryReader.java deleted file mode 100644 index 68ffaff5..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/alarm/AlarmEntryReader.java +++ /dev/null @@ -1,106 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.core.alarm; - -import android.content.Context; -import android.content.SharedPreferences; -import android.preference.PreferenceManager; - -import com.health.openscale.R; - -import java.util.Calendar; -import java.util.HashSet; -import java.util.Set; -import java.util.TreeSet; - -import static com.health.openscale.gui.preferences.ReminderPreferences.PREFERENCE_KEY_REMINDER_NOTIFY_TEXT; -import static com.health.openscale.gui.preferences.ReminderPreferences.PREFERENCE_KEY_REMINDER_TIME; -import static com.health.openscale.gui.preferences.ReminderPreferences.PREFERENCE_KEY_REMINDER_WEEKDAYS; - -public class AlarmEntryReader -{ - private Set alarmEntries; - private String alarmNotificationText; - - private AlarmEntryReader(Set alarmEntries, String alarmNotificationText) - { - this.alarmEntries = alarmEntries; - this.alarmNotificationText = alarmNotificationText; - } - - public Set getEntries() - { - return alarmEntries; - } - - public String getNotificationText() - { - return alarmNotificationText; - } - - public static AlarmEntryReader construct(Context context) - { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - Set reminderWeekdays = prefs.getStringSet(PREFERENCE_KEY_REMINDER_WEEKDAYS, new HashSet()); - Long reminderTimeInMillis = prefs.getLong(PREFERENCE_KEY_REMINDER_TIME, System.currentTimeMillis()); - String notifyText = prefs.getString(PREFERENCE_KEY_REMINDER_NOTIFY_TEXT, - context.getResources().getString(R.string.default_value_reminder_notify_text)); - - Set alarms = new TreeSet<>(); - - for (String dayOfWeek : reminderWeekdays) - { - AlarmEntry alarm = getAlarmEntry(dayOfWeek, reminderTimeInMillis); - alarms.add(alarm); - } - - return new AlarmEntryReader(alarms, notifyText); - } - - private static AlarmEntry getAlarmEntry(String dayOfWeek, Long reminderTimeInMillis) - { - AlarmEntry alarmEntry; - switch (dayOfWeek) - { - case "Monday": - alarmEntry = new AlarmEntry(Calendar.MONDAY, reminderTimeInMillis); - break; - case "Tuesday": - alarmEntry = new AlarmEntry(Calendar.TUESDAY, reminderTimeInMillis); - break; - case "Wednesday": - alarmEntry = new AlarmEntry(Calendar.WEDNESDAY, reminderTimeInMillis); - break; - case "Thursday": - alarmEntry = new AlarmEntry(Calendar.THURSDAY, reminderTimeInMillis); - break; - case "Friday": - alarmEntry = new AlarmEntry(Calendar.FRIDAY, reminderTimeInMillis); - break; - case "Saturday": - alarmEntry = new AlarmEntry(Calendar.SATURDAY, reminderTimeInMillis); - break; - default: - case "Sunday": - alarmEntry = new AlarmEntry(Calendar.SUNDAY, reminderTimeInMillis); - break; - } - return alarmEntry; - } - - -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/alarm/AlarmHandler.java b/android_app/app/src/main/java/com/health/openscale/core/alarm/AlarmHandler.java deleted file mode 100644 index 67e5017e..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/alarm/AlarmHandler.java +++ /dev/null @@ -1,195 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.core.alarm; - -import static android.content.Context.NOTIFICATION_SERVICE; - -import android.app.AlarmManager; -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.graphics.BitmapFactory; -import android.os.Build; -import android.service.notification.StatusBarNotification; - -import androidx.core.app.NotificationCompat; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.gui.MainActivity; - -import java.util.Calendar; -import java.util.LinkedList; -import java.util.List; -import java.util.Set; - -import timber.log.Timber; - -public class AlarmHandler -{ - public static final String INTENT_EXTRA_ALARM = "alarmIntent"; - private static final int ALARM_NOTIFICATION_ID = 0x01; - - public void scheduleAlarms(Context context) - { - AlarmEntryReader reader = AlarmEntryReader.construct(context); - Set alarmEntries = reader.getEntries(); - - disableAllAlarms(context); - enableAlarms(context, alarmEntries); - } - - public void entryChanged(Context context, ScaleMeasurement data) - { - long dataMillis = data.getDateTime().getTime(); - - Calendar dataTimestamp = Calendar.getInstance(); - dataTimestamp.setTimeInMillis(dataMillis); - - if (AlarmHandler.isSameDate(dataTimestamp, Calendar.getInstance())) - { - cancelAlarmNotification(context); - cancelAndRescheduleAlarmForNextWeek(context, dataTimestamp); - } - } - - private static boolean isSameDate(Calendar c1, Calendar c2) - { - int[] dateFields = {Calendar.YEAR, Calendar.MONTH, Calendar.DAY_OF_MONTH}; - for (int dateField : dateFields) - { - if (c1.get(dateField) != c2.get(dateField)) return false; - } - return true; - } - - private void enableAlarms(Context context, Set alarmEntries) - { - for (AlarmEntry alarmEntry : alarmEntries) - enableAlarm(context, alarmEntry); - } - - private void enableAlarm(Context context, AlarmEntry alarmEntry) - { - int dayOfWeek = alarmEntry.getDayOfWeek(); - Calendar nextAlarmTimestamp = alarmEntry.getNextTimestamp(); - - setRepeatingAlarm(context, dayOfWeek, nextAlarmTimestamp); - } - - private void setRepeatingAlarm(Context context, int dayOfWeek, Calendar nextAlarmTimestamp) - { - Timber.d("Set repeating alarm for %s", nextAlarmTimestamp.getTime()); - PendingIntent alarmPendingIntent = getPendingAlarmIntent(context, dayOfWeek); - AlarmManager alarmMgr = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - alarmMgr.setInexactRepeating(AlarmManager.RTC_WAKEUP, nextAlarmTimestamp.getTimeInMillis(), - AlarmManager.INTERVAL_DAY * 7, alarmPendingIntent); - } - - private List getWeekdaysPendingAlarmIntent(Context context) - { - final int[] dayOfWeeks = - {Calendar.MONDAY, Calendar.TUESDAY, Calendar.WEDNESDAY, Calendar.THURSDAY, Calendar.FRIDAY, - Calendar.SATURDAY, Calendar.SUNDAY}; - List pendingIntents = new LinkedList<>(); - for (int dayOfWeek : dayOfWeeks) - pendingIntents.add(getPendingAlarmIntent(context, dayOfWeek)); - return pendingIntents; - } - - private PendingIntent getPendingAlarmIntent(Context context, int dayOfWeek) - { - Intent alarmIntent = new Intent(context, ReminderBootReceiver.class); - alarmIntent.putExtra(INTENT_EXTRA_ALARM, true); - - return PendingIntent.getBroadcast(context, dayOfWeek, alarmIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); - } - - public void disableAllAlarms(Context context) - { - AlarmManager alarmMgr = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - List pendingIntents = getWeekdaysPendingAlarmIntent(context); - for (PendingIntent pendingIntent : pendingIntents) - alarmMgr.cancel(pendingIntent); - } - - private void cancelAndRescheduleAlarmForNextWeek(Context context, Calendar timestamp) - { - AlarmEntryReader reader = AlarmEntryReader.construct(context); - Set alarmEntries = reader.getEntries(); - for (AlarmEntry entry : alarmEntries) - { - Calendar nextAlarmTimestamp = entry.getNextTimestamp(); - - if (isSameDate(timestamp, nextAlarmTimestamp)) - { - int dayOfWeek = entry.getDayOfWeek(); - PendingIntent alarmPendingIntent = getPendingAlarmIntent(context, dayOfWeek); - AlarmManager alarmMgr = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - alarmMgr.cancel(alarmPendingIntent); - - nextAlarmTimestamp.add(Calendar.DATE, 7); - setRepeatingAlarm(context, dayOfWeek, nextAlarmTimestamp); - } - } - } - - public void showAlarmNotification(Context context) - { - AlarmEntryReader reader = AlarmEntryReader.construct(context); - String notifyText = reader.getNotificationText(); - - Intent notifyIntent = new Intent(context, MainActivity.class); - - PendingIntent notifyPendingIntent = - PendingIntent.getActivity(context, 0, notifyIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); - - NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(context, "openScale_notify"); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - NotificationChannel channel = new NotificationChannel( - "openScale_notify", - "openScale weight notification", - NotificationManager.IMPORTANCE_DEFAULT); - notificationManager.createNotificationChannel(channel); - } - - Notification notification = mBuilder.setSmallIcon(R.drawable.ic_notification_openscale_monochrome) - .setLargeIcon(BitmapFactory.decodeResource(context.getResources(), R.drawable.ic_launcher_openscale)) - .setContentTitle(context.getString(R.string.app_name)) - .setContentText(notifyText) - .setAutoCancel(true) - .setContentIntent(notifyPendingIntent) - .build(); - - NotificationManager mNotifyMgr = (NotificationManager) context.getSystemService(NOTIFICATION_SERVICE); - mNotifyMgr.notify(ALARM_NOTIFICATION_ID, notification); - } - - private void cancelAlarmNotification(Context context) - { - NotificationManager mNotifyMgr = (NotificationManager) context.getSystemService(NOTIFICATION_SERVICE); - - StatusBarNotification[] activeNotifications = mNotifyMgr.getActiveNotifications(); - for (StatusBarNotification notification : activeNotifications) { - if (notification.getId() == ALARM_NOTIFICATION_ID) mNotifyMgr.cancel(ALARM_NOTIFICATION_ID); - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/alarm/ReminderBootReceiver.java b/android_app/app/src/main/java/com/health/openscale/core/alarm/ReminderBootReceiver.java deleted file mode 100644 index 088edf78..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/alarm/ReminderBootReceiver.java +++ /dev/null @@ -1,55 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.alarm; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; - -public class ReminderBootReceiver extends BroadcastReceiver -{ - @Override - public void onReceive(Context context, Intent intent) - { - if (intent.hasExtra(AlarmHandler.INTENT_EXTRA_ALARM)) handleAlarm(context); - - if (intent.hasExtra(AlarmBackupHandler.INTENT_EXTRA_BACKUP_ALARM)) handleBackupAlarm(context); - - if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) scheduleAlarms(context); - } - - private void handleAlarm(Context context) - { - AlarmHandler alarmHandler = new AlarmHandler(); - alarmHandler.showAlarmNotification(context); - } - - private void handleBackupAlarm(Context context) - { - AlarmBackupHandler alarmBackupHandler = new AlarmBackupHandler(); - alarmBackupHandler.executeBackup(context); - } - - private void scheduleAlarms(Context context) - { - AlarmHandler alarmHandler = new AlarmHandler(); - AlarmBackupHandler alarmBackupHandler = new AlarmBackupHandler(); - - alarmHandler.scheduleAlarms(context); - alarmBackupHandler.scheduleAlarms(context); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothActiveEraBF06.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothActiveEraBF06.java deleted file mode 100644 index 0508bb31..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothActiveEraBF06.java +++ /dev/null @@ -1,326 +0,0 @@ -/* Copyright (C) 2024 olie.xdev -* 2024 Duncan Overbruck -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.bluetooth; - -import android.content.Context; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; - -import java.nio.ByteBuffer; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Calendar; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -import timber.log.Timber; - -/** - * Support for Active Era BS-06 scales - * - * based on reverse-engineered BLE protocol known as `ICBleProtocolVerScaleNew2` from the vendor APP - */ -public class BluetoothActiveEraBF06 extends BluetoothCommunication { - private static final byte MAGIC_BYTE = (byte) 0xAC; - private static final byte DEVICE_TYPE = (byte) 0x27; - - private final UUID MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0xffb0); - private final UUID WRITE_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xffb1); - private final UUID NOTIFICATION_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xffb2); - - private boolean weightStabilized = false; - private float stableWeightKg = 0.0f; - - private boolean isSupportPH = false; - private boolean isSupportHR = false; - - private boolean balanceStabilized = false; - private float stableBalanceL = 0.0f; - - private double impedance = 0.0f; - - private ScaleMeasurement scaleData; - - public BluetoothActiveEraBF06(Context context) { - super(context); - } - - private byte[] getConfigurationPacket() { - // current time - long now = Instant.now().toEpochMilli() / 1000; - byte[] time = Converters.toInt32Be(now); - - final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser(); - int height = (int) Math.ceil(selectedUser.getBodyHeight()); - int age = selectedUser.getAge(); - int gender = selectedUser.getGender() == Converters.Gender.FEMALE ? 0x02 : 0x01; - - int units = 0; // KG - switch(selectedUser.getScaleUnit()) { - case LB: - units = 1; - break; - case ST: - units = 2; - break; - }; - - int initialWeight = (int) Math.ceil(selectedUser.getInitialWeight() * 100); - byte[] initialWeightBytes = Converters.toInt16Be(initialWeight); - - byte[] targetWeightBytes; - float goalWeight = selectedUser.getGoalWeight(); - if (goalWeight > -1) { - int targetWeight = (int) Math.ceil(goalWeight * 100); - targetWeightBytes = Converters.toInt16Be(targetWeight); - } else { - targetWeightBytes = initialWeightBytes; - } - - byte[] configBytes = new byte[]{ - /* 0x00 */ MAGIC_BYTE, - /* 0x01 */ DEVICE_TYPE, - /* 0x02 */ time[0], - /* 0x03 */ time[1], - /* 0x04 */ time[2], - /* 0x05 */ time[3], - /* 0x06 */ 0x04, - /* 0x07 */ (byte)units, - /* 0x08 */ 0x01, // user id ? - /* 0x09 */ (byte)(height & 0xFF), - /* 0x0a */ initialWeightBytes[0], - /* 0x0b */ initialWeightBytes[1], - /* 0x0c */ (byte)(age & 0xFF), - /* 0x0d */ (byte)gender, - /* 0x0e */ targetWeightBytes[0], - /* 0x0f */ targetWeightBytes[1], - /* 0x10 */ 0x03, - /* 0x11 */ 0x00, - /* 0x12 */ (byte)0xd0, - /* 0x13 */ (byte)0x00 // checksum - }; - - return withCorrectCS(configBytes); - } - - private void sendConfigurationPacket() { - byte[] packet = getConfigurationPacket(); - - Timber.d("sending configuration packet: %s", byteInHex(packet)); - writeBytes(MEASUREMENT_SERVICE, WRITE_CHARACTERISTIC, packet); - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - decodePacket(value); - } - - @Override - public String driverName() { - return "Active Era BF-06"; - } - - @Override - protected boolean onNextStep(int stepNr) { - switch (stepNr) { - case 0: - //Tell device to send us measurements - setNotificationOn(MEASUREMENT_SERVICE, NOTIFICATION_CHARACTERISTIC); - - // reset old values - stableWeightKg = 0.0f; - stableBalanceL = 0.0f; - impedance = 0; - weightStabilized = false; - balanceStabilized = false; - scaleData = new ScaleMeasurement(); - - break; - - case 1: - sendConfigurationPacket(); - break; - - case 2: // weighting ... - sendMessage(R.string.info_step_on_scale, 0); - stopMachineState(); - break; - - case 3: // weighted ! measuring balance ... - stopMachineState(); - break; - - case 4: // balanced ! reporting ADC and measuring HR ... - stopMachineState(); - break; - - case 5: // HR measured! Maybe some historical will follow - Timber.i("Measuring all done!"); - - scaleData.setDateTime(Calendar.getInstance().getTime()); - addScaleMeasurement(scaleData); - default: - return false; - } - - return true; - } - - - private void decodePacket(byte[] pkt) { - if (pkt == null) { - return; - } else if (pkt[0] != MAGIC_BYTE) { - Timber.w("Wrong packet MAGIC"); - return; - } else if (pkt.length != 20) { - Timber.w("Wrong packet length %s expected 20", pkt.length); - return; - } - - int packetType = pkt[0x12] & 0xFF; - switch (packetType) { - case 0xD5: // weight measurement - byte flags = pkt[0x02]; - boolean stabilized = isBitSet(flags, 8); - isSupportHR = isBitSet(flags, 2); - isSupportPH = isBitSet(flags, 3); - - float weightKg = (Converters.fromUnsignedInt24Be(pkt, 3) & 0x3FFFF) / 1000.0f; - // TODO: test if it's always in grams ? - if (stabilized && !weightStabilized) { - weightStabilized = true; - stableWeightKg = weightKg; - Timber.i("Measured weight (stable): %.3f", stableWeightKg); - scaleData.setWeight(weightKg); - resumeMachineState(); - } - - break; - - case 0xD0: // balance measuring - byte state = pkt[0x02]; - boolean isFinal = state == 0x01; - - int weightLRaw = Converters.fromUnsignedInt16Be(pkt, 3); - int percentLRaw = Converters.fromUnsignedInt16Be(pkt, 5); - float weightL = (float)weightLRaw / 100.0f; - float percentL = (float)percentLRaw / 10.0f; - - if (isFinal && !balanceStabilized) { - balanceStabilized = true; - stableBalanceL = percentL; - Timber.i("Measured balance (stable): L %.1f R: %.1f [%.2f]", percentL, 100.0f - percentL, weightL); - resumeMachineState(); - } - break; - - case 0xD6: // reporting ADCs - byte number = pkt[0x02]; - if (number == 1) { - double imp = Converters.fromUnsignedInt16Be(pkt, 4); - if (imp >= 1500.0d) { - imp = (((imp - 1000.0d) + ((stableWeightKg * 10.0d) * (-0.4d))) / 0.6d) / 10.0d; - } - impedance = imp; - Timber.i("Measured impedance: %.1f", impedance); - - // calculate BIA using measure weight and impedance - if (impedance > 0.0) { - final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser(); - int height = (int) Math.ceil(selectedUser.getBodyHeight()); - int age = selectedUser.getAge(); - int gender = selectedUser.getGender() == Converters.Gender.FEMALE ? 0 : 1; - - calculateBIA(height, impedance, stableWeightKg, age, gender); - // TODO: report results - } - - } else { - Timber.w("Unsupported number of ADCs: %s", number); - } - - stopMachineState(); - break; - - case 0xD7: // HR measured - int hr = pkt[0x03] & 0xff; - Timber.i("Measured heart rate: %d", hr); - resumeMachineState(); - - break; - - case 0xD8: // historical measurement - parseHistoricalPacket(pkt); - - default: - Timber.w("Unsupported packet [%d]: %s", packetType, byteInHex(pkt)); - } - - } - - private byte[] withCorrectCS(byte[] pkt) { - byte[] fixed = Arrays.copyOf(pkt, pkt.length); - fixed[fixed.length - 1] = sumChecksum(fixed, 2, fixed.length - 3); - return fixed; - } - - /** - * Calculate BIA parameters - * for now, using forumlas from - * paper - * - * TODO: replace with reverse-engineered library version - * - * @param heightCm - * @param impedanceOhm - * @param weightKg - * @param age - in years - * @param gender - 0 - female, 1 - male - */ - private void calculateBIA(int heightCm, double impedanceOhm, float weightKg, int age, int gender) { - // FFM = 0.36(H2/Z) + 0.162H + 0.289W − 0.134A + 4.83G − 6.83 - double fatFreeMass = (0.36d * (Math.pow(heightCm, 2) / impedanceOhm)) - + (0.162d * heightCm) - + (0.289d * weightKg) - - (0.134 * age) - + (4.83 * gender) - - 6.83; - - double fatMass = weightKg - fatFreeMass; - double bodyFat = fatMass / weightKg * 100.0; - Timber.i("FFM: %.2f, FM: %.2f, BF: %.1f%%", fatFreeMass, fatMass, bodyFat); - } - - private void parseHistoricalPacket(byte[] pkt) { - Instant time = Instant.ofEpochSecond(Converters.fromUnsignedInt24Be(pkt, 3)); - float weight = (Converters.fromUnsignedInt24Be(pkt, 0x08) & 0x03FFFF) / 1000.0f; - float weightLeft = Converters.fromUnsignedInt16Be(pkt, 0x0b) / 100.0f; - int hr = pkt[0x0d] & 0xff; - int adc = Converters.fromUnsignedInt16Be(pkt, 0x0f); - Timber.i("Historical measurement: %.3f kg, Weight Left: %.2f kg, HR: %d, ADC: %d", weight, weightLeft, hr, adc); - // TODO: store historical results - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerBF105.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerBF105.java deleted file mode 100644 index 42af8632..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerBF105.java +++ /dev/null @@ -1,153 +0,0 @@ -/* Copyright (C) 2019 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - -/* -* Based on source-code by weliem/blessed-android -*/ - -package com.health.openscale.core.bluetooth; - -import static com.welie.blessed.BluetoothBytesParser.FORMAT_UINT16; -import static com.welie.blessed.BluetoothBytesParser.FORMAT_UINT8; - -import android.content.Context; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.welie.blessed.BluetoothBytesParser; - -import java.util.UUID; - -import timber.log.Timber; - -public class BluetoothBeurerBF105 extends BluetoothStandardWeightProfile { - private static final UUID SERVICE_BF105_CUSTOM = BluetoothGattUuid.fromShortCode(0xffff); - private static final UUID SERVICE_BF105_IMG = BluetoothGattUuid.fromShortCode(0xffc0); - - private static final UUID CHARACTERISTIC_SCALE_SETTINGS = BluetoothGattUuid.fromShortCode(0x0000); - private static final UUID CHARACTERISTIC_USER_LIST = BluetoothGattUuid.fromShortCode(0x0001); - private static final UUID CHARACTERISTIC_INITIALS = BluetoothGattUuid.fromShortCode(0x0002); - private static final UUID CHARACTERISTIC_TARGET_WEIGHT = BluetoothGattUuid.fromShortCode(0x0003); - private static final UUID CHARACTERISTIC_ACTIVITY_LEVEL = BluetoothGattUuid.fromShortCode(0x0004); - private static final UUID CHARACTERISTIC_REFER_WEIGHT_BF = BluetoothGattUuid.fromShortCode(0x000b); - private static final UUID CHARACTERISTIC_BT_MODULE = BluetoothGattUuid.fromShortCode(0x0005); - private static final UUID CHARACTERISTIC_TAKE_MEASUREMENT = BluetoothGattUuid.fromShortCode(0x0006); - private static final UUID CHARACTERISTIC_TAKE_GUEST_MEASUREMENT = BluetoothGattUuid.fromShortCode(0x0007); - private static final UUID CHARACTERISTIC_BEURER_I = BluetoothGattUuid.fromShortCode(0x0008); - private static final UUID CHARACTERISTIC_UPPER_LOWER_BODY = CHARACTERISTIC_BEURER_I; - private static final UUID CHARACTERISTIC_BEURER_II = BluetoothGattUuid.fromShortCode(0x0009); - private static final UUID CHARACTERISTIC_BEURER_III = BluetoothGattUuid.fromShortCode(0x000a); - private static final UUID CHARACTERISTIC_IMG_IDENTIFY = BluetoothGattUuid.fromShortCode(0xffc1); - private static final UUID CHARACTERISTIC_IMG_BLOCK = BluetoothGattUuid.fromShortCode(0xffc2); - - - public BluetoothBeurerBF105(Context context) { - super(context); - } - - @Override - public String driverName() { - return "Beurer BF105/720"; - } - - @Override - protected int getVendorSpecificMaxUserCount() { - return 10; - } - - @Override - protected void writeUserDataToScale() { - writeTargetWeight(); - super.writeUserDataToScale(); - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - if (characteristic.equals(CHARACTERISTIC_USER_LIST)) { - handleVendorSpecificUserList(value); - } - else { - super.onBluetoothNotify(characteristic, value); - } - } - - @Override - protected ScaleMeasurement bodyCompositionMeasurementToScaleMeasurement(byte[] value) { - ScaleMeasurement measurement = super.bodyCompositionMeasurementToScaleMeasurement(value); - float weight = measurement.getWeight(); - if (weight == 0.f && previousMeasurement != null) { - weight = previousMeasurement.getWeight(); - } - if (weight != 0.f) { - float water = Math.round(((measurement.getWater() / weight) * 10000.f))/100.f; - measurement.setWater(water); - } - return measurement; - } - - @Override - protected void setNotifyVendorSpecificUserList() { - if (setNotificationOn(SERVICE_BF105_CUSTOM, CHARACTERISTIC_USER_LIST)) { - Timber.d("setNotifyVendorSpecificUserList() OK"); - } - else { - Timber.d("setNotifyVendorSpecificUserList() FAILED"); - } - } - - @Override - protected synchronized void requestVendorSpecificUserList() { - BluetoothBytesParser parser = new BluetoothBytesParser(); - parser.setIntValue(0, FORMAT_UINT8); - writeBytes(SERVICE_BF105_CUSTOM, CHARACTERISTIC_USER_LIST, - parser.getValue()); - } - - @Override - protected void writeActivityLevel() { - BluetoothBytesParser parser = new BluetoothBytesParser(); - int activityLevel = this.selectedUser.getActivityLevel().toInt() + 1; - Timber.d(String.format("activityLevel: %d", activityLevel)); - parser.setIntValue(activityLevel, FORMAT_UINT8); - writeBytes(SERVICE_BF105_CUSTOM, CHARACTERISTIC_ACTIVITY_LEVEL, - parser.getValue()); - } - - protected void writeTargetWeight() { - BluetoothBytesParser parser = new BluetoothBytesParser(); - int targetWeight = (int) this.selectedUser.getGoalWeight(); - parser.setIntValue(targetWeight, FORMAT_UINT16); - writeBytes(SERVICE_BF105_CUSTOM, CHARACTERISTIC_TARGET_WEIGHT, - parser.getValue()); - } - - @Override - protected void writeInitials() { - BluetoothBytesParser parser = new BluetoothBytesParser(); - String initials = getInitials(this.selectedUser.getUserName()); - Timber.d("Initials: " + initials); - parser.setString(initials); - writeBytes(SERVICE_BF105_CUSTOM, CHARACTERISTIC_INITIALS, - parser.getValue()); - } - - @Override - protected synchronized void requestMeasurement() { - BluetoothBytesParser parser = new BluetoothBytesParser(); - parser.setIntValue(0, FORMAT_UINT8); - writeBytes(SERVICE_BF105_CUSTOM, CHARACTERISTIC_TAKE_MEASUREMENT, - parser.getValue()); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerBF500.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerBF500.java deleted file mode 100644 index 6a5b6236..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerBF500.java +++ /dev/null @@ -1,121 +0,0 @@ -/* Copyright (C) 2019 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - - /* - * Based on source-code by weliem/blessed-android - */ -package com.health.openscale.core.bluetooth; - -import static com.welie.blessed.BluetoothBytesParser.FORMAT_UINT8; - -import android.content.Context; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.utils.Converters; -import com.welie.blessed.BluetoothBytesParser; - -import java.util.UUID; - -import timber.log.Timber; - -public class BluetoothBeurerBF500 extends BluetoothStandardWeightProfile { - private static final UUID SERVICE_BEURER_CUSTOM_BF500 = BluetoothGattUuid.fromShortCode(0xffff); - private static final UUID CHARACTERISTIC_BEURER_BF500_SCALE_SETTING = BluetoothGattUuid.fromShortCode(0xfff0); - private static final UUID CHARACTERISTIC_BEURER_BF500_USER_LIST = BluetoothGattUuid.fromShortCode(0xfff1); - private static final UUID CHARACTERISTIC_BEURER_BF500_ACTIVITY_LEVEL = BluetoothGattUuid.fromShortCode(0xfff2); - private static final UUID CHARACTERISTIC_BEURER_BF500_TAKE_MEASUREMENT = BluetoothGattUuid.fromShortCode(0xfff4); - private static final UUID CHARACTERISTIC_BEURER_BF500_REFER_WEIGHT_BF = BluetoothGattUuid.fromShortCode(0xfff5); - - private String deviceName; - - public BluetoothBeurerBF500(Context context, String name) { - super(context); - deviceName = name; - } - - @Override - public String driverName() { - return "Beurer " + deviceName; - } - - @Override - protected int getVendorSpecificMaxUserCount() { - return 8; - } - - @Override - protected void writeActivityLevel() { - Converters.ActivityLevel al = selectedUser.getActivityLevel(); - BluetoothBytesParser parser = new BluetoothBytesParser(new byte[]{0}); - parser.setIntValue(al.toInt() + 1, FORMAT_UINT8, 0); - Timber.d(String.format("setCurrentUserData Activity level: %d", al.toInt() + 1)); - writeBytes(SERVICE_BEURER_CUSTOM_BF500, - CHARACTERISTIC_BEURER_BF500_ACTIVITY_LEVEL, parser.getValue()); - } - - @Override - protected ScaleMeasurement bodyCompositionMeasurementToScaleMeasurement(byte[] value) { - ScaleMeasurement measurement = super.bodyCompositionMeasurementToScaleMeasurement(value); - float weight = measurement.getWeight(); - if (weight == 0.f && previousMeasurement != null) { - weight = previousMeasurement.getWeight(); - } - if (weight != 0.f) { - float water = Math.round(((measurement.getWater() / weight) * 10000.f))/100.f; - measurement.setWater(water); - } - return measurement; - } - - @Override - protected void requestMeasurement() { - BluetoothBytesParser parser = new BluetoothBytesParser(new byte[]{0}); - parser.setIntValue(0x00, FORMAT_UINT8, 0); - Timber.d(String.format("requestMeasurement BEURER 0xFFF4 magic: 0x00")); - writeBytes(SERVICE_BEURER_CUSTOM_BF500, - CHARACTERISTIC_BEURER_BF500_TAKE_MEASUREMENT, parser.getValue()); - } - - @Override - protected void setNotifyVendorSpecificUserList() { - if (setNotificationOn(SERVICE_BEURER_CUSTOM_BF500, - CHARACTERISTIC_BEURER_BF500_USER_LIST)) { - Timber.d("setNotifyVendorSpecificUserList() OK"); - } - else { - Timber.d("setNotifyVendorSpecificUserList() FAILED"); - } - } - - @Override - protected synchronized void requestVendorSpecificUserList() { - BluetoothBytesParser parser = new BluetoothBytesParser(); - parser.setIntValue(0x00, FORMAT_UINT8); - writeBytes(SERVICE_BEURER_CUSTOM_BF500, CHARACTERISTIC_BEURER_BF500_USER_LIST, - parser.getValue()); - stopMachineState(); - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - if (characteristic.equals(CHARACTERISTIC_BEURER_BF500_USER_LIST)) { - handleVendorSpecificUserList(value); - } - else { - super.onBluetoothNotify(characteristic, value); - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerBF600.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerBF600.java deleted file mode 100644 index 2406bc30..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerBF600.java +++ /dev/null @@ -1,119 +0,0 @@ -/* Copyright (C) 2019 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - - /* - * Based on source-code by weliem/blessed-android - */ -package com.health.openscale.core.bluetooth; - -import static com.welie.blessed.BluetoothBytesParser.FORMAT_UINT8; - -import android.content.Context; - -import com.health.openscale.core.utils.Converters; -import com.welie.blessed.BluetoothBytesParser; - -import java.util.UUID; - -import timber.log.Timber; - -public class BluetoothBeurerBF600 extends BluetoothStandardWeightProfile { - private static final UUID SERVICE_BEURER_CUSTOM_BF600 = BluetoothGattUuid.fromShortCode(0xfff0); - private static final UUID CHARACTERISTIC_BEURER_BF600_SCALE_SETTING = BluetoothGattUuid.fromShortCode(0xfff1); - private static final UUID CHARACTERISTIC_BEURER_BF600_USER_LIST = BluetoothGattUuid.fromShortCode(0xfff2); - private static final UUID CHARACTERISTIC_BEURER_BF600_ACTIVITY_LEVEL = BluetoothGattUuid.fromShortCode(0xfff3); - private static final UUID CHARACTERISTIC_BEURER_BF600_TAKE_MEASUREMENT = BluetoothGattUuid.fromShortCode(0xfff4); - private static final UUID CHARACTERISTIC_BEURER_BF600_REFER_WEIGHT_BF = BluetoothGattUuid.fromShortCode(0xfff5); - private static final UUID CHARACTERISTIC_BEURER_BF850_INITIALS = BluetoothGattUuid.fromShortCode(0xfff6); - - private String deviceName; - - public BluetoothBeurerBF600(Context context, String name) { - super(context); - deviceName = name; - } - - @Override - public String driverName() { - return "Beurer " + deviceName; - } - - @Override - protected int getVendorSpecificMaxUserCount() { - return 8; - } - - @Override - protected void writeActivityLevel() { - Converters.ActivityLevel al = selectedUser.getActivityLevel(); - BluetoothBytesParser parser = new BluetoothBytesParser(new byte[]{0}); - parser.setIntValue(al.toInt() + 1, FORMAT_UINT8, 0); - Timber.d(String.format("setCurrentUserData Activity level: %d", al.toInt() + 1)); - writeBytes(SERVICE_BEURER_CUSTOM_BF600, - CHARACTERISTIC_BEURER_BF600_ACTIVITY_LEVEL, parser.getValue()); - } - - @Override - protected void writeInitials() { - if (haveCharacteristic(SERVICE_BEURER_CUSTOM_BF600, CHARACTERISTIC_BEURER_BF850_INITIALS)) { - BluetoothBytesParser parser = new BluetoothBytesParser(); - String initials = getInitials(this.selectedUser.getUserName()); - Timber.d("Initials: " + initials); - parser.setString(initials); - writeBytes(SERVICE_BEURER_CUSTOM_BF600, CHARACTERISTIC_BEURER_BF850_INITIALS, - parser.getValue()); - } - } - - @Override - protected void requestMeasurement() { - BluetoothBytesParser parser = new BluetoothBytesParser(new byte[]{0}); - parser.setIntValue(0x00, FORMAT_UINT8, 0); - Timber.d(String.format("requestMeasurement BEURER 0xFFF4 magic: 0x00")); - writeBytes(SERVICE_BEURER_CUSTOM_BF600, - CHARACTERISTIC_BEURER_BF600_TAKE_MEASUREMENT, parser.getValue()); - } - - @Override - protected void setNotifyVendorSpecificUserList() { - if (setNotificationOn(SERVICE_BEURER_CUSTOM_BF600, - CHARACTERISTIC_BEURER_BF600_USER_LIST)) { - Timber.d("setNotifyVendorSpecificUserList() OK"); - } - else { - Timber.d("setNotifyVendorSpecificUserList() FAILED"); - } - } - - @Override - protected synchronized void requestVendorSpecificUserList() { - BluetoothBytesParser parser = new BluetoothBytesParser(); - parser.setIntValue(0x00, FORMAT_UINT8); - writeBytes(SERVICE_BEURER_CUSTOM_BF600, CHARACTERISTIC_BEURER_BF600_USER_LIST, - parser.getValue()); - stopMachineState(); - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - if (characteristic.equals(CHARACTERISTIC_BEURER_BF600_USER_LIST)) { - handleVendorSpecificUserList(value); - } - else { - super.onBluetoothNotify(characteristic, value); - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerBF950.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerBF950.java deleted file mode 100644 index 740bf595..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerBF950.java +++ /dev/null @@ -1,48 +0,0 @@ -/* Copyright (C) 2019 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - - /* - * Based on source-code by weliem/blessed-android - */ -package com.health.openscale.core.bluetooth; - -import android.content.Context; - -import timber.log.Timber; - -public class BluetoothBeurerBF950 extends BluetoothBeurerBF105 { - private String deviceName; - - public BluetoothBeurerBF950(Context context, String name) { - super(context); - deviceName = name; - } - - @Override - public String driverName() { - return deviceName; - } - - @Override - protected int getVendorSpecificMaxUserCount() { - return 8; - } - - @Override - protected void writeTargetWeight() { - Timber.d("Target Weight not supported on " + deviceName); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerSanitas.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerSanitas.java deleted file mode 100644 index 44101c3b..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerSanitas.java +++ /dev/null @@ -1,1009 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* 2017 jflesch -* 2017 Martin Nowack -* 2017 linuxlurak with help of Dododappere, see: https://github.com/oliexdev/openScale/issues/111 -* 2018 Erik Johansson -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.core.bluetooth; - -import android.content.Context; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; - -import java.text.Normalizer; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Calendar; -import java.util.Date; -import java.util.Locale; -import java.util.UUID; - -import timber.log.Timber; - -public class BluetoothBeurerSanitas extends BluetoothCommunication { - // < 0 means we are not actually waiting for data - // any value >= 0 means we are waiting for data in that state - private int waitForDataInStep = -1; - - enum DeviceType { BEURER_BF700_800_RT_LIBRA, BEURER_BF710, SANITAS_SBF70_70 } - - private static final UUID CUSTOM_SERVICE_1 = BluetoothGattUuid.fromShortCode(0xffe0); - private static final UUID CUSTOM_CHARACTERISTIC_WEIGHT = BluetoothGattUuid.fromShortCode(0xffe1); - - private final DeviceType deviceType; - private byte startByte; - - private class RemoteUser { - final public long remoteUserId; - final public String name; - final public int year; - - public int localUserId = -1; - public boolean isNew = false; - - RemoteUser(long uid, String name, int year) { - this.remoteUserId = uid; - this.name = name; - this.year = year; - } - } - - private class StoredData { - public byte[] measurementData = null; - public long storedUid = -1; - public long candidateUid = -1; - } - - private ArrayList remoteUsers = new ArrayList<>(); - private RemoteUser currentRemoteUser; - private byte[] measurementData = null; - private StoredData storedMeasurement = new StoredData(); - private boolean readyForData = false; - private boolean dataReceived = false; - - private final int ID_START_NIBBLE_INIT = 6; - private final int ID_START_NIBBLE_CMD = 7; - private final int ID_START_NIBBLE_SET_TIME = 9; - private final int ID_START_NIBBLE_DISCONNECT = 10; - - private final byte CMD_SET_UNIT = (byte)0x4d; - private final byte CMD_SCALE_STATUS = (byte)0x4f; - - private final byte CMD_USER_ADD = (byte)0x31; - private final byte CMD_USER_DELETE = (byte)0x32; - private final byte CMD_USER_LIST = (byte)0x33; - private final byte CMD_USER_INFO = (byte)0x34; - private final byte CMD_USER_UPDATE = (byte)0x35; - private final byte CMD_USER_DETAILS = (byte)0x36; - - private final byte CMD_DO_MEASUREMENT = (byte)0x40; - private final byte CMD_GET_SAVED_MEASUREMENTS = (byte)0x41; - private final byte CMD_SAVED_MEASUREMENT = (byte)0x42; - private final byte CMD_DELETE_SAVED_MEASUREMENTS = (byte)0x43; - - private final byte CMD_GET_UNKNOWN_MEASUREMENTS = (byte)0x46; - private final byte CMD_UNKNOWN_MEASUREMENT_INFO = (byte)0x47; - private final byte CMD_ASSIGN_UNKNOWN_MEASUREMENT = (byte)0x4b; - private final byte CMD_UNKNOWN_MEASUREMENT = (byte)0x4c; - private final byte CMD_DELETE_UNKNOWN_MEASUREMENT = (byte)0x49; - - private final byte CMD_WEIGHT_MEASUREMENT = (byte)0x58; - private final byte CMD_MEASUREMENT = (byte)0x59; - - private final byte CMD_SCALE_ACK = (byte)0xf0; - private final byte CMD_APP_ACK = (byte)0xf1; - - private byte getAlternativeStartByte(int startNibble) { - return (byte) ((startByte & 0xF0) | startNibble); - } - - private long decodeUserId(byte[] data, int offset) { - long high = Converters.fromUnsignedInt32Be(data, offset); - long low = Converters.fromUnsignedInt32Be(data, offset + 4); - return (high << 32) | low; - } - - private byte[] encodeUserId(RemoteUser remoteUser) { - long uid = remoteUser != null ? remoteUser.remoteUserId : 0; - byte[] data = new byte[8]; - Converters.toInt32Be(data, 0, uid >> 32); - Converters.toInt32Be(data, 4, uid & 0xFFFFFFFF); - return data; - } - - private String decodeString(byte[] data, int offset, int maxLength) { - int length = 0; - for (; length < maxLength; ++length) { - if (data[offset + length] == 0) { - break; - } - } - return new String(data, offset, length); - } - - private String normalizeString(String input) { - String normalized = Normalizer.normalize(input, Normalizer.Form.NFD); - return normalized.replaceAll("[^A-Za-z0-9]", ""); - } - - private String convertUserNameToScale(ScaleUser user) { - String normalized = normalizeString(user.getUserName()); - if (normalized.isEmpty()) { - return String.valueOf(user.getId()); - } - return normalized.toUpperCase(Locale.US); - } - - public BluetoothBeurerSanitas(Context context, DeviceType deviceType) { - super(context); - - this.deviceType = deviceType; - switch (deviceType) { - case BEURER_BF700_800_RT_LIBRA: - startByte = (byte) (0xf0 | ID_START_NIBBLE_CMD); - break; - case BEURER_BF710: - case SANITAS_SBF70_70: - startByte = (byte) (0xe0 | ID_START_NIBBLE_CMD); - break; - } - } - - @Override - public String driverName() { - switch (deviceType) { - case BEURER_BF700_800_RT_LIBRA: - return "Beurer BF700/800 / Runtastic Libra"; - case BEURER_BF710: - return "Beurer BF710"; - case SANITAS_SBF70_70: - return "Sanitas SBF70/SilverCrest SBF75/Crane"; - } - - return "Unknown device type"; - } - - @Override - protected boolean onNextStep(int stepNr) { - switch (stepNr) { - case 0: - // Fresh start, so reset everything - measurementData = null; - storedMeasurement.measurementData = null; - readyForData = false; - dataReceived = false; - // Setup notification - setNotificationOn(CUSTOM_SERVICE_1, CUSTOM_CHARACTERISTIC_WEIGHT); - break; - case 1: - // we will be waiting for data in state 1 - waitForDataInStep = 1; - // Say "Hello" to the scale and wait for ack - Timber.d("Sending command: ID_START_NIBBLE_INIT"); - sendAlternativeStartCode(ID_START_NIBBLE_INIT, (byte) 0x01); - stopMachineState(); - break; - case 2: - // Update time on the scale (no ack) - long unixTime = System.currentTimeMillis() / 1000L; - Timber.d("Sending command: ID_START_NIBBLE_SET_TIME"); - sendAlternativeStartCode(ID_START_NIBBLE_SET_TIME, Converters.toInt32Be(unixTime)); - break; - case 3: - // We will be waiting for data in state 3 - waitForDataInStep = 3; - // Request scale status and wait for ack - Timber.d("Sending command: CMD_SCALE_STATUS"); - sendCommand(CMD_SCALE_STATUS, encodeUserId(null)); - stopMachineState(); - break; - case 4: - // We will be waiting for data in state 4 - waitForDataInStep = 4; - // Request list of all users and wait until all have been received - Timber.d("Sending command: CMD_USER_LIST"); - sendCommand(CMD_USER_LIST); - stopMachineState(); - break; - case 5: - // If currentRemoteUser is null, indexOf returns -1 and index will be 0 - int index = remoteUsers.indexOf(currentRemoteUser) + 1; - currentRemoteUser = null; - - // Find the next remote user that exists locally - for (; index < remoteUsers.size(); ++index) { - if (remoteUsers.get(index).localUserId != -1) { - currentRemoteUser = remoteUsers.get(index); - break; - } - } - - // Fetch saved measurements - if (currentRemoteUser != null) { - // We will be waiting for data in state 5 - waitForDataInStep = 5; - Timber.d("Request saved measurements (CMD_GET_SAVED_MEASUREMENTS) for %s", currentRemoteUser.name); - sendCommand(CMD_GET_SAVED_MEASUREMENTS, encodeUserId(currentRemoteUser)); - stopMachineState(); - } - // No user found, just continue to next step. - break; - case 6: - // Create a remote user for selected openScale user if needed - currentRemoteUser = null; - final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser(); - for (RemoteUser remoteUser : remoteUsers) { - if (remoteUser.localUserId == selectedUser.getId()) { - currentRemoteUser = remoteUser; - break; - } - } - if (currentRemoteUser == null) { - waitForDataInStep = 6; - createRemoteUser(selectedUser); - stopMachineState(); - } - // Do not need to create new user, just continue to next step. - break; - case 7: - waitForDataInStep = 7; - Timber.d("Sending command: CMD_USER_DETAILS"); - sendCommand(CMD_USER_DETAILS, encodeUserId(currentRemoteUser)); - stopMachineState(); - break; - case 8: - // If we have unprocessed data available, store it now. - if( storedMeasurement.measurementData != null ) { - Timber.d("Reached state 8 (end) and still have saved data available. Storing now."); - if( currentRemoteUser != null ) { - Timber.i("User has been identified in the meantime, so store the data for them."); - addMeasurement(measurementData, currentRemoteUser.localUserId); - } - else { - Timber.i("User still not identified, so storing the data for the selected user."); - addMeasurement(measurementData, OpenScale.getInstance().getSelectedScaleUser().getId()); - } - storedMeasurement.measurementData = null; - } - else if (!dataReceived && currentRemoteUser != null && !currentRemoteUser.isNew) { - // Looks like we never received a fresh measurement in this run, so request it now. - // Chances are not good that this will work, but let's try it anyway. - waitForDataInStep = 8; - Timber.d("Sending command: CMD_DO_MEASUREMENT"); - sendCommand(CMD_DO_MEASUREMENT, encodeUserId(currentRemoteUser)); - stopMachineState(); - } else { - Timber.d("All finished, nothing to do."); - return false; - } - break; - default: - // Finish init if everything is done - Timber.d("End of state flow reached."); - return false; - } - - return true; - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - byte[] data = value; - if (data == null || data.length == 0) { - Timber.d("Received empty message."); - return; - } - - if (data[0] == getAlternativeStartByte(ID_START_NIBBLE_INIT)) { - // this message should only happen in state 1 - if( waitForDataInStep == 1 ) { - Timber.d("Received init ack (ID_START_NIBBLE_INIT) from scale; scale is ready"); - } - else { - Timber.w("Received init ack (ID_START_NIBBLE_INIT) from scale in wrong state. Scale or app is confused. Continue in state 2."); - jumpNextToStepNr( 2 ); - } - // All data received, no more waiting. - waitForDataInStep = -1; - // On to state 2 - resumeMachineState(); - return; - } - - if (data[0] != startByte) { - Timber.e("Got unknown start byte 0x%02x", data[0]); - return; - } - - try { - switch (data[1]) { - case CMD_USER_INFO: - Timber.d("Received: CMD_USER_INFO"); - processUserInfo(data); - break; - case CMD_SAVED_MEASUREMENT: - Timber.d("Received: CMD_SAVED_MEASUREMENT"); - processSavedMeasurement(data); - break; - case CMD_WEIGHT_MEASUREMENT: - Timber.d("Received: CMD_WEIGHT_MEASUREMENT"); - processWeightMeasurement(data); - break; - case CMD_MEASUREMENT: - Timber.d("Received: CMD_MEASUREMENT"); - processMeasurement(data); - break; - case CMD_SCALE_ACK: - Timber.d("Received: CMD_SCALE_ACK"); - processScaleAck(data); - break; - default: - Timber.d("Unknown command 0x%02x", data[1]); - break; - } - } - catch (IndexOutOfBoundsException|NullPointerException e) { - Timber.e(e); - } - } - - private void processUserInfo(byte[] data) { - final int count = data[2] & 0xFF; - final int current = data[3] & 0xFF; - - if (remoteUsers.size() == current - 1) { - String name = decodeString(data, 12, 3); - int year = 1900 + (data[15] & 0xFF); - - remoteUsers.add(new RemoteUser(decodeUserId(data, 4), name, year)); - - Timber.d("Received user %d/%d: %s (%d)", current, count, name, year); - } - - Timber.d("Sending ack for CMD_USER_INFO"); - sendAck(data); - - if (current != count) { - Timber.d("Not all users received, waiting for more..."); - // More data should be incoming, so make sure we wait - stopMachineState(); - return; - } - - Calendar cal = Calendar.getInstance(); - - for (ScaleUser scaleUser : OpenScale.getInstance().getScaleUserList()) { - final String localName = convertUserNameToScale(scaleUser); - cal.setTime(scaleUser.getBirthday()); - final int year = cal.get(Calendar.YEAR); - - for (RemoteUser remoteUser : remoteUsers) { - if (localName.startsWith(remoteUser.name) && year == remoteUser.year) { - remoteUser.localUserId = scaleUser.getId(); - Timber.d("Remote user %s (0x%x) is local user %s (%d)", - remoteUser.name, remoteUser.remoteUserId, - scaleUser.getUserName(), remoteUser.localUserId); - break; - } - } - } - - if( waitForDataInStep != 4 ) { - Timber.w("Received final CMD_USER_INFO in wrong state..."); - if( waitForDataInStep >= 0 ){ - Timber.w("...while waiting for other data. Retrying last step."); - // We are in the wrong state. - // This may happen, so let's just retry whatever we did before. - jumpBackOneStep(); - } - else { - Timber.w("...ignored, no data expected."); - } - } - // All data received, no more waiting. - waitForDataInStep = -1; - // All users received - resumeMachineState(); - } - - private void processMeasurementData(byte[] data, int offset, boolean firstPart, boolean processingSavedMeasurements) { - if (firstPart) { - if( measurementData != null ) Timber.d("Discarding existing data."); - measurementData = Arrays.copyOfRange(data, offset, data.length); - return; - } - - if( measurementData == null ) { - Timber.w("Received second measurement part without receiving first part before. Discarding data."); - return; - } - - int oldEnd = measurementData.length; - int toCopy = data.length - offset; - - measurementData = Arrays.copyOf(measurementData, oldEnd + toCopy); - System.arraycopy(data, offset, measurementData, oldEnd, toCopy); - - // Store data, but only if we are ready and know the user. Otherwise leave it for later. - if( currentRemoteUser != null && (readyForData || processingSavedMeasurements) ) { - Timber.d("Measurement complete, user identified and app ready: Storing data."); - addMeasurement(measurementData, currentRemoteUser.localUserId); - // Do we have unsaved data? - if( storedMeasurement.measurementData != null ) { - // Does it belong to the current user - if( currentRemoteUser.remoteUserId == storedMeasurement.storedUid ) { - // Does it have the same time stamp? - if( Converters.fromUnsignedInt32Be(measurementData, 0) == Converters.fromUnsignedInt32Be(storedMeasurement.measurementData, 0) ) { - // Then delete the unsaved data because it is already part of the received saved data - Timber.d("Discarding data saved for later, because it is already part of the received saved data from the scale."); - storedMeasurement.measurementData = null; - } - } - } - // Data processed, so discard it. - measurementData = null; - // Also discard saved data, because we got and processed new data - storedMeasurement.measurementData = null; - } - else if( !processingSavedMeasurements ) { - if( !readyForData ) { - Timber.d("New measurement complete, but not stored, because app not ready: Saving data for later."); - } - else { - Timber.d("New measurement complete, but not stored, because user not identified: Saving data for later."); - } - storedMeasurement.measurementData = measurementData; - storedMeasurement.storedUid = storedMeasurement.candidateUid; - } - else { - // How the f*** did we end up here? - Timber.e("Received saved measurement, but do not know for what user. This should not happen. Discarding data."); - measurementData = null; - } - } - - private void processSavedMeasurement(byte[] data) { - int count = data[2] & 0xFF; - int current = data[3] & 0xFF; - Timber.d("Received part %d (of 2) of saved measurement %d of %d.", current % 2 == 1 ? 1 : 2, current / 2, count / 2); - - processMeasurementData(data, 4, current % 2 == 1, true); - - Timber.d("Sending ack for CMD_SAVED_MEASUREMENT"); - sendAck(data); - - if (current != count) { - Timber.d("Not all parts / saved measurements received, waiting for more..."); - // More data should be incoming, so make sure we wait - stopMachineState(); - return; - } - - Timber.i("All saved measurements received."); - - // This message should only be received in step 5 - if( waitForDataInStep != 5 ) { - Timber.w("Received final CMD_SAVED_MEASUREMENT in wrong state..."); - if( waitForDataInStep >= 0 ){ - Timber.w("...while waiting for other data. Retrying last step."); - // We are in the wrong state. - // This may happen, so let's just retry whatever we did before. - jumpBackOneStep(); - resumeMachineState(); - } - else { - Timber.w("...ignored, no data expected."); - } - // Let's not delete data we received unexpectedly, so just get out of here. - return; - } - - // We are done with saved measurements, from now on we can process unrequested measurement data. - readyForData = true; - - Timber.d("Deleting saved measurements (CMD_DELETE_SAVED_MEASUREMENTS) for %s", currentRemoteUser.name); - sendCommand(CMD_DELETE_SAVED_MEASUREMENTS, encodeUserId(currentRemoteUser)); - // We sent a new command, so make sure we wait - stopMachineState(); - - /* Why do we want to resume the state machine, when we are not the last remote user? - * In the moment I do not understand this code, so I'll comment it out but leave it here for reference. - if (currentRemoteUser.remoteUserId != remoteUsers.get(remoteUsers.size() - 1).remoteUserId) { - // Only jump back to state 5 if we are in 5 - if( jumpNextToStepNr( 5, 5 ) ) { - // Now resume - resumeMachineState(); - } - } - */ - } - - private void processWeightMeasurement(byte[] data) { - boolean stableMeasurement = data[2] == 0; - float weight = getKiloGram(data, 3); - - if (!stableMeasurement) { - Timber.d("Active measurement, weight: %.2f", weight); - sendMessage(R.string.info_measuring, weight); - return; - } - - Timber.i("Active measurement, stable weight: %.2f", weight); - } - - private void processMeasurement(byte[] data) { - int count = data[2] & 0xFF; - int current = data[3] & 0xFF; - Timber.d("Received measurement part %d of %d.", current, count ); - - if (current == 1) { - long uid = decodeUserId(data, 5); - Timber.d("Receiving measurement data for remote UID %d.", uid); - // Remember uid in case we need it to save data for later. - storedMeasurement.candidateUid = uid; - // Now search for user - currentRemoteUser = null; - for (RemoteUser remoteUser : remoteUsers) { - if (remoteUser.remoteUserId == uid) { - currentRemoteUser = remoteUser; - Timber.d("Local user %s matches remote UID %d.", currentRemoteUser.name, uid); - break; - } - } - if( currentRemoteUser == null ) { - Timber.d("No local user identified for remote UID %d.", uid); - } - } - else { - processMeasurementData(data, 4, current == 2, false); - } - - // Even if we did not process the data, always ack the message - Timber.d("Sending ack for CMD_MEASUREMENT"); - sendAck(data); - - if (current != count) { - Timber.d("Not all measurement parts received, waiting for more..."); - // More data should be incoming, so make sure we wait - stopMachineState(); - return; - } - - Timber.i("All measurement parts received."); - - // Delete saved measurement, but only when we processed it before - if (currentRemoteUser != null && readyForData ) { - Timber.d("Sending command: CMD_DELETE_SAVED_MEASUREMENTS"); - sendCommand(CMD_DELETE_SAVED_MEASUREMENTS, encodeUserId(currentRemoteUser)); - // We sent a new command, so make sure we wait - stopMachineState(); - } - // This message should only be received in step 6 and 8 - else if( waitForDataInStep != 6 && waitForDataInStep != 8 ) { - Timber.w("Received final CMD_MEASUREMENT in wrong state..."); - if( waitForDataInStep >= 0 ){ - Timber.w("...while waiting for other data. Retrying last step."); - // We are in the wrong state. - // This may happen, so let's just retry whatever we did before. - jumpBackOneStep(); - resumeMachineState(); - } - else { - Timber.w("...ignored, no data expected."); - } - } - else { - resumeMachineState(); - } - } - - - private void processScaleAck(byte[] data) { - switch (data[2]) { - case CMD_SCALE_STATUS: - Timber.d("ACK type: CMD_SCALE_STATUS"); - // data[3] != 0 if an invalid user id is given to the command, - // but it still provides some useful information (e.g. current unit). - final int batteryLevel = data[4] & 0xFF; - final float weightThreshold = (data[5] & 0xFF) / 10f; - final float bodyFatThreshold = (data[6] & 0xFF) / 10f; - final int currentUnit = data[7] & 0xFF; - final boolean userExists = data[8] == 0; - final boolean userReferWeightExists = data[9] == 0; - final boolean userMeasurementExist = data[10] == 0; - final int scaleVersion = data[11] & 0xFF; - - Timber.d("Battery level: %d; threshold: weight=%.2f, body fat=%.2f;" - + " unit: %d; requested user: exists=%b, has reference weight=%b," - + " has measurement=%b; scale version: %d", - batteryLevel, weightThreshold, bodyFatThreshold, currentUnit, userExists, - userReferWeightExists, userMeasurementExist, scaleVersion); - - if (batteryLevel <= 10) { - sendMessage(R.string.info_scale_low_battery, batteryLevel); - } - - byte requestedUnit = (byte) currentUnit; - ScaleUser user = OpenScale.getInstance().getSelectedScaleUser(); - switch (user.getScaleUnit()) { - case KG: - requestedUnit = 1; - break; - case LB: - requestedUnit = 2; - break; - case ST: - requestedUnit = 4; - break; - } - if (requestedUnit != currentUnit) { - Timber.d("Set scale unit (CMD_SET_UNIT) to %s (%d)", user.getScaleUnit(), requestedUnit); - sendCommand(CMD_SET_UNIT, requestedUnit); - // We send a new command, so make sure we wait - stopMachineState(); - } else { - // This should only be received in step 3 - if( waitForDataInStep != 3 ) { - Timber.w("Received ACK for CMD_SCALE_STATUS in wrong state..."); - if( waitForDataInStep >= 0 ){ - Timber.w("...while waiting for other data. Retrying last step."); - // We are in the wrong state. - // This may happen, so let's just retry whatever we did before. - jumpBackOneStep(); - } - else { - Timber.w("...ignored, no data expected."); - } - } - // All data received, no more waiting. - waitForDataInStep = -1; - resumeMachineState(); - } - break; - - case CMD_SET_UNIT: - Timber.d("ACK type: CMD_SET_UNIT"); - if (data[3] == 0) { - Timber.d("Scale unit successfully set"); - } - // This should only be received in step 3 - if( waitForDataInStep != 3 ) { - Timber.w("Received ACK for CMD_SET_UNIT in wrong state..."); - if( waitForDataInStep >= 0 ){ - Timber.w("...while waiting for other data. Retrying last step."); - // We are in the wrong state. - // This may happen, so let's just retry whatever we did before. - jumpBackOneStep(); - } - else { - Timber.w("...ignored, no data expected."); - } - } - // All data received, no more waiting. - waitForDataInStep = -1; - resumeMachineState(); - break; - - case CMD_USER_LIST: - Timber.d("ACK type: CMD_USER_LIST"); - int userCount = data[4] & 0xFF; - int maxUserCount = data[5] & 0xFF; - Timber.d("Have %d users (max is %d)", userCount, maxUserCount); - if (userCount == 0) { - // We expect no more data, because there are no stored users. - // This message should only be received in state 4. - if( waitForDataInStep != 4 ) { - Timber.w("Received ACK for CMD_USER_LIST in wrong state..."); - if( waitForDataInStep >= 0 ){ - Timber.w("...while waiting for other data."); - // We are in the wrong state. - // This may happen, so let's just retry whatever we did before. - jumpBackOneStep(); - } - else { - Timber.w("...ignored, no data expected."); - } - } - // User list is empty, no more waiting. - waitForDataInStep = -1; - resumeMachineState(); - } - else { - // More data should be incoming, so make sure we wait - stopMachineState(); - } - break; - - case CMD_GET_SAVED_MEASUREMENTS: - Timber.d("ACK type: CMD_GET_SAVED_MEASUREMENTS"); - int measurementCount = data[3] & 0xFF; - Timber.d("Received ACK for CMD_GET_SAVED_MEASUREMENTS for %d measurements.", measurementCount/2); - if (measurementCount == 0) { - // We expect no more data, because there are no measurements. - readyForData = true; - // This message should only be received in step 5. - if( waitForDataInStep != 5 ) { - Timber.w("Received ACK for CMD_GET_SAVED_MEASUREMENTS in wrong state..."); - if( waitForDataInStep >= 0 ){ - Timber.w("...while waiting for other data. Retrying last step."); - // We are in the wrong state. - // This may happen, so let's just retry whatever we did before. - jumpBackOneStep(); - } - else { - Timber.w("...ignored, no data expected."); - } - } - // No saved data, no more waiting. - waitForDataInStep = -1; - resumeMachineState(); - } - // Otherwise wait for CMD_SAVED_MEASUREMENT notifications which will, - // once all measurements have been received, resume the state machine. - else { - // More data should be incoming, so make sure we wait - stopMachineState(); - } - break; - - case CMD_DELETE_SAVED_MEASUREMENTS: - Timber.d("ACK type: CMD_DELETE_SAVED_MEASUREMENTS"); - if (data[3] == 0) { - Timber.d("Saved measurements successfully deleted for user " + currentRemoteUser.name); - } - // This message should only be received in state 5, 6 or 8 - if( waitForDataInStep != 5 && waitForDataInStep != 6 && waitForDataInStep != 8 ) { - Timber.w("Received ACK for CMD_DELETE_SAVED_MEASUREMENTS in wrong state..."); - if( waitForDataInStep >= 0 ){ - Timber.w("...while waiting for other data. Retrying last step."); - // We are in the wrong state. - // This may happen, so let's just retry whatever we did before. - jumpBackOneStep(); - } - else { - Timber.w("...ignored, no data expected."); - } - } - // All data received, no more waiting. - waitForDataInStep = -1; - resumeMachineState(); - break; - - case CMD_USER_ADD: - Timber.d("ACK type: CMD_USER_ADD"); - // This message should only be received in state 6 - if( waitForDataInStep != 6 ) { - Timber.w("Received ACK for CMD_USER_ADD in wrong state..."); - if( waitForDataInStep >= 0 ){ - Timber.w("...while waiting for other data. Retrying last step."); - // We are in the wrong state. - // This may happen, so let's just retry whatever we did before. - jumpBackOneStep(); - } - else { - Timber.w("...ignored, no data expected."); - } - // No more data expected after this command. - waitForDataInStep = -1; - resumeMachineState(); - // Get out of here, this wasn't supposed to happen. - break; - } - - if (data[3] == 0) { - remoteUsers.add(currentRemoteUser); - // If we have unprocessed data available, store it now. - if( storedMeasurement.measurementData != null ) { - Timber.d("User identified, storing unprocessed data."); - addMeasurement(storedMeasurement.measurementData, currentRemoteUser.localUserId); - storedMeasurement.measurementData = null; - } - // We can now receive and process data, user has been identified and send to the scale. - readyForData = true; - // Try to start a measurement to make the scale learn the reference weight to recognize the user next time. - // If we already have data, this will most likely run into time-out and the scale switches off before finishing. - Timber.d("New user successfully added; time to step on scale"); - sendMessage(R.string.info_step_on_scale_for_reference, 0); - Timber.d("Sending command: CMD_DO_MEASUREMENT"); - sendCommand(CMD_DO_MEASUREMENT, encodeUserId(currentRemoteUser)); - // We send a new command, so make sure we wait - stopMachineState(); - break; - } - - Timber.d("Cannot create additional scale user (error 0x%02x)", data[3]); - sendMessage(R.string.error_max_scale_users, 0); - // Force disconnect - Timber.d("Terminating state machine."); - jumpNextToStepNr( 9 ); - // All data received, no more waiting. - waitForDataInStep = -1; - resumeMachineState(); - break; - - case CMD_DO_MEASUREMENT: - Timber.d("ACK type: CMD_DO_MEASUREMENT"); - if (data[3] != 0) { - Timber.d("Measure command rejected."); - // We expect no more data, because measure command was not accepted. - // This message should only be received in state 6 or 8 - if( waitForDataInStep != 6 && waitForDataInStep != 8 ) { - Timber.w("Received ACK for CMD_DO_MEASUREMENT in wrong state..."); - if( waitForDataInStep >= 0 ){ - Timber.w("...while waiting for other data. Retrying last step."); - // We are in the wrong state. - // This may happen, so let's just retry whatever we did before. - jumpBackOneStep(); - } - else { - Timber.w("...ignored, no data expected."); - } - // No more data expected after this command. - waitForDataInStep = -1; - resumeMachineState(); - // Get out of here, this wasn't supposed to happen. - break; - } - } - else { - Timber.d("Measure command successfully received"); - sendMessage(R.string.info_step_on_scale, 0); - // More data should be incoming, so make sure we wait - stopMachineState(); - } - break; - - case CMD_USER_DETAILS: - Timber.d("ACK type: CMD_USER_DETAILS"); - if (data[3] == 0) { - String name = decodeString(data, 4, 3); - int year = 1900 + (data[7] & 0xFF); - int month = 1 + (data[8] & 0xFF); - int day = data[9] & 0xFF; - - int height = data[10] & 0xFF; - boolean male = (data[11] & 0xF0) != 0; - int activity = data[11] & 0x0F; - - Timber.d("Name: %s, Birthday: %d-%02d-%02d, Height: %d, Sex: %s, activity: %d", - name, year, month, day, height, male ? "male" : "female", activity); - } - // This message should only be received in state 7 - if( waitForDataInStep != 7 ) { - Timber.w("Received ACK for CMD_USER_DETAILS in wrong state..."); - if( waitForDataInStep >= 0 ){ - Timber.w("...while waiting for other data. Retrying last step."); - // We are in the wrong state. - // This may happen, so let's just retry whatever we did before. - jumpBackOneStep(); - } - else { - Timber.w("...ignored, no data expected."); - } - } - // All data received, no more waiting. - waitForDataInStep = -1; - resumeMachineState(); - break; - - default: - Timber.d("Unhandled scale ack for command 0x%02x", data[2]); - break; - } - } - - private float getKiloGram(byte[] data, int offset) { - // Unit is 50 g - return Converters.fromUnsignedInt16Be(data, offset) * 50.0f / 1000.0f; - } - - private float getPercent(byte[] data, int offset) { - // Unit is 0.1 % - return Converters.fromUnsignedInt16Be(data, offset) / 10.0f; - } - - private void addMeasurement(byte[] data, int userId) { - long timestamp = Converters.fromUnsignedInt32Be(data, 0) * 1000; - float weight = getKiloGram(data, 4); - int impedance = Converters.fromUnsignedInt16Be(data, 6); - float fat = getPercent(data, 8); - float water = getPercent(data, 10); - float muscle = getPercent(data, 12); - float bone = getKiloGram(data, 14); - int bmr = Converters.fromUnsignedInt16Be(data, 16); - int amr = Converters.fromUnsignedInt16Be(data, 18); - float bmi = Converters.fromUnsignedInt16Be(data, 20) / 10.0f; - - ScaleMeasurement receivedMeasurement = new ScaleMeasurement(); - receivedMeasurement.setUserId(userId); - receivedMeasurement.setDateTime(new Date(timestamp)); - receivedMeasurement.setWeight(weight); - receivedMeasurement.setFat(fat); - receivedMeasurement.setWater(water); - receivedMeasurement.setMuscle(muscle); - receivedMeasurement.setBone(bone); - - addScaleMeasurement(receivedMeasurement); - } - - private void writeBytes(byte[] data) { - writeBytes(CUSTOM_SERVICE_1, CUSTOM_CHARACTERISTIC_WEIGHT, data); - } - - private void sendCommand(byte command, byte... parameters) { - byte[] data = new byte[parameters.length + 2]; - data[0] = startByte; - data[1] = command; - - int i = 2; - for (byte parameter : parameters) { - data[i++] = parameter; - } - - writeBytes(data); - } - - private void sendAck(byte[] data) { - sendCommand(CMD_APP_ACK, Arrays.copyOfRange(data, 1, 4)); - } - - private void sendAlternativeStartCode(int id, byte... parameters) { - byte[] data = new byte[parameters.length + 1]; - data[0] = getAlternativeStartByte(id); - - int i = 1; - for (byte parameter : parameters) { - data[i++] = parameter; - } - - writeBytes(data); - } - - private void createRemoteUser(ScaleUser scaleUser) { - Timber.d("Create user: %s", scaleUser.getUserName()); - - Calendar cal = Calendar.getInstance(); - cal.setTime(scaleUser.getBirthday()); - - // We can only use up to 3 characters (padding with 0 if needed) - byte[] nick = Arrays.copyOf(convertUserNameToScale(scaleUser).getBytes(), 3); - byte year = (byte) (cal.get(Calendar.YEAR) - 1900); - byte month = (byte) cal.get(Calendar.MONTH); - byte day = (byte) cal.get(Calendar.DAY_OF_MONTH); - byte height = (byte) scaleUser.getBodyHeight(); - byte sex = scaleUser.getGender().isMale() ? (byte) 0x80 : 0; - byte activity = (byte) (scaleUser.getActivityLevel().toInt() + 1); // activity level: 1 - 5 - - long maxUserId = remoteUsers.isEmpty() ? 100 : 0; - for (RemoteUser remoteUser : remoteUsers) { - maxUserId = Math.max(maxUserId, remoteUser.remoteUserId); - } - - currentRemoteUser = new RemoteUser(maxUserId + 1, new String(nick), 1900 + year); - currentRemoteUser.localUserId = scaleUser.getId(); - currentRemoteUser.isNew = true; - - byte[] uid = encodeUserId(currentRemoteUser); - - Timber.d("Sending command: CMD_USER_ADD"); - sendCommand(CMD_USER_ADD, uid[0], uid[1], uid[2], uid[3], uid[4], uid[5], uid[6], uid[7], - nick[0], nick[1], nick[2], year, month, day, height, (byte) (sex | activity)); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBroadcastScale.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBroadcastScale.java deleted file mode 100644 index 0769551b..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBroadcastScale.java +++ /dev/null @@ -1,178 +0,0 @@ -/* Copyright (C) 2024 olie.xdev -* 2024 Duncan Overbruck -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.bluetooth; - -import static android.content.Context.LOCATION_SERVICE; - -import android.Manifest; -import android.bluetooth.le.ScanRecord; -import android.bluetooth.le.ScanResult; -import android.content.Context; -import android.content.pm.PackageManager; -import android.location.LocationManager; -import android.os.Handler; -import android.os.Looper; -import android.util.SparseArray; - -import androidx.core.content.ContextCompat; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.welie.blessed.BluetoothCentralManager; -import com.welie.blessed.BluetoothCentralManagerCallback; -import com.welie.blessed.BluetoothPeripheral; - -import java.util.Date; - -import timber.log.Timber; - - -public class BluetoothBroadcastScale extends BluetoothCommunication { - - - private ScaleMeasurement measurement; - - private boolean connected = false; - - private final BluetoothCentralManager central; - - public BluetoothBroadcastScale(Context context) - { - super(context); - this.context = context; - this.central = new BluetoothCentralManager(context, bluetoothCentralCallback, new Handler(Looper.getMainLooper())); - } - - // Callback for central - private final BluetoothCentralManagerCallback bluetoothCentralCallback = new BluetoothCentralManagerCallback() { - - @Override - public void onDiscoveredPeripheral(BluetoothPeripheral peripheral, ScanResult scanResult) { - ScanRecord record = scanResult.getScanRecord(); - if (record == null) - return; - - SparseArray manufacturerData = record.getManufacturerSpecificData(); - if (manufacturerData.size() != 1) - return; - - int companyId = manufacturerData.keyAt(0); - byte[] data = manufacturerData.get(companyId); - if (data.length < 12) { - Timber.d("Unexpected data length, got %d, expected %d", data.length, 12); - return; - } - - // lower byte of the two byte companyId is the xor byte, - // its used on the last 6 bytes of the data, the first 6 bytes - // are just the mac address and can be ignored. - byte xor = (byte) (companyId >> 8); - byte[] buf = new byte[6]; - for (int i = 0; i < 6; i++) { - buf[i] = (byte) (data[i + 6] ^ xor); - } - - // chk is the sum of the first 5 bytes, its 5 lower bits are compared to the 5 lower - // bites of the last byte in the packet. - int chk = 0; - for (int i = 0; i < 5; i++) { - chk += buf[i]; - } - if ((chk & 0x1F) != (buf[5] & 0x1F)) { - Timber.d("Checksum error, got %x, expected %x", chk & 0x1F, buf[5] & 0x1F); - return; - } - - if (!connected) { - // "fake" a connection, since we've got valid data. - setBluetoothStatus(BT_STATUS.CONNECTION_ESTABLISHED); - connected = true; - } - - switch (buf[4]) { - case (byte) 0xAD: - int value = (((buf[3] & 0xFF) << 0) | ((buf[2] & 0xFF) << 8) | - ((buf[1] & 0xFF) << 16) | ((buf[0] & 0xFF) << 24)); - byte state = (byte)(value >> 0x1F); - int grams = value & 0x3FFFF; - Timber.d("Got weight measurement weight=%.2f state=%d", (float)grams/1000, state); - if (state != 0 && measurement == null) { - measurement = new ScaleMeasurement(); - measurement.setDateTime(new Date()); - measurement.setWeight((float)grams / 1000); - - // stop now since we don't support any further data. - addScaleMeasurement(measurement); - disconnect(); - measurement = null; - } - break; - case (byte) 0xA6: - // this is the impedance package, not yet supported. - break; - default: - StringBuilder sb = new StringBuilder(); - for (byte b : buf) { - sb.append(String.format("0x%02X ", b)); - } - Timber.d("Unsupported packet type %x, xor key %x data: %s", buf[4], xor, sb.toString()); - } - } - }; - - @Override - public void connect(String macAddress) { - - LocationManager locationManager = (LocationManager)context.getSystemService(LOCATION_SERVICE); - - if ((ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) || - (ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED ) && - (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || - (locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER))) - ) { - Timber.d("Do LE scan before connecting to device"); - central.scanForPeripheralsWithAddresses(new String[]{macAddress}); - } - else { - Timber.e("No location permission, can't do anything"); - } - } - - - @Override - public void disconnect() { - Timber.d("Bluetooth disconnect"); - setBluetoothStatus(BT_STATUS.CONNECTION_DISCONNECT); - try { - central.stopScan(); - } catch (Exception ex) { - Timber.e("Error on Bluetooth disconnecting " + ex.getMessage()); - } - connected = false; - } - - @Override - public String driverName() { - return "BroadcastScale"; - } - - @Override - protected boolean onNextStep(int stepNr) { - return false; - } - -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothCustomOpenScale.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothCustomOpenScale.java deleted file mode 100644 index 0a47ed88..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothCustomOpenScale.java +++ /dev/null @@ -1,161 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.core.bluetooth; - -import android.content.Context; - -import com.health.openscale.core.datatypes.ScaleMeasurement; - -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.Calendar; -import java.util.Locale; -import java.util.UUID; - -import timber.log.Timber; - -public class BluetoothCustomOpenScale extends BluetoothCommunication { - private final UUID WEIGHT_MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0xffe0); - private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xffe1); // Bluetooth Modul HM-10 - - private String string_data = new String(); - - public BluetoothCustomOpenScale(Context context) { - super(context); - } - - @Override - public String driverName() { - return "Custom openScale"; - } - - @Override - protected boolean onNextStep(int stepNr) { - switch (stepNr) { - case 0: - setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC); - break; - case 1: - Calendar cal = Calendar.getInstance(); - - String date_time = String.format(Locale.US, "2%1d,%1d,%1d,%1d,%1d,%1d,", - cal.get(Calendar.YEAR)-2000, - cal.get(Calendar.MONTH) + 1, - cal.get(Calendar.DAY_OF_MONTH), - cal.get(Calendar.HOUR_OF_DAY), - cal.get(Calendar.MINUTE), - cal.get(Calendar.SECOND)); - - writeBytes(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC, date_time.getBytes()); - break; - default: - return false; - } - - return true; - } - - public void clearEEPROM() - { - byte[] cmd = {(byte)'9'}; - writeBytes(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC, cmd); - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - final byte[] data = value; - - if (data != null) { - for (byte character : data) { - string_data += (char) (character & 0xFF); - - if (character == '\n') { - parseBtString(string_data); - string_data = new String(); - } - } - } - } - - private void parseBtString(String btString) { - btString = btString.substring(0, btString.length() - 1); // delete newline '\n' of the string - - if (btString.charAt(0) != '$' && btString.charAt(2) != '$') { - setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR, "Parse error of bluetooth string. String has not a valid format: " + btString); - } - - String btMsg = btString.substring(3, btString.length()); // message string - - switch (btString.charAt(1)) { - case 'I': - Timber.d("MCU Information: %s", btMsg); - break; - case 'E': - Timber.e("MCU Error: %s", btMsg); - break; - case 'S': - Timber.d("MCU stored data size: %s", btMsg); - break; - case 'F': - Timber.d("All data sent"); - clearEEPROM(); - disconnect(); - break; - case 'D': - String[] csvField = btMsg.split(","); - - try { - int checksum = 0; - - checksum ^= Integer.parseInt(csvField[0]); - checksum ^= Integer.parseInt(csvField[1]); - checksum ^= Integer.parseInt(csvField[2]); - checksum ^= Integer.parseInt(csvField[3]); - checksum ^= Integer.parseInt(csvField[4]); - checksum ^= Integer.parseInt(csvField[5]); - checksum ^= (int) Float.parseFloat(csvField[6]); - checksum ^= (int) Float.parseFloat(csvField[7]); - checksum ^= (int) Float.parseFloat(csvField[8]); - checksum ^= (int) Float.parseFloat(csvField[9]); - - int btChecksum = Integer.parseInt(csvField[10]); - - if (checksum == btChecksum) { - ScaleMeasurement scaleBtData = new ScaleMeasurement(); - - String date_string = csvField[1] + "/" + csvField[2] + "/" + csvField[3] + "/" + csvField[4] + "/" + csvField[5]; - scaleBtData.setDateTime(new SimpleDateFormat("yyyy/MM/dd/HH/mm").parse(date_string)); - - scaleBtData.setWeight(Float.parseFloat(csvField[6])); - scaleBtData.setFat(Float.parseFloat(csvField[7])); - scaleBtData.setWater(Float.parseFloat(csvField[8])); - scaleBtData.setMuscle(Float.parseFloat(csvField[9])); - - addScaleMeasurement(scaleBtData); - } else { - setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR, "Error calculated checksum (" + checksum + ") and received checksum (" + btChecksum + ") is different"); - } - } catch (ParseException e) { - setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR, "Error while decoding bluetooth date string (" + e.getMessage() + ")"); - } catch (NumberFormatException e) { - setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR, "Error while decoding a number of bluetooth string (" + e.getMessage() + ")"); - } - break; - default: - setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR, "Error unknown MCU command : " + btString); - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothDebug.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothDebug.java deleted file mode 100644 index fd633e50..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothDebug.java +++ /dev/null @@ -1,191 +0,0 @@ -/* Copyright (C) 2018 Erik Johansson - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - -package com.health.openscale.core.bluetooth; - -import android.bluetooth.BluetoothGattCharacteristic; -import android.bluetooth.BluetoothGattDescriptor; -import android.bluetooth.BluetoothGattService; -import android.content.Context; - -import com.welie.blessed.BluetoothPeripheral; - -import java.util.HashMap; - -import timber.log.Timber; - -public class BluetoothDebug extends BluetoothCommunication { - HashMap propertyString; - - BluetoothDebug(Context context) { - super(context); - - propertyString = new HashMap<>(); - propertyString.put(BluetoothGattCharacteristic.PROPERTY_BROADCAST, "BROADCAST"); - propertyString.put(BluetoothGattCharacteristic.PROPERTY_READ, "READ"); - propertyString.put(BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE, "WRITE_NO_RESPONSE"); - propertyString.put(BluetoothGattCharacteristic.PROPERTY_WRITE, "WRITE"); - propertyString.put(BluetoothGattCharacteristic.PROPERTY_NOTIFY, "NOTIFY"); - propertyString.put(BluetoothGattCharacteristic.PROPERTY_INDICATE, "INDICATE"); - propertyString.put(BluetoothGattCharacteristic.PROPERTY_SIGNED_WRITE, "SIGNED_WRITE"); - propertyString.put(BluetoothGattCharacteristic.PROPERTY_EXTENDED_PROPS, "EXTENDED_PROPS"); - } - - @Override - public String driverName() { - return "Debug"; - } - - private boolean isBlacklisted(BluetoothGattService service, BluetoothGattCharacteristic characteristic) { - // Reading this triggers a pairing request on Beurer BF710 - if (service.getUuid().equals(BluetoothGattUuid.fromShortCode(0xffe0)) - && characteristic.getUuid().equals(BluetoothGattUuid.fromShortCode(0xffe5))) { - return true; - } - - return false; - } - - private boolean isWriteType(int property, int writeType) { - switch (property) { - case BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE: - return writeType == BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE; - case BluetoothGattCharacteristic.PROPERTY_WRITE: - return writeType == BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT; - case BluetoothGattCharacteristic.PROPERTY_SIGNED_WRITE: - return writeType == BluetoothGattCharacteristic.WRITE_TYPE_SIGNED; - } - return false; - } - - private String propertiesToString(int properties, int writeType) { - StringBuilder names = new StringBuilder(); - for (int property : propertyString.keySet()) { - if ((properties & property) != 0) { - names.append(propertyString.get(property)); - if (isWriteType(property, writeType)) { - names.append('*'); - } - names.append(", "); - } - } - - if (names.length() == 0) { - return ""; - } - - return names.substring(0, names.length() - 2); - } - - private String permissionsToString(int permissions) { - if (permissions == 0) { - return ""; - } - return String.format(" (permissions=0x%x)", permissions); - } - - private String byteToString(byte[] value) { - return new String(value).replaceAll("\\p{Cntrl}", "?"); - } - - private void logService(BluetoothGattService service, boolean included) { - Timber.d("Service %s%s", BluetoothGattUuid.prettyPrint(service.getUuid()), - included ? " (included)" : ""); - - for (BluetoothGattCharacteristic characteristic : service.getCharacteristics()) { - Timber.d("|- characteristic %s (#%d): %s%s", - BluetoothGattUuid.prettyPrint(characteristic.getUuid()), - characteristic.getInstanceId(), - propertiesToString(characteristic.getProperties(), characteristic.getWriteType()), - permissionsToString(characteristic.getPermissions())); - byte[] value = characteristic.getValue(); - if (value != null && value.length > 0) { - Timber.d("|--> value: %s (%s)", byteInHex(value), byteToString(value)); - } - - for (BluetoothGattDescriptor descriptor : characteristic.getDescriptors()) { - Timber.d("|--- descriptor %s%s", - BluetoothGattUuid.prettyPrint(descriptor.getUuid()), - permissionsToString(descriptor.getPermissions())); - - value = descriptor.getValue(); - if (value != null && value.length > 0) { - Timber.d("|-----> value: %s (%s)", byteInHex(value), byteToString(value)); - } - } - } - - for (BluetoothGattService includedService : service.getIncludedServices()) { - logService(includedService, true); - } - } - - private int readServiceCharacteristics(BluetoothGattService service, int offset) { - for (BluetoothGattCharacteristic characteristic : service.getCharacteristics()) { - if ((characteristic.getProperties() & BluetoothGattCharacteristic.PROPERTY_READ) != 0 - && !isBlacklisted(service, characteristic)) { - - if (offset == 0) { - readBytes(service.getUuid(), characteristic.getUuid()); - return -1; - } - - offset -= 1; - } - - for (BluetoothGattDescriptor descriptor : characteristic.getDescriptors()) { - if (offset == 0) { - readBytes(service.getUuid(), characteristic.getUuid()); - return -1; - } - - offset -= 1; - } - } - - for (BluetoothGattService included : service.getIncludedServices()) { - offset = readServiceCharacteristics(included, offset); - if (offset == -1) { - return offset; - } - } - - return offset; - } - - @Override - protected void onBluetoothDiscovery(BluetoothPeripheral peripheral) { - int offset = 0; - - for (BluetoothGattService service : peripheral.getServices()) { - offset = readServiceCharacteristics(service, offset); - } - - for (BluetoothGattService service : peripheral.getServices()) { - logService(service, false); - } - - setBluetoothStatus(BT_STATUS.CONNECTION_LOST); - disconnect(); - } - - - @Override - protected boolean onNextStep(int stateNr) { - return false; - } - -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothDigooDGSO38H.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothDigooDGSO38H.java deleted file mode 100644 index e5b2fd1f..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothDigooDGSO38H.java +++ /dev/null @@ -1,133 +0,0 @@ -/* Copyright (C) 2017 Murgi -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.bluetooth; - -import android.content.Context; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; - -import java.util.Date; -import java.util.UUID; - -import timber.log.Timber; - -public class BluetoothDigooDGSO38H extends BluetoothCommunication { - private final UUID WEIGHT_MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0xfff0); - private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xfff1); - private final UUID EXTRA_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xfff2); - - public BluetoothDigooDGSO38H(Context context) { - super(context); - } - - @Override - public String driverName() { - return "Digoo DG-SO38H"; - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - final byte[] data = value; - - if (data != null && data.length > 0) { - - if (data.length == 20) { - parseBytes(data); - } - } - } - - - @Override - protected boolean onNextStep(int stepNr) { - switch (stepNr) { - case 0: - //Tell device to send us weight measurements - setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC); - break; - case 1: - sendMessage(R.string.info_step_on_scale, 0); - break; - default: - return false; - } - - return true; - } - - private void parseBytes(byte[] weightBytes) { - float weight, fat, water, muscle, boneWeight, visceralFat; - //float subcutaneousFat, metabolicBaseRate, biologicalAge, boneWeight; - - final byte ctrlByte = weightBytes[5]; - final boolean allValues = isBitSet(ctrlByte, 1); - final boolean weightStabilized = isBitSet(ctrlByte, 0); - final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser(); - - if (weightStabilized) { - //The weight is stabilized, now we want to measure all available values - byte gender = selectedUser.getGender().isMale() ? (byte)0x00: (byte)0x01; - byte height = (byte) (((int)selectedUser.getBodyHeight()) & 0xFF); - byte age = (byte)(selectedUser.getAge() & 0xff); - byte unit = 0x01; // kg - switch (selectedUser.getScaleUnit()) { - case LB: - unit = 0x02; - break; - case ST: - unit = 0x8; - break; - } - byte configBytes[] = new byte[]{(byte)0x09, (byte)0x10, (byte)0x12, (byte)0x11, (byte)0x0d, (byte)0x01, height, age, gender, unit, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00}; - //Write checksum is sum of all bytes % 256 - int checksum = 0x00; - for (int i=3; i - * This will create and save a measurement. - * - * @param data The data payload (in bytes) - */ - private void handleMeasurementPayload(byte[] data) { - Timber.d("Parsing measurement"); - - // 0x01 and 0x11 are final measurements, 0x00 and 0x10 are real-time measurements - byte measurementType = data[5]; - - if (measurementType != 0x01 && measurementType != 0x11) { - // This byte indicates whether the measurement is final or not - // Discard if it isn't, we only want the final value - Timber.d("Discarded measurement since it is not final"); - return; - } - - Timber.d("Saving measurement"); - // Weight (in kg) is stored as big-endian in bytes 6 to 9 - long weightKg = Converters.fromUnsignedInt32Be(data, 6); - // Resistance/Impedance is stored as big-endian in bytes 10 to 11 - int resistance = Converters.fromUnsignedInt16Be(data, 10); - - Timber.d("Got measurement from scale. Weight: %d, Resistance: %d", weightKg, resistance); - - // FIXME weight might be in other units, investigate - - saveMeasurement(weightKg, resistance, null); - } - - /** - * Handle a packet of type "ofline measurement" (0x14). - * There are two types: real time (0x00/0x10) or final (0x01/0x11), indicated by byte 5. - * Real time measurements only have weight, whereas final measurements can also have resistance. - *

- * This will create and save a measurement if it is final, discarding real time measurements. - * - * @param data The data payload (in bytes) - */ - private void handleOfflineMeasurementPayload(byte[] data) { - Timber.d("Parsing offline measurement"); - - // Weight (in kg) is stored as big-endian in bytes 5 to 8 - long weightKg = Converters.fromUnsignedInt32Be(data, 5); - // Resistance/Impedance is stored as big-endian in bytes 9 to 10 - int resistance = Converters.fromUnsignedInt16Be(data, 9); - // Scale returns the seconds elapsed since the measurement as big-endian in bytes 11 to 14 - long secondsSinceMeasurement = Converters.fromUnsignedInt32Be(data, 11); - long measurementTimestamp = System.currentTimeMillis() - secondsSinceMeasurement * 1000; - - Timber.d("Got offline measurement from scale. Weight: %d, Resistance: %d, Timestamp: %tc", weightKg, resistance, measurementTimestamp); - - saveMeasurement(weightKg, resistance, measurementTimestamp); - - acknowledgeOfflineMeasurement(); - } - - /** - * Send acknowledge to the scale that we received one offline measurement payload, - * so that it can delete it from memory. - *

- * For each offline measurement, we have to send one of these. - */ - private void acknowledgeOfflineMeasurement() { - final byte[] payload = {(byte) 0x55, (byte) 0xAA, (byte) 0x95, (byte) 0x0, (byte) 0x1, (byte) 0x1, 0}; - payload[payload.length - 1] = sumChecksum(payload, 0, payload.length - 1); - writeBytes(WEIGHT_MEASUREMENT_SERVICE, WRITE_MEASUREMENT_CHARACTERISTIC, payload); - Timber.d("Acknowledge offline measurement"); - } - - /** - * Save a measurement from the scale to openScale. - * - * @param weightKg The weight, in kilograms, multiplied by 100 (that is, as an integer) - * @param resistance The resistance (impedance) given by the scale. Can be zero if not barefoot - * @param timestamp For offline measurements, provide the timestamp. If null, the current timestamp will be used - */ - private void saveMeasurement(long weightKg, int resistance, @Nullable Long timestamp) { - - final ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser(); - - Timber.d("Saving measurement for scale user %s", scaleUser); - - final ScaleMeasurement btScaleMeasurement = new ScaleMeasurement(); - btScaleMeasurement.setWeight(weightKg / 100f); - if (resistance != 0) { - // TODO add more measurements - // This will require us to revert engineer libnative-lib.so - } - if (timestamp != null) { - btScaleMeasurement.setDateTime(new Date(timestamp)); - } - - addScaleMeasurement(btScaleMeasurement); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothESCS20M.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothESCS20M.java deleted file mode 100644 index 4b3aba30..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothESCS20M.java +++ /dev/null @@ -1,176 +0,0 @@ -package com.health.openscale.core.bluetooth; - -import android.content.Context; - -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.bluetooth.lib.YunmaiLib; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; - -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.UUID; -import java.util.stream.Collectors; - -import timber.log.Timber; - -public class BluetoothESCS20M extends BluetoothCommunication { - - private static final UUID SERV_CUR_TIME = BluetoothGattUuid.fromShortCode(0x1a10); - - private static final UUID CHAR_CUR_TIME = BluetoothGattUuid.fromShortCode(0x2a11); - private static final UUID CHAR_RESULTS = BluetoothGattUuid.fromShortCode(0x2a10); - - private static final byte MESSAGE_ID_START_STOP_RESP = 0x11; - private static final byte MESSAGE_ID_WEIGHT_RESP = 0x14; - private static final byte MESSAGE_ID_EXTENDED_RESP = 0x15; - - private static final byte MEASUREMENT_TYPE_START_WEIGHT_ONLY = 0x18; - private static final byte MEASUREMENT_TYPE_STOP_WEIGHT_ONLY = 0x17; - private static final byte MEASUREMENT_TYPE_START_ALL = 0x19; - private static final byte MEASUREMENT_TYPE_STOP_ALL = 0x18; - - private static final byte[] MAGIC_BYTES_START_MEASUREMENT = new byte[]{ - (byte) 0x55, (byte) 0xaa, (byte) 0x90, (byte) 0x00, (byte) 0x04, (byte) 0x01, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x94 - }; - private static final byte[] MAGIC_BYTES_DELETE_HISTORY_DATA = new byte[]{ - (byte)0x55, (byte) 0xaa, (byte) 0x95, (byte)0x00, (byte)0x01, (byte)0x01,(byte) 0x96 - }; - - private List rawMeasurements = new ArrayList<>(); - private final ScaleMeasurement scaleMeasurement = new ScaleMeasurement(); - - public BluetoothESCS20M(Context context) { - super(context); - } - - @Override - public String driverName() { - return "ES-CS20M"; - } - - @Override - protected boolean onNextStep(int stepNr) { - Timber.i("onNextStep(%d)", stepNr); - - switch (stepNr) { - case 0: - setNotificationOn(SERV_CUR_TIME, CHAR_CUR_TIME); - break; - case 1: - setNotificationOn(SERV_CUR_TIME, CHAR_RESULTS); - break; - case 2: - writeBytes(SERV_CUR_TIME, CHAR_CUR_TIME, MAGIC_BYTES_START_MEASUREMENT); - writeBytes(SERV_CUR_TIME, CHAR_CUR_TIME, MAGIC_BYTES_DELETE_HISTORY_DATA); - stopMachineState(); - break; - case 3: - break; - default: - return false; - } - - return true; - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - Timber.d("Received notification on UUID = %s", characteristic.toString()); - Timber.d("Received in step(%d)", getStepNr()); - - for (int i = 0; i < value.length; i++) { - Timber.d("Byte %d = 0x%02x", i, value[i]); - } - - rawMeasurements.add(value); - - final byte msgID = value[2]; - - if (msgID != MESSAGE_ID_START_STOP_RESP) - return; - - final byte measurementType = value[10]; - - if (getStepNr() == 4 && (measurementType == MEASUREMENT_TYPE_STOP_WEIGHT_ONLY || measurementType == MEASUREMENT_TYPE_STOP_ALL)) { - final ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser(); - final int sex = scaleUser.getGender() == Converters.Gender.MALE ? 1 : 0; - YunmaiLib yunmaiLib = new YunmaiLib(sex, scaleUser.getBodyHeight(), scaleUser.getActivityLevel()); - - rawMeasurements = rawMeasurements.stream().sorted(Comparator.comparingInt(a -> a[2])).collect(Collectors.toList()); - - Timber.d("Parsing measurements"); - - for (byte[] msg : rawMeasurements) { - parseMsg(msg, yunmaiLib, scaleUser); - } - - Timber.d("Saving measurement for scale user %s", scaleUser); - - addScaleMeasurement(scaleMeasurement); - } - - if (getStepNr() == 3 && (measurementType == MEASUREMENT_TYPE_START_WEIGHT_ONLY || measurementType == MEASUREMENT_TYPE_START_ALL)) - resumeMachineState(); - } - - private void parseMsg(byte[] msg, YunmaiLib calcLib, ScaleUser user) { - final byte msgID = msg[2]; - - switch (msgID) { - case MESSAGE_ID_WEIGHT_RESP: - Timber.d("Found weight measurement"); - - final boolean stableValue = Byte.toUnsignedInt(msg[5]) != 0; - if (stableValue) { - Timber.d("Found stable weight measurement"); - scaleMeasurement.setWeight(Converters.fromUnsignedInt16Be(msg, 8) / 100.0f); - - if (msg[10] != 0x00 && msg[11] != 0x00) { - Timber.d("Found embedded extended measurements in weight message"); - if (rawMeasurements.stream().filter(a -> a[2] == 0x15).count() > 0) { - Timber.d("Ignore embedded extended measurements because separate message found"); - return; - } - - final int resistance = Converters.fromUnsignedInt16Be(msg, 10); - parseExtendedMeasurement(resistance, calcLib, user); - } - } - break; - - case MESSAGE_ID_EXTENDED_RESP: - Timber.d("Found extended measurements message"); - final int resistance = Converters.fromUnsignedInt16Be(msg, 9); - - parseExtendedMeasurement(resistance, calcLib, user); - break; - } - } - - private void parseExtendedMeasurement(final int resistance, YunmaiLib calcLib, ScaleUser user) { - Timber.d("Found extended measurements"); - - final float weight = scaleMeasurement.getWeight(); - if (weight == 0.0f) { - Timber.e("Weight is zero, could not process extended measurements"); - return; - } - - final float bodyFat = calcLib.getFat(user.getAge(), weight, resistance); - final float muscle = calcLib.getMuscle(bodyFat) / weight * 100.0f; - final float water = calcLib.getWater(bodyFat); - final float bone = calcLib.getBoneMass(muscle, weight); - final float lbm = calcLib.getLeanBodyMass(weight, bodyFat); - final float visceralFal = calcLib.getVisceralFat(bodyFat, user.getAge()); - - scaleMeasurement.setFat(bodyFat); - scaleMeasurement.setMuscle(muscle); - scaleMeasurement.setWater(water); - scaleMeasurement.setBone(bone); - scaleMeasurement.setLbm(lbm); - scaleMeasurement.setVisceralFat(visceralFal); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothExcelvanCF36xBLE.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothExcelvanCF36xBLE.java deleted file mode 100644 index 2b7f8cb6..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothExcelvanCF36xBLE.java +++ /dev/null @@ -1,145 +0,0 @@ -/* Copyright (C) 2017 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.bluetooth; - -import android.content.Context; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; - -import java.util.Arrays; -import java.util.UUID; - -public class BluetoothExcelvanCF36xBLE extends BluetoothCommunication { - private final UUID WEIGHT_MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0xfff0); - private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xfff1); - private final UUID WEIGHT_CUSTOM0_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xfff4); - - private byte[] receivedData = new byte[]{}; - - public BluetoothExcelvanCF36xBLE(Context context) { - super(context); - } - - @Override - public String driverName() { - return "Excelvan CF36xBLE"; - } - - @Override - protected boolean onNextStep(int stepNr) { - switch (stepNr) { - case 0: - final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser(); - - byte userId = (byte) 0x01; - byte sex = selectedUser.getGender().isMale() ? (byte) 0x01 : (byte) 0x00; - - // 0x00 = ordinary, 0x01 = amateur, 0x02 = professional - byte exerciseLevel = (byte) 0x01; - - switch (selectedUser.getActivityLevel()) { - case SEDENTARY: - case MILD: - exerciseLevel = (byte) 0x00; - break; - case MODERATE: - exerciseLevel = (byte) 0x01; - break; - case HEAVY: - case EXTREME: - exerciseLevel = (byte) 0x02; - break; - } - - byte height = (byte) selectedUser.getBodyHeight(); - byte age = (byte) selectedUser.getAge(); - - byte unit = 0x01; // kg - switch (selectedUser.getScaleUnit()) { - case LB: - unit = 0x02; - break; - case ST: - unit = 0x04; - break; - } - - byte[] configBytes = {(byte) 0xfe, userId, sex, exerciseLevel, height, age, unit, (byte) 0x00}; - configBytes[configBytes.length - 1] = - xorChecksum(configBytes, 1, configBytes.length - 2); - - writeBytes(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC, configBytes); - break; - case 1: - setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_CUSTOM0_CHARACTERISTIC); - break; - case 2: - sendMessage(R.string.info_step_on_scale, 0); - break; - default: - return false; - } - - return true; - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - final byte[] data = value; - - if (data != null && data.length > 0) { - - // if data is body scale type. At least some variants (e.g. CF366BLE) of this scale - // return a 17th byte representing "physiological age". Allow (but ignore) that byte - // to support those variants. - if ((data.length >= 16 && data.length <= 17) && data[0] == (byte)0xcf) { - if (!Arrays.equals(data, receivedData)) { // accepts only one data of the same content - receivedData = data; - parseBytes(data); - } - } - } - } - - private void parseBytes(byte[] weightBytes) { - float weight = Converters.fromUnsignedInt16Be(weightBytes, 4) / 10.0f; - float fat = Converters.fromUnsignedInt16Be(weightBytes, 6) / 10.0f; - float bone = (weightBytes[8] & 0xFF) / 10.0f; - float muscle = Converters.fromUnsignedInt16Be(weightBytes, 9) / 10.0f; - float visceralFat = weightBytes[11] & 0xFF; - float water = Converters.fromUnsignedInt16Be(weightBytes, 12) / 10.0f; - float bmr = Converters.fromUnsignedInt16Be(weightBytes, 14); - // weightBytes[16] is an (optional, ignored) "physiological age" in some scale variants. - - ScaleMeasurement scaleBtData = new ScaleMeasurement(); - - final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser(); - - scaleBtData.setWeight(Converters.toKilogram(weight, selectedUser.getScaleUnit())); - scaleBtData.setFat(fat); - scaleBtData.setMuscle(muscle); - scaleBtData.setWater(water); - scaleBtData.setBone(bone); - scaleBtData.setVisceralFat(visceralFat); - - addScaleMeasurement(scaleBtData); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothExingtechY1.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothExingtechY1.java deleted file mode 100644 index 507522ae..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothExingtechY1.java +++ /dev/null @@ -1,112 +0,0 @@ -/* Copyright (C) 2017 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.bluetooth; - -import android.content.Context; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; - -import java.util.Date; -import java.util.UUID; - -public class BluetoothExingtechY1 extends BluetoothCommunication { - private final UUID WEIGHT_MEASUREMENT_SERVICE = UUID.fromString("f433bd80-75b8-11e2-97d9-0002a5d5c51b"); - private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC = UUID.fromString("1a2ea400-75b9-11e2-be05-0002a5d5c51b"); // read, notify - private final UUID CMD_MEASUREMENT_CHARACTERISTIC = UUID.fromString("29f11080-75b9-11e2-8bf6-0002a5d5c51b"); // write only - - public BluetoothExingtechY1(Context context) { - super(context); - } - - @Override - public String driverName() { - return "Exingtech Y1"; - } - - @Override - protected boolean onNextStep(int stepNr) { - switch (stepNr) { - case 0: - setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC); - break; - case 1: - final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser(); - - byte gender = selectedUser.getGender().isMale() ? (byte)0x00 : (byte)0x01; // 00 - male; 01 - female - byte height = (byte)(((int)selectedUser.getBodyHeight()) & 0xff); // cm - byte age = (byte)(selectedUser.getAge() & 0xff); - - int userId = selectedUser.getId(); - - byte cmdByte[] = {(byte)0x10, (byte)userId, gender, age, height}; - - writeBytes(WEIGHT_MEASUREMENT_SERVICE, CMD_MEASUREMENT_CHARACTERISTIC, cmdByte); - break; - case 2: - sendMessage(R.string.info_step_on_scale, 0); - break; - default: - return false; - } - - return true; - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - final byte[] data = value; - - // The first notification only includes weight and all other fields are - // either 0x00 (user info) or 0xff (fat, water, etc.) - if (data != null && data.length == 20 && data[6] != (byte)0xff) { - parseBytes(data); - } - } - - private void parseBytes(byte[] weightBytes) { - int userId = weightBytes[0] & 0xFF; - int gender = weightBytes[1] & 0xFF; // 0x00 male; 0x01 female - int age = weightBytes[2] & 0xFF; // 10 ~ 99 - int height = weightBytes[3] & 0xFF; // 0 ~ 255 - float weight = Converters.fromUnsignedInt16Be(weightBytes, 4) / 10.0f; // kg - float fat = Converters.fromUnsignedInt16Be(weightBytes, 6) / 10.0f; // % - float water = Converters.fromUnsignedInt16Be(weightBytes, 8) / 10.0f; // % - float bone = Converters.fromUnsignedInt16Be(weightBytes, 10) / 10.0f; // kg - float muscle = Converters.fromUnsignedInt16Be(weightBytes, 12) / 10.0f; // % - float visc_fat = weightBytes[14] & 0xFF; // index - float calorie = Converters.fromUnsignedInt16Be(weightBytes, 15); - float bmi = Converters.fromUnsignedInt16Be(weightBytes, 17) / 10.0f; - - ScaleMeasurement scaleBtData = new ScaleMeasurement(); - - final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser(); - - scaleBtData.setWeight(weight); - scaleBtData.setFat(fat); - scaleBtData.setMuscle(muscle); - scaleBtData.setWater(water); - scaleBtData.setBone(bone); - scaleBtData.setVisceralFat(visc_fat); - scaleBtData.setDateTime(new Date()); - - addScaleMeasurement(scaleBtData); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothFactory.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothFactory.java deleted file mode 100644 index 52638fde..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothFactory.java +++ /dev/null @@ -1,177 +0,0 @@ -/* Copyright (C) 2018 Erik Johansson - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - -package com.health.openscale.core.bluetooth; - -import android.content.Context; -import android.util.SparseArray; - -import java.util.Locale; - -public class BluetoothFactory { - public static BluetoothCommunication createDebugDriver(Context context) { - return new BluetoothDebug(context); - } - - public static BluetoothCommunication createDeviceDriver(Context context, String deviceName) { - final String name = deviceName.toLowerCase(Locale.US); - - if (name.startsWith("BEURER BF700".toLowerCase(Locale.US)) - || name.startsWith("BEURER BF800".toLowerCase(Locale.US)) - || name.startsWith("BF-800".toLowerCase(Locale.US)) - || name.startsWith("BF-700".toLowerCase(Locale.US)) - || name.startsWith("RT-Libra-B".toLowerCase(Locale.US)) - || name.startsWith("RT-Libra-W".toLowerCase(Locale.US)) - || name.startsWith("Libra-B".toLowerCase(Locale.US)) - || name.startsWith("Libra-W".toLowerCase(Locale.US))) { - return new BluetoothBeurerSanitas(context, BluetoothBeurerSanitas.DeviceType.BEURER_BF700_800_RT_LIBRA); - } - if (name.startsWith("BEURER BF710".toLowerCase(Locale.US)) - || name.equals("BF700".toLowerCase(Locale.US))) { - return new BluetoothBeurerSanitas(context, BluetoothBeurerSanitas.DeviceType.BEURER_BF710); - } - if (name.equals("openScale".toLowerCase(Locale.US))) { - return new BluetoothCustomOpenScale(context); - } - if (name.equals("Mengii".toLowerCase(Locale.US))) { - return new BluetoothDigooDGSO38H(context); - } - if (name.equals("Electronic Scale".toLowerCase(Locale.US))) { - return new BluetoothExcelvanCF36xBLE(context); - } - if (name.equals("VScale".toLowerCase(Locale.US))) { - return new BluetoothExingtechY1(context); - } - if (name.equals("YunChen".toLowerCase(Locale.US))) { - return new BluetoothHesley(context); - } - if (deviceName.startsWith("iHealth HS3")) { - return new BluetoothIhealthHS3(context); - } - // BS444 || BS440 - if (deviceName.startsWith("013197") || deviceName.startsWith("013198") || deviceName.startsWith("0202B6")) { - return new BluetoothMedisanaBS44x(context, true); - } - - //BS430 - if (deviceName.startsWith("0203B")) { - return new BluetoothMedisanaBS44x(context, false); - } - - if (deviceName.startsWith("SWAN") || name.equals("icomon".toLowerCase(Locale.US)) || name.equals("YG".toLowerCase(Locale.US))) { - return new BluetoothMGB(context); - } - if (name.equals("MI_SCALE".toLowerCase(Locale.US)) || name.equals("MI SCALE2".toLowerCase(Locale.US))) { - return new BluetoothMiScale(context); - } - if (name.equals("MIBCS".toLowerCase(Locale.US)) || name.equals("MIBFS".toLowerCase(Locale.US))) { - return new BluetoothMiScale2(context); - } - if (name.equals("Health Scale".toLowerCase(Locale.US))) { - return new BluetoothOneByone(context); - } - if(name.equals("1byone scale".toLowerCase(Locale.US))) { - return new BluetoothOneByoneNew(context); - } - - if (name.equals("SENSSUN FAT".toLowerCase(Locale.US))) { - return new BluetoothSenssun(context); - } - if (name.startsWith("SANITAS SBF70".toLowerCase(Locale.US)) || name.startsWith("sbf75") || name.startsWith("AICDSCALE1".toLowerCase(Locale.US))) { - return new BluetoothBeurerSanitas(context, BluetoothBeurerSanitas.DeviceType.SANITAS_SBF70_70); - } - if (deviceName.startsWith("YUNMAI-SIGNAL") || deviceName.startsWith("YUNMAI-ISM")) { - return new BluetoothYunmaiSE_Mini(context, true); - } - if (deviceName.startsWith("YUNMAI-ISSE")) { - return new BluetoothYunmaiSE_Mini(context, false); - } - if (deviceName.startsWith("01257B") || deviceName.startsWith("11257B")) { - // Trisa Body Analyze 4.0, aka Transtek GBF-1257-B - return new BluetoothTrisaBodyAnalyze(context); - } - if (deviceName.equals("000FatScale01") || deviceName.equals("000FatScale02") - || deviceName.equals("042FatScale01")) { - return new BluetoothInlife(context); - } - if (deviceName.startsWith("QN-Scale")) { - return new BluetoothQNScale(context); - } - if (deviceName.startsWith("Shape200") || deviceName.startsWith("Shape100") || deviceName.startsWith("Shape50") || deviceName.startsWith("Style100")) { - return new BluetoothSoehnle(context); - } - if (deviceName.equals("Hoffen BS-8107")) { - return new BluetoothHoffenBBS8107(context); - } - if (deviceName.equals("ADV") || deviceName.equals("Chipsea-BLE")) { - return new BluetoothOKOK(context); - } - if (deviceName.equals("NoName OkOk")) { - return new BluetoothOKOK2(context); - } - if (deviceName.equals("BF105") || deviceName.equals("BF720")) { - return new BluetoothBeurerBF105(context); - } - if (deviceName.equals("BF500")) { - return new BluetoothBeurerBF500(context, deviceName); - } - if (deviceName.equals("BF600") || deviceName.equals("BF850")) { - return new BluetoothBeurerBF600(context, deviceName); - } - if (deviceName.equals("SBF77") || deviceName.equals("SBF76") || deviceName.equals("BF950")) { - return new BluetoothBeurerBF950(context, deviceName); - } - if (deviceName.equals("SBF72") || deviceName.equals("BF915") || deviceName.equals("SBF73")) { - return new BluetoothSanitasSBF72(context, deviceName); - } - if (deviceName.equals("Weight Scale")) { - return new BluetoothSinocare(context); - } - if (deviceName.equals("CH100")) { - return new BluetoothHuaweiAH100(context); - } - if (deviceName.equals("ES-26BB-B")){ - return new BluetoothES26BBB(context); - } - if (deviceName.equals("Yoda1")){ - return new BluetoothYoda1Scale(context); - } - if (deviceName.equals("AAA002") || deviceName.equals("AAA007") || deviceName.equals("AAA013")) { - return new BluetoothBroadcastScale(context); - } - if (deviceName.equals("AE BS-06")) { - return new BluetoothActiveEraBF06(context); - } - if (deviceName.equals("Renpho-Scale")) { - /* Driver for Renpho ES-WBE28, which has device name of "Renpho-Scale". - "Renpho-Scale" is quite generic, not sure if other Renpho scales with different - protocol match this name. - */ - return new BluetoothRenphoScale(context); - } - if(deviceName.equals("ES-CS20M")){ - return new BluetoothESCS20M(context); - } - return null; - } - - public static String convertNoNameToDeviceName(SparseArray manufacturerSpecificData) { - String deviceName = null; - deviceName = BluetoothOKOK2.convertNoNameToDeviceName(manufacturerSpecificData); - - return deviceName; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothHesley.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothHesley.java deleted file mode 100644 index b6f90030..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothHesley.java +++ /dev/null @@ -1,96 +0,0 @@ -/* Copyright (C) 2018 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.bluetooth; - -import android.content.Context; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; - -import java.util.Date; -import java.util.UUID; - -public class BluetoothHesley extends BluetoothCommunication { - private final UUID WEIGHT_MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0xfff0); - private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xfff4); // read, notify - private final UUID CMD_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xfff1); // write only - - public BluetoothHesley(Context context) { - super(context); - } - - @Override - public String driverName() { - return "Hesley scale"; - } - - @Override - protected boolean onNextStep(int stepNr) { - switch (stepNr) { - case 0: - setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC); - break; - case 1: - byte[] magicBytes = {(byte)0xa5, (byte)0x01, (byte)0x2c, (byte)0xab, (byte)0x50, (byte)0x5a, (byte)0x29}; - writeBytes(WEIGHT_MEASUREMENT_SERVICE, CMD_MEASUREMENT_CHARACTERISTIC, magicBytes); - break; - case 2: - sendMessage(R.string.info_step_on_scale, 0); - break; - default: - return false; - } - - return true; - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - final byte[] data = value; - - - if (data != null && data.length > 0) { - if (data.length == 20) { - parseBytes(data); - - - } - } - } - - private void parseBytes(byte[] weightBytes) { - int bodyage = (int)(weightBytes[17]); // 10 ~ 99 - - float weight = (float) (((weightBytes[2] & 0xFF) << 8) | (weightBytes[3] & 0xFF)) / 100.0f; // kg - float fat = (float)(((weightBytes[4] & 0xFF) << 8) | (weightBytes[5] & 0xFF)) / 10.0f; // % - float water = (float)(((weightBytes[8] & 0xFF) << 8) | (weightBytes[9] & 0xFF)) / 10.0f; // % - float muscle = (float)(((weightBytes[10] & 0xFF) << 8) | (weightBytes[11] & 0xFF)) / 10.0f; // % - float bone = (float)(((weightBytes[12] & 0xFF) << 8) | (weightBytes[13] & 0xFF)) / 10.0f; // % - float calorie = (float)(((weightBytes[14] & 0xFF) << 8) | (weightBytes[15] & 0xFF)); // kcal - - ScaleMeasurement scaleBtData = new ScaleMeasurement(); - - scaleBtData.setWeight(weight); - scaleBtData.setFat(fat); - scaleBtData.setMuscle(muscle); - scaleBtData.setWater(water); - scaleBtData.setBone(bone); - scaleBtData.setDateTime(new Date()); - - addScaleMeasurement(scaleBtData); - } -} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothHoffenBBS8107.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothHoffenBBS8107.java deleted file mode 100644 index 888f36f8..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothHoffenBBS8107.java +++ /dev/null @@ -1,217 +0,0 @@ -/* Copyright (C) 2021 Karol Werner - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - -package com.health.openscale.core.bluetooth; - -import android.content.Context; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; - -import java.util.Arrays; -import java.util.Date; -import java.util.UUID; - -import timber.log.Timber; - -public class BluetoothHoffenBBS8107 extends BluetoothCommunication { - - private static final UUID UUID_SERVICE = BluetoothGattUuid.fromShortCode(0xffb0); - private static final UUID UUID_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xffb2); - - private static final byte MAGIC_BYTE = (byte) 0xFA; - - private static final byte RESPONSE_INTERMEDIATE_MEASUREMENT = (byte) 0x01; - private static final byte RESPONSE_FINAL_MEASUREMENT = (byte) 0x02; - private static final byte RESPONSE_ACK = (byte) 0x03; - - private static final byte CMD_MEASUREMENT_DONE = (byte) 0x82; - private static final byte CMD_CHANGE_SCALE_UNIT = (byte) 0x83; - private static final byte CMD_SEND_USER_DATA = (byte) 0x85; - - private ScaleUser user; - - public BluetoothHoffenBBS8107(Context context) { - super(context); - } - - @Override - public String driverName() { - return "Hoffen BBS-8107"; - } - - @Override - protected boolean onNextStep(int stepNr) { - switch (stepNr) { - case 0: - setNotificationOn(UUID_SERVICE, UUID_CHARACTERISTIC); - user = OpenScale.getInstance().getSelectedScaleUser(); - break; - - case 1: - // Send user data to the scale - byte[] userData = { - (byte) 0x00, // "plan" id? - user.getGender().isMale() ? (byte) 0x01 : (byte) 0x00, - (byte) user.getAge(), - (byte) user.getBodyHeight(), - }; - sendPacket(CMD_SEND_USER_DATA, userData); - - // Wait for scale response for this packet - stopMachineState(); - break; - - case 2: - // Send preferred scale unit to the scale - byte[] weightUnitData = { - (byte) (0x01 + user.getScaleUnit().toInt()), - (byte) 0x00, // always empty - }; - sendPacket(CMD_CHANGE_SCALE_UNIT, weightUnitData); - - // Wait for scale response for this packet - stopMachineState(); - break; - - case 3: - // Start measurement - sendMessage(R.string.info_step_on_scale, 0); - - // Wait until measurement is done - stopMachineState(); - break; - - case 4: - // Indicate successful measurement to the scale - byte[] terminateData = { - (byte) 0x00, // always empty - }; - sendPacket(CMD_MEASUREMENT_DONE, terminateData); - - // Wait for scale response for this packet - stopMachineState(); - break; - - case 5: - // Terminate the connection - scale will turn itself down after couple seconds - disconnect(); - break; - - default: - return false; - } - return true; - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - if (value == null || value.length < 2) { - return; - } - - if (!verifyData(value) && ((value[0] != MAGIC_BYTE) || (value[1] != RESPONSE_FINAL_MEASUREMENT))) { - // For packet starting with 0xFA 0x02 checksum will be sent in next notify message so we - // will disable checking checksum for this particular packet - Timber.e("Checksum incorrect"); - return; - } - - if (value[0] != MAGIC_BYTE) { - Timber.w("Received unexpected, but correct data: %s", Arrays.toString(value)); - return; - } - - float weight; - switch (value[1]) { - case RESPONSE_INTERMEDIATE_MEASUREMENT: - // Got intermediate result - weight = Converters.fromUnsignedInt16Le(value, 4) / 10.0f; - Timber.d("Got intermediate weight: %.1f %s", weight, user.getScaleUnit().toString()); - break; - - case RESPONSE_FINAL_MEASUREMENT: - // Got final result - addScaleMeasurement(parseFinalMeasurement(value)); - resumeMachineState(); - break; - - case RESPONSE_ACK: - // Got response from scale - Timber.d("Got ack from scale, can proceed"); - resumeMachineState(); - break; - - default: - Timber.e("Got unexpected response: %x", value[1]); - } - } - - private ScaleMeasurement parseFinalMeasurement(byte[] value) { - float weight = Converters.fromUnsignedInt16Le(value, 3) / 10.0f; - Timber.d("Got final weight: %.1f %s", weight, user.getScaleUnit().toString()); - sendMessage(R.string.info_measuring, weight); - - if (user.getScaleUnit() != Converters.WeightUnit.KG) { - // For lb and st this scale will always return result in lb - weight = Converters.toKilogram(weight, Converters.WeightUnit.LB); - } - - ScaleMeasurement measurement = new ScaleMeasurement(); - measurement.setDateTime(new Date()); - measurement.setWeight(weight); - - if (value[5] == (byte) 0x00) { - // If user stands bare foot on weight scale it will report more data - measurement.setFat(Converters.fromUnsignedInt16Le(value, 6) / 10.0f); - measurement.setWater(Converters.fromUnsignedInt16Le(value, 8) / 10.0f); - measurement.setMuscle(Converters.fromUnsignedInt16Le(value, 10) / 10.0f); - // Basal metabolic rate is not stored because it's calculated by app - // Bone weight seems to be always returned in kg - measurement.setBone(value[14] / 10.0f); - // BMI is not stored because it's calculated by app - measurement.setVisceralFat(Converters.fromUnsignedInt16Le(value, 17) / 10.0f); - // Internal body age is not stored in app - } else if (value[5] == (byte) 0x04) { - Timber.w("No more data to store"); - } else { - Timber.e("Received unexpected value: %x", value[5]); - } - return measurement; - } - - private void sendPacket(byte command, byte[] payload) { - // Add required fields to provided payload and send the packet - byte[] outputArray = new byte[payload.length + 4]; - - outputArray[0] = MAGIC_BYTE; - outputArray[1] = command; - outputArray[2] = (byte) payload.length; - System.arraycopy(payload, 0, outputArray, 3, payload.length); - // Calculate checksum skipping first element - outputArray[outputArray.length - 1] = xorChecksum(outputArray, 1, outputArray.length - 2); - - writeBytes(UUID_SERVICE, UUID_CHARACTERISTIC, outputArray, true); - } - - private boolean verifyData(byte[] data) { - // First byte is skipped in calculated checksum - return xorChecksum(data, 1, data.length - 1) == 0; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothHuaweiAH100.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothHuaweiAH100.java deleted file mode 100644 index df7d8e3a..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothHuaweiAH100.java +++ /dev/null @@ -1,805 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.bluetooth; - -import android.content.Context; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.util.Arrays; -import java.util.Calendar; -import java.util.Date; -import java.util.UUID; - -import timber.log.Timber; - -// +++ -import android.os.Handler; - -import javax.crypto.Cipher; -import javax.crypto.spec.IvParameterSpec; -import javax.crypto.spec.SecretKeySpec; - - -public class BluetoothHuaweiAH100 extends BluetoothCommunication { - private static final UUID SERVICE_AH100_CUSTOM_SERVICE = BluetoothGattUuid.fromShortCode(0xfaa0); - - private static final UUID SERVICE_AH100_CUSTOM_SEND = BluetoothGattUuid.fromShortCode(0xfaa1); - private static final UUID SERVICE_AH100_CUSTOM_RECEIVE = BluetoothGattUuid.fromShortCode(0xfaa2); - - // +++ - private static byte[] user_id = {0, 0, 0, 0, 0, 0, 0}; - - private enum STEPS { - INIT, - INIT_W, - AUTHORISE, - SCALE_UNIT, - SCALE_TIME, - USER_INFO, - SCALE_VERSION, - WAIT_MEASUREMENT, - READ_HIST, - READ_HIST_NEXT, - EXIT, - BIND, - EXIT2 - } - - private static final byte AH100_NOTIFICATION_WAKEUP = 0x00; - private static final byte AH100_NOTIFICATION_GO_SLEEP = 0x01; - private static final byte AH100_NOTIFICATION_UNITS_SET = 0x02; - private static final byte AH100_NOTIFICATION_REMINDER_SET = 0x03; - private static final byte AH100_NOTIFICATION_SCALE_CLOCK = 0x08; - private static final byte AH100_NOTIFICATION_SCALE_VERSION = 0x0C; - private static final byte AH100_NOTIFICATION_MEASUREMENT = 0x0E; - private static final byte AH100_NOTIFICATION_MEASUREMENT2 = (byte) 0x8E; - private static final byte AH100_NOTIFICATION_MEASUREMENT_WEIGHT = 0x0F; - private static final byte AH100_NOTIFICATION_HISTORY_RECORD = 0x10; - private static final byte AH100_NOTIFICATION_HISTORY_RECORD2 = (byte) 0x90; - private static final byte AH100_NOTIFICATION_UPGRADE_RESPONSE = 0x11; - private static final byte AH100_NOTIFICATION_UPGRADE_RESULT = 0x12; - private static final byte AH100_NOTIFICATION_WEIGHT_OVERLOAD = 0x13; - private static final byte AH100_NOTIFICATION_LOW_POWER = 0x14; - private static final byte AH100_NOTIFICATION_MEASUREMENT_ERROR = 0x15; - private static final byte AH100_NOTIFICATION_SET_CLOCK_ACK = 0x16; - private static final byte AH100_NOTIFICATION_OTA_UPGRADE_READY = 0x17; - private static final byte AH100_NOTIFICATION_SCALE_MAC_RECEIVED = 0x18; - private static final byte AH100_NOTIFICATION_HISTORY_UPLOAD_DONE = 0x19; - private static final byte AH100_NOTIFICATION_USER_CHANGED = 0x20; - private static final byte AH100_NOTIFICATION_AUTHENTICATION_RESULT = 0x26; - private static final byte AH100_NOTIFICATION_BINDING_SUCCESSFUL = 0x27; - private static final byte AH100_NOTIFICATION_FIRMWARE_UPDATE_RECEIVED = 0x28; - - private static final byte AH100_CMD_SET_UNIT = 2; - private static final byte AH100_CMD_DELETE_ALARM_CLOCK = 3; - private static final byte AH100_CMD_SET_ALARM_CLOCK = 4; - private static final byte AH100_CMD_DELETE_ALL_ALARM_CLOCK = 5; - private static final byte AH100_CMD_GET_ALARM_CLOCK_BY_NO = 6; - private static final byte AH100_CMD_SET_SCALE_CLOCK = 8; - private static final byte AH100_CMD_SELECT_USER = 10; - private static final byte AH100_CMD_USER_INFO = 9; - private static final byte AH100_CMD_GET_RECORD = 11; - private static final byte AH100_CMD_GET_VERSION = 12; - private static final byte AH100_CMD_GET_SCALE_CLOCK = 14; - private static final byte AH100_CMD_GET_USER_LIST_MARK = 15; - private static final byte AH100_CMD_UPDATE_SIGN = 16; - private static final byte AH100_CMD_DELETE_ALL_USER = 17; - private static final byte AH100_CMD_SET_BLE_BROADCAST_TIME = 18; - private static final byte AH100_CMD_FAT_RESULT_ACK = 19; - private static final byte AH100_CMD_GET_LAST_RECORD = 20; - private static final byte AH100_CMD_DISCONNECT_BT = 22; - private static final byte AH100_CMD_HEART_BEAT = 32; - private static final byte AH100_CMD_AUTH = 36; - private static final byte AH100_CMD_BIND_USER = 37; - private static final byte AH100_CMD_OTA_PACKAGE = (byte) 0xDD; - - private Context context; - private byte[] authCode; - private byte[] initialKey ; - private byte[] initialValue ; - private byte[] magicKey ; - - private int triesToAuth = 0; - private int triesToBind = 0; - private int lastMeasuredWeight = -1; - private boolean authorised = false; - private boolean scaleWakedUp = false; - private boolean scaleBinded = false; - private byte receivedPacketType = 0x00; - private byte[] receivedPacket1; - - private Handler beatHandler; - - - public BluetoothHuaweiAH100(Context context) { - super(context); - this.context = context; - this.beatHandler = new Handler(); - authCode = getUserID(); - initialKey = hexToByteArray("3D A2 78 4A FB 87 B1 2A 98 0F DE 34 56 73 21 56"); - initialValue = hexToByteArray("4E F7 64 32 2F DA 76 32 12 3D EB 87 90 FE A2 19"); - - } - - @Override - public String driverName() { - return "Huawei AH100 Body Fat Scale"; - } - -/////////////////////////////////////////////////////////////////// -/////////////////////////////////////////////////////////////////// -/////////////////////////////////////////////////////////////////// - - @Override - protected boolean onNextStep(int stepNr) { - STEPS step; - try { - step = STEPS.values()[stepNr]; - } catch (Exception e) { - // stepNr is bigger then we have in STEPS - return false; - } - switch (step) { - case INIT: - // wait scale wake up - Timber.d("AH100::onNextStep step 0 = set notification"); - final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser(); - // Setup notification - setNotificationOn(SERVICE_AH100_CUSTOM_SERVICE, SERVICE_AH100_CUSTOM_RECEIVE); - triesToAuth = 0; - authorised = false; - stopMachineState(); - break; - case INIT_W: - stopMachineState(); - break; - case AUTHORISE: - if ( scaleWakedUp == false ) { - jumpNextToStepNr( STEPS.INIT.ordinal() ); - break; - } - // authorize in scale - Timber.d("AH100::onNextStep = authorize on scale"); - triesToAuth++; - AHcmdAutorise(); - stopMachineState(); - break; - case SCALE_UNIT: - Timber.d("AH100::onNextStep = set scale unit"); - AHcmdSetUnit(); - stopMachineState(); - break; - case SCALE_TIME: - Timber.d("AH100::onNextStep = set scale time"); - AHcmdDate(); - stopMachineState(); - break; - case USER_INFO: - Timber.d("AH100::onNextStep = send user info to scale"); - if ( !authorised ) { - jumpNextToStepNr( STEPS.AUTHORISE.ordinal() ); - break; - } - // set user data - AHcmdUserInfo(); - stopMachineState(); - break; - case SCALE_VERSION: - Timber.d("AH100::onNextStep = request scale version"); - if ( !authorised ) { - jumpNextToStepNr( STEPS.AUTHORISE.ordinal() ); - break; - } - AHcmdGetVersion(); - stopMachineState(); - break; - case WAIT_MEASUREMENT: - AHcmdGetUserList(); - Timber.d("AH100::onNextStep = Do nothing, wait while scale tries disconnect"); - sendMessage(R.string.info_step_on_scale, 0); - stopMachineState(); - break; - case READ_HIST: - Timber.d("AH100::onNextStep = read history record from scale"); - if ( !authorised ) { - jumpNextToStepNr( STEPS.AUTHORISE.ordinal() ); - break; - } - AHcmdReadHistory(); - stopMachineState(); - break; - case READ_HIST_NEXT: - Timber.d("AH100::onNextStep = read NEXT history record from scale"); - if ( !authorised ) { - jumpNextToStepNr( STEPS.AUTHORISE.ordinal() ); - break; - } - AHcmdReadHistoryNext(); - stopMachineState(); - break; - case EXIT: - Timber.d("AH100::onNextStep = Exit"); - authorised = false; - scaleWakedUp = false; - stopHeartBeat(); - disconnect(); - return false; - case BIND: - Timber.d("AH100::onNextStep = BIND scale to OpenScale"); - // Start measurement - sendMessage(R.string.info_step_on_scale, 0); - triesToBind++; - AHcmdBind(); - AHcmdBind(); - stopMachineState(); - break; - case EXIT2: - authorised = false; - scaleWakedUp = false; - stopHeartBeat(); - disconnect(); - Timber.d("AH100::onNextStep = BIND Exit"); - default: - return false; - } - - return true; - } - -/////////////////////////////////////////////////////////////////// -/////////////////////////////////////////////////////////////////// - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - final byte[] data = value; - byte cmdlength = 0; - Timber.d("AH100::onBluetoothNotify uuid: %s", characteristic.toString()); - - if (data != null && data.length > 2) { - Timber.d("===> New NOTIFY hex data: %s", byteInHex(data)); - - cmdlength = data[1]; - - // responce from scale received - switch (data[2]) { - case AH100_NOTIFICATION_WAKEUP: - scaleWakedUp = true; - if (getStepNr() - 1 == STEPS.INIT_W.ordinal() ) { - Timber.d("AH100::onNotify = Scale is waked up in Init-stage"); - startHeartBeat(); - resumeMachineState(); - break; - } -// if (getStepNr() - 1 == STEPS.BIND.ordinal() ) { -// Timber.d("AH100::onNotify = Scale is waked up in Init-stage"); -// jumpBackOneStep(); -// resumeMachineState(); -// break; -// } - Timber.d("AH100::onNotify = Scale is waked up"); -// authorised = false; -// jumpNextToStepNr(STEPS.AUTHORISE.ordinal()); -// resumeMachineState(); - break; - case AH100_NOTIFICATION_GO_SLEEP: - resumeMachineState(); - break; - case AH100_NOTIFICATION_UNITS_SET: - resumeMachineState(); - break; - case AH100_NOTIFICATION_REMINDER_SET: - break; - case AH100_NOTIFICATION_SCALE_CLOCK: - resumeMachineState(); - break; - case AH100_NOTIFICATION_SCALE_VERSION: - byte[] VERpayload = getPayload(data); - Timber.d("Get Scale Version: input data: %s", byteInHex(VERpayload)); - resumeMachineState(); - break; - case AH100_NOTIFICATION_MEASUREMENT: - if (data[0] == (byte) 0xBD) { - Timber.d("Scale plain response received"); - } - if (data[0] == (byte) 0xBC) { - Timber.d("Scale encoded response received"); - receivedPacket1 = Arrays.copyOfRange(data, 0, data.length ); - receivedPacketType = AH100_NOTIFICATION_MEASUREMENT; - } - break; - case AH100_NOTIFICATION_MEASUREMENT2: - if (data[0] == (byte) 0xBC) { /// normal packet - Timber.d("Scale encoded response received"); - if (receivedPacketType == AH100_NOTIFICATION_MEASUREMENT) { - AHrcvEncodedMeasurement(receivedPacket1, data, AH100_NOTIFICATION_MEASUREMENT); - receivedPacketType = 0x00; - if (scaleBinded == true) { - AHcmdMeasurementAck(); - } else { - if (lastMeasuredWeight > 0) { - AHcmdUserInfo(lastMeasuredWeight); - } - } - jumpNextToStepNr( STEPS.READ_HIST.ordinal() ); - resumeMachineState(); - } - break; - } - if (data[0] == (byte) 0xBD) { - Timber.d("Scale plain response received"); - } - jumpNextToStepNr( STEPS.INIT.ordinal() ); - resumeMachineState(); - break; - case AH100_NOTIFICATION_MEASUREMENT_WEIGHT: - break; - case AH100_NOTIFICATION_HISTORY_RECORD: - if (data[0] == (byte) 0xBD) { - Timber.d("Scale plain response received"); - } - if (data[0] == (byte) 0xBC) { - Timber.d("Scale encoded response received"); - receivedPacket1 = Arrays.copyOfRange(data, 0, data.length ); - receivedPacketType = AH100_NOTIFICATION_HISTORY_RECORD; - } - break; - case AH100_NOTIFICATION_HISTORY_RECORD2: - if (data[0] == (byte) 0xBC) { /// normal packet - Timber.d("Scale encoded response received"); - if (receivedPacketType == AH100_NOTIFICATION_HISTORY_RECORD) { - AHrcvEncodedMeasurement(receivedPacket1, data, AH100_NOTIFICATION_HISTORY_RECORD); - receivedPacketType = 0x00; - // todo: jumpback only in ReadHistoryNext - jumpNextToStepNr(STEPS.READ_HIST_NEXT.ordinal(), - STEPS.READ_HIST_NEXT.ordinal()); - resumeMachineState(); - } - break; - } - if (data[0] == (byte) 0xBD) { - Timber.d("Scale plain response received"); - } - jumpNextToStepNr( STEPS.INIT.ordinal() ); - resumeMachineState(); - break; - case AH100_NOTIFICATION_UPGRADE_RESPONSE: - break; - case AH100_NOTIFICATION_UPGRADE_RESULT: - break; - case AH100_NOTIFICATION_WEIGHT_OVERLOAD: - break; - case AH100_NOTIFICATION_LOW_POWER: - break; - case AH100_NOTIFICATION_MEASUREMENT_ERROR: - break; - case AH100_NOTIFICATION_SET_CLOCK_ACK: - break; - case AH100_NOTIFICATION_OTA_UPGRADE_READY: - break; - case AH100_NOTIFICATION_SCALE_MAC_RECEIVED: - break; - case AH100_NOTIFICATION_HISTORY_UPLOAD_DONE: - resumeMachineState(); - break; - case AH100_NOTIFICATION_USER_CHANGED: - resumeMachineState(STEPS.USER_INFO.ordinal()); // waiting wake up in state 4 - break; - case AH100_NOTIFICATION_AUTHENTICATION_RESULT: - byte[] ARpayload = getPayload(data); - if ( 1 == ARpayload[0] ){ - authorised = true; - magicKey = hexConcatenate(obfuscate(authCode) , Arrays.copyOfRange(initialKey, 7, initialKey.length ) ); - resumeMachineState(STEPS.AUTHORISE.ordinal()); // waiting wake up in state 4 - } else { - if (triesToAuth < 3){ // try again - jumpNextToStepNr(STEPS.AUTHORISE.ordinal()); - } else { // bind scale to own code - jumpNextToStepNr(STEPS.BIND.ordinal()); - } - resumeMachineState(); - } - // acknowledge that you received the last history data - break; - case AH100_NOTIFICATION_BINDING_SUCCESSFUL: - // jump to authorise again - jumpNextToStepNr(STEPS.SCALE_TIME.ordinal()); - scaleBinded = true; - // TODO: count binding tries - break; - case AH100_NOTIFICATION_FIRMWARE_UPDATE_RECEIVED: - break; - default: - break; - - } // switch command - - } - } - - - - - - private void AHcmdHeartBeat() { - AHsendCommand(AH100_CMD_HEART_BEAT, new byte[0] ); - } - - - private void AHcmdAutorise() { - AHsendCommand(AH100_CMD_AUTH, authCode); - } - - private void AHcmdBind() { - AHsendCommand(AH100_CMD_BIND_USER, authCode); - } - - - private void AHcmdDate() { - /* - payload[0]: lowerByte(year) - payload[1]: upperByte(year) - payload[2]: month (1..12) - payload[3]: dayOfMonth - payload[4]: hourOfDay (0-23) - payload[5]: minute - payload[6]: second - payload[7]: day of week (Monday=1, Sunday=7) - */ - Calendar currentDateTime = Calendar.getInstance(); - int year = currentDateTime.get(Calendar.YEAR); - byte month = (byte)(currentDateTime.get(Calendar.MONTH)+1); - byte day = (byte)currentDateTime.get(Calendar.DAY_OF_MONTH); - byte hour = (byte)currentDateTime.get(Calendar.HOUR_OF_DAY); - byte min = (byte)currentDateTime.get(Calendar.MINUTE); - byte sec = (byte)currentDateTime.get(Calendar.SECOND); - byte dow = (byte)currentDateTime.get(Calendar.DAY_OF_WEEK); - byte[] date = new byte[]{ - 0x00, 0x00, // year, fill later - month, - day, - hour, - min, - sec, - dow - }; - Converters.toInt16Le(date, 0, year); - Timber.d("AH100::AHcmdDate: data to send: %s", byteInHex(date) ); - AHsendCommand(AH100_CMD_SET_SCALE_CLOCK, date); - } - - private void AHcmdUserInfo() { - ///String user example = "27 af 00 2a 03 ff ff"; - - ScaleUser currentUser = OpenScale.getInstance().getSelectedScaleUser(); - int weight = (int) currentUser.getInitialWeight() * 10; - AHcmdUserInfo(weight); - } - - private void AHcmdUserInfo(int weight) { - ///String user example = "27 af 00 2a 03 ff ff"; - /* - payload[7] = sex == 1 ? age | 0x80 : age - payload[8] = height of the user - payload[9] = 0 - payload[10] = lowerByte(weight) - payload[11] = upperByte(weight) - payload[12] = lowerByte(impedance) - payload[13] = upperByte(impedance) - */ - ScaleUser currentUser = OpenScale.getInstance().getSelectedScaleUser(); - byte height = (byte) currentUser.getBodyHeight(); - byte sex = currentUser.getGender().isMale() ? 0 : (byte) 0x80; - byte age = (byte) ( sex | ((byte) currentUser.getAge()) ); - byte[] user = new byte[]{ - age, - height, - 0, - 0x00, 0x00, // weight, fill later - (byte) 0xFF, (byte) 0xFF, // resistance, wkwtfdim - (byte) 0x1C, (byte) 0xE2, - }; - Converters.toInt16Le(user, 3, weight); - byte[] userinfo = hexConcatenate( authCode, user ); - AHsendCommand(AH100_CMD_USER_INFO, userinfo, 14); - } - - private void AHcmdReadHistory() { - byte[] pl; - byte[] xp = {xorChecksum(authCode, 0, authCode.length)}; - pl = hexConcatenate( authCode, xp ); - AHsendCommand(AH100_CMD_GET_RECORD, pl, 0x07 - 1); - } - - private void AHcmdReadHistoryNext() { - byte[] pl = {0x01}; - AHsendCommand(AH100_CMD_GET_RECORD, pl); - } - - private void AHcmdSetUnit() { - // TODO: set correct units - byte[] pl = new byte[]{0x01}; // 1 = kg; 2 = pounds. set kg only - AHsendCommand(AH100_CMD_SET_UNIT, pl); - } - - private void AHcmdGetUserList() { - //byte[] pl = new byte[]{}; -// byte[] pl = authCode; -// AHsendCommand(AH100_CMD_SELECT_USER, pl); - } - - private void AHcmdGetVersion() { - byte[] pl = new byte[]{}; - AHsendCommand(AH100_CMD_GET_VERSION, pl); - } - - private void AHcmdMeasurementAck() { - byte[] pl = new byte[]{0x00}; - AHsendCommand(AH100_CMD_FAT_RESULT_ACK, pl); - } - - private void AHrcvEncodedMeasurement(byte[] encdata, byte[] encdata2, byte type) { - byte[] payload = getPayload(encdata); - byte[] data; - try{ - data = decryptAES(payload, magicKey, initialValue); - Timber.d("Decrypted measurement: hex data: %s", byteInHex(data)); - if ( (type == AH100_NOTIFICATION_MEASUREMENT) || - (type == AH100_NOTIFICATION_HISTORY_RECORD) ) { - AHaddFatMeasurement(data); - } - } catch (Exception e) { - Timber.d("Decrypting FAIL!!!"); - } - } - - private void AHaddFatMeasurement(byte[] data) { - if (data.length < 14) { - Timber.d(":: AHaddFatMeasurement : data is too short. Expected at least 14 bytes of data." ); - return ; - } - byte userid = data[0]; ///// Arrays.copyOfRange(data, 0, 0 ); - lastMeasuredWeight = Converters.fromUnsignedInt16Le(data, 1); - float weight = lastMeasuredWeight / 10.0f; - float fat = Converters.fromUnsignedInt16Le(data, 3) / 10.0f; - int year = Converters.fromUnsignedInt16Le(data, 5) ; - int resistance = Converters.fromUnsignedInt16Le(data, 13) ; - byte month = (byte) (data[7] - 1); // 1..12 to zero-based month - byte dayInMonth = data[8]; - byte hour = data[9]; - byte minute = data[10]; - byte second = data[11]; - byte weekNumber = data[12]; - Timber.d("---- measured userid %d",userid ); - Timber.d("---- measured weight %f",weight ); - Timber.d("---- measured fat %f",fat ); - Timber.d("---- measured resistance %d",resistance ); - Timber.d("---- measured year %d",year ); - Timber.d("---- measured month %d",month ); - Timber.d("---- measured dayInMonth %d",dayInMonth ); - Timber.d("---- measured hour %d",hour ); - Timber.d("---- measured minute %d",minute ); - Timber.d("---- measured second %d",second ); - Timber.d("---- measured week day %d",weekNumber ); - /////////////////////////// - Calendar calendar = Calendar.getInstance(); - calendar.set( year, month, dayInMonth, hour, minute, second); - Date date = calendar.getTime(); - - ScaleUser currentUser = OpenScale.getInstance().getSelectedScaleUser(); - ScaleMeasurement receivedMeasurement = new ScaleMeasurement(); - receivedMeasurement.setUserId(currentUser.getId()); - receivedMeasurement.setDateTime( date ); - receivedMeasurement.setWeight(weight); - receivedMeasurement.setFat(fat); -// receivedMeasurement.setWater(water); -// receivedMeasurement.setMuscle(muscle); -// receivedMeasurement.setBone(bone); -// todo: calculate water, muscle, bones - addScaleMeasurement(receivedMeasurement); - } - - - private void startHeartBeat() { - Timber.d("*** Heart beat started"); - beatHandler.postDelayed(new Runnable() { - @Override - public void run() { - Timber.d("*** heart beat."); - AHcmdHeartBeat(); - } - }, 2000); // 2 s - } - - private void resetHeartBeat() { - Timber.d("*** 0 heart beat reset"); - beatHandler.removeCallbacksAndMessages(null); - startHeartBeat(); - } - - private void stopHeartBeat() { - Timber.d("*** ! heart beat stopped"); - beatHandler.removeCallbacksAndMessages(null); - } - - - private void AHsendCommand(byte cmd, byte[] payload ) { - AHsendCommand(cmd, payload, payload.length ); - } - - private void AHsendCommand(byte cmd, byte[] payload, int len ) { - resetHeartBeat(); - if ( (cmd == AH100_CMD_USER_INFO) ) { - AHsendEncryptedCommand(cmd, payload, len); - return; - } - byte[] packet ; - byte[] header; - header = new byte[]{(byte) (0xDB), - (byte) (len + 1), - cmd}; - packet = hexConcatenate( header, obfuscate(payload) ); - - try { - writeBytes(SERVICE_AH100_CUSTOM_SERVICE, - SERVICE_AH100_CUSTOM_SEND, - packet); - } catch (Exception e) { - Timber.d("AHsendCommand: CANNOT WRITE COMMAND"); - stopHeartBeat(); - } - } - - - private void AHsendEncryptedCommand(byte cmd, byte[] payload , int len ) { - byte[] packet ; - byte[] header; - byte[] encrypted; - Timber.d("AHsendEncryptedCommand: input data: %s", byteInHex(payload)); - - encrypted = encryptAES(payload, magicKey, initialValue); //encryptAES - header = new byte[]{(byte) (0xDC), - (byte) (len + 0 ), - cmd}; - packet = hexConcatenate( header, obfuscate(encrypted) ); - try { - writeBytes(SERVICE_AH100_CUSTOM_SERVICE, - SERVICE_AH100_CUSTOM_SEND, - packet); - } catch (Exception e) { - Timber.d("AHsendEncryptedCommand: CANNOT WRITE COMMAND"); - stopHeartBeat(); - } - } - - - public byte[] getUserID() { - ScaleUser currentUser = OpenScale.getInstance().getSelectedScaleUser(); - byte id = (byte) currentUser.getId(); - byte[] auth = new byte[] {0x11, 0x22, 0x33, 0x44, 0x55, 0x00, id}; - auth[5] = xorChecksum(auth, 0, auth.length); // set xor of authorise code to 0x00 - return auth; -///// return getfakeUserID(); - } - - public byte[] getfakeUserID() { - String fid = "0f 00 43 06 7b 4e 7f"; // "c7b25de6bed0b7"; - byte[] auth = hexToByteArray(fid) ; - return auth; - } - - - public byte[] encryptAES(byte[] data, byte[] key, byte[] ivs) { - Timber.d("Encoding : input hex data: %s", byteInHex(data)); - Timber.d("Encoding : encoding key : %s", byteInHex(key)); - Timber.d("Encoding : initial value : %s", byteInHex(ivs)); - try { - Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding"); - SecretKeySpec secretKeySpec = new SecretKeySpec(key, "AES"); - byte[] finalIvs = new byte[16]; - int len = ivs.length > 16 ? 16 : ivs.length; - System.arraycopy(ivs, 0, finalIvs, 0, len); - IvParameterSpec ivps = new IvParameterSpec(finalIvs); - cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivps); - return cipher.doFinal(data); - } catch (Exception e) { - e.printStackTrace(); - } - - return null; - } - - public byte[] decryptAES(byte[] data, byte[] key, byte[] ivs) { - Timber.d("Decoding : input hex data: %s", byteInHex(data)); - Timber.d("Decoding : encoding key : %s", byteInHex(key)); - Timber.d("Decoding : initial value : %s", byteInHex(ivs)); - try { - Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding"); - SecretKeySpec secretKeySpec = new SecretKeySpec(key, "AES"); - byte[] finalIvs = new byte[16]; - int len = ivs.length > 16 ? 16 : ivs.length; - System.arraycopy(ivs, 0, finalIvs, 0, len); - IvParameterSpec ivps = new IvParameterSpec(finalIvs); - cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivps); - byte[] ret = cipher.doFinal(data); - Timber.d("### decryptAES : hex data: %s", byteInHex(ret)); - return ret; - } catch (Exception e) { - e.printStackTrace(); - } - - return null; - } - - public byte[] hexToByteArray(String hexStr) { - String hex = hexStr.replaceAll (" ","").replaceAll (":",""); - hex = hex.length()%2 != 0?"0"+hex:hex; - - byte[] b = new byte[hex.length() / 2]; - - for (int i = 0; i < b.length; i++) { - int index = i * 2; - int v = Integer.parseInt(hex.substring(index, index + 2), 16); - b[i] = (byte) v; - } - return b; - } - - public byte[] hexConcatenate(byte[] A, byte[] B) { - byte[] C = new byte[A.length + B.length]; - ByteArrayOutputStream outputStream = new ByteArrayOutputStream( ); - try { - outputStream.write( A ); - outputStream.write( B ); - C = outputStream.toByteArray( ); - } catch (IOException e) { - e.printStackTrace(); - } - return C; - } - - public byte[] getPayload(byte[] data) { - byte[] obfpayload = Arrays.copyOfRange(data, 3, data.length ); - byte[] payload = obfuscate(obfpayload); - Timber.d("Deobfuscated payload: %s", byteInHex(payload)); - return payload; - } - - - private byte[] obfuscate(byte[] rawdata) { - final byte[] data = Arrays.copyOfRange(rawdata, 0, rawdata.length ); - final byte[] MAC; - MAC = getScaleMacAddress(); - Timber.d("Obfuscation: input hex data: %s", byteInHex(data)); - //Timber.d("Obfuscation: MAC hex data: %s", byteInHex(MAC)); - - byte m = 0 ; - for(int l=0; l< data.length; l++,m++){ - if (MAC.length <= m) { m = 0; } - data[l] ^= MAC[m]; - } - //Timber.d("Obfuscation: out hex data: %s", byteInHex(data)); - return data; - } - -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothIhealthHS3.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothIhealthHS3.java deleted file mode 100644 index 90e16c77..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothIhealthHS3.java +++ /dev/null @@ -1,264 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* Copyright (C) 2018 John Lines -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.bluetooth; - -import android.bluetooth.BluetoothAdapter; -import android.bluetooth.BluetoothDevice; -import android.bluetooth.BluetoothSocket; -import android.content.Context; - -import com.health.openscale.core.datatypes.ScaleMeasurement; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.util.Arrays; -import java.util.Date; -import java.util.UUID; - -import timber.log.Timber; - -public class BluetoothIhealthHS3 extends BluetoothCommunication { - private final UUID uuid = BluetoothGattUuid.fromShortCode(0x1101); // Standard SerialPortService ID - - private BluetoothSocket btSocket = null; - private BluetoothDevice btDevice = null; - - private BluetoothConnectedThread btConnectThread = null; - - private byte[] lastWeight = new byte[2]; - private Date lastWeighed = new Date(); - private final long maxTimeDiff = 60000; // maximum time interval we will consider two identical - // weight readings to be the same and hence ignored - 60 seconds in milliseconds - - public BluetoothIhealthHS3(Context context) { - super(context); - } - - @Override - public String driverName() { - return "iHealth HS33FA4A"; - } - - @Override - protected boolean onNextStep(int stepNr) { - Timber.w("ihealthHS3 - onNextStep - returning false"); - return false; - } - - @Override - public void connect(String hwAddress) { - BluetoothAdapter btAdapter = BluetoothAdapter.getDefaultAdapter(); - - if (btAdapter == null) { - setBluetoothStatus(BT_STATUS.NO_DEVICE_FOUND); - return; - } - - btDevice = btAdapter.getRemoteDevice(hwAddress); - try { - // Get a BluetoothSocket to connect with the given BluetoothDevice - btSocket = btDevice.createRfcommSocketToServiceRecord(uuid); - } catch (IOException e) { - setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR, "Can't get a bluetooth socket"); - btDevice = null; - return; - } - - Thread socketThread = new Thread() { - @Override - public void run() { - try { - if (!btSocket.isConnected()) { - // Connect the device through the socket. This will block - // until it succeeds or throws an exception - btSocket.connect(); - - // Bluetooth connection was successful - setBluetoothStatus(BT_STATUS.CONNECTION_ESTABLISHED); - - btConnectThread = new BluetoothConnectedThread(); - btConnectThread.start(); - } - } catch (IOException connectException) { - // Unable to connect; close the socket and get out - disconnect(); - setBluetoothStatus(BT_STATUS.NO_DEVICE_FOUND); - } - } - }; - - socketThread.start(); - } - - @Override - public void disconnect() { - - Timber.w("HS3 - disconnect"); - if (btSocket != null) { - if (btSocket.isConnected()) { - try { - btSocket.close(); - btSocket = null; - } catch (IOException closeException) { - setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR, "Can't close bluetooth socket"); - } - } - } - - if (btConnectThread != null) { - btConnectThread.cancel(); - btConnectThread = null; - } - - btDevice = null; - } - - - private boolean sendBtData(String data) { - Timber.w("ihealthHS3 - sendBtData %s", data); - if (btSocket.isConnected()) { - btConnectThread = new BluetoothConnectedThread(); - btConnectThread.write(data.getBytes()); - - btConnectThread.cancel(); - - return true; - } - Timber.w("ihealthHS3 - sendBtData - socket is not connected"); - return false; - } - - private class BluetoothConnectedThread extends Thread { - private InputStream btInStream; - private OutputStream btOutStream; - private volatile boolean isCancel; - - public BluetoothConnectedThread() { -// Timber.w("ihealthHS3 - BluetoothConnectedThread"); - isCancel = false; - - // Get the input and output bluetooth streams - try { - btInStream = btSocket.getInputStream(); - btOutStream = btSocket.getOutputStream(); - } catch (IOException e) { - setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR, "Can't get bluetooth input or output stream " + e.getMessage()); - } - } - - public void run() { - - byte btByte; - byte[] weightBytes = new byte[2]; -// Timber.w("ihealthHS3 - run"); - // Keep listening to the InputStream until an exception occurs (e.g. device partner goes offline) - while (!isCancel) { - try { - // stream read is a blocking method - - btByte = (byte) btInStream.read(); -// Timber.w("iheathHS3 - seen a byte "+String.format("%02X",btByte)); - - if ( btByte == (byte) 0xA0 ) { - btByte = (byte) btInStream.read(); - if ( btByte == (byte) 0x09 ) { - btByte = (byte) btInStream.read(); - if ( btByte == (byte) 0xa6 ) { - btByte = (byte) btInStream.read(); - if ( btByte == (byte) 0x28 ) { -// Timber.w("seen 0xa009a628 - Weight packet"); - // deal with a weight packet - read 5 bytes we dont care about - btByte = (byte) btInStream.read(); - btByte = (byte) btInStream.read(); - btByte = (byte) btInStream.read(); - btByte = (byte) btInStream.read(); - btByte = (byte) btInStream.read(); -// and the weight - which should follow - weightBytes[0] = (byte) btInStream.read(); - weightBytes[1] = (byte) btInStream.read(); - - ScaleMeasurement scaleMeasurement = parseWeightArray(weightBytes); - - if (scaleMeasurement != null) { - addScaleMeasurement(scaleMeasurement); - } - - } - else if (btByte == (byte) 0x33 ) { - Timber.w("seen 0xa009a633 - time packet"); - // deal with a time packet, if needed - } else { - Timber.w("iHealthHS3 - seen byte after control leader %02X", btByte); - } - } - } - } - - - - } catch (IOException e) { - cancel(); - setBluetoothStatus(BT_STATUS.CONNECTION_LOST); - } - } - } - - private ScaleMeasurement parseWeightArray(byte[] weightBytes ) throws IOException { - ScaleMeasurement scaleBtData = new ScaleMeasurement(); - -// Timber.w("iHealthHS3 - ScaleMeasurement "+String.format("%02X",weightBytes[0])+String.format("%02X",weightBytes[1])); - - String ws = String.format("%02X",weightBytes[0])+String.format("%02X",weightBytes[1]); - StringBuilder ws1 = new StringBuilder (ws); - ws1.insert(ws.length()-1,"."); - - - float weight = Float.parseFloat(ws1.toString()); -// Timber.w("iHealthHS3 - ScaleMeasurement "+String.format("%f",weight)); - - Date now = new Date(); - -// If the weight is the same as the lastWeight, and the time since the last reading is less than maxTimeDiff then return null - if (Arrays.equals(weightBytes,lastWeight) && (now.getTime() - lastWeighed.getTime() < maxTimeDiff)) { -// Timber.w("iHealthHS3 - parseWeightArray returning null"); - return null; - } - - - scaleBtData.setDateTime(now); - scaleBtData.setWeight(weight); - lastWeighed = now; - System.arraycopy(weightBytes,0,lastWeight,0,lastWeight.length); - return scaleBtData; - - } - - public void write(byte[] bytes) { - try { - btOutStream.write(bytes); - } catch (IOException e) { - setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR, "Error while writing to bluetooth socket " + e.getMessage()); - } - } - - public void cancel() { - isCancel = true; - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothInlife.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothInlife.java deleted file mode 100644 index cdab898a..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothInlife.java +++ /dev/null @@ -1,235 +0,0 @@ -/* Copyright (C) 2018 Erik Johansson - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - -package com.health.openscale.core.bluetooth; - -import android.content.Context; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; - -import java.util.Arrays; -import java.util.UUID; - -import timber.log.Timber; - -public class BluetoothInlife extends BluetoothCommunication { - private final UUID WEIGHT_SERVICE = BluetoothGattUuid.fromShortCode(0xfff0); - private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xfff1); - private final UUID WEIGHT_CMD_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xfff2); - - private final byte START_BYTE = 0x02; - private final byte END_BYTE = (byte)0xaa; - - private byte[] lastData = null; - - private int getAthleteLevel(ScaleUser scaleUser) { - switch (scaleUser.getActivityLevel()) { - case SEDENTARY: - case MILD: - return 0; // General - case MODERATE: - return 1; // Amateur - case HEAVY: - case EXTREME: - return 2; // Profession - } - return 0; - } - - private void sendCommand(int command, byte... parameters) { - byte[] data = {START_BYTE, (byte)command, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, END_BYTE}; - int i = 2; - for (byte parameter : parameters) { - data[i++] = parameter; - } - data[data.length - 2] = xorChecksum(data, 1, data.length - 3); - writeBytes(WEIGHT_SERVICE, WEIGHT_CMD_CHARACTERISTIC, data); - } - - public BluetoothInlife(Context context) { - super(context); - } - - @Override - public String driverName() { - return "Inlinfe"; - } - - @Override - protected boolean onNextStep(int stepNr) { - switch (stepNr) { - case 0: - setNotificationOn(WEIGHT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC); - break; - case 1: - ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser(); - byte level = (byte)(getAthleteLevel(scaleUser) + 1); - byte sex = (byte)scaleUser.getGender().toInt(); - byte userId = (byte)scaleUser.getId(); - byte age = (byte)scaleUser.getAge(); - byte height = (byte)scaleUser.getBodyHeight(); - - sendCommand(0xd2, level, sex, userId, age, height); - break; - case 2: - sendMessage(R.string.info_step_on_scale, 0); - break; - default: - return false; - } - - return true; - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - final byte[] data = value; - - if (data == null || data.length != 14) { - return; - } - - if (data[0] != START_BYTE || data[data.length - 1] != END_BYTE) { - Timber.e("Wrong start or end byte"); - return; - } - - if (xorChecksum(data, 1, data.length - 2) != 0) { - Timber.e("Invalid checksum"); - return; - } - - if (Arrays.equals(data, lastData)) { - Timber.d("Ignoring duplicate data"); - return; - } - lastData = data; - - switch (data[1]) { - case (byte) 0x0f: - Timber.d("Scale disconnecting"); - break; - case (byte) 0xd8: - float weight = Converters.fromUnsignedInt16Be(data, 2) / 10.0f; - Timber.d("Current weight %.2f kg", weight); - sendMessage(R.string.info_measuring, weight); - break; - case (byte) 0xdd: - if (data[11] == (byte) 0x80 || data[11] == (byte) 0x81) { - processMeasurementDataNewVersion(data); - } - else { - processMeasurementData(data); - } - break; - case (byte) 0xdf: - Timber.d("User data acked by scale: %s", data[2] == 0 ? "OK" : "error"); - break; - default: - Timber.d("Unknown command 0x%02x", data[1]); - break; - } - } - - void processMeasurementData(byte[] data) { - float weight = Converters.fromUnsignedInt16Be(data, 2) / 10.0f; - float lbm = Converters.fromUnsignedInt24Be(data, 4) / 1000.0f; - float visceralFactor = Converters.fromUnsignedInt16Be(data, 7) / 10.0f; - float bmr = Converters.fromUnsignedInt16Be(data, 9) / 10.0f; - - if (lbm == 0xffffff / 1000.0f) { - Timber.e("Measurement failed; feet not correctly placed on scale?"); - return; - } - - Timber.d("weight=%.1f, LBM=%.3f, visceral factor=%.1f, BMR=%.1f", - weight, lbm, visceralFactor, bmr); - - final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser(); - switch (getAthleteLevel(selectedUser)) { - case 0: - break; - case 1: - lbm *= 1.0427f; - break; - case 2: - lbm *= 1.0958f; - break; - } - - final float fatKg = weight - lbm; - final double fat = (fatKg / weight) * 100.0; - final double water = (0.73f * (weight - fatKg) / weight) * 100.0; - final double muscle = (0.548 * lbm / weight) * 100.0; - final double bone = 0.05158 * lbm; - - final float height = selectedUser.getBodyHeight(); - - double visceral = visceralFactor - 50; - if (selectedUser.getGender().isMale()) { - if (height >= 1.6 * weight + 63) { - visceral += (0.765 - 0.002 * height) * weight; - } - else { - visceral += 380 * weight / (((0.0826 * height * height) - 0.4 * height) + 48); - } - } - else { - if (weight <= height / 2 - 13) { - visceral += (0.691 - 0.0024 * height) * weight; - } - else { - visceral += 500 * weight / (((0.1158 * height * height) + 1.45 * height) - 120); - } - } - - if (getAthleteLevel(selectedUser) != 0) { - if (visceral >= 21) { - visceral *= 0.85; - } - if (visceral >= 10) { - visceral *= 0.8; - } - visceral -= getAthleteLevel(selectedUser) * 2; - } - - ScaleMeasurement measurement = new ScaleMeasurement(); - measurement.setWeight(weight); - measurement.setFat(clamp(fat, 5, 80)); - measurement.setWater(clamp(water, 5, 80)); - measurement.setMuscle(clamp(muscle, 5, 80)); - measurement.setBone(clamp(bone, 0.5, 8)); - measurement.setLbm(lbm); - measurement.setVisceralFat(clamp(visceral, 1, 50)); - - addScaleMeasurement(measurement); - - sendCommand(0xd4); - } - - void processMeasurementDataNewVersion(byte[] data) { - float weight = Converters.fromUnsignedInt16Be(data, 2) / 10.0f; - long impedance = Converters.fromUnsignedInt32Be(data, 4); - Timber.d("weight=%.2f, impedance=%d", weight, impedance); - - // Uses the same library as 1byone, but we need someone that has the scale to be able to - // test if it works the same way. - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothMGB.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothMGB.java deleted file mode 100644 index d7fe201b..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothMGB.java +++ /dev/null @@ -1,201 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* 2017 DreamNik -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.bluetooth; - -import android.content.Context; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; - -import java.util.Calendar; -import java.util.Date; -import java.util.UUID; - - - -public class BluetoothMGB extends BluetoothCommunication { - - private static final UUID uuid_service = BluetoothGattUuid.fromShortCode(0xffb0); - private static final UUID uuid_char_cfg = BluetoothGattUuid.fromShortCode(0xffb1); - private static final UUID uuid_char_ctrl = BluetoothGattUuid.fromShortCode(0xffb2); - - - private Calendar now; - private ScaleUser user; - private ScaleMeasurement measurement; - private byte[] packet_buf; - private int packet_pos; - - - - private int popInt() { - return packet_buf[packet_pos++] & 0xFF; - } - - - private float popFloat() { - int r = popInt(); - r = popInt() | (r<<8); - return r * 0.1f; - } - - - private void writeCfg(int b2, int b3, int b4, int b5) { - byte[] buf = new byte[8]; - buf[0] = (byte)0xAC; - buf[1] = (byte)0x02; - buf[2] = (byte)b2; - buf[3] = (byte)b3; - buf[4] = (byte)b4; - buf[5] = (byte)b5; - buf[6] = (byte)0xCC; - buf[7] = (byte)((buf[2] + buf[3] + buf[4] + buf[5] + buf[6]) & 0xFF); - - writeBytes(uuid_service, uuid_char_cfg, buf, true); - } - - - public BluetoothMGB(Context context) { - super(context); - } - - @Override - public String driverName() { - return "SWAN"; - } - - @Override - protected boolean onNextStep(int stepNr) { - switch (stepNr) { - case 0: - setNotificationOn(uuid_service, uuid_char_ctrl); - now = Calendar.getInstance(); - user = OpenScale.getInstance().getSelectedScaleUser(); - break; - - case 1: - writeCfg(0xF7, 0, 0, 0); - break; - - case 2: - writeCfg(0xFA, 0, 0, 0); - break; - - case 3: - writeCfg(0xFB, (user.getGender().isMale() ? 1 : 2), user.getAge(), (int)user.getBodyHeight()); - break; - - case 4: - writeCfg(0xFD, now.get(Calendar.YEAR) - 2000, now.get(Calendar.MONTH) - Calendar.JANUARY + 1, now.get(Calendar.DAY_OF_MONTH)); - break; - - case 5: - writeCfg(0xFC, now.get(Calendar.HOUR_OF_DAY), now.get(Calendar.MINUTE), now.get(Calendar.SECOND)); - break; - - case 6: - writeCfg(0xFE, 6, user.getScaleUnit().toInt(), 0); - break; - - case 7: - sendMessage(R.string.info_step_on_scale, 0); - break; - - default: - return false; - } - - return true; - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - packet_buf = value; - packet_pos = 0; - - if (packet_buf == null || packet_buf.length <= 0) { - return; - } - - if (packet_buf.length != 20) { - return; - } - - int hdr_1 = popInt(); - int hdr_2 = popInt(); - int hdr_3 = popInt(); - - if (hdr_1 == 0xAC && (hdr_2 == 0x02 || hdr_2 == 0x03) && hdr_3 == 0xFF) { - measurement = new ScaleMeasurement(); - - popInt(); //unknown =00 - popInt(); //unknown =02 - popInt(); //unknown =21 - - popInt(); //Year - popInt(); //Month - popInt(); //Day - popInt(); //Hour - popInt(); //Minute - popInt(); //Second - - measurement.setDateTime(new Date()); - - measurement.setWeight(popFloat()); - - popFloat(); //BMI - - measurement.setFat(popFloat()); - - popInt(); //unknown =00 - popInt(); //unknown =00 - - } - else if (measurement != null && hdr_1 == 0x01 && hdr_2 == 0x00) { - measurement.setMuscle(popFloat()); - - popFloat(); //BMR - - measurement.setBone(popFloat()); - - measurement.setWater(popFloat()); - - popInt(); // Age - - popFloat();// protein rate - - popInt(); // unknown =00 - popInt(); // unknown =01 - popInt(); // unknown =1b - popInt(); // unknown =a5 - popInt(); // unknown =02 - popInt(); // unknown =47;48;4e;4b;42 - - addScaleMeasurement(measurement); - - // Visceral fat? - // Standard weight? - // WeightControl? - // Body fat? - // Muscle weight? - } - } - -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothMedisanaBS44x.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothMedisanaBS44x.java deleted file mode 100644 index a851214b..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothMedisanaBS44x.java +++ /dev/null @@ -1,127 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.core.bluetooth; - -import android.content.Context; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.utils.Converters; - -import java.util.Date; -import java.util.UUID; - -public class BluetoothMedisanaBS44x extends BluetoothCommunication { - private final UUID WEIGHT_MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0x78b2); - private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0x8a21); // indication, read-only - private final UUID FEATURE_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0x8a22); // indication, read-only - private final UUID CMD_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0x8a81); // write-only - private final UUID CUSTOM5_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0x8a82); // indication, read-only - - private ScaleMeasurement btScaleMeasurement; - - private boolean applyOffset; - - // Scale time is in seconds since 2010-01-01 - private static final long SCALE_UNIX_TIMESTAMP_OFFSET = 1262304000; - - - public BluetoothMedisanaBS44x(Context context, boolean applyOffset) { - super(context); - btScaleMeasurement = new ScaleMeasurement(); - this.applyOffset = applyOffset; - } - - @Override - public String driverName() { - return "Medisana BS44x"; - } - - @Override - protected boolean onNextStep(int stepNr) { - switch (stepNr) { - case 0: - // set indication on for feature characteristic - setIndicationOn(WEIGHT_MEASUREMENT_SERVICE, FEATURE_MEASUREMENT_CHARACTERISTIC); - break; - case 1: - // set indication on for weight measurement - setIndicationOn(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC); - break; - case 2: - // set indication on for custom5 measurement - setIndicationOn(WEIGHT_MEASUREMENT_SERVICE, CUSTOM5_MEASUREMENT_CHARACTERISTIC); - break; - case 3: - // send magic number to receive weight data - long timestamp = new Date().getTime() / 1000; - if(applyOffset){ - timestamp -= SCALE_UNIX_TIMESTAMP_OFFSET; - } - byte[] date = Converters.toInt32Le(timestamp); - - byte[] magicBytes = new byte[] {(byte)0x02, date[0], date[1], date[2], date[3]}; - - writeBytes(WEIGHT_MEASUREMENT_SERVICE, CMD_MEASUREMENT_CHARACTERISTIC, magicBytes); - break; - case 4: - sendMessage(R.string.info_step_on_scale, 0); - break; - default: - return false; - } - - return true; - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - final byte[] data = value; - - if (characteristic.equals(WEIGHT_MEASUREMENT_CHARACTERISTIC)) { - parseWeightData(data); - } - - if (characteristic.equals(FEATURE_MEASUREMENT_CHARACTERISTIC)) { - parseFeatureData(data); - - addScaleMeasurement(btScaleMeasurement); - } - } - - private void parseWeightData(byte[] weightData) { - float weight = Converters.fromUnsignedInt16Le(weightData, 1) / 100.0f; - long timestamp = Converters.fromUnsignedInt32Le(weightData, 5); - if (applyOffset) { - timestamp += SCALE_UNIX_TIMESTAMP_OFFSET; - } - - btScaleMeasurement.setDateTime(new Date(timestamp * 1000)); - btScaleMeasurement.setWeight(weight); - } - - private void parseFeatureData(byte[] featureData) { - //btScaleData.setKCal(Converters.fromUnsignedInt16Le(featureData, 6)); - btScaleMeasurement.setFat(decodeFeature(featureData, 8)); - btScaleMeasurement.setWater(decodeFeature(featureData, 10)); - btScaleMeasurement.setMuscle(decodeFeature(featureData, 12)); - btScaleMeasurement.setBone(decodeFeature(featureData, 14)); - } - - private float decodeFeature(byte[] featureData, int offset) { - return (Converters.fromUnsignedInt16Le(featureData, offset) & 0x0FFF) / 10.0f; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothMiScale.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothMiScale.java deleted file mode 100644 index d47e40c0..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothMiScale.java +++ /dev/null @@ -1,246 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.bluetooth; - -import android.content.Context; -import android.content.SharedPreferences; -import android.preference.PreferenceManager; - -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; - -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.Arrays; -import java.util.Calendar; -import java.util.Date; -import java.util.Random; -import java.util.UUID; - -import timber.log.Timber; - -import static com.health.openscale.core.bluetooth.BluetoothCommunication.BT_STATUS.UNEXPECTED_ERROR; - -public class BluetoothMiScale extends BluetoothCommunication { - private final UUID WEIGHT_MEASUREMENT_SERVICE = UUID.fromString("0000181d-0000-1000-8000-00805f9b34fb"); - private final UUID WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC = UUID.fromString("00002a2f-0000-3512-2118-0009af100700"); - - public BluetoothMiScale(Context context) { - super(context); - } - - @Override - public String driverName() { - return "Xiaomi Mi Scale v1"; - } - - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - - if (characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_CURRENT_TIME)) { - byte[] data = value; - - int currentYear = Calendar.getInstance().get(Calendar.YEAR); - int currentMonth = Calendar.getInstance().get(Calendar.MONTH) + 1; - int currentDay = Calendar.getInstance().get(Calendar.DAY_OF_MONTH); - int scaleYear = ((data[1] & 0xFF) << 8) | (data[0] & 0xFF); - int scaleMonth = (int) data[2]; - int scaleDay = (int) data[3]; - - if (!(currentYear == scaleYear && currentMonth == scaleMonth && currentDay == scaleDay)) { - Timber.d("Current year and scale year is different"); - - // set current time - Calendar currentDateTime = Calendar.getInstance(); - int year = currentDateTime.get(Calendar.YEAR); - byte month = (byte) (currentDateTime.get(Calendar.MONTH) + 1); - byte day = (byte) currentDateTime.get(Calendar.DAY_OF_MONTH); - byte hour = (byte) currentDateTime.get(Calendar.HOUR_OF_DAY); - byte min = (byte) currentDateTime.get(Calendar.MINUTE); - byte sec = (byte) currentDateTime.get(Calendar.SECOND); - - byte[] dateTimeByte = {(byte) (year), (byte) (year >> 8), month, day, hour, min, sec, 0x03, 0x00, 0x00}; - - writeBytes(WEIGHT_MEASUREMENT_SERVICE, BluetoothGattUuid.CHARACTERISTIC_CURRENT_TIME, dateTimeByte); - } - } else { - final byte[] data = value; - - if (data != null && data.length > 0) { - - // Stop command from mi scale received - if (data[0] == 0x03) { - // send stop command to mi scale - writeBytes(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, new byte[]{0x03}); - // acknowledge that you received the last history data - int uniqueNumber = getUniqueNumber(); - - byte[] userIdentifier = new byte[]{(byte) 0x04, (byte) 0xFF, (byte) 0xFF, (byte) ((uniqueNumber & 0xFF00) >> 8), (byte) ((uniqueNumber & 0xFF) >> 0)}; - writeBytes(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, userIdentifier); - - resumeMachineState(); - } - - if (data.length == 20) { - final byte[] firstWeight = Arrays.copyOfRange(data, 0, 10); - final byte[] secondWeight = Arrays.copyOfRange(data, 10, 20); - parseBytes(firstWeight); - parseBytes(secondWeight); - } - - if (data.length == 10) { - parseBytes(data); - } - - } - } - } - - - @Override - protected boolean onNextStep(int stepNr) { - switch (stepNr) { - case 0: - // read device time - readBytes(WEIGHT_MEASUREMENT_SERVICE, BluetoothGattUuid.CHARACTERISTIC_CURRENT_TIME); - break; - case 1: - // Set on history weight measurement - byte[] magicBytes = new byte[]{(byte)0x01, (byte)0x96, (byte)0x8a, (byte)0xbd, (byte)0x62}; - - writeBytes(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, magicBytes); - break; - case 2: - // set notification on for weight measurement history - setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC); - break; - case 3: - // set notification on for weight measurement - setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, BluetoothGattUuid.CHARACTERISTIC_WEIGHT_MEASUREMENT); - break; - case 4: - // configure scale to get only last measurements - int uniqueNumber = getUniqueNumber(); - - byte[] userIdentifier = new byte[]{(byte)0x01, (byte)0xFF, (byte)0xFF, (byte) ((uniqueNumber & 0xFF00) >> 8), (byte) ((uniqueNumber & 0xFF) >> 0)}; - writeBytes(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, userIdentifier); - break; - case 5: - // invoke receiving history data - writeBytes(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, new byte[]{0x02}); - stopMachineState(); - break; - default: - return false; - } - - return true; - } - - private void parseBytes(byte[] weightBytes) { - try { - final byte ctrlByte = weightBytes[0]; - - final boolean isWeightRemoved = isBitSet(ctrlByte, 7); - final boolean isStabilized = isBitSet(ctrlByte, 5); - final boolean isLBSUnit = isBitSet(ctrlByte, 0); - final boolean isCattyUnit = isBitSet(ctrlByte, 4); - - /*Timber.d("IsWeightRemoved: " + isBitSet(ctrlByte, 7)); - Timber.d("6 LSB Unknown: " + isBitSet(ctrlByte, 6)); - Timber.d("IsStabilized: " + isBitSet(ctrlByte, 5)); - Timber.d("IsCattyOrKg: " + isBitSet(ctrlByte, 4)); - Timber.d("3 LSB Unknown: " + isBitSet(ctrlByte, 3)); - Timber.d("2 LSB Unknown: " + isBitSet(ctrlByte, 2)); - Timber.d("1 LSB Unknown: " + isBitSet(ctrlByte, 1)); - Timber.d("IsLBS: " + isBitSet(ctrlByte, 0));*/ - - // Only if the value is stabilized and the weight is *not* removed, the date is valid - if (isStabilized && !isWeightRemoved) { - - final int year = ((weightBytes[4] & 0xFF) << 8) | (weightBytes[3] & 0xFF); - final int month = (int) weightBytes[5]; - final int day = (int) weightBytes[6]; - final int hours = (int) weightBytes[7]; - final int min = (int) weightBytes[8]; - final int sec = (int) weightBytes[9]; - - float weight; - if (isLBSUnit || isCattyUnit) { - weight = (float) (((weightBytes[2] & 0xFF) << 8) | (weightBytes[1] & 0xFF)) / 100.0f; - } else { - weight = (float) (((weightBytes[2] & 0xFF) << 8) | (weightBytes[1] & 0xFF)) / 200.0f; - } - - String date_string = year + "/" + month + "/" + day + "/" + hours + "/" + min; - Date date_time = new SimpleDateFormat("yyyy/MM/dd/HH/mm").parse(date_string); - - // Is the year plausible? Check if the year is in the range of 20 years... - if (validateDate(date_time, 20)) { - final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser(); - ScaleMeasurement scaleBtData = new ScaleMeasurement(); - - scaleBtData.setWeight(Converters.toKilogram(weight, selectedUser.getScaleUnit())); - scaleBtData.setDateTime(date_time); - - addScaleMeasurement(scaleBtData); - } else { - Timber.e("Invalid Mi scale weight year %d", year); - } - } - } catch (ParseException e) { - setBluetoothStatus(UNEXPECTED_ERROR, "Error while decoding bluetooth date string (" + e.getMessage() + ")"); - } - } - - private boolean validateDate(Date weightDate, int range) { - - Calendar currentDatePos = Calendar.getInstance(); - currentDatePos.add(Calendar.YEAR, range); - - Calendar currentDateNeg = Calendar.getInstance(); - currentDateNeg.add(Calendar.YEAR, -range); - - if (weightDate.before(currentDatePos.getTime()) && weightDate.after(currentDateNeg.getTime())) { - return true; - } - - return false; - } - - private int getUniqueNumber() { - int uniqueNumber; - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - uniqueNumber = prefs.getInt("uniqueNumber", 0x00); - - if (uniqueNumber == 0x00) { - Random r = new Random(); - uniqueNumber = r.nextInt(65535 - 100 + 1) + 100; - - prefs.edit().putInt("uniqueNumber", uniqueNumber).apply(); - } - - int userId = OpenScale.getInstance().getSelectedScaleUserId(); - - return uniqueNumber + userId; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothMiScale2.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothMiScale2.java deleted file mode 100644 index 827742f6..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothMiScale2.java +++ /dev/null @@ -1,239 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.bluetooth; - -import static com.health.openscale.core.bluetooth.BluetoothCommunication.BT_STATUS.UNEXPECTED_ERROR; - -import android.content.Context; -import android.content.SharedPreferences; -import android.preference.PreferenceManager; - -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.bluetooth.lib.MiScaleLib; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; - -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.Calendar; -import java.util.Date; -import java.util.Random; -import java.util.UUID; - -import timber.log.Timber; - -public class BluetoothMiScale2 extends BluetoothCommunication { - private final UUID WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC = UUID.fromString("00002a2f-0000-3512-2118-0009af100700"); - - private final UUID WEIGHT_CUSTOM_SERVICE = UUID.fromString("00001530-0000-3512-2118-0009af100700"); - private final UUID WEIGHT_CUSTOM_CONFIG = UUID.fromString("00001542-0000-3512-2118-0009af100700"); - - public BluetoothMiScale2(Context context) { - super(context); - } - - @Override - public String driverName() { - return "Xiaomi Mi Scale v2"; - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - final byte[] data = value; - - if (data != null && data.length > 0) { - Timber.d("DataChange hex data: %s", byteInHex(data)); - - // Stop command from mi scale received - if (data[0] == 0x03) { - Timber.d("Scale stop byte received"); - // send stop command to mi scale - writeBytes(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, new byte[]{0x03}); - // acknowledge that you received the last history data - int uniqueNumber = getUniqueNumber(); - - byte[] userIdentifier = new byte[]{(byte)0x04, (byte)0xFF, (byte)0xFF, (byte) ((uniqueNumber & 0xFF00) >> 8), (byte) ((uniqueNumber & 0xFF) >> 0)}; - writeBytes(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, userIdentifier); - - resumeMachineState(); - } - - if (data.length == 13) { - parseBytes(data); - } - - } - } - - - @Override - protected boolean onNextStep(int stepNr) { - switch (stepNr) { - case 0: - // set scale units - final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser(); - byte[] setUnitCmd = new byte[]{(byte)0x06, (byte)0x04, (byte)0x00, (byte) selectedUser.getScaleUnit().toInt()}; - writeBytes(WEIGHT_CUSTOM_SERVICE, WEIGHT_CUSTOM_CONFIG, setUnitCmd); - break; - case 1: - // set current time - Calendar currentDateTime = Calendar.getInstance(); - int year = currentDateTime.get(Calendar.YEAR); - byte month = (byte)(currentDateTime.get(Calendar.MONTH)+1); - byte day = (byte)currentDateTime.get(Calendar.DAY_OF_MONTH); - byte hour = (byte)currentDateTime.get(Calendar.HOUR_OF_DAY); - byte min = (byte)currentDateTime.get(Calendar.MINUTE); - byte sec = (byte)currentDateTime.get(Calendar.SECOND); - - byte[] dateTimeByte = {(byte)(year), (byte)(year >> 8), month, day, hour, min, sec, 0x03, 0x00, 0x00}; - - writeBytes(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, BluetoothGattUuid.CHARACTERISTIC_CURRENT_TIME, dateTimeByte); - break; - case 2: - // set notification on for weight measurement history - setNotificationOn(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC); - break; - case 3: - // configure scale to get only last measurements - int uniqueNumber = getUniqueNumber(); - - byte[] userIdentifier = new byte[]{(byte)0x01, (byte)0xFF, (byte)0xFF, (byte) ((uniqueNumber & 0xFF00) >> 8), (byte) ((uniqueNumber & 0xFF) >> 0)}; - writeBytes(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, userIdentifier); - break; - case 4: - // invoke receiving history data - writeBytes(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, new byte[]{0x02}); - stopMachineState(); - break; - default: - return false; - } - - return true; - } - - private void parseBytes(byte[] data) { - try { - final byte ctrlByte0 = data[0]; - final byte ctrlByte1 = data[1]; - - final boolean isWeightRemoved = isBitSet(ctrlByte1, 7); - final boolean isStabilized = isBitSet(ctrlByte1, 5); - final boolean isLBSUnit = isBitSet(ctrlByte0, 0); - final boolean isCattyUnit = isBitSet(ctrlByte1, 6); - final boolean isImpedance = isBitSet(ctrlByte1, 1); - - if (isStabilized && !isWeightRemoved) { - - final int year = ((data[3] & 0xFF) << 8) | (data[2] & 0xFF); - final int month = (int) data[4]; - final int day = (int) data[5]; - final int hours = (int) data[6]; - final int min = (int) data[7]; - final int sec = (int) data[8]; - - float weight; - float impedance = 0.0f; - - if (isLBSUnit || isCattyUnit) { - weight = (float) (((data[12] & 0xFF) << 8) | (data[11] & 0xFF)) / 100.0f; - } else { - weight = (float) (((data[12] & 0xFF) << 8) | (data[11] & 0xFF)) / 200.0f; - } - - if (isImpedance) { - impedance = ((data[10] & 0xFF) << 8) | (data[9] & 0xFF); - Timber.d("impedance value is " + impedance); - } - - String date_string = year + "/" + month + "/" + day + "/" + hours + "/" + min; - Date date_time = new SimpleDateFormat("yyyy/MM/dd/HH/mm").parse(date_string); - - // Is the year plausible? Check if the year is in the range of 20 years... - if (validateDate(date_time, 20)) { - final ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser(); - ScaleMeasurement scaleBtData = new ScaleMeasurement(); - - scaleBtData.setWeight(Converters.toKilogram(weight, scaleUser.getScaleUnit())); - scaleBtData.setDateTime(date_time); - - int sex; - - if (scaleUser.getGender() == Converters.Gender.MALE) { - sex = 1; - } else { - sex = 0; - } - - if (impedance != 0.0f) { - MiScaleLib miScaleLib = new MiScaleLib(sex, scaleUser.getAge(), scaleUser.getBodyHeight()); - - scaleBtData.setWater(miScaleLib.getWater(weight, impedance)); - scaleBtData.setVisceralFat(miScaleLib.getVisceralFat(weight)); - scaleBtData.setFat(miScaleLib.getBodyFat(weight, impedance)); - scaleBtData.setMuscle((100.0f / weight) * miScaleLib.getMuscle(weight, impedance)); // convert muscle in kg to percent - scaleBtData.setLbm(miScaleLib.getLBM(weight, impedance)); - scaleBtData.setBone(miScaleLib.getBoneMass(weight, impedance)); - } else { - Timber.d("Impedance value is zero"); - } - - addScaleMeasurement(scaleBtData); - } else { - Timber.e("Invalid Mi scale weight year %d", year); - } - } - } catch (ParseException e) { - setBluetoothStatus(UNEXPECTED_ERROR, "Error while decoding bluetooth date string (" + e.getMessage() + ")"); - } - } - - private boolean validateDate(Date weightDate, int range) { - - Calendar currentDatePos = Calendar.getInstance(); - currentDatePos.add(Calendar.YEAR, range); - - Calendar currentDateNeg = Calendar.getInstance(); - currentDateNeg.add(Calendar.YEAR, -range); - - if (weightDate.before(currentDatePos.getTime()) && weightDate.after(currentDateNeg.getTime())) { - return true; - } - - return false; - } - - private int getUniqueNumber() { - int uniqueNumber; - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - uniqueNumber = prefs.getInt("uniqueNumber", 0x00); - - if (uniqueNumber == 0x00) { - Random r = new Random(); - uniqueNumber = r.nextInt(65535 - 100 + 1) + 100; - - prefs.edit().putInt("uniqueNumber", uniqueNumber).apply(); - } - - int userId = OpenScale.getInstance().getSelectedScaleUserId(); - - return uniqueNumber + userId; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothOKOK.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothOKOK.java deleted file mode 100644 index dd252852..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothOKOK.java +++ /dev/null @@ -1,178 +0,0 @@ -package com.health.openscale.core.bluetooth; - -import android.bluetooth.le.ScanFilter; -import android.bluetooth.le.ScanResult; -import android.content.Context; -import android.os.Handler; -import android.os.Looper; -import android.util.SparseArray; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.welie.blessed.BluetoothCentralManager; -import com.welie.blessed.BluetoothCentralManagerCallback; -import com.welie.blessed.BluetoothPeripheral; - -import org.jetbrains.annotations.NotNull; - -import java.util.LinkedList; -import java.util.List; - -import timber.log.Timber; - -public class BluetoothOKOK extends BluetoothCommunication { - private static final int MANUFACTURER_DATA_ID_V20 = 0x20ca; // 16-bit little endian "header" 0xca 0x20 - private static final int MANUFACTURER_DATA_ID_V11 = 0x11ca; // 16-bit little endian "header" 0xca 0x11 - private static final int MANUFACTURER_DATA_ID_VF0 = 0xf0ff; // 16-bit little endian "header" 0xff 0xf0 - private static final int IDX_V20_FINAL = 6; - private static final int IDX_V20_WEIGHT_MSB = 8; - private static final int IDX_V20_WEIGHT_LSB = 9; - private static final int IDX_V20_IMPEDANCE_MSB = 10; - private static final int IDX_V20_IMPEDANCE_LSB = 11; - private static final int IDX_V20_CHECKSUM = 12; - - private static final int IDX_V11_WEIGHT_MSB = 3; - private static final int IDX_V11_WEIGHT_LSB = 4; - private static final int IDX_V11_BODY_PROPERTIES = 9; - private static final int IDX_V11_CHECKSUM = 16; - - private static final int IDX_VF0_WEIGHT_MSB = 3; - private static final int IDX_VF0_WEIGHT_LSB = 2; - - private BluetoothCentralManager central; - private final BluetoothCentralManagerCallback btCallback = new BluetoothCentralManagerCallback() { - @Override - public void onDiscoveredPeripheral(@NotNull BluetoothPeripheral peripheral, @NotNull ScanResult scanResult) { - SparseArray manufacturerSpecificData = scanResult.getScanRecord().getManufacturerSpecificData(); - if (manufacturerSpecificData.indexOfKey(MANUFACTURER_DATA_ID_V20) > -1) { - byte[] data = manufacturerSpecificData.get(MANUFACTURER_DATA_ID_V20); - float divider = 10.0f; - byte checksum = 0x20; // Version field is part of the checksum, but not in array - if (data == null || data.length != 19) - return; - if ((data[IDX_V20_FINAL] & 1) == 0) - return; - for (int i = 0; i < IDX_V20_CHECKSUM; i++) - checksum ^= data[i]; - if (data[IDX_V20_CHECKSUM] != checksum) { - Timber.d("Checksum error, got %x, expected %x", data[IDX_V20_CHECKSUM] & 0xff, checksum & 0xff); - return; - } - if ((data[IDX_V20_FINAL] & 4) == 4) - divider = 100.0f; - int weight = data[IDX_V20_WEIGHT_MSB] & 0xff; - weight = weight << 8 | (data[IDX_V20_WEIGHT_LSB] & 0xff); - int impedance = data[IDX_V20_IMPEDANCE_MSB] & 0xff; - impedance = impedance << 8 | (data[IDX_V20_IMPEDANCE_LSB] & 0xff); - Timber.d("Got weight: %f and impedance %f", weight / divider, impedance / 10f); - ScaleMeasurement entry = new ScaleMeasurement(); - entry.setWeight(weight / divider); - addScaleMeasurement(entry); - disconnect(); - } else if (manufacturerSpecificData.indexOfKey(MANUFACTURER_DATA_ID_V11) > -1) { - byte[] data = manufacturerSpecificData.get(MANUFACTURER_DATA_ID_V11); - float divider = 10.0f; - float extraWeight = 0; - byte checksum = (byte)0xca ^ (byte)0x11; // Version and magic fields are part of the checksum, but not in array - if (data == null || data.length != IDX_V11_CHECKSUM + 6 + 1) - return; - for (int i = 0; i < IDX_V11_CHECKSUM; i++) - checksum ^= data[i]; - if (data[IDX_V11_CHECKSUM] != checksum) { - Timber.d("Checksum error, got %x, expected %x", data[IDX_V11_CHECKSUM] & 0xff, checksum & 0xff); - return; - } - - int weight = data[IDX_V11_WEIGHT_MSB] & 0xff; - weight = weight << 8 | (data[IDX_V11_WEIGHT_LSB] & 0xff); - - switch ((data[IDX_V11_BODY_PROPERTIES] >> 1) & 3) { - default: - Timber.w("Invalid weight scale received, assuming 1 decimal"); - /* fall-through */ - case 0: - divider = 10.0f; - break; - case 1: - divider = 1.0f; - break; - case 2: - divider = 100.0f; - break; - } - - switch ((data[IDX_V11_BODY_PROPERTIES] >> 3) & 3) { - case 0: // kg - break; - case 1: // Jin - divider *= 2; - break; - case 3: // st & lb - extraWeight = (weight >> 8) * 6.350293f; - weight &= 0xff; - /* fall-through */ - case 2: // lb - divider *= 2.204623; - break; - } - Timber.d("Got weight: %f", weight / divider); - ScaleMeasurement entry = new ScaleMeasurement(); - entry.setWeight(extraWeight + weight / divider); - addScaleMeasurement(entry); - disconnect(); - } else if (manufacturerSpecificData.indexOfKey(MANUFACTURER_DATA_ID_VF0) > -1) { - byte[] data = manufacturerSpecificData.get(MANUFACTURER_DATA_ID_VF0); - float divider = 10.0f; - int weight = data[IDX_VF0_WEIGHT_MSB] & 0xff; - weight = weight << 8 | (data[IDX_VF0_WEIGHT_LSB] & 0xff); - Timber.d("Got weight: %f", weight / divider); - ScaleMeasurement entry = new ScaleMeasurement(); - entry.setWeight(weight / divider); - addScaleMeasurement(entry); - disconnect(); - } - } - }; - - public BluetoothOKOK(Context context) - { - super(context); - central = new BluetoothCentralManager(context, btCallback, new Handler(Looper.getMainLooper())); - } - - @Override - public String driverName() { - return "OKOK"; - } - - @Override - public void connect(String macAddress) { - Timber.d("Mac address: %s", macAddress); - List filters = new LinkedList(); - - ScanFilter.Builder b = new ScanFilter.Builder(); - b.setDeviceAddress(macAddress); - - b.setDeviceName("ADV"); - b.setManufacturerData(MANUFACTURER_DATA_ID_V20, null, null); - filters.add(b.build()); - - b.setDeviceName("Chipsea-BLE"); - b.setManufacturerData(MANUFACTURER_DATA_ID_V11, null, null); - filters.add(b.build()); - - central.scanForPeripheralsUsingFilters(filters); - } - - @Override - public void disconnect() { - if (central != null) - central.stopScan(); - central = null; - super.disconnect(); - } - - @Override - protected boolean onNextStep(int stepNr) { - return false; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothOKOK2.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothOKOK2.java deleted file mode 100644 index 48d72498..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothOKOK2.java +++ /dev/null @@ -1,202 +0,0 @@ -/* Copyright (C) 2024 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.core.bluetooth; - -import static com.health.openscale.core.utils.Converters.WeightUnit.LB; -import static com.health.openscale.core.utils.Converters.WeightUnit.ST; - -import android.bluetooth.le.ScanFilter; -import android.bluetooth.le.ScanResult; -import android.content.Context; -import android.os.Handler; -import android.os.Looper; -import android.util.SparseArray; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.utils.Converters; -import com.welie.blessed.BluetoothCentralManager; -import com.welie.blessed.BluetoothCentralManagerCallback; -import com.welie.blessed.BluetoothPeripheral; - -import org.jetbrains.annotations.NotNull; - -import java.util.LinkedList; -import java.util.List; - -import timber.log.Timber; - -public class BluetoothOKOK2 extends BluetoothCommunication { - private static final int IDX_WEIGHT_MSB = 0; - private static final int IDX_WEIGHT_LSB = 1; - private static final int IDX_IMPEDANCE_MSB = 2; - private static final int IDX_IMPEDANCE_LSB = 3; - private static final int IDX_PRODUCTID_MSB = 4; - private static final int IDX_PRODUCTID_LSB = 5; - private static final int IDX_ATTRIB = 6; - private static final int IDX_MAC_1 = 7; - private static final int IDX_MAC_2 = 8; - private static final int IDX_MAC_3 = 9; - private static final int IDX_MAC_4 = 10; - private static final int IDX_MAC_5 = 11; - private static final int IDX_MAC_6 = 12; - - private static final int UNIT_KG = 0; - private static final int UNIT_LB = 2; - private static final int UNIT_STLB = 3; - - private BluetoothCentralManager central; - private String mMacAddress; - private float mLastWeight = 0f; - - public BluetoothOKOK2(Context context) { - super(context); - central = new BluetoothCentralManager(context, btCallback, new Handler(Looper.getMainLooper())); - } - - static String convertNoNameToDeviceName(SparseArray manufacturerSpecificData) { - int vendorIndex = -1; - for (int i = 0; i < manufacturerSpecificData.size(); i++) { - int vendorId = manufacturerSpecificData.keyAt(i); - if ((vendorId & 0xff) == 0xc0) { // 0x00c0-->0xffc0 - vendorIndex = vendorId; - } - } - if (vendorIndex == -1) { - return null; - } - - return "NoName OkOk"; - } - - private final BluetoothCentralManagerCallback btCallback = new BluetoothCentralManagerCallback() { - @Override - public void onDiscoveredPeripheral(@NotNull BluetoothPeripheral peripheral, @NotNull ScanResult scanResult) { - SparseArray manufacturerSpecificData = scanResult.getScanRecord().getManufacturerSpecificData(); - int vendorIndex = -1; - for (int i = 0; i < manufacturerSpecificData.size(); i++) { - int vendorId = manufacturerSpecificData.keyAt(i); - if ((vendorId & 0xff) == 0xc0) { // 0x00c0-->0xffc0 - vendorIndex = vendorId; - break; - } - } - if (vendorIndex == -1) { - return; - } - byte[] data = manufacturerSpecificData.get(vendorIndex); - - StringBuilder sb = new StringBuilder(data.length * 3); - for (byte b : data) { - sb.append(String.format("%02x ", b)); - } - Timber.d("manufacturerSpecificData: [VID=%04x] %s", vendorIndex, sb.toString()); - - if (data[IDX_MAC_1] != (byte) ((Character.digit(mMacAddress.charAt(0), 16) << 4) + Character.digit(mMacAddress.charAt(1), 16)) - || data[IDX_MAC_2] != (byte) ((Character.digit(mMacAddress.charAt(3), 16) << 4) + Character.digit(mMacAddress.charAt(4), 16)) - || data[IDX_MAC_3] != (byte) ((Character.digit(mMacAddress.charAt(6), 16) << 4) + Character.digit(mMacAddress.charAt(7), 16)) - || data[IDX_MAC_4] != (byte) ((Character.digit(mMacAddress.charAt(9), 16) << 4) + Character.digit(mMacAddress.charAt(10), 16)) - || data[IDX_MAC_5] != (byte) ((Character.digit(mMacAddress.charAt(12), 16) << 4) + Character.digit(mMacAddress.charAt(13), 16)) - || data[IDX_MAC_6] != (byte) ((Character.digit(mMacAddress.charAt(15), 16) << 4) + Character.digit(mMacAddress.charAt(16), 16))) - return; - - if ((data[IDX_ATTRIB] & 1) == 0) // in progress - return; - - float divider = 10f; - switch ((data[IDX_ATTRIB] >> 1) & 3) { - case 0: - divider = 10f; - break; - case 1: - divider = 1f; - break; - case 2: - divider = 100f; - break; - } - - float weight = 0f; - switch ((data[IDX_ATTRIB] >> 3) & 3) { - case UNIT_KG: { - float val = ((data[IDX_WEIGHT_MSB] & 0xff) << 8) | (data[IDX_WEIGHT_LSB] & 0xff); - weight = val / divider; - break; - } - case UNIT_LB: { - float val = ((data[IDX_WEIGHT_MSB] & 0xff) << 8) | (data[IDX_WEIGHT_LSB] & 0xff); - weight = Converters.toKilogram(val / divider, LB); - break; - } - case UNIT_STLB: { - float val = data[IDX_WEIGHT_MSB] /*ST*/ + data[IDX_WEIGHT_LSB] /*LB*/ / divider / 14f; - weight = Converters.toKilogram(val, ST); - break; - } - } - - if (mLastWeight != weight) { - ScaleMeasurement entry = new ScaleMeasurement(); - entry.setWeight(weight); - addScaleMeasurement(entry); - mLastWeight = weight; - // disconnect(); - } - } - }; - - @Override - public void connect(String macAddress) { - mMacAddress = macAddress; - List filters = new LinkedList<>(); - - byte[] data = new byte[13]; - data[IDX_MAC_1] = (byte) ((Character.digit(macAddress.charAt(0), 16) << 4) + Character.digit(macAddress.charAt(1), 16)); - data[IDX_MAC_2] = (byte) ((Character.digit(macAddress.charAt(3), 16) << 4) + Character.digit(macAddress.charAt(4), 16)); - data[IDX_MAC_3] = (byte) ((Character.digit(macAddress.charAt(6), 16) << 4) + Character.digit(macAddress.charAt(7), 16)); - data[IDX_MAC_4] = (byte) ((Character.digit(macAddress.charAt(9), 16) << 4) + Character.digit(macAddress.charAt(10), 16)); - data[IDX_MAC_5] = (byte) ((Character.digit(macAddress.charAt(12), 16) << 4) + Character.digit(macAddress.charAt(13), 16)); - data[IDX_MAC_6] = (byte) ((Character.digit(macAddress.charAt(15), 16) << 4) + Character.digit(macAddress.charAt(16), 16)); - byte[] mask = new byte[13]; - mask[IDX_MAC_1] = mask[IDX_MAC_2] = mask[IDX_MAC_3] = mask[IDX_MAC_4] = mask[IDX_MAC_5] = mask[IDX_MAC_6] = (byte) 0xff; - - for (int i = 0x00; i <= 0xff; i++) { - ScanFilter.Builder b = new ScanFilter.Builder(); - b.setDeviceAddress(macAddress); - b.setManufacturerData((i << 8) | 0xc0, data, mask); - filters.add(b.build()); - } - - central.scanForPeripheralsUsingFilters(filters); - } - - @Override - public void disconnect() { - if (central != null) - central.stopScan(); - central = null; - super.disconnect(); - } - - @Override - public String driverName() { - return "OKOK (nameless)"; - } - - @Override - protected boolean onNextStep(int stepNr) { - return false; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothOneByone.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothOneByone.java deleted file mode 100644 index 280b273a..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothOneByone.java +++ /dev/null @@ -1,231 +0,0 @@ -/* Copyright (C) 2018 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.bluetooth; - -import android.content.Context; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.bluetooth.lib.OneByoneLib; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; - -import java.util.Calendar; -import java.util.UUID; - -import timber.log.Timber; - -public class BluetoothOneByone extends BluetoothCommunication { - private final UUID WEIGHT_MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0xfff0); - - private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC_BODY_COMPOSITION = BluetoothGattUuid.fromShortCode(0xfff4); // notify - - private final UUID CMD_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xfff1); // write only - - private boolean waitAck = false; // if true, resume after receiving acknowledgement - private boolean historicMeasurement = false; // processing real-time vs historic measurement - private int noHistoric = 0; // number of historic measurements received - - // don't save any measurements closer than 3 seconds to each other - private Calendar lastDateTime; - private final int DATE_TIME_THRESHOLD = 3000; - - public BluetoothOneByone(Context context) { - super(context); - lastDateTime = Calendar.getInstance(); - lastDateTime.set(2000, 1, 1); - } - - @Override - public String driverName() { - return "1byone"; - } - - @Override - protected boolean onNextStep(int stepNr) { - switch (stepNr) { - case 0: - setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC_BODY_COMPOSITION); - break; - case 1: - ScaleUser currentUser = OpenScale.getInstance().getSelectedScaleUser(); - byte unit = 0x00; // kg - switch (currentUser.getScaleUnit()) { - case LB: - unit = 0x01; - break; - case ST: - unit = 0x02; - break; - } - byte group = 0x01; - byte[] magicBytes = {(byte)0xfd, (byte)0x37, unit, group, - (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, - (byte)0x00, (byte)0x00, (byte)0x00}; - magicBytes[magicBytes.length - 1] = - xorChecksum(magicBytes, 0, magicBytes.length - 1); - writeBytes(WEIGHT_MEASUREMENT_SERVICE, CMD_MEASUREMENT_CHARACTERISTIC, magicBytes); - break; - case 2: - Calendar dt = Calendar.getInstance(); - final byte[] setClockCmd = {(byte)0xf1, (byte)(dt.get(Calendar.YEAR) >> 8), - (byte)(dt.get(Calendar.YEAR) & 255), (byte)(dt.get(Calendar.MONTH) + 1), - (byte)dt.get(Calendar.DAY_OF_MONTH), (byte)dt.get(Calendar.HOUR_OF_DAY), - (byte)dt.get(Calendar.MINUTE), (byte)dt.get(Calendar.SECOND)}; - waitAck = true; - writeBytes(WEIGHT_MEASUREMENT_SERVICE, CMD_MEASUREMENT_CHARACTERISTIC, setClockCmd); - // 2-byte notification value f1 00 will be received after this command - stopMachineState(); // we will resume after receiving acknowledgement f1 00 - break; - case 3: - // request historic measurements; they are followed by real-time measurements - historicMeasurement = true; - final byte[] getHistoryCmd = {(byte)0xf2, (byte)0x00}; - writeBytes(WEIGHT_MEASUREMENT_SERVICE, CMD_MEASUREMENT_CHARACTERISTIC, getHistoryCmd); - // multiple measurements will be received, they start cf ... and are 11 or 18 bytes long - // 2-byte notification value f2 00 follows last historic measurement - break; - case 4: - sendMessage(R.string.info_step_on_scale, 0); - break; - default: - return false; - } - - return true; - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - final byte[] data = value; - if (data == null) { - return; - } - - // if data is valid data - if (data.length >= 11 && data[0] == (byte)0xcf) { - if (historicMeasurement) { - ++noHistoric; - } - parseBytes(data); - } else { - // show 2-byte ack messages in debug output: - // f1 00 setClockCmd acknowledgement - // f2 00 end of historic measurements, real-time measurements follow - // f2 01 clearHistoryCmd acknowledgement - Timber.d("received bytes [%s]", byteInHex(data)); - - if (waitAck && data.length == 2 && data[0] == (byte)0xf1 && data[1] == 0) { - waitAck = false; - resumeMachineState(); - } else if (data.length == 2 && data[0] == (byte)0xf2 && data[1] == 0) { - historicMeasurement = false; - if (noHistoric > 0) { - final byte[] clearHistoryCmd = {(byte)0xf2, (byte)0x01}; - writeBytes(WEIGHT_MEASUREMENT_SERVICE, CMD_MEASUREMENT_CHARACTERISTIC, clearHistoryCmd); - } - } - } - } - - private void parseBytes(byte[] weightBytes) { - float weight = Converters.fromUnsignedInt16Le(weightBytes, 3) / 100.0f; - float impedanceValue = ((float)(((weightBytes[2] & 0xFF) << 8) + (weightBytes[1] & 0xFF))) * 0.1f; - boolean impedancePresent = (weightBytes[9] != 1) && (impedanceValue != 0); - boolean dateTimePresent = weightBytes.length >= 18; - - if (!impedancePresent || (!dateTimePresent && historicMeasurement)) { - // unwanted, no impedance or historic measurement w/o time-stamp - return; - } - - Calendar dateTime = Calendar.getInstance(); - if (dateTimePresent) { - // 18-byte or longer measurements contain date and time, used in history - dateTime.set(Converters.fromUnsignedInt16Be(weightBytes, 11), - weightBytes[13] - 1, weightBytes[14], weightBytes[15], - weightBytes[16], weightBytes[17]); - } - - final ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser(); - - Timber.d("received bytes [%s]", byteInHex(weightBytes)); - Timber.d("received decoded bytes [weight: %.2f, impedanceValue: %f]", weight, impedanceValue); - Timber.d("user [%s]", scaleUser); - - int sex = 0, peopleType = 0; - - if (scaleUser.getGender() == Converters.Gender.MALE) { - sex = 1; - } else { - sex = 0; - } - - switch (scaleUser.getActivityLevel()) { - case SEDENTARY: - peopleType = 0; - break; - case MILD: - peopleType = 0; - break; - case MODERATE: - peopleType = 1; - break; - case HEAVY: - peopleType = 2; - break; - case EXTREME: - peopleType = 2; - break; - } - - OneByoneLib oneByoneLib = new OneByoneLib(sex, scaleUser.getAge(), scaleUser.getBodyHeight(), peopleType); - - ScaleMeasurement scaleBtData = new ScaleMeasurement(); - scaleBtData.setWeight(weight); - try { - dateTime.setLenient(false); - scaleBtData.setDateTime(dateTime.getTime()); - - scaleBtData.setFat(oneByoneLib.getBodyFat(weight, impedanceValue)); - scaleBtData.setWater(oneByoneLib.getWater(scaleBtData.getFat())); - scaleBtData.setBone(oneByoneLib.getBoneMass(weight, impedanceValue)); - scaleBtData.setVisceralFat(oneByoneLib.getVisceralFat(weight)); - scaleBtData.setMuscle(oneByoneLib.getMuscle(weight, impedanceValue)); - scaleBtData.setLbm(oneByoneLib.getLBM(weight, scaleBtData.getFat())); - - Timber.d("scale measurement [%s]", scaleBtData); - - if (dateTime.getTimeInMillis() - lastDateTime.getTimeInMillis() < DATE_TIME_THRESHOLD) { - return; // don't save measurements too close to each other - } - lastDateTime = dateTime; - - addScaleMeasurement(scaleBtData); - } - catch (IllegalArgumentException e) { - if (historicMeasurement) { - Timber.d("invalid time-stamp: year %d, month %d, day %d, hour %d, minute %d, second %d", - Converters.fromUnsignedInt16Be(weightBytes, 11), - weightBytes[13], weightBytes[14], weightBytes[15], - weightBytes[16], weightBytes[17]); - return; // discard historic measurement with invalid time-stamp - } - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothOneByoneNew.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothOneByoneNew.java deleted file mode 100644 index fccd28b1..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothOneByoneNew.java +++ /dev/null @@ -1,333 +0,0 @@ -package com.health.openscale.core.bluetooth; - -import android.content.Context; - -import androidx.annotation.NonNull; - -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.bluetooth.lib.OneByoneNewLib; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; - -import java.math.BigInteger; -import java.util.Calendar; -import java.util.Collections; -import java.util.Date; -import java.util.List; -import java.util.UUID; - -import timber.log.Timber; - -public class BluetoothOneByoneNew extends BluetoothCommunication{ - private final UUID WEIGHT_MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0xffb0); - private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC_BODY_COMPOSITION = BluetoothGattUuid.fromShortCode(0xffb2); - private final UUID CMD_AFTER_MEASUREMENT = BluetoothGattUuid.fromShortCode(0xffb1); - - private final int MSG_LENGTH = 20; - private final byte[] HEADER_BYTES = { (byte)0xAB, (byte)0x2A }; - - private ScaleMeasurement currentMeasurement; - - public BluetoothOneByoneNew(Context context) { - super(context); - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] data){ - if(data == null){ - Timber.e("Received an empty message"); - return; - } - - Timber.i("Received %s", new BigInteger(1, data).toString(16)); - - if(data.length < MSG_LENGTH){ - Timber.e("Received a message too short"); - return; - } - - if(!(data[0] == HEADER_BYTES[0] && data[1] == HEADER_BYTES[1])){ - Timber.e("Unrecognized message received from scale."); - } - - float weight; - int impedance; - - - switch(data[2]){ - case (byte)0x00: - // real time measurement OR historic measurement - // real time has the exact same format of 0x80, but we can ignore it - // we want to capture the historic measures - - // filter out real time measurments - if (data[7] != (byte)0x80){ - Timber.i("Received real-time measurement. Skipping."); - break; - } - - Date time = getTimestamp32(data, 3); - weight = Converters.fromUnsignedInt24Be(data, 8) & 0x03ffff; - weight /= 1000; - impedance = Converters.fromUnsignedInt16Be(data, 15); - - ScaleMeasurement historicMeasurement = new ScaleMeasurement(); - int assignableUserId = OpenScale.getInstance().getAssignableUser(weight); - if(assignableUserId == -1){ - Timber.i("Discarding historic measurement: no user found with intelligent user recognition"); - break; - } - populateMeasurement(assignableUserId, historicMeasurement, impedance, weight); - historicMeasurement.setDateTime(time); - addScaleMeasurement(historicMeasurement); - Timber.i("Added historic measurement. Weight: %s, impedance: %s, timestamp: %s", weight, impedance, time.toString()); - break; - - case (byte)0x80: - // final measurement - currentMeasurement = new ScaleMeasurement(); - weight = Converters.fromUnsignedInt24Be(data, 3) & 0x03ffff; - weight = weight / 1000; - currentMeasurement.setWeight(weight); - Timber.d("Weight: %s", weight); - break; - case (byte)0x01: - impedance = Converters.fromUnsignedInt16Be(data, 4); - Timber.d("impedance: %s", impedance); - - if(currentMeasurement == null){ - Timber.e("Received impedance value without weight"); - break; - } - - float measurementWeight = currentMeasurement.getWeight(); - ScaleUser user = OpenScale.getInstance().getSelectedScaleUser(); - populateMeasurement(user.getId(), currentMeasurement, impedance, measurementWeight); - addScaleMeasurement(currentMeasurement); - resumeMachineState(); - break; - default: - Timber.e("Unrecognized message receveid"); - } - } - - private void populateMeasurement(int userId, ScaleMeasurement measurement, int impedance, float weight) { - if(userId == -1){ - Timber.e("Discarding measurement population since invalid user"); - return; - } - ScaleUser user = OpenScale.getInstance().getScaleUser(userId); - float cmHeight = Converters.fromCentimeter(user.getBodyHeight(), user.getMeasureUnit()); - OneByoneNewLib onebyoneLib = new OneByoneNewLib(getUserGender(user), user.getAge(), cmHeight, user.getActivityLevel().toInt()); - measurement.setUserId(userId); - measurement.setWeight(weight); - measurement.setDateTime(Calendar.getInstance().getTime()); - measurement.setFat(onebyoneLib.getBodyFatPercentage(weight, impedance)); - measurement.setWater(onebyoneLib.getWaterPercentage(weight, impedance)); - measurement.setBone(onebyoneLib.getBoneMass(weight, impedance)); - measurement.setVisceralFat(onebyoneLib.getVisceralFat(weight)); - measurement.setMuscle(onebyoneLib.getSkeletonMusclePercentage(weight, impedance)); - measurement.setLbm(onebyoneLib.getLBM(weight, impedance)); - } - - @Override - public String driverName() { - return "OneByoneNew"; - } - - @Override - protected boolean onNextStep(int stepNr) { - switch(stepNr){ - case 0: - setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC_BODY_COMPOSITION); - break; - case 1: - // Setup notification on new weight - sendWeightRequest(); - - // Update the user history on the scale - // Priority given to the current user - ScaleUser currentUser = OpenScale.getInstance().getSelectedScaleUser(); - sendUsersHistory(currentUser.getId()); - - // We wait for the response - stopMachineState(); - break; - case 2: - // After the measurement took place, we store the data and send back to the scale - sendUsersHistory(OpenScale.getInstance().getSelectedScaleUserId()); - break; - default: - return false; - } - - return true; - } - - private void sendWeightRequest() { - byte[] msgSetup = new byte[MSG_LENGTH]; - setupMeasurementMessage(msgSetup, 0); - writeBytes(WEIGHT_MEASUREMENT_SERVICE, CMD_AFTER_MEASUREMENT, msgSetup, true); - } - - private void sendUsersHistory(int priorityUser){ - List scaleUsers = OpenScale.getInstance().getScaleUserList(); - Collections.sort(scaleUsers, (ScaleUser u1, ScaleUser u2) -> { - if(u1.getId() == priorityUser) return -9999; - if(u2.getId() == priorityUser) return 9999; - Date u1LastMeasureDate = OpenScale.getInstance().getLastScaleMeasurement(u1.getId()).getDateTime(); - Date u2LastMeasureDate = OpenScale.getInstance().getLastScaleMeasurement(u2.getId()).getDateTime(); - return u1LastMeasureDate.compareTo(u2LastMeasureDate); - } - ); - byte[] msg = new byte[MSG_LENGTH]; - int msgCounter = 0; - for(int i = 0; i < scaleUsers.size(); i++){ - ScaleUser user = scaleUsers.get(i); - ScaleMeasurement lastMeasure = OpenScale.getInstance().getLastScaleMeasurement(user.getId()); - float weight = 0; - int impedance = 0; - if(lastMeasure != null){ - weight = lastMeasure.getWeight(); - impedance = getImpedanceFromLBM(user, lastMeasure); - } - - int entryPosition = i % 2; - - if (entryPosition == 0){ - msg = new byte[MSG_LENGTH]; - msgCounter ++; - msg[0] = HEADER_BYTES[0]; - msg[1] = HEADER_BYTES[1]; - msg[2] = (byte) scaleUsers.size(); - msg[3] = (byte) msgCounter; - } - - setMeasurementEntry(msg, 4 + entryPosition * 7, i + 1, - Math.round(user.getBodyHeight()), - weight, - getUserGender(user), - user.getAge(), - impedance, - true); - - if (entryPosition == 1 || i + 1 == scaleUsers.size()){ - msg[18] = (byte)0xD4; - msg[19] = d4Checksum(msg, 0, MSG_LENGTH); - writeBytes(WEIGHT_MEASUREMENT_SERVICE, CMD_AFTER_MEASUREMENT, msg, true); - } - - } - } - - private void setMeasurementEntry(byte[] msg, int offset, int entryNum, int height, float weight, int sex, int age, int impedance, boolean impedanceLe){ - // The scale wants a value rounded to the first decimal place - // Otherwise we receive always a UP/DOWN arrow since we would communicate - // AB.CX instead of AB.D0 where D0 is the approximation of CX and it is what the scale uses - // to compute the UP/DOWN arrows - int roundedWeight = Math.round( weight * 10) * 10; - msg[offset] = (byte)(entryNum & 0xFF); - msg[offset+1] = (byte)(height & 0xFF); - Converters.toInt16Be(msg, offset+2, roundedWeight); - msg[offset+4] = (byte)(((sex & 0xFF) << 7) + (age & 0x7F)); - - if(impedanceLe) { - msg[offset + 5] = (byte) (impedance >> 8); - msg[offset + 6] = (byte) impedance; - } else { - msg[offset + 5] = (byte) impedance; - msg[offset + 6] = (byte) (impedance >> 8); - } - } - - private void setTimestamp32(byte[] msg, int offset){ - long timestamp = System.currentTimeMillis()/1000L; - Converters.toInt32Be(msg, offset, timestamp); - } - - private Date getTimestamp32(byte[] msg, int offset){ - long timestamp = Converters.fromUnsignedInt32Be(msg, offset); - return new Date(timestamp * 1000); - } - - private boolean setupMeasurementMessage(byte[] msg, int offset){ - if(offset + MSG_LENGTH > msg.length){ - return false; - } - - ScaleUser currentUser = OpenScale.getInstance().getSelectedScaleUser(); - Converters.WeightUnit weightUnit = currentUser.getScaleUnit(); - - msg[offset] = HEADER_BYTES[0]; - msg[offset+1] = HEADER_BYTES[1]; - setTimestamp32(msg, offset+2); - // This byte has been left empty in all the observations, unknown meaning - msg[offset+6] = 0; - msg[offset+7] = (byte) weightUnit.toInt(); - int userId = currentUser.getId(); - - - // We send the last measurement or if not present an empty one - ScaleMeasurement lastMeasure = OpenScale.getInstance().getLastScaleMeasurement(userId); - float weight = 0; - int impedance = 0; - if(lastMeasure != null){ - weight = lastMeasure.getWeight(); - impedance = getImpedanceFromLBM(currentUser, lastMeasure); - } - - setMeasurementEntry(msg, offset+8, - userId, - Math.round(currentUser.getBodyHeight()), - weight, - getUserGender(currentUser), - currentUser.getAge(), - impedance, - false - ); - - // Blank bytes after the empty measurement - msg[offset + 18] = (byte) 0xD7; - msg[offset+19] = d7Checksum(msg, offset+2, 17); - return true; - } - - private int getUserGender(ScaleUser user){ - // Custom function since the toInt() gives the opposite values - return user.getGender().isMale() ? 1 : 0; - } - - private byte d4Checksum(byte[] msg, int offset, int length){ - byte sum = sumChecksum(msg, offset + 2, length - 2); - - // Remove impedance MSB first entry - sum -= msg[offset+9]; - - // Remove second entry weight - sum -= msg[offset+13]; - sum -= msg[offset+14]; - - // Remove impedance MSB second entry - sum -= msg[offset+16]; - return sum; - } - - private byte d7Checksum(byte[] msg, int offset, int length){ - byte sum = sumChecksum(msg, offset+2, length-2); - - // Remove impedance MSB - sum -= msg[offset+14]; - return sum; - } - - // Since we need to send the impedance to the scale the next time, - // we obtain it from the previous measurement using the LBM - public int getImpedanceFromLBM(ScaleUser user, ScaleMeasurement measurement) { - float finalLbm = measurement.getLbm(); - float postImpedanceLbm = finalLbm + user.getAge() * 0.0542F; - float preImpedanceLbm = user.getBodyHeight() / 100 * user.getBodyHeight() / 100 * 9.058F + 12.226F + measurement.getWeight() * 0.32F; - return Math.round((preImpedanceLbm - postImpedanceLbm) / 0.0068F); - } - -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothQNScale.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothQNScale.java deleted file mode 100644 index 70a25b7a..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothQNScale.java +++ /dev/null @@ -1,283 +0,0 @@ -/* Copyright (C) 2014 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - -package com.health.openscale.core.bluetooth; - -import android.content.Context; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.bluetooth.lib.TrisaBodyAnalyzeLib; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; - -import java.util.Date; -import java.util.UUID; - -import timber.log.Timber; - -public class BluetoothQNScale extends BluetoothCommunication { - // accurate. Indication means requires ack. notification does not - private final UUID WEIGHT_MEASUREMENT_SERVICE = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb"); - //private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb"); // notify, read-only - //private final UUID CMD_MEASUREMENT_CHARACTERISTIC = UUID.fromString("29f11080-75b9-11e2-8bf6-0002a5d5c51b"); // write only - // Client Characteristic Configuration Descriptor, constant value of 0x2902 - private final UUID WEIGHT_MEASUREMENT_CONFIG = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"); - - ///////////// explore - // Read value notification to get weight. Some other payload structures as well 120f15 & 140b15 - // Also handle 14. Send write requests that are empty? Subscribes to notification on 0xffe1 - private final UUID CUSTOM1_MEASUREMENT_CHARACTERISTIC = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb"); // notify, read-only - // Receive value indication. Is always magic value 210515013c. Message occurs before or after last weight...almost always before. - // Also send (empty?) write requests on handle 17? Subscribes to indication on 0xffe2 - private final UUID CUSTOM2_MEASUREMENT_CHARACTERISTIC = UUID.fromString("0000ffe2-0000-1000-8000-00805f9b34fb"); // indication, read-only - // Sending write with magic 1f05151049 terminates connection. Sending magic 130915011000000042 only occurs after receiveing a 12 or 14 on 0xffe1 and is always followed by receiving a 14 on 0xffe1 - private final UUID CUSTOM3_MEASUREMENT_CHARACTERISTIC = UUID.fromString("0000ffe3-0000-1000-8000-00805f9b34fb"); // write-only - // Send write of value like 20081568df4023e7 (constant until 815. assuming this is time?). Always sent following the receipt of a 14 on 0xffe1. Always prompts the receipt of a value indication on 0xffe2. This has to be sending time, then prompting for scale to send time for host to finally confirm - private final UUID CUSTOM4_MEASUREMENT_CHARACTERISTIC = UUID.fromString("0000ffe4-0000-1000-8000-00805f9b34fb"); // write-only - // Never used - private final UUID CUSTOM5_MEASUREMENT_CHARACTERISTIC = UUID.fromString("0000ffe5-0000-1000-8000-00805f9b34fb"); // write-only - ///////////// - - // 2nd Type Service and Characteristics (2nd Type doesn't need to indicate, and 4th characteristic is shared with 3rd.) - private final UUID WEIGHT_MEASUREMENT_SERVICE_ALTERNATIVE = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb"); - private final UUID CUSTOM1_MEASUREMENT_CHARACTERISTIC_ALTERNATIVE = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb"); // notify, read-only - private final UUID CUSTOM3_MEASUREMENT_CHARACTERISTIC_ALTERNATIVE = UUID.fromString("0000fff2-0000-1000-8000-00805f9b34fb"); // write-only - - private boolean useFirstType = true; - - - /** API - connectDevice(device, "userId", 170, 1, birthday, new new QNBleCallback(){ - void onConnectStart(QNBleDevice bleDevice); - void onConnected(QNBleDevice bleDevice); - void onDisconnected(QNBleDevice bleDevice,int status); - void onUnsteadyWeight(QNBleDevice bleDevice, float weight); - void onReceivedData(QNBleDevice bleDevice, QNData data); - void onReceivedStoreData(QNBleDevice bleDevice, List datas); - void onLowPower(); - **/ - - // Scale time is in seconds since 2000-01-01 00:00:00 (utc). - private static final long SCALE_UNIX_TIMESTAMP_OFFSET = 946702800; - - - private static long MILLIS_2000_YEAR = 949334400000L; - private boolean hasReceived; - private float weightScale=100.0f; - - - public BluetoothQNScale(Context context) { - super(context); - } - - // Includes FITINDEX ES-26M - @Override - public String driverName() { - return "QN Scale"; - } - - @Override - protected boolean onNextStep(int stepNr) { - switch (stepNr) { - case 0: - // Try writing bytes to 0xffe4 to check whether to use 1st or 2nd type - try { - long timestamp = new Date().getTime() / 1000; - timestamp -= SCALE_UNIX_TIMESTAMP_OFFSET; - byte[] date = new byte[4]; - Converters.toInt32Le(date, 0, timestamp); - writeBytes(WEIGHT_MEASUREMENT_SERVICE, CUSTOM4_MEASUREMENT_CHARACTERISTIC, new byte[]{(byte) 0x02, date[0], date[1], date[2], date[3]}); - } catch (NullPointerException e) { - useFirstType = false; - } - break; - case 1: - // set indication on for weight measurement and for custom characteristic 1 (weight, time, and others) - if (useFirstType) { - setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, CUSTOM1_MEASUREMENT_CHARACTERISTIC); - setIndicationOn(WEIGHT_MEASUREMENT_SERVICE, CUSTOM2_MEASUREMENT_CHARACTERISTIC); - } else { - setNotificationOn(WEIGHT_MEASUREMENT_SERVICE_ALTERNATIVE, CUSTOM1_MEASUREMENT_CHARACTERISTIC_ALTERNATIVE); - } - break; - case 2: - final ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser(); - final Converters.WeightUnit scaleUserWeightUnit = scaleUser.getScaleUnit(); - // Value of 0x01 = KG. 0x02 = LB. Requests with stones unit are sent as LB, with post-processing in vendor app. - byte weightUnitByte = (byte) 0x01; - // Default weight unit KG. If user config set to LB or ST, scale will show LB units, consistent with vendor app - if (scaleUserWeightUnit == Converters.WeightUnit.LB || scaleUserWeightUnit == Converters.WeightUnit.ST){ - weightUnitByte = (byte) 0x02; - } - // write magicnumber 0x130915[WEIGHT_BYTE]10000000[CHECK_SUM] to 0xffe3 - // 0x01 weight byte = KG. 0x02 weight byte = LB. - byte[] ffe3magicBytes = new byte[]{(byte) 0x13, (byte) 0x09, (byte) 0x15, weightUnitByte, (byte) 0x10, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00}; - // Set last byte to be checksum - ffe3magicBytes[ffe3magicBytes.length -1] = sumChecksum(ffe3magicBytes, 0, ffe3magicBytes.length - 1); - - if (useFirstType) { - writeBytes(WEIGHT_MEASUREMENT_SERVICE, CUSTOM3_MEASUREMENT_CHARACTERISTIC, ffe3magicBytes); - } else { - writeBytes(WEIGHT_MEASUREMENT_SERVICE_ALTERNATIVE, CUSTOM3_MEASUREMENT_CHARACTERISTIC_ALTERNATIVE, ffe3magicBytes); - } - break; - case 3: - // send time magic number to receive weight data - long timestamp = new Date().getTime() / 1000; - timestamp -= SCALE_UNIX_TIMESTAMP_OFFSET; - byte[] date = new byte[4]; - Converters.toInt32Le(date, 0, timestamp); - byte[] timeMagicBytes = new byte[]{(byte) 0x02, date[0], date[1], date[2], date[3]}; - - if (useFirstType) { - writeBytes(WEIGHT_MEASUREMENT_SERVICE, CUSTOM4_MEASUREMENT_CHARACTERISTIC, timeMagicBytes); - } else { - writeBytes(WEIGHT_MEASUREMENT_SERVICE_ALTERNATIVE, CUSTOM3_MEASUREMENT_CHARACTERISTIC_ALTERNATIVE, timeMagicBytes); - } - break; - case 4: - sendMessage(R.string.info_step_on_scale, 0); - break; - /*case 5: - // send stop command to scale (0x1f05151049) - writeBytes(CUSTOM3_MEASUREMENT_CHARACTERISTIC, new byte[]{(byte)0x1f, (byte)0x05, (byte)0x15, (byte)0x10, (byte)0x49}); - break;*/ - default: - return false; - } - - return true; - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - final byte[] data = value; - - if (characteristic.equals(CUSTOM1_MEASUREMENT_CHARACTERISTIC) || characteristic.equals(CUSTOM1_MEASUREMENT_CHARACTERISTIC_ALTERNATIVE)) { - parseCustom1Data(data); - } - } - - private void parseCustom1Data(byte[] data){ - StringBuilder sb = new StringBuilder(); - - int len = data.length; - for (int i = 0; i < len; i++) { - sb.append(String.format("%02X ", new Object[]{Byte.valueOf(data[i])})); - - } - Timber.d(sb.toString()); - float weightKg=0; - switch (data[0]) { - case (byte) 16: - if (data[5] == (byte) 0) { - this.hasReceived = false; - //this.callback.onUnsteadyWeight(this.qnBleDevice, decodeWeight(data[3], data[4])); - } else if (data[5] == (byte) 1) { - // writeData(CmdBuilder.buildOverCmd(this.protocolType, 16)); - if (!this.hasReceived) { - this.hasReceived = true; - weightKg = decodeWeight(data[3], data[4]); - - // Weight needs to be divided by 10 if 2nd type - if (!useFirstType) { - weightKg /= 10; - } - - int weightByteOne = data[3] & 0xFF; - int weightByteTwo = data[4] & 0xFF; - - Timber.d("Weight byte 1 %d", weightByteOne); - Timber.d("Weight byte 2 %d", weightByteTwo); - Timber.d("Raw Weight: %f", weightKg); - - if (weightKg > 0.0f) { - //QNData md = buildMeasuredData(this.qnUser, weight, decodeIntegerValue - // (data[6], data[7]), decodeIntegerValue(data[8], data[9]), - // new Date(), data); - - int resistance1 = decodeIntegerValue (data[6], data[7]); - int resistance2 = decodeIntegerValue(data[8], data[9]); - Timber.d("resistance1: %d", resistance1); - Timber.d("resistance2: %d", resistance2); - - final ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser(); - Timber.d("scale user " + scaleUser); - ScaleMeasurement btScaleMeasurement = new ScaleMeasurement(); - //TrisaBodyAnalyzeLib gives almost simillar values for QNScale body fat calcualtion - TrisaBodyAnalyzeLib qnscalelib = new TrisaBodyAnalyzeLib(scaleUser.getGender().isMale() ? 1 : 0, scaleUser.getAge(), (int)scaleUser.getBodyHeight()); - - //Now much difference between resistance1 and resistance2. - //Will use resistance 1 for now - float impedance = resistance1 < 410f ? 3.0f : 0.3f * (resistance1 - 400f); - btScaleMeasurement.setFat(qnscalelib.getFat(weightKg, impedance)); - btScaleMeasurement.setWater(qnscalelib.getWater(weightKg, impedance)); - btScaleMeasurement.setMuscle(qnscalelib.getMuscle(weightKg, impedance)); - btScaleMeasurement.setBone(qnscalelib.getBone(weightKg, impedance)); - btScaleMeasurement.setWeight(weightKg); - addScaleMeasurement(btScaleMeasurement); - } - } - } - break; - case (byte) 18: - byte protocolType = data[2]; - this.weightScale = data[10] == (byte) 1 ? 100.0f : 10.0f; - int[] iArr = new int[5]; - //TODO - //writeData(CmdBuilder.buildCmd(19, this.protocolType, 1, 16, 0, 0, 0)); - break; - case (byte) 33: - // TODO - //writeBleData(CmdBuilder.buildCmd(34, this.protocolType, new int[0])); - break; - case (byte) 35: - weightKg = decodeWeight(data[9], data[10]); - if (weightKg > 0.0f) { - int resistance = decodeIntegerValue(data[11], data[12]); - int resistance500 = decodeIntegerValue(data[13], data[14]); - long differTime = 0; - for (int i = 0; i < 4; i++) { - differTime |= (((long) data[i + 5]) & 255) << (i * 8); - } - Date date = new Date(MILLIS_2000_YEAR + (1000 * differTime)); - - // TODO - // QNData qnData = buildMeasuredData(user, weight, resistance, - // resistance500, date, data); - - - if (data[3] == data[4]) { - // TODO - } - } - break; - } - } - - private float decodeWeight(byte a, byte b) { - return ((float) (((a & 255) << 8) + (b & 255))) / this.weightScale; - } - - private int decodeIntegerValue(byte a, byte b) { - return ((a & 255) << 8) + (b & 255); - } - - - -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothRenphoScale.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothRenphoScale.java deleted file mode 100644 index 45b83c0e..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothRenphoScale.java +++ /dev/null @@ -1,255 +0,0 @@ -package com.health.openscale.core.bluetooth; - -import static com.health.openscale.core.utils.Converters.toCentimeter; - -import android.content.Context; - -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; - -import java.util.Calendar; -import java.util.Date; -import java.util.UUID; -import java.time.LocalDateTime; - -import timber.log.Timber; - -public class BluetoothRenphoScale extends BluetoothCommunication { - - private static final UUID SERV_BODY_COMP = BluetoothGattUuid.fromShortCode(0x181b); - private static final UUID SERV_USER_DATA = BluetoothGattUuid.fromShortCode(0x181c); - private static final UUID SERV_WEIGHT_SCALE = BluetoothGattUuid.fromShortCode(0x181d); - private static final UUID SERV_CUR_TIME = BluetoothGattUuid.fromShortCode(0x1805); - - // Custom characteristic nr. 0 (Service: body comp) - // Written data was always the same on all my tests - private static final UUID CHAR_CUSTOM0_NOTIFY = BluetoothGattUuid.fromShortCode(0xffe1); - private static final UUID CHAR_CUSTOM0 = BluetoothGattUuid.fromShortCode(0xffe2); - private static final byte[] CHAR_CUSTOM0_MAGIC0 = new byte[]{(byte) 0x10, (byte) 0x01, (byte) 0x00, (byte) 0x11}; - private static final byte[] CHAR_CUSTOM0_MAGIC1 = new byte[]{(byte) 0x03, (byte) 0x00, (byte) 0x01, (byte) 0x04}; - - // Custom characteristic nr. 1 (Service: user data) - // Written data was always the same on all my tests - private static final UUID CHAR_CUSTOM1_NOTIFY = BluetoothGattUuid.fromShortCode(0x2a9f); - private static final UUID CHAR_CUSTOM1 = BluetoothGattUuid.fromShortCode(0x2a9f); - private static final byte[] CHAR_CUSTOM1_MAGIC = new byte[]{(byte) 0x02, (byte) 0xaa, (byte) 0x0f, (byte) 0x27}; - - // Service: body comp - private static final UUID CHAR_BODY_COMP_FEAT = BluetoothGattUuid.fromShortCode(0x2a9b); - private static final UUID CHAR_BODY_COMP_MEAS = BluetoothGattUuid.fromShortCode(0x2a9c); - - // Service: user data - private static final UUID CHAR_GENDER = BluetoothGattUuid.fromShortCode(0x2a8c); // 0x00 male, 0x01 female - private static final UUID CHAR_HEIGHT = BluetoothGattUuid.fromShortCode(0x2a8e); // in cm. 177cm = {0xb1 0x00} - private static final UUID CHAR_BIRTH = BluetoothGattUuid.fromShortCode(0x2a85); // 2 bytes year, 1 byte month, 1 byte day of year (1-366) - private static final UUID CHAR_AGE = BluetoothGattUuid.fromShortCode(0x2a80); // 1 byte - private static final UUID CHAR_ATHLETE= BluetoothGattUuid.fromShortCode(0x2aff); // {0x0d 0x00} = Athlete; {0x03 0x00} = Not athlete - - // Service: weight scale - private static final UUID CHAR_WEIGHT = BluetoothGattUuid.fromShortCode(0x2a9d); // {0x0d 0x00} = Athlete; {0x03 0x00} = Not athlete - - // Curr time - private static final UUID CHAR_CUR_TIME = BluetoothGattUuid.fromShortCode(0x2a2b); - private static final UUID CHAR_ICCEDK = BluetoothGattUuid.fromShortCode(0xfff1); - - /* - Despite notified data is discarded, notify must be set on - - 0x2a2b (CHAR_CUR_TIME) - - 0x2a9f (CHAR_CUSTOM1_NOTIFY) - - 0xfff1 (CHAR_ICCEDK) - - 0xffe1 (CHAR_CUSTOM0_NOTIFY) - */ - - private ScaleUser user; - - public BluetoothRenphoScale(Context context) { - super(context); - } - - @Override - public String driverName() { - // Not sure of the driver name. Tested with ES-WBE28 - return "RENPHO ES-WBE28"; - } - - @Override - protected boolean onNextStep(int stepNr) { - Timber.i("onNextStep(%d)", stepNr); - - switch (stepNr) { - case 0: - user = OpenScale.getInstance().getSelectedScaleUser(); - setNotificationOn(SERV_CUR_TIME, CHAR_CUR_TIME); - break; - case 1: - setIndicationOn(SERV_USER_DATA, CHAR_CUSTOM1_NOTIFY); - break; - case 2: - setNotificationOn(SERV_CUR_TIME, CHAR_ICCEDK); - break; - case 3: - setNotificationOn(SERV_BODY_COMP, CHAR_CUSTOM0_NOTIFY); - break; - case 4: - LocalDateTime now = LocalDateTime.now(); - - byte[] currtime = new byte[]{ - (byte) (now.getYear() & 0xff), // Year LSB - (byte) (now.getYear() >> 8), // Year MSB - (byte) (now.getMonthValue()), - (byte) (now.getDayOfMonth()), - (byte) (now.getHour()), - (byte) (now.getMinute()), - (byte) (now.getSecond()), - (byte) (now.getDayOfWeek().getValue()), // 1 = Monday, 7 = Sunday - (byte) 0, // Fraction of seconds, unused - (byte) 0 // Reason of update: not specified - - }; - - writeBytes(SERV_CUR_TIME, CHAR_CUR_TIME, currtime); - break; - case 5: - stopMachineState(); - writeBytes(SERV_BODY_COMP, CHAR_CUSTOM0, CHAR_CUSTOM0_MAGIC0); - break; - case 6: - stopMachineState(); - writeBytes(SERV_BODY_COMP, CHAR_CUSTOM0, CHAR_CUSTOM0_MAGIC1); - break; - case 7: - stopMachineState(); - writeBytes(SERV_USER_DATA, CHAR_CUSTOM1, CHAR_CUSTOM1_MAGIC); - break; - case 8: - byte[] gender = new byte[]{(byte) (user.getGender().isMale() ? 0x00 : 0x01)}; - writeBytes(SERV_USER_DATA, CHAR_GENDER, gender); - break; - case 9: - int height = (int) toCentimeter(user.getBodyHeight(), user.getMeasureUnit()); - byte[] height_data = new byte[]{ - (byte) (height & 0xff) , // Height, cm, LSB - (byte) (height >> 8) // Height, cm, MSB - }; - writeBytes(SERV_USER_DATA, CHAR_HEIGHT, height_data); - break; - case 10: - Date dob_d = user.getBirthday(); - - // Needed to calculate DAY_OF_YEAR. - // Moreover, Date::getXXX() is deprecated and replaced by Calendar::get - Calendar dob = Calendar.getInstance(); - dob.setTime(dob_d); - - byte[] dob_data = new byte[]{ - (byte) (dob.get(Calendar.YEAR) & 0xff), // Year LSB - (byte) (dob.get(Calendar.YEAR) >> 8), // Year MSB - - // Calendar.JANUARY is zero, but scale needs Jan = 1, Dec = 12 - (byte) (dob.get(Calendar.MONTH) - Calendar.JANUARY + 1), - - // GATT spec says DAY_OF_MONTH (1-31) but Renpho app sends some strange values - (byte) dob.get(Calendar.DAY_OF_MONTH) - }; - writeBytes(SERV_USER_DATA, CHAR_BIRTH, dob_data); - break; - case 11: - byte[] age = new byte[]{(byte) user.getAge()}; - writeBytes(SERV_USER_DATA, CHAR_AGE, age); - break; - case 12: - byte[] athl = new byte[]{(byte) 0x03, (byte)0x00}; // Non athlete - - switch (user.getActivityLevel()) { - case HEAVY: - case EXTREME: - athl[0] = (byte) 0x0d; - break; - } - - writeBytes(SERV_USER_DATA, CHAR_ATHLETE, athl); - break; - case 13: - readBytes(SERV_BODY_COMP, CHAR_BODY_COMP_FEAT); - break; - case 14: - setNotificationOn(SERV_WEIGHT_SCALE, CHAR_WEIGHT); - break; - case 15: - setIndicationOn(SERV_BODY_COMP, CHAR_BODY_COMP_MEAS); - break; - case 16: - stopMachineState(); - break; - default: - return false; - } - return true; - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - Timber.d("Received notification on UUID = %s", characteristic.toString()); - for(int i = 0; i < value.length; i++) { - Timber.d("Byte %d = 0x%02x", i, value[i]); - } - - switch (getStepNr()) { - case 6: - case 7: - case 8: - resumeMachineState(); - break; - case 17: - if (characteristic.equals(CHAR_WEIGHT)) { - if (value[0] == 0x2e) { - - float weight_kg = (Byte.toUnsignedInt(value[2])*256 + Byte.toUnsignedInt(value[1])) / 20.0f; - - Timber.d("Weight = 0x%02x, 0x%02x = %f",value[1], value[2], weight_kg); - saveMeasurement(weight_kg); - resumeMachineState(); - - } - } - if (characteristic.equals(CHAR_BODY_COMP_MEAS)) { - // TODO - /* - Not yet decoded (it does not follow GATT Body Comp standard fields). - What I've found is: - byte 0 : Unknown (always zero?) - byte 1- 3 : Unknown - byte 4 : Unknown (always zero?) - byte 5 : "metabolic_age" in years - byte 6 : Unknown (always zero?) - byte 7 : "protein" in units of 0.1% - byte 8- 9 : "subcutaneous_fat" in units of 0.1% - byte 10 : "visceral_fat_grade" in unknown/absolute units - byte 11 : Unknown (always zero?) - byte 12 : int part of "lean_body_mass" in kg. Dunno where decimal digit is encoded. - bytes 13-16 : Unknown (some flags/counters?). These fields change even between identical measurements. byte 16 = (byte 14) + 2. - bytes 17-18 : "body_water" in units of 0.1% - */ - } - break; - } - } - - /** - * Save a measurement from the scale to openScale. - * - * @param weightKg The weight, in kilograms - */ - private void saveMeasurement(float weightKg) { - - final ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser(); - - Timber.d("Saving measurement for scale user %s", scaleUser); - - final ScaleMeasurement btScaleMeasurement = new ScaleMeasurement(); - btScaleMeasurement.setWeight(weightKg); - - addScaleMeasurement(btScaleMeasurement); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothSanitasSBF72.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothSanitasSBF72.java deleted file mode 100644 index cdb6d86d..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothSanitasSBF72.java +++ /dev/null @@ -1,138 +0,0 @@ -/* Copyright (C) 2021 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - -/* -* Based on source-code by weliem/blessed-android -*/ - -package com.health.openscale.core.bluetooth; - -import android.content.Context; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.welie.blessed.BluetoothBytesParser; - -import java.util.UUID; - -import timber.log.Timber; - -import static com.welie.blessed.BluetoothBytesParser.FORMAT_UINT8; - -public class BluetoothSanitasSBF72 extends BluetoothStandardWeightProfile { - private String deviceName; - - private static final UUID SERVICE_SBF72_CUSTOM = BluetoothGattUuid.fromShortCode(0xffff); - - private static final UUID CHARACTERISTIC_SCALE_SETTINGS = BluetoothGattUuid.fromShortCode(0x0000); - private static final UUID CHARACTERISTIC_USER_LIST = BluetoothGattUuid.fromShortCode(0x0001); - private static final UUID CHARACTERISTIC_ACTIVITY_LEVEL = BluetoothGattUuid.fromShortCode(0x0004); - private static final UUID CHARACTERISTIC_REFER_WEIGHT_BF = BluetoothGattUuid.fromShortCode(0x000b); - private static final UUID CHARACTERISTIC_TAKE_MEASUREMENT = BluetoothGattUuid.fromShortCode(0x0006); - - public BluetoothSanitasSBF72(Context context, String name) { - super(context); - deviceName = name; - } - - @Override - public String driverName() { - return deviceName; - } - - @Override - protected int getVendorSpecificMaxUserCount() { - return 8; - } - - @Override - protected void enterScaleUserConsentUi(int appScaleUserId, int scaleUserIndex) { - //Requests the scale to display the pin for the user in it's display. - //As parameter we need to send a pin-index to the custom user-list characteristic. - //For user with index 1 the pin-index is 0x11, for user with index 2 it is 0x12 and so on. - int scalePinIndex = scaleUserIndex + 16; - BluetoothBytesParser parser = new BluetoothBytesParser(); - parser.setIntValue(scalePinIndex, FORMAT_UINT8); - writeBytes(SERVICE_SBF72_CUSTOM, CHARACTERISTIC_USER_LIST, parser.getValue()); - - //opens the input screen for the pin in the app - super.enterScaleUserConsentUi(appScaleUserId, scaleUserIndex); - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - if (characteristic.equals(CHARACTERISTIC_USER_LIST)) { - //the if condition is to catch the response to "display-pin-on-scale", because this response would produce an error in handleVendorSpecificUserList(). - if (value != null && value.length > 0 && value[0] != 17) { - handleVendorSpecificUserList(value); - } - } - else { - super.onBluetoothNotify(characteristic, value); - } - } - - @Override - protected ScaleMeasurement bodyCompositionMeasurementToScaleMeasurement(byte[] value) { - ScaleMeasurement measurement = super.bodyCompositionMeasurementToScaleMeasurement(value); - float weight = measurement.getWeight(); - if (weight == 0.f && previousMeasurement != null) { - weight = previousMeasurement.getWeight(); - } - if (weight != 0.f) { - float water = Math.round(((measurement.getWater() / weight) * 10000.f))/100.f; - measurement.setWater(water); - } - return measurement; - } - - @Override - protected void setNotifyVendorSpecificUserList() { - if (setNotificationOn(SERVICE_SBF72_CUSTOM, CHARACTERISTIC_USER_LIST)) { - Timber.d("setNotifyVendorSpecificUserList() OK"); - } else { - Timber.d("setNotifyVendorSpecificUserList() FAILED"); - } - } - - @Override - protected synchronized void requestVendorSpecificUserList() { - BluetoothBytesParser parser = new BluetoothBytesParser(); - parser.setIntValue(0, FORMAT_UINT8); - writeBytes(SERVICE_SBF72_CUSTOM, CHARACTERISTIC_USER_LIST, parser.getValue()); - } - - @Override - protected void writeActivityLevel() { - BluetoothBytesParser parser = new BluetoothBytesParser(); - int activityLevel = this.selectedUser.getActivityLevel().toInt() + 1; - Timber.d(String.format("activityLevel: %d", activityLevel)); - parser.setIntValue(activityLevel, FORMAT_UINT8); - writeBytes(SERVICE_SBF72_CUSTOM, CHARACTERISTIC_ACTIVITY_LEVEL, parser.getValue()); - } - - @Override - protected void writeInitials() { - Timber.d("Write user initials is not supported by " + deviceName + "!"); - } - - @Override - protected synchronized void requestMeasurement() { - BluetoothBytesParser parser = new BluetoothBytesParser(); - parser.setIntValue(0, FORMAT_UINT8); - writeBytes(SERVICE_SBF72_CUSTOM, CHARACTERISTIC_TAKE_MEASUREMENT, parser.getValue()); - } - -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothSenssun.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothSenssun.java deleted file mode 100644 index 39d4873f..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothSenssun.java +++ /dev/null @@ -1,234 +0,0 @@ -/* Copyright (C) 2018 Marco Gittler -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.bluetooth; - -import android.content.Context; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.welie.blessed.BluetoothPeripheral; - -import java.util.Calendar; -import java.util.Date; -import java.util.UUID; - -import timber.log.Timber; - -public class BluetoothSenssun extends BluetoothCommunication { - private final UUID MODEL_A_MEASUREMENT_SERVICE = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb"); - private final UUID MODEL_A_NOTIFICATION_CHARACTERISTIC = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb"); - private final UUID MODEL_A_WRITE_CHARACTERISTIC = UUID.fromString("0000fff2-0000-1000-8000-00805f9b34fb"); - - private final UUID MODEL_B_MEASUREMENT_SERVICE = UUID.fromString("0000ffb0-0000-1000-8000-00805f9b34fb"); - private final UUID MODEL_B_NOTIFICATION_CHARACTERISTIC = UUID.fromString("0000ffb2-0000-1000-8000-00805f9b34fb"); - private final UUID MODEL_B_WRITE_CHARACTERISTIC = UUID.fromString("0000ffb2-0000-1000-8000-00805f9b34fb"); - - private UUID writeService; - private UUID writeCharacteristic; - - private int lastWeight, lastFat, lastHydration, lastMuscle, lastBone, lastKcal; - private boolean weightStabilized, stepMessageDisplayed; - - private int values; - - public BluetoothSenssun(Context context) { - super(context); - } - - @Override - public String driverName() { - return "Senssun Fat"; - } - - @Override - protected void onBluetoothDiscovery(BluetoothPeripheral peripheral) { - - if (peripheral.getService(MODEL_A_MEASUREMENT_SERVICE) != null) { - writeService = MODEL_A_MEASUREMENT_SERVICE; - writeCharacteristic = MODEL_A_WRITE_CHARACTERISTIC; - setNotificationOn(MODEL_A_MEASUREMENT_SERVICE, MODEL_A_NOTIFICATION_CHARACTERISTIC); - Timber.d("Found a Model A"); - } - - if (peripheral.getService(MODEL_B_MEASUREMENT_SERVICE) != null) { - writeService = MODEL_B_MEASUREMENT_SERVICE; - writeCharacteristic = MODEL_B_WRITE_CHARACTERISTIC; - setNotificationOn(MODEL_B_MEASUREMENT_SERVICE, MODEL_B_NOTIFICATION_CHARACTERISTIC); - Timber.d("Found a Model B"); - } - } - - @Override - protected boolean onNextStep(int stepNr) { - switch (stepNr) { - case 0: - weightStabilized = false; - stepMessageDisplayed = false; - values = 0; - Timber.d("Sync Date"); - synchroniseDate(); - break; - case 1: - Timber.d("Sync Time"); - synchroniseTime(); - break; - default: - return false; - } - - return true; - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - if (value == null || value[0] != (byte)0xFF) { - return; - } - - System.arraycopy(value, 1, value, 0, value.length - 1); - - switch (value[0]) { - case (byte)0xA5: - parseMeasurement(value); - break; - } - - } - - private void parseMeasurement(byte[] data) { - switch(data[5]) { - case (byte)0xAA: - case (byte)0xA0: - if (weightStabilized) { - return; - } - if (!stepMessageDisplayed) { - sendMessage(R.string.info_step_on_scale, 0); - stepMessageDisplayed = true; - } - - weightStabilized = data[5] == (byte)0xAA; - Timber.d("the byte is %d stable is %s", (data[5] & 0xff), weightStabilized ? "true": "false"); - lastWeight = ((data[1] & 0xff) << 8) | (data[2] & 0xff); - - if (weightStabilized) { - values |= 1; - sendMessage(R.string.info_measuring, lastWeight / 10.0f); - synchroniseUser(); - } - break; - case (byte)0xBE: - setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR, "Fat Test Error"); - disconnect(); - break; - - case (byte)0xB0: - lastFat = ((data[1] & 0xff) << 8) | (data[2] & 0xff); - lastHydration = ((data[3] & 0xff) << 8) | (data[4] & 0xff); - values |= 2; - Timber.d("got fat %d", values); - - break; - - case (byte)0xC0: - lastMuscle = ((data[1] & 0xff) << 8) | (data[2] & 0xff); - lastBone = ((data[4] & 0xff) << 8) | (data[3] & 0xff); - values |= 4; - Timber.d("got muscle %d", values); - - break; - - case (byte)0xD0: - lastKcal = ((data[1] & 0xff) << 8) | (data[2] & 0xff); - int unknown = ((data[3] & 0xff) << 8) | (data[4] & 0xff); - values |= 8; - Timber.d("got kal %d", values); - - break; - } - - if (values == 15) { - ScaleMeasurement scaleBtData = new ScaleMeasurement(); - scaleBtData.setWeight((float)lastWeight / 10.0f); - scaleBtData.setFat((float)lastFat / 10.0f); - scaleBtData.setWater((float)lastHydration / 10.0f); - scaleBtData.setBone((float)lastBone / 10.0f); - scaleBtData.setMuscle((float)lastMuscle / 10.0f); - scaleBtData.setDateTime(new Date()); - addScaleMeasurement(scaleBtData); - disconnect(); - } - } - - private void synchroniseDate() { - Calendar cal = Calendar.getInstance(); - - byte message[] = new byte[]{(byte)0xA5, (byte)0x30, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00}; - message[2] = (byte)Integer.parseInt(Long.toHexString(Integer.valueOf(String.valueOf(cal.get(Calendar.YEAR)).substring(2))), 16); - - String DayLength=Long.toHexString(cal.get(Calendar.DAY_OF_YEAR)); - DayLength=DayLength.length()==1?"000"+DayLength: - DayLength.length()==2?"00"+DayLength: - DayLength.length()==3?"0"+DayLength:DayLength; - - message[3]=(byte)Integer.parseInt(DayLength.substring(0,2), 16); - message[4]=(byte)Integer.parseInt(DayLength.substring(2,4), 16); - - addChecksum(message); - - writeBytes(writeService, writeCharacteristic, message); - } - - private void synchroniseTime() { - Calendar cal = Calendar.getInstance(); - - byte message[] = new byte[]{(byte)0xA5, (byte)0x31, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00}; - - message[2]=(byte)Integer.parseInt(Long.toHexString(cal.get(Calendar.HOUR_OF_DAY)), 16); - message[3]=(byte)Integer.parseInt(Long.toHexString(cal.get(Calendar.MINUTE)), 16); - message[4]=(byte)Integer.parseInt(Long.toHexString(cal.get(Calendar.SECOND)), 16); - - addChecksum(message); - - writeBytes(writeService, writeCharacteristic, message); - } - - private void addChecksum(byte[] message) { - byte verify = 0; - for(int i=1;i manufacturerSpecificData = scanResult.getScanRecord().getManufacturerSpecificData(); - byte[] data = manufacturerSpecificData.get(MANUFACTURER_DATA_ID); - float divider = 100.0f; - byte checksum = 0x00; - //the checksum here only covers the data that is between the MAC address and the checksum - //this should be bytes at indices 6-15 (both inclusive) - for (int i = 6; i < CHECKSUM_INDEX; i++) - checksum ^= data[i]; - if (data[CHECKSUM_INDEX] != checksum) { - Timber.d("Checksum error, got %x, expected %x", data[CHECKSUM_INDEX] & 0xff, checksum & 0xff); - return; - } - // mac address is first 6 bytes, might be helpful if this needs to be capable of handling - // multiple scales at once. Is this a priority? -// byte[] macAddress = ; - - //this is the "raw" weight as an integer number of dekagrams (1 dekagram is 0.01kg or 10 grams), - // regardless of what unit the scale is set to - int weight = data[WEIGHT_MSB] & 0xff; - weight = weight << 8 | (data[WEIGHT_LSB] & 0xff); - if (weight > 0){ - if (weight != last_seen_weight) { - //record the current weight and reset the count for mow many times that value has been seen - last_seen_weight = weight; - last_wait_repeat_count = 1; - } else if (weight == last_seen_weight && last_wait_repeat_count >= WEIGHT_TRIGGER_THRESHOLD){ - // record the weight - ScaleMeasurement entry = new ScaleMeasurement(); - entry.setWeight(last_seen_weight / divider); - addScaleMeasurement(entry); - disconnect(); - } else { - //increment the counter for the number of times this weight value has been seen - last_wait_repeat_count += 1; - } - } - } - }; - - public BluetoothSinocare(Context context) - { - super(context); - central = new BluetoothCentralManager(context, btCallback, new Handler(Looper.getMainLooper())); - } - - @Override - public String driverName() { - return "Sinocare"; - } - - @Override - public void connect(String macAddress) { - Timber.d("Mac address: %s", macAddress); - List filters = new LinkedList(); - - ScanFilter.Builder b = new ScanFilter.Builder(); - b.setDeviceAddress(macAddress); - - b.setDeviceName("Weight Scale"); - b.setManufacturerData(MANUFACTURER_DATA_ID, null, null); - filters.add(b.build()); - - central.scanForPeripheralsUsingFilters(filters); - } - - @Override - public void disconnect() { - if (central != null) - central.stopScan(); - central = null; - super.disconnect(); - } - - @Override - protected boolean onNextStep(int stepNr) { - return false; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothSoehnle.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothSoehnle.java deleted file mode 100644 index 481eb8f4..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothSoehnle.java +++ /dev/null @@ -1,275 +0,0 @@ -/* Copyright (C) 2019 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.bluetooth; - -import android.content.Context; -import android.content.SharedPreferences; -import android.preference.PreferenceManager; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.bluetooth.lib.SoehnleLib; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; -import com.welie.blessed.BluetoothBytesParser; - -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.Calendar; -import java.util.Date; -import java.util.List; -import java.util.UUID; - -import timber.log.Timber; - -public class BluetoothSoehnle extends BluetoothCommunication { - private final UUID WEIGHT_CUSTOM_SERVICE = UUID.fromString("352e3000-28e9-40b8-a361-6db4cca4147c"); - private final UUID WEIGHT_CUSTOM_A_CHARACTERISTIC = UUID.fromString("352e3001-28e9-40b8-a361-6db4cca4147c"); // notify, read - private final UUID WEIGHT_CUSTOM_B_CHARACTERISTIC = UUID.fromString("352e3004-28e9-40b8-a361-6db4cca4147c"); // notify, read - private final UUID WEIGHT_CUSTOM_CMD_CHARACTERISTIC = UUID.fromString("352e3002-28e9-40b8-a361-6db4cca4147c"); // write - - SharedPreferences prefs; - - public BluetoothSoehnle(Context context) { - super(context); - prefs = PreferenceManager.getDefaultSharedPreferences(context); - } - - @Override - public String driverName() { - return "Soehnle Scale"; - } - - @Override - protected boolean onNextStep(int stepNr) { - switch (stepNr) { - case 0: - List openScaleUserList = OpenScale.getInstance().getScaleUserList(); - - int index = -1; - - // check if an openScale user is stored as a Soehnle user otherwise do a factory reset - for (ScaleUser openScaleUser : openScaleUserList) { - index = getSoehnleUserIndex(openScaleUser.getId()); - if (index != -1) { - break; - } - } - - if (index == -1) { - invokeScaleFactoryReset(); - } - break; - case 1: - setNotificationOn(BluetoothGattUuid.SERVICE_BATTERY_LEVEL, BluetoothGattUuid.CHARACTERISTIC_BATTERY_LEVEL); - readBytes(BluetoothGattUuid.SERVICE_BATTERY_LEVEL, BluetoothGattUuid.CHARACTERISTIC_BATTERY_LEVEL); - break; - case 2: - // Write the current time - BluetoothBytesParser parser = new BluetoothBytesParser(); - parser.setCurrentTime(Calendar.getInstance()); - writeBytes(BluetoothGattUuid.SERVICE_CURRENT_TIME, BluetoothGattUuid.CHARACTERISTIC_CURRENT_TIME, parser.getValue()); - break; - case 3: - // Turn on notification for User Data Service - setNotificationOn(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT); - break; - case 4: - int openScaleUserId = OpenScale.getInstance().getSelectedScaleUserId(); - int soehnleUserIndex = getSoehnleUserIndex(openScaleUserId); - - if (soehnleUserIndex == -1) { - // create new user - Timber.d("create new Soehnle scale user"); - writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT, new byte[]{(byte)0x01, (byte)0x00, (byte)0x00}); - } else { - // select user - Timber.d("select Soehnle scale user with index " + soehnleUserIndex); - writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT, new byte[]{(byte) 0x02, (byte) soehnleUserIndex, (byte) 0x00, (byte) 0x00}); - } - break; - case 5: - // set age - writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_AGE, new byte[]{(byte)OpenScale.getInstance().getSelectedScaleUser().getAge()}); - break; - case 6: - // set gender - writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_GENDER, new byte[]{OpenScale.getInstance().getSelectedScaleUser().getGender().isMale() ? (byte)0x00 : (byte)0x01}); - break; - case 7: - // set height - writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_HEIGHT, Converters.toInt16Le((int)OpenScale.getInstance().getSelectedScaleUser().getBodyHeight())); - break; - case 8: - setNotificationOn(WEIGHT_CUSTOM_SERVICE, WEIGHT_CUSTOM_A_CHARACTERISTIC); - setNotificationOn(WEIGHT_CUSTOM_SERVICE, WEIGHT_CUSTOM_B_CHARACTERISTIC); - //writeBytes(WEIGHT_CUSTOM_SERVICE, WEIGHT_CUSTOM_CMD_CHARACTERISTIC, new byte[] {(byte)0x0c, (byte)0xff}); - break; - case 9: - for (int i=1; i<8; i++) { - // get history data for soehnle user index i - writeBytes(WEIGHT_CUSTOM_SERVICE, WEIGHT_CUSTOM_CMD_CHARACTERISTIC, new byte[]{(byte) 0x09, (byte) i}); - } - break; - default: - return false; - } - - return true; - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - Timber.d("on bluetooth notify change " + byteInHex(value) + " on " + characteristic.toString()); - - if (value == null) { - return; - } - - if (characteristic.equals(WEIGHT_CUSTOM_A_CHARACTERISTIC) && value.length == 15) { - if (value[0] == (byte) 0x09) { - handleWeightMeasurement(value); - } - } else if (characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT)) { - handleUserControlPoint(value); - } else if (characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_BATTERY_LEVEL)) { - int batteryLevel = value[0]; - - Timber.d("Soehnle scale battery level is " + batteryLevel); - if (batteryLevel <= 10) { - sendMessage(R.string.info_scale_low_battery, batteryLevel); - } - } - } - - private void handleUserControlPoint(byte[] value) { - if (value[0] == (byte)0x20) { - int cmd = value[1]; - - if (cmd == (byte)0x01) { // user create - int userId = OpenScale.getInstance().getSelectedScaleUserId(); - int success = value[2]; - int soehnleUserIndex = value[3]; - - if (success == (byte)0x01) { - Timber.d("User control point index is " + soehnleUserIndex + " for user id " + userId); - - prefs.edit().putInt("userScaleIndex" + soehnleUserIndex, userId).apply(); - sendMessage(R.string.info_step_on_scale_for_reference, 0); - } else { - Timber.e("Error creating new Sohnle user"); - } - } - else if (cmd == (byte)0x02) { // user select - int success = value[2]; - - if (success != (byte)0x01) { - Timber.e("Error selecting Soehnle user"); - - invokeScaleFactoryReset(); - jumpNextToStepNr(0); - } - } - } - } - - private int getSoehnleUserIndex(int openScaleUserId) { - for (int i= 1; i<8; i++) { - int prefOpenScaleUserId = prefs.getInt("userScaleIndex"+i, -1); - - if (openScaleUserId == prefOpenScaleUserId) { - return i; - } - } - - return -1; - } - - private void invokeScaleFactoryReset() { - Timber.d("Do a factory reset on Soehnle scale to swipe old users"); - // factory reset - writeBytes(WEIGHT_CUSTOM_SERVICE, WEIGHT_CUSTOM_CMD_CHARACTERISTIC, new byte[]{(byte) 0x0b, (byte) 0xff}); - - for (int i= 1; i<8; i++) { - prefs.edit().putInt("userScaleIndex" + i, -1).apply(); - } - } - - private void handleWeightMeasurement(byte[] value) { - float weight = Converters.fromUnsignedInt16Be(value, 9) / 10.0f; // kg - int soehnleUserIndex = (int) value[1]; - final int year = Converters.fromUnsignedInt16Be(value, 2); - final int month = (int) value[4]; - final int day = (int) value[5]; - final int hours = (int) value[6]; - final int min = (int) value[7]; - final int sec = (int) value[8]; - - final int imp5 = Converters.fromUnsignedInt16Be(value, 11); - final int imp50 = Converters.fromUnsignedInt16Be(value, 13); - - String date_string = year + "/" + month + "/" + day + "/" + hours + "/" + min; - Date date_time = new Date(); - try { - date_time = new SimpleDateFormat("yyyy/MM/dd/HH/mm").parse(date_string); - } catch (ParseException e) { - Timber.e("parse error " + e.getMessage()); - } - - final ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser(); - - int activityLevel = 0; - - switch (scaleUser.getActivityLevel()) { - case SEDENTARY: - activityLevel = 0; - break; - case MILD: - activityLevel = 1; - break; - case MODERATE: - activityLevel = 2; - break; - case HEAVY: - activityLevel = 4; - break; - case EXTREME: - activityLevel = 5; - break; - } - - int openScaleUserId = prefs.getInt("userScaleIndex"+soehnleUserIndex, -1); - - if (openScaleUserId == -1) { - Timber.e("Unknown Soehnle user index " + soehnleUserIndex); - } else { - SoehnleLib soehnleLib = new SoehnleLib(scaleUser.getGender().isMale(), scaleUser.getAge(), scaleUser.getBodyHeight(), activityLevel); - - ScaleMeasurement scaleMeasurement = new ScaleMeasurement(); - - scaleMeasurement.setUserId(openScaleUserId); - scaleMeasurement.setWeight(weight); - scaleMeasurement.setDateTime(date_time); - scaleMeasurement.setWater(soehnleLib.getWater(weight, imp50)); - scaleMeasurement.setFat(soehnleLib.getFat(weight, imp50)); - scaleMeasurement.setMuscle(soehnleLib.getMuscle(weight, imp50, imp5)); - - addScaleMeasurement(scaleMeasurement); - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothStandardWeightProfile.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothStandardWeightProfile.java deleted file mode 100644 index 6c99424a..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothStandardWeightProfile.java +++ /dev/null @@ -1,869 +0,0 @@ -/* Copyright (C) 2019 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - - /* - * Based on source-code by weliem/blessed-android - */ - package com.health.openscale.core.bluetooth; - -import static com.welie.blessed.BluetoothBytesParser.FORMAT_UINT32; -import static com.welie.blessed.BluetoothBytesParser.FORMAT_UINT16; -import static com.welie.blessed.BluetoothBytesParser.FORMAT_UINT8; - -import android.content.Context; -import android.content.SharedPreferences; -import android.os.Handler; -import android.preference.PreferenceManager; -import android.util.Pair; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; -import com.welie.blessed.BluetoothBytesParser; - -import java.text.DateFormat; -import java.util.Arrays; -import java.util.Calendar; -import java.util.Date; -import java.util.GregorianCalendar; -import java.util.Random; -import java.util.UUID; -import java.util.Vector; - -import timber.log.Timber; - -public abstract class BluetoothStandardWeightProfile extends BluetoothCommunication { - - // UDS control point codes - protected static final byte UDS_CP_REGISTER_NEW_USER = 0x01; - protected static final byte UDS_CP_CONSENT = 0x02; - protected static final byte UDS_CP_DELETE_USER_DATA = 0x03; - protected static final byte UDS_CP_LIST_ALL_USERS = 0x04; - protected static final byte UDS_CP_DELETE_USERS = 0x05; - protected static final byte UDS_CP_RESPONSE = 0x20; - - // UDS response codes - protected static final byte UDS_CP_RESP_VALUE_SUCCESS = 0x01; - protected static final byte UDS_CP_RESP_OP_CODE_NOT_SUPPORTED = 0x02; - protected static final byte UDS_CP_RESP_INVALID_PARAMETER = 0x03; - protected static final byte UDS_CP_RESP_OPERATION_FAILED = 0x04; - protected static final byte UDS_CP_RESP_USER_NOT_AUTHORIZED = 0x05; - - SharedPreferences prefs; - protected boolean registerNewUser; - ScaleUser selectedUser; - ScaleMeasurement previousMeasurement; - protected boolean haveBatteryService; - protected Vector scaleUserList; - - public BluetoothStandardWeightProfile(Context context) { - super(context); - this.prefs = PreferenceManager.getDefaultSharedPreferences(context); - this.selectedUser = OpenScale.getInstance().getSelectedScaleUser(); - this.registerNewUser = false; - previousMeasurement = null; - haveBatteryService = false; - scaleUserList = new Vector(); - } - - @Override - public String driverName() { - return "Bluetooth Standard Weight Profile"; - } - - protected abstract int getVendorSpecificMaxUserCount(); - - private enum SM_STEPS { - START, - READ_DEVICE_MANUFACTURER, - READ_DEVICE_MODEL, - WRITE_CURRENT_TIME, - SET_NOTIFY_WEIGHT_MEASUREMENT, - SET_NOTIFY_BODY_COMPOSITION_MEASUREMENT, - SET_NOTIFY_CHANGE_INCREMENT, - SET_INDICATION_USER_CONTROL_POINT, - SET_NOTIFY_BATTERY_LEVEL, - READ_BATTERY_LEVEL, - SET_NOTIFY_VENDOR_SPECIFIC_USER_LIST, - REQUEST_VENDOR_SPECIFIC_USER_LIST, - REGISTER_NEW_SCALE_USER, - SELECT_SCALE_USER, - SET_SCALE_USER_DATA, - REQUEST_MEASUREMENT, - MAX_STEP - } - - @Override - protected boolean onNextStep(int stepNr) { - - if (stepNr > SM_STEPS.MAX_STEP.ordinal()) { - Timber.d( "WARNING: stepNr == " + stepNr + " outside range, must be from 0 to " + SM_STEPS.MAX_STEP.ordinal()); - stepNr = SM_STEPS.MAX_STEP.ordinal(); - } - SM_STEPS step = SM_STEPS.values()[stepNr]; - Timber.d("stepNr: " + stepNr + " " + step); - - switch (step) { - case START: - break; - case READ_DEVICE_MANUFACTURER: - // Read manufacturer from the Device Information Service - readBytes(BluetoothGattUuid.SERVICE_DEVICE_INFORMATION, BluetoothGattUuid.CHARACTERISTIC_MANUFACTURER_NAME_STRING); - break; - case READ_DEVICE_MODEL: - // Read model number from the Device Information Service - readBytes(BluetoothGattUuid.SERVICE_DEVICE_INFORMATION, BluetoothGattUuid.CHARACTERISTIC_MODEL_NUMBER_STRING); - break; - case WRITE_CURRENT_TIME: - writeCurrentTime(); - break; - case SET_NOTIFY_WEIGHT_MEASUREMENT: - // Turn on notification for Weight Service - setNotificationOn(BluetoothGattUuid.SERVICE_WEIGHT_SCALE, BluetoothGattUuid.CHARACTERISTIC_WEIGHT_MEASUREMENT); - break; - case SET_NOTIFY_BODY_COMPOSITION_MEASUREMENT: - // Turn on notification for Body Composition Service - setNotificationOn(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, BluetoothGattUuid.CHARACTERISTIC_BODY_COMPOSITION_MEASUREMENT); - break; - case SET_NOTIFY_CHANGE_INCREMENT: - // Turn on notification for User Data Service Change Increment - setNotificationOn(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_CHANGE_INCREMENT); - break; - case SET_INDICATION_USER_CONTROL_POINT: - // Turn on notification for User Control Point - setIndicationOn(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT); - break; - case SET_NOTIFY_BATTERY_LEVEL: - // Turn on notifications for Battery Service - if (setNotificationOn(BluetoothGattUuid.SERVICE_BATTERY_LEVEL, BluetoothGattUuid.CHARACTERISTIC_BATTERY_LEVEL)) { - haveBatteryService = true; - } - else { - haveBatteryService = false; - } - break; - case READ_BATTERY_LEVEL: - // read Battery Service - if (haveBatteryService) { - readBytes(BluetoothGattUuid.SERVICE_BATTERY_LEVEL, BluetoothGattUuid.CHARACTERISTIC_BATTERY_LEVEL); - } - break; - case SET_NOTIFY_VENDOR_SPECIFIC_USER_LIST: - setNotifyVendorSpecificUserList(); - break; - case REQUEST_VENDOR_SPECIFIC_USER_LIST: - scaleUserList.clear(); - requestVendorSpecificUserList(); - stopMachineState(); - break; - case REGISTER_NEW_SCALE_USER: - int userId = this.selectedUser.getId(); - int consentCode = getUserScaleConsent(userId); - int userIndex = getUserScaleIndex(userId); - if (consentCode == -1 || userIndex == -1) { - registerNewUser = true; - } - if (registerNewUser) { - Random randomFactory = new Random(); - consentCode = randomFactory.nextInt(10000); - storeUserScaleConsentCode(userId, consentCode); - registerUser(consentCode); - stopMachineState(); - } - break; - case SELECT_SCALE_USER: - Timber.d("Select user on scale!"); - setUser(this.selectedUser.getId()); - stopMachineState(); - break; - case SET_SCALE_USER_DATA: - if (registerNewUser) { - writeUserDataToScale(); - // stopping machine state to have all user data written, before the reference measurment starts, otherwise the scale might not store the user - stopMachineState(); - // reading CHARACTERISTIC_CHANGE_INCREMENT to resume machine state - readBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_CHANGE_INCREMENT); - } - break; - case REQUEST_MEASUREMENT: - if (registerNewUser) { - requestMeasurement(); - stopMachineState(); - sendMessage(R.string.info_step_on_scale_for_reference, 0); - } - break; - default: - return false; - } - - return true; - } - - protected void writeUserDataToScale() { - writeBirthday(); - writeGender(); - writeHeight(); - writeActivityLevel(); - writeInitials(); - setChangeIncrement(); - } - - @Override - public void onBluetoothNotify(UUID characteristic, byte[] value) { - BluetoothBytesParser parser = new BluetoothBytesParser(value); - - if(characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_CURRENT_TIME)) { - Date currentTime = parser.getDateTime(); - Timber.d(String.format("Received device time: %s", currentTime)); - } - else if(characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_WEIGHT_MEASUREMENT)) { - handleWeightMeasurement(value); - } - else if(characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_BODY_COMPOSITION_MEASUREMENT)) { - handleBodyCompositionMeasurement(value); - } - else if(characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_BATTERY_LEVEL)) { - int batteryLevel = parser.getIntValue(FORMAT_UINT8); - Timber.d(String.format("Received battery level %d%%", batteryLevel)); - if (batteryLevel <= 10) { - sendMessage(R.string.info_scale_low_battery, batteryLevel); - } - } - else if(characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_MANUFACTURER_NAME_STRING)) { - String manufacturer = parser.getStringValue(0); - Timber.d(String.format("Received manufacturer: %s", manufacturer)); - } - else if(characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_MODEL_NUMBER_STRING)) { - String modelNumber = parser.getStringValue(0); - Timber.d(String.format("Received modelnumber: %s", modelNumber)); - } - else if (characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT)) { - handleUserControlPointNotify(value); - } - else if (characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_CHANGE_INCREMENT)) { - int increment = parser.getIntValue(FORMAT_UINT32); - Timber.d(String.format("Notification from CHARACTERISTIC_CHANGE_INCREMENT, value: %s", increment)); - resumeMachineState(); - } - else { - Timber.d(String.format("Notification from unhandled characteristic: %s, value: [%s]", - characteristic.toString(), byteInHex(value))); - } - } - - protected void handleUserControlPointNotify(byte[] value) { - if(value[0]==UDS_CP_RESPONSE) { - switch (value[1]) { - case UDS_CP_LIST_ALL_USERS: - Timber.d("UDS_CP_LIST_ALL_USERS value [" + byteInHex(value) + "]"); - break; - case UDS_CP_REGISTER_NEW_USER: - if (value[2] == UDS_CP_RESP_VALUE_SUCCESS) { - int userIndex = value[3]; - int userId = this.selectedUser.getId(); - Timber.d(String.format("UDS_CP_REGISTER_NEW_USER: Created scale user index: " - + "%d (app user id: %d)", userIndex, userId)); - storeUserScaleIndex(userId, userIndex); - resumeMachineState(); - } else { - Timber.e("UDS_CP_REGISTER_NEW_USER: ERROR: could not register new scale user, code: " + value[2]); - } - break; - case UDS_CP_CONSENT: - if (registerNewUser) { - Timber.d("UDS_CP_CONSENT: registerNewUser==true, value[2] == " + value[2]); - resumeMachineState(); - break; - } - if (value[2] == UDS_CP_RESP_VALUE_SUCCESS) { - Timber.d("UDS_CP_CONSENT: Success user consent"); - resumeMachineState(); - } else if (value[2] == UDS_CP_RESP_USER_NOT_AUTHORIZED) { - Timber.e("UDS_CP_CONSENT: Not authorized"); - enterScaleUserConsentUi(this.selectedUser.getId(), getUserScaleIndex(this.selectedUser.getId())); - } - else { - Timber.e("UDS_CP_CONSENT: unhandled, code: " + value[2]); - } - break; - default: - Timber.e("CHARACTERISTIC_USER_CONTROL_POINT: Unhandled response code " - + value[1] + " value [" + byteInHex(value) + "]"); - break; - } - } - else { - Timber.d("CHARACTERISTIC_USER_CONTROL_POINT: non-response code " + value[0] - + " value [" + byteInHex(value) + "]"); - } - } - - protected ScaleMeasurement weightMeasurementToScaleMeasurement(byte[] value) { - String prefix = "weightMeasurementToScaleMeasurement() "; - Timber.d(String.format(prefix + "value: [%s]", byteInHex(value))); - BluetoothBytesParser parser = new BluetoothBytesParser(value); - - final int flags = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT8); - boolean isKg = (flags & 0x01) == 0; - final boolean timestampPresent = (flags & 0x02) > 0; - final boolean userIDPresent = (flags & 0x04) > 0; - final boolean bmiAndHeightPresent = (flags & 0x08) > 0; - Timber.d(String.format(prefix + "flags: 0x%02x ", flags) - + "[" + (isKg ? "SI" : "Imperial") - + (timestampPresent ? ", timestamp" : "") - + (userIDPresent ? ", userID" : "") - + (bmiAndHeightPresent ? ", bmiAndHeight" : "") - + "], " + String.format("reserved flags: 0x%02x ", flags & 0xf0)); - - ScaleMeasurement scaleMeasurement = new ScaleMeasurement(); - - // Determine the right weight multiplier - float weightMultiplier = isKg ? 0.005f : 0.01f; - - // Get weight - float weightValue = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * weightMultiplier; - Timber.d(prefix+ "weight: " + weightValue); - scaleMeasurement.setWeight(weightValue); - - if(timestampPresent) { - Date timestamp = parser.getDateTime(); - Timber.d(prefix + "timestamp: " + timestamp.toString()); - scaleMeasurement.setDateTime(timestamp); - } - - if(userIDPresent) { - int scaleUserIndex = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT8); - int userID = getUserIdFromScaleIndex(scaleUserIndex); - Timber.d(String.format(prefix + "scale user index: %d (app user id: %d)", scaleUserIndex, userID)); - if (userID != -1) { - scaleMeasurement.setUserId(userID); - } - - if (registerNewUser) { - Timber.d(String.format(prefix + "Setting initial weight for user %s to: %s and registerNewUser to false", userID, - weightValue)); - if (selectedUser.getId() == userID) { - this.selectedUser.setInitialWeight(weightValue); - OpenScale.getInstance().updateScaleUser(selectedUser); - } - registerNewUser = false; - resumeMachineState(); - } - } - - if(bmiAndHeightPresent) { - float BMI = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * 0.1f; - Timber.d(prefix + "BMI: " + BMI); - float heightInMeters = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * 0.001f; - Timber.d(prefix + "heightInMeters: " + heightInMeters); - } - - Timber.d(String.format("Got weight: %s", weightValue)); - return scaleMeasurement; - } - - protected void handleWeightMeasurement(byte[] value) { - mergeWithPreviousScaleMeasurement(weightMeasurementToScaleMeasurement(value)); - } - - protected ScaleMeasurement bodyCompositionMeasurementToScaleMeasurement(byte[] value) { - String prefix = "bodyCompositionMeasurementToScaleMeasurement() "; - Timber.d(String.format(prefix + "value: [%s]", byteInHex(value))); - BluetoothBytesParser parser = new BluetoothBytesParser(value); - - final int flags = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16); - boolean isKg = (flags & 0x0001) == 0; - float massMultiplier = (float) (isKg ? 0.005 : 0.01); - boolean timestampPresent = (flags & 0x0002) > 0; - boolean userIDPresent = (flags & 0x0004) > 0; - boolean bmrPresent = (flags & 0x0008) > 0; - boolean musclePercentagePresent = (flags & 0x0010) > 0; - boolean muscleMassPresent = (flags & 0x0020) > 0; - boolean fatFreeMassPresent = (flags & 0x0040) > 0; - boolean softLeanMassPresent = (flags & 0x0080) > 0; - boolean bodyWaterMassPresent = (flags & 0x0100) > 0; - boolean impedancePresent = (flags & 0x0200) > 0; - boolean weightPresent = (flags & 0x0400) > 0; - boolean heightPresent = (flags & 0x0800) > 0; - boolean multiPacketMeasurement = (flags & 0x1000) > 0; - Timber.d(String.format(prefix + "flags: 0x%02x ", flags) - + "[" + (isKg ? "SI" : "Imperial") - + (timestampPresent ? ", timestamp" : "") - + (userIDPresent ? ", userID" : "") - + (bmrPresent ? ", bmr" : "") - + (musclePercentagePresent ? ", musclePercentage" : "") - + (muscleMassPresent ? ", muscleMass" : "") - + (fatFreeMassPresent ? ", fatFreeMass" : "") - + (softLeanMassPresent ? ", softLeanMass" : "") - + (bodyWaterMassPresent ? ", bodyWaterMass" : "") - + (impedancePresent ? ", impedance" : "") - + (weightPresent ? ", weight" : "") - + (heightPresent ? ", height" : "") - + (multiPacketMeasurement ? ", multiPacketMeasurement" : "") - + "], " + String.format("reserved flags: 0x%04x ", flags & 0xe000)); - - ScaleMeasurement scaleMeasurement = new ScaleMeasurement(); - - float bodyFatPercentage = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * 0.1f; - Timber.d(prefix + "bodyFatPercentage: " + bodyFatPercentage); - scaleMeasurement.setFat(bodyFatPercentage); - - // Read timestamp if present - if (timestampPresent) { - Date timestamp = parser.getDateTime(); - Timber.d(prefix + "timestamp: " + timestamp.toString()); - scaleMeasurement.setDateTime(timestamp); - } - - // Read userID if present - if (userIDPresent) { - int scaleUserIndex = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT8); - int userID = getUserIdFromScaleIndex(scaleUserIndex); - Timber.d(String.format(prefix + "scale user index: %d (app user id: %d)", scaleUserIndex, userID)); - if (userID != -1) { - scaleMeasurement.setUserId(userID); - } - } - - // Read bmr if present - if (bmrPresent) { - int bmrInJoules = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16); - int bmrInKcal = Math.round(((bmrInJoules / 4.1868f) * 10.0f) / 10.0f); - Timber.d(prefix + "bmrInJoules: " + bmrInJoules + " bmrInKcal: " + bmrInKcal); - } - - // Read musclePercentage if present - if (musclePercentagePresent) { - float musclePercentage = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * 0.1f; - Timber.d(prefix + "musclePercentage: " + musclePercentage); - scaleMeasurement.setMuscle(musclePercentage); - } - - // Read muscleMass if present - if (muscleMassPresent) { - float muscleMass = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * massMultiplier; - Timber.d(prefix + "muscleMass: " + muscleMass); - } - - // Read fatFreeMassPresent if present - if (fatFreeMassPresent) { - float fatFreeMass = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * massMultiplier; - Timber.d(prefix + "fatFreeMass: " + fatFreeMass); - } - - // Read softleanMass if present - float softLeanMass = 0.0f; - if (softLeanMassPresent) { - softLeanMass = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * massMultiplier; - Timber.d(prefix + "softLeanMass: " + softLeanMass); - } - - // Read bodyWaterMass if present - if (bodyWaterMassPresent) { - float bodyWaterMass = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * massMultiplier; - Timber.d(prefix + "bodyWaterMass: " + bodyWaterMass); - scaleMeasurement.setWater(bodyWaterMass); - } - - // Read impedance if present - if (impedancePresent) { - float impedance = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * 0.1f; - Timber.d(prefix + "impedance: " + impedance); - } - - // Read weight if present - float weightValue = 0.0f; - if (weightPresent) { - weightValue = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * massMultiplier; - Timber.d(prefix + "weightValue: " + weightValue); - scaleMeasurement.setWeight(weightValue); - } - else { - if (previousMeasurement != null) { - weightValue = previousMeasurement.getWeight(); - if (weightValue > 0) { - weightPresent = true; - } - } - } - - // calculate lean body mass and bone mass - if (weightPresent && softLeanMassPresent) { - float fatMass = weightValue * bodyFatPercentage / 100.0f; - float leanBodyMass = weightValue - fatMass; - float boneMass = leanBodyMass - softLeanMass; - scaleMeasurement.setLbm(leanBodyMass); - scaleMeasurement.setBone(boneMass); - } - - // Read height if present - if (heightPresent) { - float heightValue = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16); - Timber.d(prefix + "heightValue: " + heightValue); - } - - if (multiPacketMeasurement) { - Timber.e(prefix + "multiPacketMeasurement not supported!"); - } - - Timber.d(String.format("Got body composition: %s", byteInHex(value))); - return scaleMeasurement; - } - - protected void handleBodyCompositionMeasurement(byte[] value) { - mergeWithPreviousScaleMeasurement(bodyCompositionMeasurementToScaleMeasurement(value)); - } - - /** - * Bluetooth scales usually implement both "Weight Scale Feature" and "Body Composition Feature". - * It seems that scale first transmits weight measurement (with user index and timestamp) and - * later transmits body composition measurement (without user index and timestamp). - * If previous measurement contains user index and new measurements does not then merge them and - * store as one. - * disconnect() function must store previousMeasurement to openScale db (if present). - * - * @param newMeasurement the scale data that should be merged with previous measurement or - * stored as previous measurement. - */ - protected void mergeWithPreviousScaleMeasurement(ScaleMeasurement newMeasurement) { - if (previousMeasurement == null) { - if (newMeasurement.getUserId() == -1) { - addScaleMeasurement(newMeasurement); - } - else { - previousMeasurement = newMeasurement; - } - } - else { - if ((newMeasurement.getUserId() == -1) && (previousMeasurement.getUserId() != -1)) { - previousMeasurement.merge(newMeasurement); - addScaleMeasurement(previousMeasurement); - previousMeasurement = null; - } - else { - addScaleMeasurement(previousMeasurement); - if (newMeasurement.getUserId() == -1) { - addScaleMeasurement(newMeasurement); - previousMeasurement = null; - } - else { - previousMeasurement = newMeasurement; - } - } - } - } - - @Override - public void disconnect() { - if (previousMeasurement != null) { - addScaleMeasurement(previousMeasurement); - previousMeasurement = null; - } - super.disconnect(); - } - - protected abstract void setNotifyVendorSpecificUserList(); - - protected abstract void requestVendorSpecificUserList(); - - protected void registerUser(int consentCode) { - BluetoothBytesParser parser = new BluetoothBytesParser(new byte[]{0,0,0}); - parser.setIntValue(UDS_CP_REGISTER_NEW_USER, FORMAT_UINT8,0); - parser.setIntValue(consentCode, FORMAT_UINT16,1); - Timber.d(String.format("registerUser consentCode: %d", consentCode)); - writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT, parser.getValue()); - } - - protected void setUser(int userIndex, int consentCode) { - BluetoothBytesParser parser = new BluetoothBytesParser(new byte[]{0,0,0,0}); - parser.setIntValue(UDS_CP_CONSENT,FORMAT_UINT8,0); - parser.setIntValue(userIndex, FORMAT_UINT8,1); - parser.setIntValue(consentCode, FORMAT_UINT16,2); - Timber.d(String.format("setUser userIndex: %d, consentCode: %d", userIndex, consentCode)); - writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT, parser.getValue()); - } - - protected synchronized void setUser(int userId) { - int userIndex = getUserScaleIndex(userId); - int consentCode = getUserScaleConsent(userId); - Timber.d(String.format("setting: userId %d, userIndex: %d, consent Code: %d ", userId, userIndex, consentCode)); - setUser(userIndex, consentCode); - } - - protected void deleteUser(int userIndex, int consentCode) { - setUser(userIndex, consentCode); - deleteUser(); - } - - protected void deleteUser() { - BluetoothBytesParser parser = new BluetoothBytesParser(new byte[] { 0 }); - parser.setIntValue(UDS_CP_DELETE_USER_DATA, FORMAT_UINT8, 0); - writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT, - parser.getValue()); - } - - protected void writeCurrentTime() { - BluetoothBytesParser parser = new BluetoothBytesParser(); - parser.setCurrentTime(Calendar.getInstance()); - writeBytes(BluetoothGattUuid.SERVICE_CURRENT_TIME, BluetoothGattUuid.CHARACTERISTIC_CURRENT_TIME, - parser.getValue()); - } - - protected void writeBirthday() { - BluetoothBytesParser parser = new BluetoothBytesParser(); - Calendar userBirthday = dateToCalender(this.selectedUser.getBirthday()); - Timber.d(String.format("user Birthday: %tD", userBirthday)); - parser.setDateTime(userBirthday); - writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_DATE_OF_BIRTH, - Arrays.copyOfRange(parser.getValue(), 0, 4)); - } - - protected Calendar dateToCalender(Date date) { - Calendar calendar = Calendar.getInstance(); - calendar.setTime(date); - return calendar; - } - - protected void writeGender() { - BluetoothBytesParser parser = new BluetoothBytesParser(); - int gender = this.selectedUser.getGender().toInt(); - Timber.d(String.format("gender: %d", gender)); - parser.setIntValue(gender, FORMAT_UINT8); - writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_GENDER, - parser.getValue()); - } - - protected void writeHeight() { - BluetoothBytesParser parser = new BluetoothBytesParser(); - int height = (int) this.selectedUser.getBodyHeight(); - Timber.d(String.format("height: %d", height)); - parser.setIntValue(height, FORMAT_UINT16); - writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_HEIGHT, - parser.getValue()); - } - - protected void writeActivityLevel() { - Timber.d("Write user activity level not implemented!"); - } - - protected void writeInitials() { - Timber.d("Write user initials not implemented!"); - } - - protected void setChangeIncrement() { - BluetoothBytesParser parser = new BluetoothBytesParser(); - int i = 1; - Timber.d(String.format("Setting Change increment to %s", i)); - parser.setIntValue(i, FORMAT_UINT32); - writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_CHANGE_INCREMENT, - parser.getValue()); - } - - protected void requestMeasurement() { - Timber.d("Take measurement command not implemented!"); - } - - protected synchronized void storeUserScaleConsentCode(int userId, int consentCode) { - prefs.edit().putInt("userConsentCode" + userId, consentCode).apply(); - } - - protected synchronized int getUserScaleConsent(int userId) { - return prefs.getInt("userConsentCode" + userId, -1); - } - - protected synchronized void storeUserScaleIndex(int userId, int userIndex) { - int currentUserIndex = getUserScaleIndex(userId); - if (currentUserIndex != -1) { - prefs.edit().putInt("userIdFromUserScaleIndex" + currentUserIndex, -1); - } - prefs.edit().putInt("userScaleIndex" + userId, userIndex).apply(); - if (userIndex != -1) { - prefs.edit().putInt("userIdFromUserScaleIndex" + userIndex, userId).apply(); - } - } - - protected synchronized int getUserIdFromScaleIndex(int userScaleIndex) { - return prefs.getInt("userIdFromUserScaleIndex" + userScaleIndex, -1); - } - - protected synchronized int getUserScaleIndex(int userId) { - return prefs.getInt("userScaleIndex" + userId, -1); - } - - protected void reconnectOrSetSmState(SM_STEPS requestedState, SM_STEPS minState, Handler uiHandler) { - if (needReConnect()) { - jumpNextToStepNr(SM_STEPS.START.ordinal()); - stopMachineState(); - reConnectPreviousPeripheral(uiHandler); - return; - } - if (getStepNr() > minState.ordinal()) { - jumpNextToStepNr(requestedState.ordinal()); - } - resumeMachineState(); - } - - @Override - public void selectScaleUserIndexForAppUserId(int appUserId, int scaleUserIndex, Handler uiHandler) { - Timber.d("Select scale user index from UI: user id: " + appUserId + ", scale user index: " + scaleUserIndex); - if (scaleUserIndex == -1) { - reconnectOrSetSmState(SM_STEPS.REGISTER_NEW_SCALE_USER, SM_STEPS.REGISTER_NEW_SCALE_USER, uiHandler); - } - else { - storeUserScaleIndex(appUserId, scaleUserIndex); - if (getUserScaleConsent(appUserId) == -1) { - enterScaleUserConsentUi(appUserId, scaleUserIndex); - } - else { - reconnectOrSetSmState(SM_STEPS.SELECT_SCALE_USER, SM_STEPS.REQUEST_VENDOR_SPECIFIC_USER_LIST, uiHandler); - } - } - } - - @Override - public void setScaleUserConsent(int appUserId, int scaleUserConsent, Handler uiHandler) { - Timber.d("set scale user consent from UI: user id: " + appUserId + ", scale user consent: " + scaleUserConsent); - storeUserScaleConsentCode(appUserId, scaleUserConsent); - if (scaleUserConsent == -1) { - reconnectOrSetSmState(SM_STEPS.REQUEST_VENDOR_SPECIFIC_USER_LIST, SM_STEPS.REQUEST_VENDOR_SPECIFIC_USER_LIST, uiHandler); - } - else { - reconnectOrSetSmState(SM_STEPS.SELECT_SCALE_USER, SM_STEPS.REQUEST_VENDOR_SPECIFIC_USER_LIST, uiHandler); - } - } - - protected void handleVendorSpecificUserList(byte[] value) { - Timber.d(String.format("Got user data: <%s>", byteInHex(value))); - BluetoothBytesParser parser = new BluetoothBytesParser(value); - int userListStatus = parser.getIntValue(FORMAT_UINT8); - if (userListStatus == 2) { - Timber.d("scale have no users!"); - storeUserScaleConsentCode(selectedUser.getId(), -1); - storeUserScaleIndex(selectedUser.getId(), -1); - jumpNextToStepNr(SM_STEPS.REGISTER_NEW_SCALE_USER.ordinal()); - resumeMachineState(); - return; - } - else if (userListStatus == 1) { - for (int i = 0; i < scaleUserList.size(); i++) { - if (i == 0) { - Timber.d("scale user list:"); - } - Timber.d("\n" + (i + 1) + ". " + scaleUserList.get(i)); - } - if ((scaleUserList.size() == 0)) { - storeUserScaleConsentCode(selectedUser.getId(), -1); - storeUserScaleIndex(selectedUser.getId(), -1); - jumpNextToStepNr(SM_STEPS.REGISTER_NEW_SCALE_USER.ordinal()); - resumeMachineState(); - return; - } - if (getUserScaleIndex(selectedUser.getId()) == -1 || getUserScaleConsent(selectedUser.getId()) == -1) { - chooseExistingScaleUser(scaleUserList); - return; - } - resumeMachineState(); - return; - } - int index = parser.getIntValue(FORMAT_UINT8); - String initials = parser.getStringValue(); - int end = 3 > initials.length() ? initials.length() : 3; - initials = initials.substring(0, end); - if (initials.length() == 3) { - if (initials.charAt(0) == 0xff && initials.charAt(1) == 0xff && initials.charAt(2) == 0xff) { - initials = ""; - } - } - parser.setOffset(5); - int year = parser.getIntValue(FORMAT_UINT16); - int month = parser.getIntValue(FORMAT_UINT8); - int day = parser.getIntValue(FORMAT_UINT8); - int height = parser.getIntValue(FORMAT_UINT8); - int gender = parser.getIntValue(FORMAT_UINT8); - int activityLevel = parser.getIntValue(FORMAT_UINT8); - GregorianCalendar calendar = new GregorianCalendar(year, month - 1, day); - ScaleUser scaleUser = new ScaleUser(); - scaleUser.setUserName(initials); - scaleUser.setBirthday(calendar.getTime()); - scaleUser.setBodyHeight(height); - scaleUser.setGender(Converters.Gender.fromInt(gender)); - scaleUser.setActivityLevel(Converters.ActivityLevel.fromInt(activityLevel - 1)); - scaleUser.setId(index); - scaleUserList.add(scaleUser); - if (scaleUserList.size() == getVendorSpecificMaxUserCount()) { - if (getUserScaleIndex(selectedUser.getId()) == -1 || getUserScaleConsent(selectedUser.getId()) == -1) { - chooseExistingScaleUser(scaleUserList); - return; - } - resumeMachineState(); - } - } - - protected void chooseExistingScaleUser(Vector userList) { - final DateFormat dateFormat = DateFormat.getDateInstance(); - int choicesCount = userList.size(); - if (userList.size() < getVendorSpecificMaxUserCount()) { - choicesCount = userList.size() + 1; - } - CharSequence[] choiceStrings = new String[choicesCount]; - int indexArray[] = new int[choicesCount]; - int selectedItem = -1; - for (int i = 0; i < userList.size(); ++i) { - ScaleUser u = userList.get(i); - String name = u.getUserName(); - choiceStrings[i] = (name.length() > 0 ? name : String.format("P%02d", u.getId())) - + " " + context.getString(u.getGender().isMale() ? R.string.label_male : R.string.label_female).toLowerCase() - + " " + context.getString(R.string.label_height).toLowerCase() + ":" + u.getBodyHeight() - + " " + context.getString(R.string.label_birthday).toLowerCase() + ":" + dateFormat.format(u.getBirthday()) - + " " + context.getString(R.string.label_activity_level).toLowerCase() + ":" + (u.getActivityLevel().toInt() + 1); - indexArray[i] = u.getId(); - } - if (userList.size() < getVendorSpecificMaxUserCount()) { - choiceStrings[userList.size()] = context.getString(R.string.info_create_new_user_on_scale); - indexArray[userList.size()] = -1; - } - Pair choices = new Pair(choiceStrings, indexArray); - chooseScaleUserUi(choices); - } - - protected String getInitials(String fullName) { - if (fullName == null || fullName.isEmpty() || fullName.chars().allMatch(Character::isWhitespace)) { - return getDefaultInitials(); - } - return buildInitialsStringFrom(fullName).toUpperCase(); - } - - private String getDefaultInitials() { - int userId = this.selectedUser.getId(); - int userIndex = getUserScaleIndex(userId); - return "P" + userIndex + " "; - } - - private String buildInitialsStringFrom(String fullName) { - String[] name = fullName.trim().split(" +"); - String initials = ""; - for (int i = 0; i < 3; i++) { - if (i < name.length && name[i] != "") { - initials += name[i].charAt(0); - } else { - initials += " "; - } - } - return initials; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothTrisaBodyAnalyze.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothTrisaBodyAnalyze.java deleted file mode 100644 index 173e9fad..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothTrisaBodyAnalyze.java +++ /dev/null @@ -1,360 +0,0 @@ -/* Copyright (C) 2018 Maks Verver - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.core.bluetooth; - -import android.content.Context; -import android.content.SharedPreferences; -import android.preference.PreferenceManager; - -import androidx.annotation.Nullable; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.bluetooth.lib.TrisaBodyAnalyzeLib; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; - -import java.util.Date; -import java.util.UUID; - -import timber.log.Timber; - -/** - * Driver for Trisa Body Analyze 4.0. - * - * @see Protocol details - */ -public class BluetoothTrisaBodyAnalyze extends BluetoothCommunication { - - // GATT service UUID - private static final UUID WEIGHT_SCALE_SERVICE_UUID = - BluetoothGattUuid.fromShortCode(0x7802); - - // GATT service characteristics. - private static final UUID MEASUREMENT_CHARACTERISTIC_UUID = - BluetoothGattUuid.fromShortCode(0x8a21); - private static final UUID DOWNLOAD_COMMAND_CHARACTERISTIC_UUID = - BluetoothGattUuid.fromShortCode(0x8a81); - private static final UUID UPLOAD_COMMAND_CHARACTERISTIC_UUID = - BluetoothGattUuid.fromShortCode(0x8a82); - - // Commands sent from device to host. - private static final byte UPLOAD_PASSWORD = (byte) 0xa0; - private static final byte UPLOAD_CHALLENGE = (byte) 0xa1; - - // Commands sent from host to device. - private static final byte DOWNLOAD_INFORMATION_UTC_COMMAND = 0x02; - private static final byte DOWNLOAD_INFORMATION_RESULT_COMMAND = 0x20; - private static final byte DOWNLOAD_INFORMATION_BROADCAST_ID_COMMAND = 0x21; - private static final byte DOWNLOAD_INFORMATION_ENABLE_DISCONNECT_COMMAND = 0x22; - - /** - * Broadcast id, which the scale will include in its Bluetooth alias. This must be set to some - * value to complete the pairing process (though the actual value doesn't seem to matter). - */ - private static final int BROADCAST_ID = 0; - - /** - * Prefix for {@link SharedPreferences} keys that store device passwords. - * - * @see #loadDevicePassword - * @see #saveDevicePassword - */ - private static final String SHARED_PREFERENCES_PASSWORD_KEY_PREFIX = - "trisa_body_analyze_password_for_device_"; - - /** - * ASCII string that identifies the connected device (i.e. the hex-encoded Bluetooth MAC - * address). Used in shared preference keys to store per-device settings. - */ - @Nullable - private String deviceId; - - /** Device password as a 32-bit integer, or {@code null} if the device password is unknown. */ - @Nullable - private static Integer password; - - /** - * Indicates whether we are pairing. If this is {@code true} then we have written the - * set-broadcast-id command, and should disconnect after the write succeeds. - * - * @see #onPasswordReceived - * @see #onNextStep - */ - private boolean pairing = false; - - /** - * Timestamp of 2010-01-01 00:00:00 UTC (or local time?) - */ - private static final long TIMESTAMP_OFFSET_SECONDS = 1262304000L; - - public BluetoothTrisaBodyAnalyze(Context context) { - super(context); - } - - @Override - public String driverName() { - return "Trisa Body Analyze 4.0"; - } - - @Override - public void connect(String hwAddress) { - Timber.i("connect(\"%s\")", hwAddress); - super.connect(hwAddress); - this.deviceId = hwAddress; - this.password = loadDevicePassword(context, hwAddress); - } - - @Override - protected boolean onNextStep(int stepNr) { - Timber.i("onNextStep(%d)", stepNr); - switch (stepNr) { - case 0: - // Register for notifications of the measurement characteristic. - setIndicationOn(WEIGHT_SCALE_SERVICE_UUID, MEASUREMENT_CHARACTERISTIC_UUID); - break; // more commands follow - case 1: - // Register for notifications of the command upload characteristic. - // - // This is the last init command, which causes a switch to the main state machine - // immediately after. This is important because we should be in the main state - // to handle pairing correctly. - setIndicationOn(WEIGHT_SCALE_SERVICE_UUID, UPLOAD_COMMAND_CHARACTERISTIC_UUID); - break; - case 2: - // This state is triggered by the write in onPasswordReceived() - if (pairing) { - pairing = false; - disconnect(); - } - break; - case 3: - writeCommand(DOWNLOAD_INFORMATION_ENABLE_DISCONNECT_COMMAND); - break; - default: - return false; // no more commands - } - - return true; - } - - @Override - protected void onBluetoothNotify(UUID characteristic, byte[] value) { - - Timber.i("onBluetoothdataChange() characteristic=%s value=%s", characteristic, byteInHex(value)); - if (UPLOAD_COMMAND_CHARACTERISTIC_UUID.equals(characteristic)) { - if (value.length == 0) { - Timber.e("Missing command byte!"); - return; - } - byte command = value[0]; - switch (command) { - case UPLOAD_PASSWORD: - onPasswordReceived(value); - break; - case UPLOAD_CHALLENGE: - onChallengeReceived(value); - break; - default: - Timber.e("Unknown command byte received: %d", command); - } - return; - } - if (MEASUREMENT_CHARACTERISTIC_UUID.equals(characteristic)) { - onScaleMeasurumentReceived(value); - return; - } - Timber.e("Unknown characteristic changed: %s", characteristic); - } - - private void onPasswordReceived(byte[] data) { - if (data.length < 5) { - Timber.e("Password data too short"); - return; - } - password = Converters.fromSignedInt32Le(data, 1); - if (deviceId == null) { - Timber.e("Can't save password: device id not set!"); - } else { - Timber.i("Saving password '%08x' for device id '%s'", password, deviceId); - saveDevicePassword(context, deviceId, password); - } - - sendMessage(R.string.trisa_scale_pairing_succeeded, null); - - // To complete the pairing process, we must set the scale's broadcast id, and then - // disconnect. The writeCommand() call below will trigger the next state machine transition, - // which will disconnect when `pairing == true`. - pairing = true; - writeCommand(DOWNLOAD_INFORMATION_BROADCAST_ID_COMMAND, BROADCAST_ID); - } - - private void onChallengeReceived(byte[] data) { - if (data.length < 5) { - Timber.e("Challenge data too short"); - return; - } - if (password == null) { - Timber.w("Received challenge, but password is unknown."); - sendMessage(R.string.trisa_scale_not_paired, null); - disconnect(); - return; - } - int challenge = Converters.fromSignedInt32Le(data, 1); - int response = challenge ^ password; - writeCommand(DOWNLOAD_INFORMATION_RESULT_COMMAND, response); - int deviceTimestamp = convertJavaTimestampToDevice(System.currentTimeMillis()); - writeCommand(DOWNLOAD_INFORMATION_UTC_COMMAND, deviceTimestamp); - } - - private void onScaleMeasurumentReceived(byte[] data) { - ScaleUser user = OpenScale.getInstance().getSelectedScaleUser(); - ScaleMeasurement measurement = parseScaleMeasurementData(data, user); - - if (measurement == null) { - Timber.e("Failed to parse scale measure measurement data: %s", byteInHex(data)); - return; - } - - addScaleMeasurement(measurement); - } - - public ScaleMeasurement parseScaleMeasurementData(byte[] data, ScaleUser user) { - // data contains: - // - // 1 byte: info about presence of other fields: - // bit 0: timestamp - // bit 1: resistance1 - // bit 2: resistance2 - // (other bits aren't used here) - // 4 bytes: weight - // 4 bytes: timestamp (if info bit 0 is set) - // 4 bytes: resistance1 (if info bit 1 is set) - // 4 bytes: resistance2 (if info bit 2 is set) - // (following fields aren't used here) - - // Check that we have at least weight & timestamp, which is the minimum information that - // ScaleMeasurement needs. - if (data.length < 9) { - return null; // data is too short - } - byte infoByte = data[0]; - boolean hasTimestamp = (infoByte & 1) == 1; - boolean hasResistance1 = (infoByte & 2) == 2; - boolean hasResistance2 = (infoByte & 4) == 4; - if (!hasTimestamp) { - return null; - } - float weightKg = getBase10Float(data, 1); - int deviceTimestamp = Converters.fromSignedInt32Le(data, 5); - - ScaleMeasurement measurement = new ScaleMeasurement(); - measurement.setDateTime(new Date(convertDeviceTimestampToJava(deviceTimestamp))); - measurement.setWeight((float) weightKg); - - // Only resistance 2 is used; resistance 1 is 0, even if it is present. - int resistance2Offset = 9 + (hasResistance1 ? 4 : 0); - if (hasResistance2 && resistance2Offset + 4 <= data.length && isValidUser(user)) { - // Calculate body composition statistics from measured weight & resistance, combined - // with age, height and sex from the user profile. The accuracy of the resulting figures - // is questionable, but it's better than nothing. Even if the absolute numbers aren't - // very meaningful, it might still be useful to track changes over time. - float resistance2 = getBase10Float(data, resistance2Offset); - float impedance = resistance2 < 410f ? 3.0f : 0.3f * (resistance2 - 400f); - - TrisaBodyAnalyzeLib trisaBodyAnalyzeLib = new TrisaBodyAnalyzeLib(user.getGender().isMale() ? 1 : 0, user.getAge(), user.getBodyHeight()); - - measurement.setFat(trisaBodyAnalyzeLib.getFat(weightKg, impedance)); - measurement.setWater(trisaBodyAnalyzeLib.getWater(weightKg, impedance)); - measurement.setMuscle(trisaBodyAnalyzeLib.getMuscle(weightKg, impedance)); - measurement.setBone(trisaBodyAnalyzeLib.getBone(weightKg, impedance)); - } - - return measurement; - } - - /** Write a single command byte, without any arguments. */ - private void writeCommand(byte commandByte) { - writeCommandBytes(new byte[]{commandByte}); - } - - /** - * Write a command with a 32-bit integer argument. - * - *

The command string consists of the command byte followed by 4 bytes: the argument - * encoded in little-endian byte order.

- */ - private void writeCommand(byte commandByte, int argument) { - byte[] bytes = new byte[5]; - bytes[0] = commandByte; - Converters.toInt32Le(bytes, 1, argument); - writeCommandBytes(bytes); - } - - private void writeCommandBytes(byte[] bytes) { - Timber.d("writeCommand bytes=%s", byteInHex(bytes)); - writeBytes(WEIGHT_SCALE_SERVICE_UUID, DOWNLOAD_COMMAND_CHARACTERISTIC_UUID, bytes); - } - - private static String getDevicePasswordKey(String deviceId) { - return SHARED_PREFERENCES_PASSWORD_KEY_PREFIX + deviceId; - } - - @Nullable - private static Integer loadDevicePassword(Context context, String deviceId) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - String key = getDevicePasswordKey(deviceId); - try { - // Strictly speaking, there is a race condition between the calls to contains() and - // getInt(), but it's not a problem because we never delete passwords. - return prefs.contains(key) ? Integer.valueOf(prefs.getInt(key, 0)) : null; - } catch (ClassCastException e) { - Timber.e(e, "Password preference value is not an integer."); - return null; - } - } - - private static void saveDevicePassword(Context context, String deviceId, int password) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - prefs.edit().putInt(getDevicePasswordKey(deviceId), password).apply(); - } - - /** Converts 4 bytes to a floating point number, starting from {@code offset}. - * - *

The first three little-endian bytes form the 24-bit mantissa. The last byte contains the - * signed exponent, applied in base 10. - * - * @throws IndexOutOfBoundsException if {@code offset < 0} or {@code offset + 4> data.length} - */ - public float getBase10Float(byte[] data, int offset) { - int mantissa = Converters.fromUnsignedInt24Le(data, offset); - int exponent = data[offset + 3]; // note: byte is signed. - return (float)(mantissa * Math.pow(10, exponent)); - } - - public int convertJavaTimestampToDevice(long javaTimestampMillis) { - return (int)((javaTimestampMillis + 500)/1000 - TIMESTAMP_OFFSET_SECONDS); - } - - public long convertDeviceTimestampToJava(int deviceTimestampSeconds) { - return 1000 * (TIMESTAMP_OFFSET_SECONDS + (long)deviceTimestampSeconds); - } - - private boolean isValidUser(@Nullable ScaleUser user) { - return user != null && user.getAge() > 0 && user.getBodyHeight() > 0; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothYoda1Scale.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothYoda1Scale.java deleted file mode 100644 index 87930476..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothYoda1Scale.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.health.openscale.core.bluetooth; - -import android.bluetooth.le.ScanFilter; -import android.bluetooth.le.ScanResult; -import android.content.Context; -import android.os.Handler; -import android.os.Looper; -import android.util.SparseArray; - -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; -import com.welie.blessed.BluetoothCentralManager; -import com.welie.blessed.BluetoothCentralManagerCallback; -import com.welie.blessed.BluetoothPeripheral; - -import org.jetbrains.annotations.NotNull; - -import java.util.LinkedList; -import java.util.List; - -import timber.log.Timber; - -public class BluetoothYoda1Scale extends BluetoothCommunication { - - private BluetoothCentralManager central; - private final BluetoothCentralManagerCallback btCallback = new BluetoothCentralManagerCallback() { - @Override - public void onDiscoveredPeripheral(@NotNull BluetoothPeripheral peripheral, @NotNull ScanResult scanResult) { - SparseArray manufacturerSpecificData = scanResult.getScanRecord().getManufacturerSpecificData(); - byte[] weightBytes = manufacturerSpecificData.valueAt(0); - - //int featuresAndCounter = manufacturerSpecificData.keyAt(0); - //Timber.d("Feature: %d, Counter: %d", featuresAndCounter & 0xFF, featuresAndCounter >> 8); - - final byte ctrlByte = weightBytes[6]; - - final boolean isStabilized = isBitSet(ctrlByte, 0); - final boolean isKgUnit = isBitSet(ctrlByte, 2); - final boolean isOneDecimal = isBitSet(ctrlByte, 3); - - if (isStabilized) { - Timber.d("One digit decimal: %s", isOneDecimal); - Timber.d("Unit Kg: %s", isKgUnit); - - float weight; - - if (isKgUnit) { - weight = (float) (((weightBytes[0] & 0xFF) << 8) | (weightBytes[1] & 0xFF)) / 10.0f; - } else { - // catty - weight = (float) (((weightBytes[0] & 0xFF) << 8) | (weightBytes[1] & 0xFF)) / 20.0f; - } - - if (!isOneDecimal) { - weight /= 10.0; - } - - Timber.d("Got weight: %f", weight); - - final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser(); - ScaleMeasurement scaleBtData = new ScaleMeasurement(); - - scaleBtData.setWeight(Converters.toKilogram(weight, selectedUser.getScaleUnit())); - addScaleMeasurement(scaleBtData); - - disconnect(); - } - } - }; - - public BluetoothYoda1Scale(Context context) { - super(context); - central = new BluetoothCentralManager(context, btCallback, new Handler(Looper.getMainLooper())); - } - - @Override - public void connect(String macAddress) { - Timber.d("Mac address: %s", macAddress); - List filters = new LinkedList(); - - ScanFilter.Builder b = new ScanFilter.Builder(); - b.setDeviceAddress(macAddress); - - b.setDeviceName("Yoda1"); - filters.add(b.build()); - - central.scanForPeripheralsUsingFilters(filters); - } - - @Override - public void disconnect() { - if (central != null) - central.stopScan(); - central = null; - super.disconnect(); - } - - @Override - public String driverName() { - return "Yoda1 Scale"; - } - - @Override - protected boolean onNextStep(int stepNr) { - return false; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleCommnuicator.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleCommnuicator.kt new file mode 100644 index 00000000..c290cf7a --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleCommnuicator.kt @@ -0,0 +1,135 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth + +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Defines the events that can be emitted by a [ScaleCommunicator]. + */ +sealed class BluetoothEvent { + /** + * Event triggered when a connection to a device has been successfully established. + * @param deviceName The name of the connected device. + * @param deviceAddress The MAC address of the connected device. + */ + data class Connected(val deviceName: String, val deviceAddress: String) : BluetoothEvent() + + /** + * Event triggered when an existing connection to a device has been disconnected. + * @param deviceAddress The MAC address of the disconnected device. + * @param reason An optional reason for the disconnection (e.g., "Connection lost", "Manually disconnected"). + */ + data class Disconnected(val deviceAddress: String, val reason: String? = null) : BluetoothEvent() + + /** + * Event triggered when a connection attempt to a device has failed. + * @param deviceAddress The MAC address of the device to which the connection failed. + * @param error An error message describing the reason for the failure. + */ + data class ConnectionFailed(val deviceAddress: String, val error: String) : BluetoothEvent() + + /** + * Event triggered when measurement data has been received from the scale. + * Uses [ScaleMeasurement] as the common data format. + * @param measurement The received [ScaleMeasurement] object. + * @param deviceAddress The MAC address of the device from which the measurement originated. + */ + data class MeasurementReceived( + val measurement: ScaleMeasurement, + val deviceAddress: String + ) : BluetoothEvent() + + /** + * Event triggered when a general error related to a device occurs. + * @param deviceAddress The MAC address of the device associated with the error. + * @param error An error message describing the issue. + */ + data class Error(val deviceAddress: String, val error: String) : BluetoothEvent() + + /** + * Event triggered when a text message (e.g., status or instruction) is received from the device. + * @param message The received message. + * @param deviceAddress The MAC address of the device from which the message originated. + */ + data class DeviceMessage(val message: String, val deviceAddress: String) : BluetoothEvent() + + /** + * Event triggered when user interaction is required to select a user on the scale. + * This is often used when a scale supports multiple users and the app needs to clarify + * which app user corresponds to the scale user. + * @param description A message describing why user selection is needed. + * @param deviceIdentifier The identifier (e.g., MAC address) of the device requiring user selection. + * @param userData Optional data associated with the event, potentially containing information about users on the scale. + * The exact type should be defined by the communicator implementation if more specific data is available. + */ + data class UserSelectionRequired( + val description: String, + val deviceIdentifier: String, + val userData: Any? // Consider a more specific type if the structure of eventData is known. + ) : BluetoothEvent() +} + +/** + * A generic interface for communication with a Bluetooth scale. + * This interface abstracts the specific Bluetooth implementation (e.g., legacy Bluetooth or BLE). + */ +interface ScaleCommunicator { + + /** + * A [StateFlow] indicating whether a connection attempt to a device is currently in progress. + * `true` if a connection attempt is active, `false` otherwise. + */ + val isConnecting: StateFlow + + /** + * A [StateFlow] indicating whether an active connection to a device currently exists. + * `true` if connected, `false` otherwise. + */ + val isConnected: StateFlow + + /** + * Initiates a connection attempt to the device with the specified MAC address. + * @param address The MAC address of the target device. + * @param scaleUser The user to be selected or used on the scale (optional). + * @param appUserId The ID of the user in the application (optional, can be used for context). + */ + fun connect(address: String, scaleUser: ScaleUser?, appUserId: Int?) + + /** + * Disconnects the existing connection to the currently connected device. + */ + fun disconnect() + + /** + * Explicitly requests a new measurement from the connected device. + * (Note: Not always supported or required by all scale devices). + */ + fun requestMeasurement() + + /** + * Provides a [SharedFlow] that emits [BluetoothEvent]s. + * Consumers can collect events from this flow to react to connection changes, + * received measurements, errors, and other device-related events. + * @return A [SharedFlow] of [BluetoothEvent]s. + */ + fun getEventsFlow(): SharedFlow +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt new file mode 100644 index 00000000..236c46af --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -0,0 +1,210 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth + +import android.content.Context +import android.util.SparseArray +import com.health.openscale.core.bluetooth.scales.DummyScaleHandler +import com.health.openscale.core.bluetooth.scales.ScaleDeviceHandler +import com.health.openscale.core.bluetooth.scalesJava.BluetoothCommunication +import com.health.openscale.core.bluetooth.scalesJava.BluetoothYunmaiSE_Mini +import com.health.openscale.core.bluetooth.scalesJava.LegacyScaleAdapter +import com.health.openscale.core.database.DatabaseRepository +import com.health.openscale.core.utils.LogManager +import com.health.openscale.ui.screen.bluetooth.ScannedDeviceInfo +import java.util.UUID + +/** + * Factory class responsible for creating appropriate [ScaleCommunicator] instances + * for different Bluetooth scale devices. It decides whether to use a modern Kotlin-based + * handler or a legacy Java-based adapter. + */ +class ScaleFactory( + private val applicationContext: Context, + private val databaseRepository: DatabaseRepository // Needed for LegacyScaleAdapter +) { + private val TAG = "ScaleHandlerFactory" + + // List of modern Kotlin-based device handlers. + // These are checked first for device compatibility. + private val modernKotlinHandlers: List = listOf( + DummyScaleHandler("Mi Scale"), // Recognizes devices with "Mi Scale" in their name + DummyScaleHandler("Beurer"), // Recognizes devices with "Beurer" in their name + DummyScaleHandler("BF700") // Recognizes devices with "BF700" in their name + ) + + /** + * Attempts to create a legacy Java Bluetooth driver instance based on the device name. + * This method contains the logic to map device names to specific Java driver classes. + * + * @param context The application context. + * @param deviceName The name of the Bluetooth device. + * @return A [BluetoothCommunication] instance if a matching driver is found, otherwise null. + */ + private fun createLegacyJavaDriver(context: Context?, deviceName: String): BluetoothCommunication? { + // val name = deviceName.lowercase() // deviceName is already used directly below, toLowerCase is not strictly needed if comparisons handle case. + + // Currently, only Yunmai drivers are active examples. + // The extensive list of commented-out drivers can be re-enabled or migrated as needed. + if (deviceName.startsWith("YUNMAI-SIGNAL") || deviceName.startsWith("YUNMAI-ISM")) { + return BluetoothYunmaiSE_Mini(context, true) + } + if (deviceName.startsWith("YUNMAI-ISSE")) { + return BluetoothYunmaiSE_Mini(context, false) + } + // Add other legacy driver instantiations here based on deviceName. + // Example: + // if (name.startsWith("some_legacy_device")) { + // return SomeLegacyDeviceDriver(context) + // } + return null + } + + /** + * Creates a [ScaleCommunicator] using the legacy Java driver approach. + * It wraps a [BluetoothCommunication] instance (Java driver) in a [LegacyScaleAdapter]. + * + * @param identifier The device name or other identifier used to find a legacy Java driver. + * @return A [LegacyScaleAdapter] instance if a suitable Java driver is found, otherwise null. + */ + private fun createLegacyCommunicator(identifier: String): ScaleCommunicator? { + val javaDriverInstance = createLegacyJavaDriver(applicationContext, identifier) + return if (javaDriverInstance != null) { + LogManager.i(TAG, "Creating LegacyScaleAdapter with Java driver '${javaDriverInstance.javaClass.simpleName}'.") + LegacyScaleAdapter( + applicationContext = applicationContext, + bluetoothDriverInstance = javaDriverInstance, + databaseRepository = databaseRepository + ) + } else { + LogManager.w(TAG, "Could not create LegacyScaleAdapter: No Java driver found for '$identifier'.") + null + } + } + + /** + * Creates a [ScaleCommunicator] based on a modern [ScaleDeviceHandler]. + * This method is conceptual for now, as the current DummyScaleHandlers are not full communicators. + * In a full implementation, this might return the handler itself if it's a ScaleCommunicator, + * or wrap it in a modern adapter. + * + * @param handler The [ScaleDeviceHandler] that can handle the device. + * @return A [ScaleCommunicator] instance if one can be provided by or for the handler, otherwise null. + */ + private fun createModernCommunicator(handler: ScaleDeviceHandler): ScaleCommunicator? { + LogManager.i(TAG, "Attempting to create modern communicator for handler '${handler.getDriverName()}'.") + // If the ScaleDeviceHandler itself is a ScaleCommunicator: + if (handler is ScaleCommunicator) { + return handler + } else { + // Placeholder: Logic to wrap the handler in a specific "ModernScaleCommunicator" + // if the handler itself isn't a ScaleCommunicator. + // e.g., return ModernScaleAdapter(applicationContext, handler, databaseRepository) + LogManager.w(TAG, "Modern handler '${handler.getDriverName()}' is not a ScaleCommunicator, and no wrapper is implemented.") + return null + } + } + + /** + * Creates the most suitable [ScaleCommunicator] for the given scanned device. + * It prioritizes modern Kotlin-based handlers and falls back to legacy adapters if necessary. + * + * @param deviceInfo Information about the scanned Bluetooth device. + * @return A [ScaleCommunicator] instance if a suitable handler or adapter is found, otherwise null. + */ + fun createCommunicator(deviceInfo: ScannedDeviceInfo): ScaleCommunicator? { + // The `determinedHandlerDisplayName` from ScannedDeviceInfo can be useful here if it was + // specifically set by getSupportingHandlerInfo for a known handler. + // Otherwise, `deviceInfo.name` is the primary identifier for the logic here. + val primaryIdentifier = deviceInfo.name ?: "UnknownDevice" + LogManager.d(TAG, "createCommunicator: Searching for communicator for '${primaryIdentifier}' (${deviceInfo.address}). Handler hint: '${deviceInfo.determinedHandlerDisplayName}'") + + // 1. Check if a modern Kotlin handler explicitly supports the device. + for (handler in modernKotlinHandlers) { + if (handler.canHandleDevice( + deviceName = deviceInfo.name, + deviceAddress = deviceInfo.address, + serviceUuids = deviceInfo.serviceUuids, + manufacturerData = deviceInfo.manufacturerData + )) { + LogManager.i(TAG, "Modern Kotlin handler '${handler.getDriverName()}' claims '${primaryIdentifier}'.") + val modernCommunicator = createModernCommunicator(handler) + if (modernCommunicator != null) { + LogManager.i(TAG, "Modern communicator '${modernCommunicator.javaClass.simpleName}' created for '${primaryIdentifier}'.") + return modernCommunicator + } else { + LogManager.w(TAG, "Modern handler '${handler.getDriverName()}' claimed '${primaryIdentifier}', but failed to create a communicator.") + } + } + } + LogManager.d(TAG, "No modern Kotlin handler actively claimed '${primaryIdentifier}' or could create a communicator.") + + // 2. Fallback to legacy adapter if no modern handler matched or created a communicator. + // The device name (or a specific legacy handler name, if known from `determinedHandlerDisplayName`) is used. + val identifierForLegacy = deviceInfo.determinedHandlerDisplayName ?: primaryIdentifier + LogManager.i(TAG, "Attempting fallback to legacy adapter for identifier '${identifierForLegacy}'.") + val legacyCommunicator = createLegacyCommunicator(identifierForLegacy) + if (legacyCommunicator != null) { + LogManager.i(TAG, "Legacy communicator '${legacyCommunicator.javaClass.simpleName}' created for device (identifier: '${identifierForLegacy}').") + return legacyCommunicator + } + + LogManager.w(TAG, "No suitable communicator (neither modern nor legacy) found for device (name: '${deviceInfo.name}', address: '${deviceInfo.address}', handler hint: '${deviceInfo.determinedHandlerDisplayName}').") + return null + } + + /** + * Checks if any known handler (modern Kotlin or legacy Java-based) can theoretically support the given device. + * This can be used by the UI to indicate if a device is potentially recognizable. + * + * @param deviceName The name of the Bluetooth device. + * @param deviceAddress The MAC address of the device. + * @param serviceUuids A list of advertised service UUIDs. + * @param manufacturerData Manufacturer-specific data from the advertisement. + * @return A Pair where `first` is true if a handler is found, and `second` is the name of the handler/driver, or null. + */ + fun getSupportingHandlerInfo( + deviceName: String?, + deviceAddress: String, + serviceUuids: List, + manufacturerData: SparseArray? + ): Pair { + val primaryIdentifier = deviceName ?: "UnknownDevice" + // LogManager.d(TAG, "getSupportingHandlerInfo for: '$primaryIdentifier', Addr: $deviceAddress, UUIDs: ${serviceUuids.size}, ManuData: ${manufacturerData != null}") + + // Check modern handlers first + for (handler in modernKotlinHandlers) { + if (handler.canHandleDevice(deviceName, deviceAddress, serviceUuids, manufacturerData)) { + // LogManager.d(TAG, "getSupportingHandlerInfo: Modern handler '${handler.getDriverName()}' matches '$primaryIdentifier'.") + return true to handler.getDriverName() // The "driver name" of the modern handler + } + } + + // Then check if a legacy driver would exist based on the name + if (deviceName != null) { + val legacyJavaDriver = createLegacyJavaDriver(applicationContext, deviceName) + if (legacyJavaDriver != null) { + // LogManager.d(TAG, "getSupportingHandlerInfo: Legacy driver '${legacyJavaDriver.javaClass.simpleName}' matches '$deviceName'.") + // Return the driver name from the BluetoothCommunication interface if available and meaningful. + return true to legacyJavaDriver.driverName() // Assumes BluetoothCommunication has a driverName() method. + } + } + LogManager.d(TAG, "getSupportingHandlerInfo: No supporting handler found for '$primaryIdentifier'.") + return false to null + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleMeasurement.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleMeasurement.java new file mode 100644 index 00000000..16f6a0b2 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleMeasurement.java @@ -0,0 +1,124 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.data; + +import java.util.Date; + +public class ScaleMeasurement implements Cloneable { + private int id; + private int userId; + private boolean enabled; + private Date dateTime; + private float weight; + private float fat; + private float water; + private float muscle; + private float visceralFat; + private float lbm; + private float bone; + + public ScaleMeasurement() { + userId = -1; + enabled = true; + dateTime = new Date(); + weight = 0.0f; + fat = 0.0f; + water = 0.0f; + muscle = 0.0f; + lbm = 0.0f; + bone = 0.0f; + } + + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public int getUserId() { + return userId; + } + + public void setUserId(int user_id) { + this.userId = user_id; + } + + public Date getDateTime() { + return dateTime; + } + + public void setDateTime(Date date_time) { + this.dateTime = date_time; + } + + public float getWeight() { + return weight; + } + + public void setWeight(float weight) { + this.weight = weight; + } + + public float getFat() { + return fat; + } + + public void setFat(float fat) { + this.fat = fat; + } + + public float getWater() { + return water; + } + + public void setWater(float water) { + this.water = water; + } + + public float getMuscle() { + return muscle; + } + + public void setMuscle(float muscle) { + this.muscle = muscle; + } + + public float getVisceralFat() { + return visceralFat; + } + + public void setVisceralFat(float visceralFat) { + this.visceralFat = visceralFat; + } + + public float getLbm() { + return lbm; + } + + public void setLbm(float lbm) { + this.lbm = lbm; + } + + public float getBone() { return bone; } + + public void setBone(float bone) {this.bone = bone; } + +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleUser.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleUser.java new file mode 100644 index 00000000..22bb313a --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleUser.java @@ -0,0 +1,134 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.data; + +import com.health.openscale.core.data.ActivityLevel; +import com.health.openscale.core.data.GenderType; +import com.health.openscale.core.data.WeightUnit; + +import java.util.Calendar; +import java.util.Date; + +public class ScaleUser { + private int id; + + + private String userName; + private Date birthday; + + private float bodyHeight; + + private GenderType gender; + + private WeightUnit scaleUnit; + + private ActivityLevel activityLevel; + + public ScaleUser() { + userName = ""; + birthday = new Date(); + bodyHeight = -1; + gender = GenderType.MALE; + activityLevel = ActivityLevel.SEDENTARY; + } + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getUserName() { + return userName; + } + + public void setUserName(String userName) { + this.userName = userName; + } + + public Date getBirthday() { + return birthday; + } + + public void setBirthday(Date birthday) { + this.birthday = birthday; + } + + public float getBodyHeight() { + return bodyHeight; + } + + public void setBodyHeight(float bodyHeight) { + this.bodyHeight = bodyHeight; + } + + public int getAge(Date todayDate) { + Calendar calToday = Calendar.getInstance(); + if (todayDate != null) { + calToday.setTime(todayDate); + } + + Calendar calBirthday = Calendar.getInstance(); + calBirthday.setTime(birthday); + + return yearsBetween(calBirthday, calToday); + } + + public int getAge() { + return getAge(null); + } + + public WeightUnit getScaleUnit() { + return scaleUnit; + } + + public void setScaleUnit(WeightUnit scaleUnit) { + this.scaleUnit = scaleUnit; + } + + public void setActivityLevel(ActivityLevel level) { + activityLevel = level; + } + + public ActivityLevel getActivityLevel() { + return activityLevel; + } + + private int yearsBetween(Calendar start, Calendar end) { + int years = end.get(Calendar.YEAR) - start.get(Calendar.YEAR); + + final int startMonth = start.get(Calendar.MONTH); + final int endMonth = end.get(Calendar.MONTH); + if (endMonth < startMonth + || (endMonth == startMonth + && end.get(Calendar.DAY_OF_MONTH) < start.get(Calendar.DAY_OF_MONTH))) { + years -= 1; + } + return years; + } + + public GenderType getGender() { + return gender; + } + + public void setGender(GenderType gender) { + this.gender = gender; + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/MiScaleLib.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/MiScaleLib.java deleted file mode 100644 index 1ae5effd..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/MiScaleLib.java +++ /dev/null @@ -1,173 +0,0 @@ -/* Copyright (C) 2019 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - -/** - * based on https://github.com/prototux/MIBCS-reverse-engineering by prototux - */ - -package com.health.openscale.core.bluetooth.lib; - -public class MiScaleLib { - private int sex; // male = 1; female = 0 - private int age; - private 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 && age > 49) { - lbmSub = 7.25f; - } - - float lbmCoeff = getLBMCoefficient(weight, impedance); - float coeff = 1.0f; - - if (sex == 1 && weight < 61.0f) { - coeff = 0.98f; - } - else if (sex == 0 && weight > 60.0f) { - coeff = 0.96f; - - if (height > 160.0f) { - coeff *= 1.03f; - } - } else if (sex == 0 && weight < 50.0f) { - coeff = 1.02f; - - if (height > 160.0f) { - coeff *= 1.03f; - } - } - - bodyFat = (1.0f - (((lbmCoeff - lbmSub) * coeff) / weight)) * 100.0f; - - if (bodyFat > 63.0f) { - bodyFat = 75.0f; - } - - return bodyFat; - } -} - diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/OneByoneLib.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/OneByoneLib.java deleted file mode 100644 index 8edfdc40..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/OneByoneLib.java +++ /dev/null @@ -1,254 +0,0 @@ -/* Copyright (C) 2018 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - -package com.health.openscale.core.bluetooth.lib; - -public class OneByoneLib { - private int sex; // male = 1; female = 0 - private int age; - private float height; - private int peopleType; // low activity = 0; medium activity = 1; high activity = 2 - - public OneByoneLib(int sex, int age, float height, int peopleType) { - this.sex = sex; - this.age = age; - this.height = height; - this.peopleType = peopleType; - } - - public float getBMI(float weight) { - return weight / (((height * height) / 100.0f) / 100.0f); - } - - public float getLBM(float weight, float bodyFat) { - return weight - (bodyFat / 100.0f * weight); - } - - public float getMuscle(float weight, float impedanceValue){ - return (float)((height * height / impedanceValue * 0.401) + (sex * 3.825) - (age * 0.071) + 5.102) / weight * 100.0f; - } - - public float getWater(float bodyFat) { - float coeff; - float water = (100.0f - bodyFat) * 0.7f; - - if (water < 50) { - coeff = 1.02f; - } else { - coeff = 0.98f; - } - - return coeff * water; - } - - public float getBoneMass(float weight, float impedanceValue) { - float boneMass, sexConst , peopleCoeff = 0.0f; - - switch (peopleType) { - case 0: - peopleCoeff = 1.0f; - break; - case 1: - peopleCoeff = 1.0427f; - break; - case 2: - peopleCoeff = 1.0958f; - break; - } - - boneMass = (9.058f * (height / 100.0f) * (height / 100.0f) + 12.226f + (0.32f * weight)) - (0.0068f * impedanceValue); - - if (sex == 1) { // male - sexConst = 3.49305f; - } else { - sexConst = 4.76325f; - } - - boneMass = boneMass - sexConst - (age * 0.0542f) * peopleCoeff; - - if (boneMass <= 2.2f) { - boneMass = boneMass - 0.1f; - } else { - boneMass = boneMass + 0.1f; - } - - boneMass = boneMass * 0.05158f; - - if (0.5f > boneMass) { - return 0.5f; - } else if (boneMass > 8.0f) { - return 8.0f; - } - - return boneMass; - } - - public float getVisceralFat(float weight) { - float visceralFat; - - if (sex == 1) { - if (height < ((1.6f * weight) + 63.0f)) { - visceralFat = (((weight * 305.0f) / (0.0826f * height * height - (0.4f * height) + 48.0f)) - 2.9f) + ((float)age * 0.15f); - - if (peopleType == 0) { - return visceralFat; - } else { - return subVisceralFat_A(visceralFat); - } - } else { - visceralFat = (((float)age * 0.15f) + ((weight * (-0.0015f * height + 0.765f)) - height * 0.143f)) - 5.0f; - - if (peopleType == 0) { - return visceralFat; - } else { - return subVisceralFat_A(visceralFat); - } - } - - } else { - if (((0.5f * height) - 13.0f) > weight) { - visceralFat = (((float)age * 0.07f) + ((weight * (-0.0024f * height + 0.691f)) - (height * 0.027f))) - 10.5f; - - if (peopleType != 0) { - return subVisceralFat_A(visceralFat); - } else { - return visceralFat; - } - - } else { - visceralFat = (weight * 500.0f) / (((1.45f * height) + 0.1158f * height * height) - 120.0f) - 6.0f + ((float)age * 0.07f); - - if (peopleType == 0) { - return visceralFat; - } else { - return subVisceralFat_A(visceralFat); - } - } - - } - } - - private float subVisceralFat_A(float visceralFat) { - - if (peopleType != 0) { - if (10.0f <= visceralFat) { - - return subVisceralFat_B(visceralFat); - } else { - visceralFat = visceralFat - 4.0f; - return visceralFat; - } - } else { - if (10.0f > visceralFat) { - visceralFat = visceralFat - 2.0f; - return visceralFat; - } else { - return subVisceralFat_B(visceralFat); - } - } - } - - private float subVisceralFat_B(float visceralFat) { - if (visceralFat < 10.0f) { - visceralFat = visceralFat * 0.85f; - return visceralFat; - } else { - - if (20.0f < visceralFat) { - visceralFat = visceralFat * 0.85f; - return visceralFat; - } else { - visceralFat = visceralFat * 0.8f; - return visceralFat; - } - } - } - - public float getBodyFat(float weight, float impedanceValue) { - float bodyFatConst=0; - - if (impedanceValue >= 1200.0f) bodyFatConst = 8.16f; - else if (impedanceValue >= 200.0f) bodyFatConst = 0.0068f * impedanceValue; - else if (impedanceValue >= 50.0f) bodyFatConst = 1.36f; - - float peopleTypeCoeff, bodyVar, bodyFat; - - if (peopleType == 0) { - peopleTypeCoeff = 1.0f; - } else { - if (peopleType == 1) { - peopleTypeCoeff = 1.0427f; - } else { - peopleTypeCoeff = 1.0958f; - } - } - - bodyVar = (9.058f * height) / 100.0f; - bodyVar = bodyVar * height; - bodyVar = bodyVar / 100.0f + 12.226f; - bodyVar = bodyVar + 0.32f * weight; - bodyVar = bodyVar - bodyFatConst; - - if (age > 0x31) { - bodyFatConst = 7.25f; - - if (sex == 1) { - bodyFatConst = 0.8f; - } - } else { - bodyFatConst = 9.25f; - - if (sex == 1) { - bodyFatConst = 0.8f; - } - } - - bodyVar = bodyVar - bodyFatConst; - bodyVar = bodyVar - (age * 0.0542f); - bodyVar = bodyVar * peopleTypeCoeff; - - if (sex != 0) { - if (61.0f > weight) { - bodyVar *= 0.98f; - } - } else { - if (50.0f > weight) { - bodyVar *= 1.02f; - } - - if (weight > 60.0f) { - bodyVar *= 0.96f; - } - - if (height > 160.0f) { - bodyVar *= 1.03f; - } - } - - bodyVar = bodyVar / weight; - bodyFat = 100.0f * (1.0f - bodyVar); - - if (1.0f > bodyFat) { - return 1.0f; - } else { - if (bodyFat > 45.0f) { - return 45.0f; - } else { - return bodyFat; - } - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/OneByoneNewLib.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/OneByoneNewLib.java deleted file mode 100644 index cada8a28..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/OneByoneNewLib.java +++ /dev/null @@ -1,201 +0,0 @@ -package com.health.openscale.core.bluetooth.lib; - -// This class is similar to OneByoneLib, but the way measures are computer are slightly different -public class OneByoneNewLib { - - private int sex; - private int age; - private float height; - private int peopleType; // low activity = 0; medium activity = 1; high activity = 2 - - public OneByoneNewLib(int sex, int age, float height, int peopleType) { - this.sex = sex; - this.age = age; - this.height = height; - this.peopleType = peopleType; - } - - public float getBMI(float weight) { - float bmi = weight / (((height * height) / 100.0f) / 100.0f); - return getBounded(bmi, 10, 90); - } - - public float getLBM(float weight, int impedance) { - float lbmCoeff = height / 100 * height / 100 * 9.058F; - lbmCoeff += 12.226; - lbmCoeff += weight * 0.32; - lbmCoeff -= impedance * 0.0068; - lbmCoeff -= age * 0.0542; - return lbmCoeff; - } - - - - public float getBMMRCoeff(float weight){ - int bmmrCoeff = 20; - if(sex == 1){ - bmmrCoeff = 21; - if(age < 0xd){ - bmmrCoeff = 36; - } else if(age < 0x10){ - bmmrCoeff = 30; - } else if(age < 0x12){ - bmmrCoeff = 26; - } else if(age < 0x1e){ - bmmrCoeff = 23; - } else if (age >= 0x32){ - bmmrCoeff = 20; - } - } else { - if(age < 0xd){ - bmmrCoeff = 34; - } else if(age < 0x10){ - bmmrCoeff = 29; - } else if(age < 0x12){ - bmmrCoeff = 24; - } else if(age < 0x1e){ - bmmrCoeff = 22; - } else if (age >= 0x32){ - bmmrCoeff = 19; - } - } - return bmmrCoeff; - } - - public float getBMMR(float weight){ - float bmmr; - if(sex == 1){ - bmmr = (weight * 14.916F + 877.8F) - height * 0.726F; - bmmr -= age * 8.976; - } else { - bmmr = (weight * 10.2036F + 864.6F) - height * 0.39336F; - bmmr -= age * 6.204; - } - - return getBounded(bmmr, 500, 1000); - } - - public float getBodyFatPercentage(float weight, int impedance) { - float bodyFat = getLBM(weight, impedance); - - float bodyFatConst; - if (sex == 0) { - if (age < 0x32) { - bodyFatConst = 9.25F; - } else { - bodyFatConst = 7.25F; - } - } else { - bodyFatConst = 0.8F; - } - - bodyFat -= bodyFatConst; - - if (sex == 0){ - if (weight < 50){ - bodyFat *= 1.02; - } else if(weight > 60){ - bodyFat *= 0.96; - } - - if(height > 160){ - bodyFat *= 1.03; - } - } else { - if (weight < 61){ - bodyFat *= 0.98; - } - } - - return 100 * (1 - bodyFat / weight); - } - - public float getBoneMass(float weight, int impedance){ - float lbmCoeff = getLBM(weight, impedance); - - float boneMassConst; - if(sex == 1){ - boneMassConst = 0.18016894F; - } else { - boneMassConst = 0.245691014F; - } - - boneMassConst = lbmCoeff * 0.05158F - boneMassConst; - float boneMass; - if(boneMassConst <= 2.2){ - boneMass = boneMassConst - 0.1F; - } else { - boneMass = boneMassConst + 0.1F; - } - - return getBounded(boneMass, 0.5F, 8); - } - - public float getMuscleMass(float weight, int impedance){ - float muscleMass = weight - getBodyFatPercentage(weight, impedance) * 0.01F * weight; - muscleMass -= getBoneMass(weight, impedance); - return getBounded(muscleMass, 10, 120); - } - - public float getSkeletonMusclePercentage(float weight, int impedance){ - float skeletonMuscleMass = getWaterPercentage(weight, impedance); - skeletonMuscleMass *= weight; - skeletonMuscleMass *= 0.8422F * 0.01F; - skeletonMuscleMass -= 2.9903; - skeletonMuscleMass /= weight; - return skeletonMuscleMass * 100; - } - - public float getVisceralFat(float weight){ - float visceralFat; - if (sex == 1) { - if (height < weight * 1.6 + 63.0) { - visceralFat = - age * 0.15F + ((weight * 305.0F) /((height * 0.0826F * height - height * 0.4F) + 48.0F) - 2.9F); - } - else { - visceralFat = age * 0.15F + (weight * (height * -0.0015F + 0.765F) - height * 0.143F) - 5.0F; - } - } - else { - if (weight <= height * 0.5 - 13.0) { - visceralFat = age * 0.07F + (weight * (height * -0.0024F + 0.691F) - height * 0.027F) - 10.5F; - } - else { - visceralFat = age * 0.07F + ((weight * 500.0F) / ((height * 1.45F + height * 0.1158F * height) - 120.0F) - 6.0F); - } - } - - return getBounded(visceralFat, 1, 50); - } - - public float getWaterPercentage(float weight, int impedance){ - float waterPercentage = (100 - getBodyFatPercentage(weight, impedance)) * 0.7F; - if (waterPercentage > 50){ - waterPercentage *= 0.98; - } else { - waterPercentage *= 1.02; - } - - return getBounded(waterPercentage, 35, 75); - } - - public float getProteinPercentage(float weight, int impedance){ - return ( - (100.0F - getBodyFatPercentage(weight, impedance)) - - getWaterPercentage(weight, impedance) * 1.08F - ) - - (getBoneMass(weight, impedance) / weight) * 100.0F; - } - - - private float getBounded(float value, float lowerBound, float upperBound){ - if(value < lowerBound){ - return lowerBound; - } else if (value > upperBound){ - return upperBound; - } - return value; - } - -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/SoehnleLib.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/SoehnleLib.java deleted file mode 100644 index 7bc9f0a2..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/SoehnleLib.java +++ /dev/null @@ -1,147 +0,0 @@ -/* Copyright (C) 2019 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.core.bluetooth.lib; - -public class SoehnleLib { - private boolean isMale; // male = 1; female = 0 - private int age; - private float height; - private int activityLevel; - - public SoehnleLib(boolean isMale, int age, float height, int activityLevel) { - this.isMale = isMale; - this.age = age; - this.height = height; - this.activityLevel = activityLevel; - } - - public float getFat(final float weight, final float imp50) { // in % - float activityCorrFac = 0.0f; - - switch (activityLevel) { - case 4: { - if (isMale) { - activityCorrFac = 2.5f; - } - else { - activityCorrFac = 2.3f; - } - break; - } - case 5: { - if (isMale) { - activityCorrFac = 4.3f; - } - else { - activityCorrFac = 4.1f; - } - break; - } - } - - float sexCorrFac; - float activitySexDiv; - - if (isMale) { - sexCorrFac = 0.250f; - activitySexDiv = 65.5f; - } - else { - sexCorrFac = 0.214f; - activitySexDiv = 55.1f; - } - - return 1.847f * weight * 10000.0f / (height * height) + sexCorrFac * age + 0.062f * imp50 - (activitySexDiv - activityCorrFac); - } - - public float computeBodyMassIndex(final float weight) { - return 10000.0f * weight / (height * height); - } - - public float getWater(final float weight, final float imp50) { // in % - float activityCorrFac = 0.0f; - - switch (activityLevel) { - case 1: - case 2: - case 3: { - if (isMale) { - activityCorrFac = 2.83f; - } - else { - activityCorrFac = 0.0f; - } - break; - } - case 4: { - if (isMale) { - activityCorrFac = 3.93f; - } - else { - activityCorrFac = 0.4f; - } - break; - } - case 5: { - if (isMale) { - activityCorrFac = 5.33f; - } - else { - activityCorrFac = 1.4f; - } - break; - } - } - return (0.3674f * height * height / imp50 + 0.17530f * weight - 0.11f * age + (6.53f + activityCorrFac)) / weight * 100.0f; - } - - public float getMuscle(final float weight, final float imp50, final float imp5) { // in % - float activityCorrFac = 0.0f; - - switch (activityLevel) { - case 1: - case 2: - case 3: { - if (isMale) { - activityCorrFac = 3.6224f; - } - else { - activityCorrFac = 0.0f; - } - break; - } - case 4: { - if (isMale) { - activityCorrFac = 4.3904f; - } - else { - activityCorrFac = 0.0f; - } - break; - } - case 5: { - if (isMale) { - activityCorrFac = 5.4144f; - } - else { - activityCorrFac = 1.664f; - } - break; - } - } - return ((0.47027f / imp50 - 0.24196f / imp5) * height * height + 0.13796f * weight - 0.1152f * age + (5.12f + activityCorrFac)) / weight * 100.0f; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/TrisaBodyAnalyzeLib.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/TrisaBodyAnalyzeLib.java deleted file mode 100644 index 0ff1ea6b..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/TrisaBodyAnalyzeLib.java +++ /dev/null @@ -1,79 +0,0 @@ -/* Copyright (C) 2018 Maks Verver - * 2019 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.core.bluetooth.lib; - -/** - * Class with static helper methods. This is a separate class for testing purposes. - * - * @see com.health.openscale.core.bluetooth.BluetoothTrisaBodyAnalyze - */ -public class TrisaBodyAnalyzeLib { - - private boolean isMale; - private int ageYears; - private float heightCm; - - public TrisaBodyAnalyzeLib(int sex, int age, float height) { - isMale = sex == 1 ? true : false; // male = 1; female = 0 - ageYears = age; - heightCm = height; - } - - public float getBMI(float weightKg) { - return weightKg * 1e4f / (heightCm * heightCm); - } - - public float getWater(float weightKg, float impedance) { - float bmi = getBMI(weightKg); - - float water = isMale - ? 87.51f + (-1.162f * bmi - 0.00813f * impedance + 0.07594f * ageYears) - : 77.721f + (-1.148f * bmi - 0.00573f * impedance + 0.06448f * ageYears); - - return water; - } - - public float getFat(float weightKg, float impedance) { - float bmi = getBMI(weightKg); - - float fat = isMale - ? bmi * (1.479f + 4.4e-4f * impedance) + 0.1f * ageYears - 21.764f - : bmi * (1.506f + 3.908e-4f * impedance) + 0.1f * ageYears - 12.834f; - - return fat; - } - - public float getMuscle(float weightKg, float impedance) { - float bmi = getBMI(weightKg); - - float muscle = isMale - ? 74.627f + (-0.811f * bmi - 0.00565f * impedance - 0.367f * ageYears) - : 57.0f + (-0.694f * bmi - 0.00344f * impedance - 0.255f * ageYears); - - return muscle; - } - - public float getBone(float weightKg, float impedance) { - float bmi = getBMI(weightKg); - - float bone = isMale - ? 7.829f + (-0.0855f * bmi - 5.92e-4f * impedance - 0.0389f * ageYears) - : 7.98f + (-0.0973f * bmi - 4.84e-4f * impedance - 0.036f * ageYears); - - return bone; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/YunmaiLib.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/YunmaiLib.java similarity index 81% rename from android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/YunmaiLib.java rename to android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/YunmaiLib.java index 353b327e..a08092f6 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/YunmaiLib.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/YunmaiLib.java @@ -1,22 +1,24 @@ -/* Copyright (C) 2018 olie.xdev +/* + * openScale + * Copyright (C) 2025 olie.xdev * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. + * This program is 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. + * 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 + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . */ +package com.health.openscale.core.bluetooth.libs; -package com.health.openscale.core.bluetooth.lib; -import com.health.openscale.core.utils.Converters.ActivityLevel; +import com.health.openscale.core.data.ActivityLevel; public class YunmaiLib { private int sex; // male = 1; female = 0 diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DummyScaleHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DummyScaleHandler.kt new file mode 100644 index 00000000..ea346968 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DummyScaleHandler.kt @@ -0,0 +1,92 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.scales + +import android.util.SparseArray +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import java.util.UUID + + +class DummyScaleHandler(private val driverName: String) : ScaleDeviceHandler { + override fun getDriverName(): String { + return driverName + } + + // Updated signature to match the interface + override fun canHandleDevice( + deviceName: String?, + deviceAddress: String, // Added this parameter + serviceUuids: List, + manufacturerData: SparseArray? + ): Boolean { + // Implement your logic to check if this handler can handle the device + // For example, based on the deviceName: + return deviceName?.contains(driverName, ignoreCase = true) == true + } + + // --- Implement missing members --- + override val handlerId: String + get() = "dummy_handler_$driverName" // Example implementation + + override fun connectAndReceiveEvents( + deviceAddress: String, + currentAppUserAttributes: Map? + ): Flow { + // Dummy implementation: return an empty flow or a flow that emits some dummy events + println("DummyScaleHandler: connectAndReceiveEvents called for $deviceAddress") + return flow { + // emit(DummyScaleEvent("Connected")) // Example + // emit(DummyScaleEvent("Weight: 70.5")) // Example + } + } + + override suspend fun disconnect() { + // Dummy implementation + println("DummyScaleHandler: disconnect called") + // Add actual disconnection logic here + } + + override suspend fun provideUserSelection( + selectedUser: ScaleUserListItem, + requestContext: Any? + ): Boolean { + // Dummy implementation + println("DummyScaleHandler: provideUserSelection called for user ${selectedUser.displayData}") + return true // Or false based on logic + } + + override suspend fun provideUserConsent( + consentType: String, + consented: Boolean, + details: Map? + ): Boolean { + // Dummy implementation + println("DummyScaleHandler: provideUserConsent called for $consentType, consented: $consented") + return true // Or false + } + + override suspend fun provideUserAttributes( + attributes: Map, + scaleUserIdentifier: Any? + ): Boolean { + // Dummy implementation + println("DummyScaleHandler: provideUserAttributes called with $attributes") + return true // Or false + } +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt new file mode 100644 index 00000000..69e83d6e --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt @@ -0,0 +1,467 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.scales + +import android.Manifest +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.le.ScanResult +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import androidx.core.content.ContextCompat +import com.health.openscale.core.bluetooth.BluetoothEvent +import com.health.openscale.core.bluetooth.ScaleCommunicator +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.utils.LogManager +import com.welie.blessed.BluetoothCentralManager +import com.welie.blessed.BluetoothCentralManagerCallback +import com.welie.blessed.BluetoothPeripheral +import com.welie.blessed.BluetoothPeripheralCallback +import com.welie.blessed.GattStatus +import com.welie.blessed.HciStatus +import com.welie.blessed.ScanFailure +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.Date +import java.util.UUID + +// Beispielhafte UUIDs - DIESE MÜSSEN DURCH DIE KORREKTEN UUIDs IHRER ZIELGERÄTE ERSETZT WERDEN! +object ScaleGattAttributes { + // Beispiel: Body Composition Service + val BODY_COMPOSITION_SERVICE_UUID: UUID = UUID.fromString("0000181B-0000-1000-8000-00805F9B34FB") + // Beispiel: Body Composition Measurement Characteristic + val BODY_COMPOSITION_MEASUREMENT_UUID: UUID = UUID.fromString("00002A9C-0000-1000-8000-00805F9B34FB") + // Beispiel: Weight Scale Service + val WEIGHT_SCALE_SERVICE_UUID: UUID = UUID.fromString("0000181D-0000-1000-8000-00805F9B34FB") + // Beispiel: Weight Measurement Characteristic + val WEIGHT_MEASUREMENT_UUID: UUID = UUID.fromString("00002A9D-0000-1000-8000-00805F9B34FB") + // Beispiel: Current Time Service (oft für Bonding oder User-Setup verwendet) + val CURRENT_TIME_SERVICE_UUID: UUID = UUID.fromString("00001805-0000-1000-8000-00805F9B34FB") + val CURRENT_TIME_CHARACTERISTIC_UUID: UUID = UUID.fromString("00002A2B-0000-1000-8000-00805F9B34FB") + // Client Characteristic Configuration Descriptor + val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") +} + + +class ModernScaleAdapter( + private val context: Context +) : ScaleCommunicator { + + private companion object { + const val TAG = "ModernScaleAdapter" + } + + private val adapterScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val mainHandler = Handler(Looper.getMainLooper()) + private lateinit var central: BluetoothCentralManager // Initialisiert in init + + private var currentPeripheral: BluetoothPeripheral? = null + private var targetAddress: String? = null + private var currentScaleUser: ScaleUser? = null // von der Schnittstelle + private var currentAppUserId: Int? = null + + private val _eventsFlow = MutableSharedFlow(replay = 1, extraBufferCapacity = 5) + override fun getEventsFlow(): SharedFlow = _eventsFlow.asSharedFlow() + + private val _isConnecting = MutableStateFlow(false) + override val isConnecting: StateFlow = _isConnecting.asStateFlow() + + private val _isConnected = MutableStateFlow(false) + override val isConnected: StateFlow = _isConnected.asStateFlow() + + private val bluetoothCentralManagerCallback: BluetoothCentralManagerCallback = + object : BluetoothCentralManagerCallback() { + override fun onConnectedPeripheral(peripheral: BluetoothPeripheral) { + adapterScope.launch { + LogManager.i(TAG, "Verbunden mit ${peripheral.name} (${peripheral.address})") + currentPeripheral = peripheral + _isConnected.value = true + _isConnecting.value = false + _eventsFlow.tryEmit(BluetoothEvent.Connected(peripheral.name ?: "Unbekannt", peripheral.address)) + + // Nachdem verbunden, Services entdecken + LogManager.d(TAG, "Starte Service Discovery für ${peripheral.address}") + } + } + + override fun onConnectionFailed(peripheral: BluetoothPeripheral, status: HciStatus) { + adapterScope.launch { + LogManager.e(TAG, "Verbindung zu ${peripheral.address} fehlgeschlagen. Status: $status") + _eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed(peripheral.address, "Verbindung fehlgeschlagen: $status")) + cleanupAfterDisconnect(peripheral.address) + } + } + + override fun onDisconnectedPeripheral(peripheral: BluetoothPeripheral, status: HciStatus) { + adapterScope.launch { + LogManager.i(TAG, "Getrennt von ${peripheral.name} (${peripheral.address}). Status: $status") + val reason = "Getrennt: $status" + // Nur Event senden, wenn es das aktuell verbundene/verbindende Gerät war + if (targetAddress == peripheral.address) { + _eventsFlow.tryEmit(BluetoothEvent.Disconnected(peripheral.address, reason)) + cleanupAfterDisconnect(peripheral.address) + } else { + LogManager.w(TAG, "Disconnected Event für nicht-Zielgerät ${peripheral.address} ignoriert (Ziel war $targetAddress).") + } + } + } + + override fun onDiscoveredPeripheral(peripheral: BluetoothPeripheral, scanResult: ScanResult) { + // Wir scannen spezifisch nach Adresse, also sollte dies unser Gerät sein. + if (peripheral.address == targetAddress) { + LogManager.i(TAG, "Gerät ${peripheral.name} (${peripheral.address}) gefunden. Stoppe Scan und verbinde.") + central.stopScan() + central.connectPeripheral(peripheral, peripheralCallback) + // _isConnecting bleibt true, bis onConnectedPeripheral oder onConnectionFailed aufgerufen wird + } else { + LogManager.d(TAG, "Scan hat anderes Gerät gefunden: ${peripheral.address}. Ignoriere.") + } + } + + override fun onScanFailed(scanFailure: ScanFailure) { + adapterScope.launch { + LogManager.e(TAG, "Scan fehlgeschlagen: $scanFailure") + if (targetAddress != null && _isConnecting.value) { + _eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed(targetAddress!!, "Scan fehlgeschlagen: $scanFailure")) + cleanupAfterDisconnect(targetAddress) // targetAddress könnte null sein, wenn connect nie erfolgreich war + } + } + } + } + + + init { + if (!hasRequiredBluetoothPermissions()) { + LogManager.e(TAG, "Fehlende Bluetooth-Berechtigungen. Adapter kann nicht initialisiert werden.") + // Sende einen Fehler-Event oder werfe eine Exception, um das Problem anzuzeigen. + // _eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed("initialization", "Missing Bluetooth permissions")) + } else { + central = BluetoothCentralManager( + context, + bluetoothCentralManagerCallback, + mainHandler + ) + LogManager.d(TAG, "BlessedScaleAdapter instanziiert und BluetoothCentralManager initialisiert.") + } + } + + private fun hasRequiredBluetoothPermissions(): Boolean { + val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) + } else { + listOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN, Manifest.permission.ACCESS_FINE_LOCATION) + } + return requiredPermissions.all { + ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + } + } + + override fun connect(address: String, scaleUser: ScaleUser?, appUserId: Int?) { + adapterScope.launch { + if (!::central.isInitialized) { + LogManager.e(TAG, "BluetoothCentralManager nicht initialisiert, wahrscheinlich aufgrund fehlender Berechtigungen.") + _eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed(address, "Bluetooth nicht initialisiert (Berechtigungen?)")) + return@launch + } + + if (_isConnecting.value || (_isConnected.value && targetAddress == address)) { + LogManager.d(TAG, "Verbindungsanfrage für $address ignoriert: Bereits verbunden oder Verbindungsaufbau läuft.") + if (_isConnected.value && targetAddress == address) { + val deviceName = currentPeripheral?.name ?: "Unbekanntes Gerät" + _eventsFlow.tryEmit(BluetoothEvent.Connected(deviceName, address)) + } + return@launch + } + + if ((_isConnected.value || _isConnecting.value) && targetAddress != address) { + LogManager.d(TAG, "Bestehende Verbindung/Versuch zu $targetAddress wird für neue Verbindung zu $address getrennt.") + disconnectLogic() + } + + _isConnecting.value = true + _isConnected.value = false + targetAddress = address + currentScaleUser = scaleUser + currentAppUserId = appUserId + + LogManager.i(TAG, "Verbindungsversuch zu $address mit Benutzer: ${scaleUser?.id}, AppUserID: $appUserId") + + // Stoppe vorherige Scans, falls vorhanden + central.stopScan() + + try { + // Versuche, direkt ein Peripheral-Objekt zu bekommen, falls die Adresse bekannt ist. + //Blessed erlaubt auch das Scannen nach Adresse, was oft robuster ist. + //central.getPeripheral(address) ist eine Option, aber scanForPeripheralsWithAddresses ist oft besser. + central.scanForPeripheralsWithAddresses(arrayOf(address)) + LogManager.d(TAG, "Scan gestartet für Adresse: $address") + } catch (e: Exception) { + LogManager.e(TAG, "Fehler beim Starten des Scans für $address", e) + _eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed(address, "Scan konnte nicht gestartet werden: ${e.message}")) + _isConnecting.value = false + targetAddress = null + } + } + } + + private val peripheralCallback: BluetoothPeripheralCallback = + object : BluetoothPeripheralCallback() { + override fun onServicesDiscovered(peripheral: BluetoothPeripheral) { + LogManager.i(TAG, "Services entdeckt für ${peripheral.address}") + // HIER kommt die Logik, um die relevanten Characteristics zu abonnieren (Notifications/Indications) + // Beispiel für Weight Measurement und Body Composition Measurement: + enableNotifications(peripheral, ScaleGattAttributes.WEIGHT_SCALE_SERVICE_UUID, ScaleGattAttributes.WEIGHT_MEASUREMENT_UUID) + enableNotifications(peripheral, ScaleGattAttributes.BODY_COMPOSITION_SERVICE_UUID, ScaleGattAttributes.BODY_COMPOSITION_MEASUREMENT_UUID) + + // Optional: Benutzerdaten schreiben oder andere Initialisierungssequenzen + // sendUserDataIfNeeded(peripheral) + } + + override fun onCharacteristicUpdate( + peripheral: BluetoothPeripheral, + value: ByteArray, + characteristic: BluetoothGattCharacteristic, + status: GattStatus + ) { + if (status == GattStatus.SUCCESS) { + LogManager.d(TAG, "Characteristic ${characteristic.uuid} Update von ${peripheral.address}: ${value.toHexString()}") + // HIER PARSEN SIE DIE `value` (ByteArray) basierend auf der `characteristic.uuid` + // und erstellen ein `com.health.openscale.core.datatypes.ScaleMeasurement` + val measurement = parseMeasurementData(characteristic.uuid, value, peripheral.address) + if (measurement != null) { + adapterScope.launch { + _eventsFlow.tryEmit(BluetoothEvent.MeasurementReceived(measurement, peripheral.address)) + } + } else { + LogManager.w(TAG, "Konnte Daten von ${characteristic.uuid} nicht parsen.") + adapterScope.launch { + _eventsFlow.tryEmit(BluetoothEvent.DeviceMessage("Unbekannte Daten empfangen von ${characteristic.uuid}", peripheral.address)) + } + } + } else { + LogManager.e(TAG, "Characteristic ${characteristic.uuid} Update Fehler: $status von ${peripheral.address}") + } + } + + override fun onCharacteristicWrite( + peripheral: BluetoothPeripheral, + value: ByteArray, + characteristic: BluetoothGattCharacteristic, + status: GattStatus + ) { + if (status == GattStatus.SUCCESS) { + LogManager.i(TAG, "Erfolgreich auf Characteristic ${characteristic.uuid} geschrieben: ${value.toHexString()}") + // Hier ggf. weitere Logik, falls ein Schreibvorgang Teil einer Sequenz ist + } else { + LogManager.e(TAG, "Fehler beim Schreiben auf Characteristic ${characteristic.uuid}: $status") + adapterScope.launch { + _eventsFlow.tryEmit(BluetoothEvent.DeviceMessage("Fehler beim Schreiben (${characteristic.uuid}): $status", peripheral.address)) + } + } + } + + override fun onNotificationStateUpdate( + peripheral: BluetoothPeripheral, + characteristic: BluetoothGattCharacteristic, + status: GattStatus + ) { + if (status == GattStatus.SUCCESS) { + if (peripheral.isNotifying(characteristic)) { + LogManager.i(TAG, "Notifications erfolgreich aktiviert für ${characteristic.uuid} auf ${peripheral.address}") + } else { + LogManager.i(TAG, "Notifications erfolgreich deaktiviert für ${characteristic.uuid} auf ${peripheral.address}") + } + } else { + LogManager.e(TAG, "Fehler beim Aktualisieren des Notification Status für ${characteristic.uuid}: $status") + adapterScope.launch { + _eventsFlow.tryEmit(BluetoothEvent.DeviceMessage("Fehler bei Notif. für ${characteristic.uuid}: $status", peripheral.address)) + } + } + } + } + + private fun enableNotifications(peripheral: BluetoothPeripheral, serviceUUID: UUID, characteristicUUID: UUID) { + val characteristic = peripheral.getCharacteristic(serviceUUID, characteristicUUID) + if (characteristic != null) { + if (peripheral.setNotify(characteristic, true)) { + LogManager.d(TAG, "Versuche Notifications für ${characteristicUUID} zu aktivieren.") + } else { + LogManager.e(TAG, "Fehler beim Versuch, Notifications für ${characteristicUUID} zu aktivieren (setNotify gab false zurück).") + adapterScope.launch { + _eventsFlow.tryEmit(BluetoothEvent.DeviceMessage("Konnte Notif. nicht aktivieren für ${characteristic.uuid}", peripheral.address)) + } + } + } else { + LogManager.w(TAG, "Characteristic ${characteristicUUID} nicht gefunden im Service ${serviceUUID}.") + } + } + + /** + * Parst die Rohdaten einer BLE Characteristic und konvertiert sie in ein ScaleMeasurement Objekt. + * DIES IST EINE SEHR SPEZIFISCHE FUNKTION UND MUSS FÜR JEDE WAAGE/PROTOKOLL IMPLEMENTIERT WERDEN. + * + * @param characteristicUuid Die UUID der Characteristic, von der die Daten stammen. + * @param value Das ByteArray mit den Rohdaten. + * @param deviceAddress Die Adresse des Geräts. + * @return Ein [ScaleMeasurement] Objekt oder null, wenn das Parsen fehlschlägt. + */ + private fun parseMeasurementData(characteristicUuid: UUID, value: ByteArray, deviceAddress: String): ScaleMeasurement? { + // Beispielhafte, sehr vereinfachte Parsing-Logik. + // Die tatsächliche Implementierung hängt STARK vom jeweiligen Waagenprotokoll ab! + val measurement = ScaleMeasurement() + measurement.dateTime = Date() // Zeitstempel der App, Waage könnte eigenen haben + + try { + when (characteristicUuid) { + ScaleGattAttributes.WEIGHT_MEASUREMENT_UUID -> { + // Annahme: Bluetooth SIG Weight Scale Characteristic + // Byte 0: Flags + // Byte 1-2: Gewicht (LSB, MSB) + // ... weitere Felder je nach Flags (Timestamp, UserID, BMI, Height) + val flags = value[0].toInt() + val isImperial = (flags and 0x01) != 0 // Bit 0: 0 für kg/m, 1 für lb/in + val hasTimestamp = (flags and 0x02) != 0 // Bit 1 + val hasUserID = (flags and 0x04) != 0 // Bit 2 + + var offset = 1 + var weight = ((value[offset++].toInt() and 0xFF) or ((value[offset++].toInt() and 0xFF) shl 8)) / if (isImperial) 100.0f else 200.0f + if (isImperial) { + weight *= 0.453592f // lb in kg umrechnen + } + measurement.weight = weight.takeIf { it.isFinite() } ?: 0.0f + LogManager.d(TAG, "Geparsed Weight: ${measurement.weight} kg") + + if (hasTimestamp) { + // Hier Timestamp parsen (7 Bytes) + offset += 7 + } + if (hasUserID) { + val userId = value[offset].toInt() + // measurement.scaleUserIndex = userId // oder ähnliches Feld + LogManager.d(TAG, "Geparsed UserID from scale: $userId") + } + return measurement + } + ScaleGattAttributes.BODY_COMPOSITION_MEASUREMENT_UUID -> { + // Annahme: Bluetooth SIG Body Composition Characteristic + // Ähnlich komplex wie Weight, mit vielen optionalen Feldern + // Byte 0-1: Flags + // Byte 2-3: Body Fat Percentage + // ... viele weitere Felder (Timestamp, UserID, Basal Metabolism, Muscle Percentage, etc.) + // Diese Implementierung ist nur ein Platzhalter! + LogManager.d(TAG, "Body Composition Data empfangen, Parsing noch nicht voll implementiert.") + val bodyFatPercentage = ((value[2].toInt() and 0xFF) or ((value[3].toInt() and 0xFF) shl 8)) / 10.0f + measurement.fat = bodyFatPercentage.takeIf { it.isFinite() } ?: 0.0f + // Setze ein beliebiges Gewicht, da Body Comp oft kein Gewicht enthält + // Besser wäre es, Messungen zu kombinieren oder auf eine vorherige Gewichtsmessung zu warten. + if (measurement.weight == 0.0f) measurement.weight = 70.0f // Platzhalter + return measurement + } + else -> { + LogManager.w(TAG, "Keine Parsing-Logik für UUID: $characteristicUuid") + return null + } + } + } catch (e: Exception) { + LogManager.e(TAG, "Fehler beim Parsen der Messdaten für $characteristicUuid: ${value.toHexString()}", e) + return null + } + } + + + override fun disconnect() { + adapterScope.launch { + LogManager.i(TAG, "Disconnect aufgerufen für $targetAddress") + disconnectLogic() + } + } + + private fun disconnectLogic() { + currentPeripheral?.let { + // Notifications deaktivieren, bevor die Verbindung getrennt wird? Optional. + // disableNotifications(it, ScaleGattAttributes.WEIGHT_SCALE_SERVICE_UUID, ScaleGattAttributes.WEIGHT_MEASUREMENT_UUID) + // disableNotifications(it, ScaleGattAttributes.BODY_COMPOSITION_SERVICE_UUID, ScaleGattAttributes.BODY_COMPOSITION_MEASUREMENT_UUID) + central.cancelConnection(it) + } + // Cleanup wird im onDisconnectedPeripheral Callback oder hier als Fallback gemacht, + // falls der Callback aus irgendeinem Grund nicht kommt. + val addr = targetAddress + if (_isConnected.value || _isConnecting.value) { + if (addr != null) { + // Event wird in onDisconnectedPeripheral gesendet, aber als Fallback, falls der Callback nicht kommt + // _eventsFlow.tryEmit(BluetoothEvent.Disconnected(addr, "Manuell getrennt durch disconnectLogic")) + } + } + cleanupAfterDisconnect(addr) // Rufe cleanup auf, um sicherzustellen, dass die Zustände zurückgesetzt werden. + } + + private fun cleanupAfterDisconnect(disconnectedAddress: String?) { + // Nur aufräumen, wenn die Adresse mit dem Ziel übereinstimmt oder wenn targetAddress null ist (z.B. nach fehlgeschlagenem Scan) + if (targetAddress == null || targetAddress == disconnectedAddress) { + _isConnected.value = false + _isConnecting.value = false + currentPeripheral = null // Referenz auf Peripheral entfernen + targetAddress = null + currentScaleUser = null + currentAppUserId = null + LogManager.d(TAG, "Blessed Communicator aufgeräumt für Adresse: $disconnectedAddress.") + } else { + LogManager.d(TAG, "Cleanup übersprungen: Disconnected Address ($disconnectedAddress) stimmt nicht mit Target ($targetAddress) überein.") + } + } + + override fun requestMeasurement() { + adapterScope.launch { + if (!_isConnected.value || currentPeripheral == null) { + LogManager.w(TAG, "requestMeasurement: Nicht verbunden oder kein Peripheral.") + _eventsFlow.tryEmit(BluetoothEvent.DeviceMessage("Nicht verbunden für Messanfrage.", targetAddress ?: "unbekannt")) + return@launch + } + // Die meisten BLE-Waagen senden Daten automatisch nach Aktivierung der Notifications. + // Eine explizite "Anfrage" ist oft nicht nötig oder nicht standardisiert. + // Falls Ihr Gerät eine spezielle Characteristic zum Anfordern von Daten hat, + // könnten Sie hier darauf schreiben. + LogManager.d(TAG, "requestMeasurement aufgerufen. Für BLE typischerweise keine explizite Aktion nötig, Daten kommen über Notifications.") + _eventsFlow.tryEmit(BluetoothEvent.DeviceMessage("Messdaten werden erwartet (BLE Notifications).", targetAddress!!)) + } + } + + fun release() { + LogManager.d(TAG, "BlessedScaleAdapter wird freigegeben.") + disconnectLogic() + // Blessed CentralManager hat keine explizite close() oder release() Methode für sich selbst, + // die Verbindungen werden über cancelConnection() verwaltet. + // Der Handler wird implizit mit dem Context-Lifecycle verwaltet. + adapterScope.cancel() + } +} + +// Hilfsfunktion zum Konvertieren eines ByteArrays in einen Hex-String (nützlich für Logging) +fun ByteArray.toHexString(): String = joinToString(separator = " ", prefix = "0x") { String.format("%02X", it) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt new file mode 100644 index 00000000..4ecb5c34 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt @@ -0,0 +1,282 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.scales + +import android.util.SparseArray +import com.health.openscale.core.data.MeasurementTypeKey // Required for DeviceValue +import kotlinx.coroutines.flow.Flow +import java.util.UUID + +// ---- Data classes for abstracting measurement data provided by the handler ---- + +/** + * Represents a single value of a specific measurement type from the device. + * @param typeKey The key identifying the type of measurement (e.g., weight, body fat). + * @param value The actual value of the measurement. + */ +data class DeviceValue( + val typeKey: MeasurementTypeKey, + val value: Any +) + +/** + * Represents a complete measurement reading from the device. + * @param timestamp The time the measurement was taken, in milliseconds since epoch. Defaults to current time. + * @param values A list of [DeviceValue] objects representing the different components of the measurement. + * @param deviceIdentifier An optional identifier for the device that produced the measurement (e.g., MAC address). + * @param scaleUserIndex Optional: Indicates if this measurement originates from a specific user profile on the scale. + * The value is the index/ID of the user on the scale, if known. + * Can be useful for later assignment or filtering of measurements. + * @param isStableMeasurement Optional: Indicates if this measurement is a "stable" or "final" reading. + * Some scales send intermediate values before the final weight is determined. + * Defaults to true if not otherwise specified. + */ +data class DeviceMeasurement( + val timestamp: Long = System.currentTimeMillis(), + val values: List, + val deviceIdentifier: String? = null, + val scaleUserIndex: Int? = null, + val isStableMeasurement: Boolean = true +) + +// ---- End of data classes for abstraction ---- + +/** + * Represents the data provided by a handler for selecting a user on the scale. + * Handler implementations should derive a more specific data class or use this as a base. + */ +interface ScaleUserListItem { + /** Text to be displayed in the UI for this user item. */ + val displayData: String + /** The internal ID that the handler requires to identify this user on the scale. */ + val scaleInternalId: Any +} + +/** + * Example implementation for a generic user list item. + * @param displayData Text to be displayed in the UI. + * @param scaleInternalId The internal ID used by the scale/handler. + */ +data class GenericScaleUserListItem( + override val displayData: String, + override val scaleInternalId: Any +) : ScaleUserListItem + + +/** + * Defines various events that a [ScaleDeviceHandler] can emit to communicate its state + * and data to the application. + */ +sealed class ScaleDeviceEvent { + /** The handler is starting to search for the device (advertising) or establishing a GATT connection. */ + data object PreparingConnection : ScaleDeviceEvent() // Renamed from Connecting for more generality + + /** The handler is actively scanning for advertising packets from the device. Only relevant for advertising-based handlers. */ + data object ScanningForAdvertisement : ScaleDeviceEvent() + + /** + * A connection to the device has been successfully established. + * @param message A descriptive message about the established connection. + */ + data class ConnectionEstablished(val message: String) : ScaleDeviceEvent() + + /** + * The device-specific initialization sequence has been successfully completed. + * For GATT-based handlers, this usually means notifications are set up and initial checks are done. + * For advertising-based handlers, this might not be relevant or could be sent directly after the first data reception. + */ + data object InitializationComplete : ScaleDeviceEvent() + + /** The connection to the device was lost unexpectedly, or the target device was no longer found (during scanning). */ + data object ConnectionLost : ScaleDeviceEvent() + + /** The connection to the device was actively disconnected, or the scan was stopped. */ + data object Disconnected : ScaleDeviceEvent() + + /** + * An error occurred during communication or device handling. + * @param message A descriptive error message. + * @param throwable An optional [Throwable] associated with the error. + * @param errorCode An optional, handler-specific error code. + */ + data class Error(val message: String, val throwable: Throwable? = null, val errorCode: Int? = null) : ScaleDeviceEvent() + + /** + * A new measurement, parsed from the device, is available. + * @param measurement The [DeviceMeasurement] containing the data. + */ + data class DeviceMeasurementAvailable(val measurement: DeviceMeasurement) : ScaleDeviceEvent() + + /** + * An informational message for the user. + * @param text The message text (can be null if `stringResId` is used). + * @param stringResId An optional string resource ID for localization. + * @param payload Optional structured data associated with the info message. + */ + data class InfoMessage( + val text: String? = null, + val stringResId: Int? = null, + val payload: Any? = null // For optionally more structured info data + ) : ScaleDeviceEvent() + + /** + * Emitted when the scale requires the user to be selected on the device. + * The UI should present this list to the user. The response is provided via [ScaleDeviceHandler.provideUserSelection]. + * @param userList A list of [ScaleUserListItem] objects representing the user profiles available on the scale. + * @param requestContext An optional context object that the handler can send to correlate the response later. + */ + data class UserSelectionRequired(val userList: List, val requestContext: Any? = null) : ScaleDeviceEvent() + + /** + * Emitted when the scale requires user consent (e.g., in the app) to perform an action + * (e.g., user profile synchronization, data assignment, registration). + * The response is provided via [ScaleDeviceHandler.provideUserConsent]. + * @param consentType An identifier for the type of consent requested (handler-specific, e.g., "register_new_user"). + * @param messageToUser A user-readable message explaining the reason for the consent. + * @param details Optional additional details or data relevant to the consent + * (e.g., proposed scaleUserIndex if registering a new user: `mapOf("scaleUserIndexProposal" -> 3)`). + */ + data class UserConsentRequired( + val consentType: String, // e.g., "register_new_user", "confirm_user_match" + val messageToUser: String, + val details: Map? = null // e.g., mapOf("appUserId" -> 1, "scaleUserIndexProposal" -> 3) + ) : ScaleDeviceEvent() + + /** + * Emitted when the handler needs specific attributes of the app user to interact with the scale + * (e.g., to create or update a user on the scale). + * The response is provided via [ScaleDeviceHandler.provideUserAttributes]. + * @param requestedAttributes A list of keys indicating which attributes are needed (e.g., "height", "birthdate", "gender"). + * Example: `listOf("height_cm", "birth_date_epoch_ms", "gender_string")` + * @param scaleUserIdentifier The identifier of the scale user for whom the attributes are needed (if applicable). + */ + data class UserAttributesRequired( + val requestedAttributes: List, // e.g., listOf("height_cm", "birth_date_epoch_ms", "gender_string") + val scaleUserIdentifier: Any? = null + ) : ScaleDeviceEvent() +} + +/** + * Interface for a device-specific handler that manages communication with a Bluetooth scale. + * It abstracts the low-level Bluetooth operations and provides a standardized way to interact + * with different types of scales. + */ +interface ScaleDeviceHandler { + + /** + * @return The display name of this scale driver/handler (e.g., "Mi Scale v2 Handler"). + */ + fun getDriverName(): String + + /** + * A unique ID for this handler type. Can be, for example, the class name. + * Important for persistence and later retrieval of the correct handler. + */ + val handlerId: String + + /** + * Indicates whether this handler primarily communicates via advertising data (`true`) + * or GATT connections (`false`). + * This can help the Bluetooth management layer optimize scans and connection attempts. + * Defaults to `false` (GATT-based). + */ + val communicatesViaAdvertising: Boolean + get() = false // Default is GATT-based + + /** + * Checks if this handler can manage communication with the specified device. + * + * @param deviceName The advertised name of the Bluetooth device. + * @param deviceAddress The MAC address of the device. + * @param serviceUuids A list of advertised service UUIDs. + * @param manufacturerData Manufacturer-specific data from the advertisement. + * @return `true` if this handler can handle the device, `false` otherwise. + */ + fun canHandleDevice( + deviceName: String?, + deviceAddress: String, + serviceUuids: List, + manufacturerData: SparseArray? + ): Boolean + + /** + * Prepares communication, potentially scans for advertising data or establishes a GATT connection, + * performs the necessary initialization sequence, and starts receiving data. + * + * @param deviceAddress The MAC address of the Bluetooth device. + * @param currentAppUserAttributes Optional attributes of the current app user that the handler might + * need for initialization or user synchronization. + * The handler should explicitly request what it needs via [ScaleDeviceEvent.UserAttributesRequired] + * if these are insufficient or missing. + * @return A [Flow] of [ScaleDeviceEvent]s. + */ + fun connectAndReceiveEvents( + deviceAddress: String, + currentAppUserAttributes: Map? = null + ): Flow + + /** + * Disconnects from the currently connected device and cleans up resources. + */ + suspend fun disconnect() + + /** + * Called by the application to provide the user's selection in response to a + * [ScaleDeviceEvent.UserSelectionRequired] event. + * + * @param selectedUser The [ScaleUserListItem] object selected by the user. + * @param requestContext The context that was sent with the original `UserSelectionRequired` event. + * @return `true` if the selection was processed successfully, `false` otherwise. + */ + suspend fun provideUserSelection(selectedUser: ScaleUserListItem, requestContext: Any? = null): Boolean + + /** + * Called by the application to provide the user's consent (or denial) in response to a + * [ScaleDeviceEvent.UserConsentRequired] event. + * + * @param consentType The type of consent, as specified in the original event. + * @param consented `true` if the user consented, `false` otherwise. + * @param details Additional details that were sent with the original `UserConsentRequired` event. + * @return `true` if the consent was processed successfully, `false` otherwise. + */ + suspend fun provideUserConsent(consentType: String, consented: Boolean, details: Map? = null): Boolean + + /** + * Called by the application to provide the requested user attributes in response to a + * [ScaleDeviceEvent.UserAttributesRequired] event. + * + * @param attributes A map of the provided attributes (keys as requested in the event). + * @param scaleUserIdentifier The identifier of the scale user, as requested in the event. + * @return `true` if the attributes were processed successfully, `false` otherwise. + */ + suspend fun provideUserAttributes(attributes: Map, scaleUserIdentifier: Any? = null): Boolean + + /** + * Sends a device-specific command to the scale. + * This is an escape-hatch function for handler-specific actions not covered by + * the standard events/methods. + * Its use should be minimized to maintain abstraction. + * + * @param commandId An identifier for the command. + * @param commandData Optional data for the command. + * @return A result object indicating success/failure and optional response data. + */ + // suspend fun sendRawCommand(commandId: String, commandData: Any? = null): CommandResult // Commented out for now as it increases complexity +} + +// data class CommandResult(val success: Boolean, val responseData: Any? = null) // For sendRawCommand diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothCommunication.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothCommunication.java similarity index 78% rename from android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothCommunication.java rename to android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothCommunication.java index 86f2224f..5ea094b0 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothCommunication.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothCommunication.java @@ -1,20 +1,21 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.bluetooth; +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.scalesJava; import static android.bluetooth.BluetoothGatt.GATT_SUCCESS; import static android.content.Context.LOCATION_SERVICE; @@ -30,8 +31,11 @@ import android.os.Looper; import androidx.core.content.ContextCompat; + import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; +import com.health.openscale.core.bluetooth.data.ScaleMeasurement; +import com.health.openscale.core.bluetooth.data.ScaleUser; +import com.health.openscale.core.utils.LogManager; import com.welie.blessed.BluetoothCentralManager; import com.welie.blessed.BluetoothCentralManagerCallback; import com.welie.blessed.BluetoothPeripheral; @@ -43,8 +47,6 @@ import com.welie.blessed.WriteType; import java.util.UUID; -import timber.log.Timber; - public abstract class BluetoothCommunication { public enum BT_STATUS { RETRIEVE_SCALE_DATA, @@ -71,6 +73,9 @@ public abstract class BluetoothCommunication { private BluetoothCentralManager central; private BluetoothPeripheral btPeripheral; + private ScaleUser selectedScaleUser; + private int selectedScaleUserId; + public BluetoothCommunication(Context context) { this.context = context; @@ -78,6 +83,27 @@ public abstract class BluetoothCommunication { this.stepNr = 0; this.stopped = false; this.central = new BluetoothCentralManager(context, bluetoothCentralCallback, new Handler(Looper.getMainLooper())); + this.selectedScaleUser = new ScaleUser(); + this.selectedScaleUserId = 0; + } + + public void setSelectedScaleUser(ScaleUser user) { + selectedScaleUser = user; + } + + public ScaleUser getSelectedScaleUser() { + return selectedScaleUser; + } + + public void setSelectedScaleUserId(int userId) { + selectedScaleUserId = userId; + } + public int getSelectedScaleUserId() { + return selectedScaleUserId; + } + + public BluetoothPeripheral getBtPeripheral() { + return btPeripheral; } protected boolean needReConnect() { @@ -197,7 +223,7 @@ public abstract class BluetoothCommunication { * Stopped current state machine */ protected synchronized void stopMachineState() { - Timber.d("Stop machine state"); + LogManager.d("BluetoothCommunication","Stop machine state"); stopped = true; } @@ -205,7 +231,7 @@ public abstract class BluetoothCommunication { * resume current state machine */ protected synchronized void resumeMachineState() { - Timber.d("Resume machine state"); + LogManager.d("BluetoothCommunication","Resume machine state"); stopped = false; nextMachineStep(); } @@ -216,13 +242,13 @@ public abstract class BluetoothCommunication { */ protected synchronized boolean resumeMachineState( int curStep ) { if( curStep == stepNr-1 ) { - Timber.d("curStep " + curStep + " matches stepNr " + stepNr + "-1, resume state machine."); + LogManager.d("BluetoothCommunication","curStep " + curStep + " matches stepNr " + stepNr + "-1, resume state machine."); stopped = false; nextMachineStep(); return true; } else { - Timber.d("curStep " + curStep + " does not match stepNr " + stepNr + "-1, not resuming state machine."); + LogManager.d("BluetoothCommunication","curStep " + curStep + " does not match stepNr " + stepNr + "-1, not resuming state machine."); return false; } } @@ -232,7 +258,7 @@ public abstract class BluetoothCommunication { * @param nr the step number which the state machine should jump to. */ protected synchronized void jumpNextToStepNr(int nr) { - Timber.d("Jump next to step nr " + nr); + LogManager.d("BluetoothCommunication","Jump next to step nr " + nr); stepNr = nr; } @@ -250,12 +276,12 @@ public abstract class BluetoothCommunication { */ protected synchronized boolean jumpNextToStepNr( int curStepNr, int newStepNr ) { if( curStepNr == stepNr-1 ) { - Timber.d("curStepNr " + curStepNr + " matches stepNr " + stepNr + "-1, jumping next to step nr " + newStepNr); + LogManager.d("BluetoothCommunication","curStepNr " + curStepNr + " matches stepNr " + stepNr + "-1, jumping next to step nr " + newStepNr); stepNr = newStepNr; return true; } else { - Timber.d("curStepNr " + curStepNr + " does not match stepNr " + stepNr + "-1, keeping next at step nr " + stepNr); + LogManager.d("BluetoothCommunication","curStepNr " + curStepNr + " does not match stepNr " + stepNr + "-1, keeping next at step nr " + stepNr); return false; } } @@ -267,7 +293,7 @@ public abstract class BluetoothCommunication { */ protected synchronized void jumpBackOneStep() { stepNr--; - Timber.d("Jumped back one step to " + stepNr); + LogManager.d("BluetoothCommunication","Jumped back one step to " + stepNr); } /** @@ -299,7 +325,7 @@ public abstract class BluetoothCommunication { * @param noResponse true if no response is required */ protected void writeBytes(UUID service, UUID characteristic, byte[] bytes, boolean noResponse) { - Timber.d("Invoke write bytes [" + byteInHex(bytes) + "] on " + BluetoothGattUuid.prettyPrint(characteristic)); + LogManager.d("BluetoothCommunication","Invoke write bytes [" + byteInHex(bytes) + "] on " + BluetoothGattUuid.prettyPrint(characteristic)); btPeripheral.writeCharacteristic(btPeripheral.getCharacteristic(service, characteristic), bytes, noResponse ? WriteType.WITHOUT_RESPONSE : WriteType.WITH_RESPONSE); } @@ -311,7 +337,7 @@ public abstract class BluetoothCommunication { *@param characteristic the Bluetooth UUID characteristic */ void readBytes(UUID service, UUID characteristic) { - Timber.d("Invoke read bytes on " + BluetoothGattUuid.prettyPrint(characteristic)); + LogManager.d("BluetoothCommunication","Invoke read bytes on " + BluetoothGattUuid.prettyPrint(characteristic)); btPeripheral.readCharacteristic(btPeripheral.getCharacteristic(service, characteristic)); } @@ -322,7 +348,7 @@ public abstract class BluetoothCommunication { * @param characteristic the Bluetooth UUID characteristic */ protected void setIndicationOn(UUID service, UUID characteristic) { - Timber.d("Invoke set indication on " + BluetoothGattUuid.prettyPrint(characteristic)); + LogManager.d("BluetoothCommunication","Invoke set indication on " + BluetoothGattUuid.prettyPrint(characteristic)); if(btPeripheral.getService(service) != null) { stopMachineState(); BluetoothGattCharacteristic currentTimeCharacteristic = btPeripheral.getCharacteristic(service, characteristic); @@ -337,7 +363,7 @@ public abstract class BluetoothCommunication { * @return true if the operation was enqueued, false if the characteristic doesn't support notification or indications or */ protected boolean setNotificationOn(UUID service, UUID characteristic) { - Timber.d("Invoke set notification on " + BluetoothGattUuid.prettyPrint(characteristic)); + LogManager.d("BluetoothCommunication","Invoke set notification on " + BluetoothGattUuid.prettyPrint(characteristic)); if(btPeripheral.getService(service) != null) { BluetoothGattCharacteristic currentTimeCharacteristic = btPeripheral.getCharacteristic(service, characteristic); if (currentTimeCharacteristic != null) { @@ -361,12 +387,12 @@ public abstract class BluetoothCommunication { * Disconnect from a Bluetooth device */ public void disconnect() { - Timber.d("Bluetooth disconnect"); + LogManager.d("BluetoothCommunication","Bluetooth disconnect"); setBluetoothStatus(BT_STATUS.CONNECTION_DISCONNECT); try { central.stopScan(); } catch (Exception ex) { - Timber.e("Error on Bluetooth disconnecting " + ex.getMessage()); + LogManager.e("BluetoothCommunication", "Error on Bluetooth disconnecting " + ex.getMessage(), ex); } if (btPeripheral != null) { @@ -377,11 +403,11 @@ public abstract class BluetoothCommunication { } public void selectScaleUserIndexForAppUserId(int appUserId, int scaleUserIndex, Handler uiHandler) { - Timber.d("Set scale user index for app user id: Not implemented!"); + LogManager.d("BluetoothCommunication","Set scale user index for app user id: Not implemented!"); } public void setScaleUserConsent(int appUserId, int scaleUserConsent, Handler uiHandler) { - Timber.d("Set scale user consent for app user id: Not implemented!"); + LogManager.d("BluetoothCommunication","Set scale user consent for app user id: Not implemented!"); } // +++ @@ -403,7 +429,7 @@ public abstract class BluetoothCommunication { */ protected String byteInHex(byte[] data) { if (data == null) { - Timber.e("Data is null"); + LogManager.e("BluetoothCommunication", "Data is null", null); return ""; } @@ -459,7 +485,7 @@ public abstract class BluetoothCommunication { private final BluetoothPeripheralCallback peripheralCallback = new BluetoothPeripheralCallback() { @Override public void onServicesDiscovered(BluetoothPeripheral peripheral) { - Timber.d("Successful Bluetooth services discovered"); + LogManager.d("BluetoothCommunication","Successful Bluetooth services discovered"); onBluetoothDiscovery(peripheral); resumeMachineState(); } @@ -468,22 +494,22 @@ public abstract class BluetoothCommunication { public void onNotificationStateUpdate(BluetoothPeripheral peripheral, BluetoothGattCharacteristic characteristic, GattStatus status) { if( status.value == GATT_SUCCESS) { if(peripheral.isNotifying(characteristic)) { - Timber.d(String.format("SUCCESS: Notify set for %s", characteristic.getUuid())); + LogManager.d("BluetoothCommunication",String.format("SUCCESS: Notify set for %s", characteristic.getUuid())); resumeMachineState(); } } else { - Timber.e(String.format("ERROR: Changing notification state failed for %s", characteristic.getUuid())); + LogManager.e("BluetoothCommunication",String.format("ERROR: Changing notification state failed for %s", characteristic.getUuid()), null); } } @Override public void onCharacteristicWrite(BluetoothPeripheral peripheral, byte[] value, BluetoothGattCharacteristic characteristic, GattStatus status) { if( status.value == GATT_SUCCESS) { - Timber.d(String.format("SUCCESS: Writing <%s> to <%s>", byteInHex(value), characteristic.getUuid().toString())); + LogManager.d("BluetoothCommunication",String.format("SUCCESS: Writing <%s> to <%s>", byteInHex(value), characteristic.getUuid().toString())); nextMachineStep(); } else { - Timber.e(String.format("ERROR: Failed writing <%s> to <%s>", byteInHex(value), characteristic.getUuid().toString())); + LogManager.e("BluetoothCommunication",String.format("ERROR: Failed writing <%s> to <%s>", byteInHex(value), characteristic.getUuid().toString()), null); } } @@ -499,7 +525,7 @@ public abstract class BluetoothCommunication { @Override public void onConnectedPeripheral(BluetoothPeripheral peripheral) { - Timber.d(String.format("connected to '%s'", peripheral.getName())); + LogManager.d("BluetoothCommunication",String.format("connected to '%s'", peripheral.getName())); setBluetoothStatus(BT_STATUS.CONNECTION_ESTABLISHED); btPeripheral = peripheral; nextMachineStep(); @@ -508,7 +534,7 @@ public abstract class BluetoothCommunication { @Override public void onConnectionFailed(BluetoothPeripheral peripheral, HciStatus status) { - Timber.e(String.format("connection '%s' failed with status %d", peripheral.getName(), status.value)); + LogManager.e("BluetoothCommunication", String.format("connection '%s' failed with status %d", peripheral.getName(), status.value),null); setBluetoothStatus(BT_STATUS.CONNECTION_LOST); if (status.value == 8) { @@ -518,12 +544,12 @@ public abstract class BluetoothCommunication { @Override public void onDisconnectedPeripheral(final BluetoothPeripheral peripheral, HciStatus status) { - Timber.d(String.format("disconnected '%s' with status %d", peripheral.getName(), status.value)); + LogManager.d("BluetoothCommunication",String.format("disconnected '%s' with status %d", peripheral.getName(), status.value)); } @Override public void onDiscoveredPeripheral(BluetoothPeripheral peripheral, ScanResult scanResult) { - Timber.d(String.format("Found peripheral '%s'", peripheral.getName())); + LogManager.d("BluetoothCommunication",String.format("Found peripheral '%s'", peripheral.getName())); central.stopScan(); connectToDevice(peripheral); } @@ -549,12 +575,12 @@ public abstract class BluetoothCommunication { (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || (locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER))) ) { - Timber.d("Do LE scan before connecting to device"); + LogManager.d("BluetoothCommunication","Do LE scan before connecting to device"); central.scanForPeripheralsWithAddresses(new String[]{macAddress}); stopMachineState(); } else { - Timber.d("No location permission, connecting without LE scan"); + LogManager.d("BluetoothCommunication","No location permission, connecting without LE scan"); BluetoothPeripheral peripheral = central.getPeripheral(macAddress); connectToDevice(peripheral); } @@ -566,7 +592,7 @@ public abstract class BluetoothCommunication { handler.postDelayed(new Runnable() { @Override public void run() { - Timber.d("Try to connect to BLE device " + peripheral.getAddress()); + LogManager.d("BluetoothCommunication","Try to connect to BLE device " + peripheral.getAddress()); stepNr = 0; @@ -598,7 +624,7 @@ public abstract class BluetoothCommunication { disconnectHandler.postDelayed(new Runnable() { @Override public void run() { - Timber.d("Timeout Bluetooth disconnect"); + LogManager.d("BluetoothCommunication","Timeout Bluetooth disconnect"); disconnect(); } }, 60000); // 60s timeout @@ -606,12 +632,12 @@ public abstract class BluetoothCommunication { private synchronized void nextMachineStep() { if (!stopped) { - Timber.d("Step Nr " + stepNr); + LogManager.d("BluetoothCommunication","Step Nr " + stepNr); if (onNextStep(stepNr)) { stepNr++; nextMachineStep(); } else { - Timber.d("Invoke delayed disconnect in 60s"); + LogManager.d("BluetoothCommunication","Invoke delayed disconnect in 60s"); disconnectWithDelay(); } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothGattUuid.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothGattUuid.java similarity index 85% rename from android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothGattUuid.java rename to android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothGattUuid.java index 7facd805..31a0bcdd 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothGattUuid.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothGattUuid.java @@ -1,20 +1,21 @@ -/* Copyright (C) 2018 Erik Johansson +/* + * openScale + * Copyright (C) 2025 olie.xdev * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. + * This program is 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. + * 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 + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . */ - -package com.health.openscale.core.bluetooth; +package com.health.openscale.core.bluetooth.scalesJava; import java.lang.reflect.Field; import java.util.Locale; diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothYunmaiSE_Mini.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothYunmaiSE_Mini.java similarity index 71% rename from android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothYunmaiSE_Mini.java rename to android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothYunmaiSE_Mini.java index 5dfef108..e4ba9e57 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothYunmaiSE_Mini.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothYunmaiSE_Mini.java @@ -1,38 +1,40 @@ -/* Copyright (C) 2017 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.bluetooth; +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.scalesJava; import android.content.Context; import android.content.SharedPreferences; import android.preference.PreferenceManager; import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.bluetooth.lib.YunmaiLib; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; +import com.health.openscale.core.bluetooth.data.ScaleMeasurement; +import com.health.openscale.core.bluetooth.data.ScaleUser; +import com.health.openscale.core.bluetooth.libs.YunmaiLib; +import com.health.openscale.core.data.GenderType; +import com.health.openscale.core.data.WeightUnit; +import com.health.openscale.core.utils.LogManager; import com.health.openscale.core.utils.Converters; import java.util.Date; +import java.util.Locale; import java.util.Random; import java.util.UUID; -import timber.log.Timber; - public class BluetoothYunmaiSE_Mini extends BluetoothCommunication { private final UUID WEIGHT_MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0xffe0); private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xffe4); @@ -57,9 +59,9 @@ public class BluetoothYunmaiSE_Mini extends BluetoothCommunication { case 0: byte[] userId = Converters.toInt16Be(getUniqueNumber()); - final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser(); + final ScaleUser selectedUser = getSelectedScaleUser(); byte sex = selectedUser.getGender().isMale() ? (byte)0x01 : (byte)0x02; - byte display_unit = selectedUser.getScaleUnit() == Converters.WeightUnit.KG ? (byte) 0x01 : (byte) 0x02; + byte display_unit = selectedUser.getScaleUnit() == WeightUnit.KG ? (byte) 0x01 : (byte) 0x02; byte body_type = (byte) YunmaiLib.toYunmaiActivityLevel(selectedUser.getActivityLevel()); byte[] user_add_or_query = new byte[]{ @@ -116,7 +118,7 @@ public class BluetoothYunmaiSE_Mini extends BluetoothCommunication { } private void parseBytes(byte[] weightBytes) { - final ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser(); + final ScaleUser scaleUser = getSelectedScaleUser(); ScaleMeasurement scaleBtData = new ScaleMeasurement(); @@ -129,7 +131,7 @@ public class BluetoothYunmaiSE_Mini extends BluetoothCommunication { if (isMini) { int sex; - if (scaleUser.getGender() == Converters.Gender.MALE) { + if (scaleUser.getGender() == GenderType.MALE) { sex = 1; } else { sex = 0; @@ -139,10 +141,10 @@ public class BluetoothYunmaiSE_Mini extends BluetoothCommunication { float bodyFat; int resistance = Converters.fromUnsignedInt16Be(weightBytes, 15); if (weightBytes[1] >= (byte)0x1E) { - Timber.d("Extract the fat value from received bytes"); + LogManager.d("BluetoothYunmaiSE_Mini","Extract the fat value from received bytes"); bodyFat = Converters.fromUnsignedInt16Be(weightBytes, 17) / 100.0f; } else { - Timber.d("Calculate the fat value using the Yunmai lib"); + LogManager.d("BluetoothYunmaiSE_Mini","Calculate the fat value using the Yunmai lib"); bodyFat = yunmaiLib.getFat(scaleUser.getAge(), weight, resistance); } @@ -154,13 +156,14 @@ public class BluetoothYunmaiSE_Mini extends BluetoothCommunication { scaleBtData.setLbm(yunmaiLib.getLeanBodyMass(weight, bodyFat)); scaleBtData.setVisceralFat(yunmaiLib.getVisceralFat(bodyFat, scaleUser.getAge())); } else { - Timber.e("body fat is zero"); + LogManager.e("BluetoothYunmaiSE_Mini","body fat is zero", null); } - Timber.d("received bytes [%s]", byteInHex(weightBytes)); - Timber.d("received decrypted bytes [weight: %.2f, fat: %.2f, resistance: %d]", weight, bodyFat, resistance); - Timber.d("user [%s]", scaleUser); - Timber.d("scale measurement [%s]", scaleBtData); + LogManager.d("BluetoothYunmaiSE_Mini", "received bytes [" + byteInHex(weightBytes) + "]"); + String decryptedBytesLog = String.format(Locale.US, "received decrypted bytes [weight: %.2f, fat: %.2f, resistance: %d]", weight, bodyFat, resistance); + LogManager.d("BluetoothYunmaiSE_Mini", decryptedBytesLog); + LogManager.d("BluetoothYunmaiSE_Mini", "user [" + scaleUser + "]"); + LogManager.d("BluetoothYunmaiSE_Mini", "scale measurement [" + scaleBtData + "]"); } addScaleMeasurement(scaleBtData); @@ -180,7 +183,7 @@ public class BluetoothYunmaiSE_Mini extends BluetoothCommunication { prefs.edit().putInt("uniqueNumber", uniqueNumber).apply(); } - int userId = OpenScale.getInstance().getSelectedScaleUserId(); + int userId = getSelectedScaleUserId(); return uniqueNumber + userId; } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/LegacyScaleAdapter.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/LegacyScaleAdapter.kt new file mode 100644 index 00000000..e2253f4a --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/LegacyScaleAdapter.kt @@ -0,0 +1,379 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.scalesJava + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.os.Message +import com.health.openscale.R +import com.health.openscale.core.bluetooth.BluetoothEvent +import com.health.openscale.core.bluetooth.ScaleCommunicator +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.database.DatabaseRepository +import com.health.openscale.core.utils.LogManager +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.lang.ref.WeakReference + +/** + * Adapter that adapts a legacy `BluetoothCommunication` (Java driver) instance + * to the `ScaleCommunicator` interface (without getScaleInfo). + * The identity of the scale is determined by the passed `bluetoothDriverInstance`. + * + * @property applicationContext The application context, used for accessing string resources. + * @property bluetoothDriverInstance The specific legacy Java Bluetooth driver instance. + * @property databaseRepository Repository for database operations, currently unused in this adapter but kept for potential future use. + */ +class LegacyScaleAdapter( + private val applicationContext: Context, + private val bluetoothDriverInstance: BluetoothCommunication, // The specific driver instance + private val databaseRepository: DatabaseRepository // Maintained for potential future use, though not directly used in current logic +) : ScaleCommunicator { + + companion object { + private const val TAG = "LegacyScaleAdapter" + } + + private val adapterScope = + CoroutineScope(Dispatchers.IO + SupervisorJob() + CoroutineName("LegacyScaleAdapterScope")) + + private val _eventsFlow = + MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + /** + * A [SharedFlow] that emits [BluetoothEvent]s from the scale driver. + */ + val events: SharedFlow = _eventsFlow.asSharedFlow() + + private val _isConnected = MutableStateFlow(false) + override val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _isConnecting = MutableStateFlow(false) + override val isConnecting: StateFlow = _isConnecting.asStateFlow() + + private var currentTargetAddress: String? = null + private var currentInternalUser: ScaleUser? = null + + private val driverEventHandler = DriverEventHandler(this) + + init { + LogManager.i(TAG, "CONSTRUCTOR with driver instance: ${bluetoothDriverInstance.javaClass.name} (${bluetoothDriverInstance.driverName()})") + bluetoothDriverInstance.registerCallbackHandler(driverEventHandler) + } + + /** + * Handles messages received from the legacy [BluetoothCommunication] driver. + * It translates these messages into [BluetoothEvent]s and updates the adapter's state. + */ + private inner class DriverEventHandler(adapter: LegacyScaleAdapter) : Handler(Looper.getMainLooper()) { + private val adapterRef: WeakReference = WeakReference(adapter) + + override fun handleMessage(msg: Message) { + val adapter = adapterRef.get() ?: return // Adapter instance might have been garbage collected + + val status = BluetoothCommunication.BT_STATUS.values().getOrNull(msg.what) + val eventData = msg.obj + val arg1 = msg.arg1 + val arg2 = msg.arg2 + + LogManager.d(TAG, "DriverEventHandler: Message received - what: ${msg.what} ($status), obj: $eventData, arg1: $arg1, arg2: $arg2") + + val deviceIdentifier = adapter.currentTargetAddress ?: adapter.bluetoothDriverInstance.driverName() + + when (status) { + BluetoothCommunication.BT_STATUS.RETRIEVE_SCALE_DATA -> { + if (eventData is ScaleMeasurement) { + LogManager.i(TAG, "RETRIEVE_SCALE_DATA: Weight: ${eventData.weight}") + adapter._eventsFlow.tryEmit(BluetoothEvent.MeasurementReceived(eventData, deviceIdentifier)) + } else { + LogManager.w(TAG, "RETRIEVE_SCALE_DATA: Unexpected data type: $eventData") + // Optionally, emit an error or generic message event + adapter._eventsFlow.tryEmit(BluetoothEvent.DeviceMessage( + applicationContext.getString(R.string.legacy_adapter_event_unexpected_data, eventData?.javaClass?.simpleName ?: "null"), + deviceIdentifier + )) + } + } + BluetoothCommunication.BT_STATUS.INIT_PROCESS -> { + adapter._isConnecting.value = true + val infoText = eventData as? String ?: applicationContext.getString(R.string.legacy_adapter_event_initializing) + LogManager.d(TAG, "INIT_PROCESS: $infoText") + adapter._eventsFlow.tryEmit(BluetoothEvent.DeviceMessage(infoText, deviceIdentifier)) + } + BluetoothCommunication.BT_STATUS.CONNECTION_ESTABLISHED -> { + LogManager.i(TAG, "CONNECTION_ESTABLISHED to $deviceIdentifier (Target: ${adapter.currentTargetAddress})") + adapter._isConnected.value = true + adapter._isConnecting.value = false + // adapter.currentTargetAddress should not be null here if connection is established + adapter._eventsFlow.tryEmit(BluetoothEvent.Connected(deviceIdentifier, adapter.currentTargetAddress!!)) + } + BluetoothCommunication.BT_STATUS.CONNECTION_DISCONNECT, BluetoothCommunication.BT_STATUS.CONNECTION_LOST -> { + val reasonKey = if (status == BluetoothCommunication.BT_STATUS.CONNECTION_LOST) R.string.legacy_adapter_event_connection_lost else R.string.legacy_adapter_event_connection_disconnected + val reasonString = applicationContext.getString(reasonKey) + val additionalInfo = eventData as? String ?: "" + val fullMessage = if (additionalInfo.isNotEmpty()) "$reasonString - $additionalInfo" else reasonString + + LogManager.i(TAG, "$status for $deviceIdentifier: $fullMessage") + adapter._isConnected.value = false + adapter._isConnecting.value = false + adapter._eventsFlow.tryEmit(BluetoothEvent.Disconnected(deviceIdentifier, fullMessage)) + adapter.cleanupAfterDisconnect() + } + BluetoothCommunication.BT_STATUS.NO_DEVICE_FOUND -> { + val additionalInfo = eventData as? String ?: "" + val message = applicationContext.getString(R.string.legacy_adapter_event_device_not_found, additionalInfo).trim() + LogManager.w(TAG, "NO_DEVICE_FOUND for $deviceIdentifier. Info: $additionalInfo") + adapter._isConnected.value = false + adapter._isConnecting.value = false + adapter._eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed(deviceIdentifier, message)) + adapter.cleanupAfterDisconnect() + } + BluetoothCommunication.BT_STATUS.UNEXPECTED_ERROR -> { + val additionalInfo = eventData as? String ?: "" + val message = applicationContext.getString(R.string.legacy_adapter_event_unexpected_error, additionalInfo).trim() + LogManager.e(TAG, "UNEXPECTED_ERROR for $deviceIdentifier. Info: $additionalInfo") + adapter._isConnected.value = false + adapter._isConnecting.value = false + adapter._eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed(deviceIdentifier, message)) + adapter.cleanupAfterDisconnect() + } + BluetoothCommunication.BT_STATUS.SCALE_MESSAGE -> { + try { + val messageResId = arg1 + val messageArg = eventData + val messageText = if (messageArg != null) { + adapter.applicationContext.getString(messageResId, messageArg.toString()) + } else { + adapter.applicationContext.getString(messageResId) + } + LogManager.d(TAG, "SCALE_MESSAGE: $messageText (ID: $messageResId)") + adapter._eventsFlow.tryEmit(BluetoothEvent.DeviceMessage(messageText, deviceIdentifier)) + } catch (e: Exception) { + LogManager.e(TAG, "Error retrieving SCALE_MESSAGE string resource (ID $arg1)", e) + val fallbackMessage = applicationContext.getString(R.string.legacy_adapter_event_scale_message_fallback, arg1, eventData?.toString() ?: "N/A") + adapter._eventsFlow.tryEmit(BluetoothEvent.DeviceMessage(fallbackMessage, deviceIdentifier)) + } + } + BluetoothCommunication.BT_STATUS.CHOOSE_SCALE_USER -> { + LogManager.d(TAG, "CHOOSE_SCALE_USER for $deviceIdentifier: Data: $eventData") + var userListDescription = applicationContext.getString(R.string.legacy_adapter_event_user_selection_required) + if (eventData is List<*>) { + val stringList = eventData.mapNotNull { item -> + if (item is ScaleUser) { + applicationContext.getString(R.string.legacy_adapter_event_user_details, item.id, item.age, item.bodyHeight) + } else { + item.toString() + } + } + if (stringList.isNotEmpty()) { + userListDescription = stringList.joinToString(separator = "\n") + } + } else if (eventData != null) { + userListDescription = eventData.toString() + } + adapter._eventsFlow.tryEmit(BluetoothEvent.UserSelectionRequired(userListDescription, deviceIdentifier, eventData)) + } + BluetoothCommunication.BT_STATUS.ENTER_SCALE_USER_CONSENT -> { + val appScaleUserId = arg1 + val scaleUserIndex = arg2 + LogManager.d(TAG, "ENTER_SCALE_USER_CONSENT for $deviceIdentifier: AppUserID: $appScaleUserId, ScaleUserIndex: $scaleUserIndex. Data: $eventData") + val message = applicationContext.getString(R.string.legacy_adapter_event_user_consent_required, appScaleUserId, scaleUserIndex) + adapter._eventsFlow.tryEmit(BluetoothEvent.DeviceMessage(message, deviceIdentifier)) + } + else -> { + LogManager.w(TAG, "Unknown BT_STATUS ($status) or message (what=${msg.what}) from driver ${adapter.bluetoothDriverInstance.driverName()} received.") + adapter._eventsFlow.tryEmit(BluetoothEvent.DeviceMessage( + applicationContext.getString(R.string.legacy_adapter_event_unknown_status, status?.name ?: msg.what.toString()), + deviceIdentifier + )) + } + } + } + } + + override fun connect(deviceAddress: String, uiScaleUser: ScaleUser?, appUserId: Int?) { + adapterScope.launch { + val currentDeviceName = currentTargetAddress ?: bluetoothDriverInstance.driverName() + if (_isConnected.value || _isConnecting.value) { + LogManager.w(TAG, "connect: Already connected/connecting to $currentDeviceName. Ignoring request for $deviceAddress.") + if (currentTargetAddress != deviceAddress && currentTargetAddress != null) { + val message = applicationContext.getString(R.string.legacy_adapter_connect_busy, currentTargetAddress) + _eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed(deviceAddress, message)) + } else if (currentTargetAddress == null) { + // This case implies isConnecting is true but currentTargetAddress is null, + // which might indicate a race condition or an incomplete previous cleanup. + // Allow proceeding with the new connection attempt. + LogManager.d(TAG, "connect: Retrying connection for $deviceAddress to ${bluetoothDriverInstance.driverName()} while isConnecting=true but currentTargetAddress=null") + } else { + // Already connecting to or connected to the same deviceAddress + return@launch + } + } + + LogManager.i(TAG, "connect: REQUEST for address $deviceAddress to driver ${bluetoothDriverInstance.driverName()}, UI ScaleUser ID: ${uiScaleUser?.id}, AppUserID: $appUserId") + _isConnecting.value = true + _isConnected.value = false + currentTargetAddress = deviceAddress // Store the address being connected to + currentInternalUser = uiScaleUser + + LogManager.d(TAG, "connect: Internal user for connection: ${currentInternalUser?.id}, AppUserID: $appUserId") + + currentInternalUser?.let { bluetoothDriverInstance.setSelectedScaleUser(it) } + appUserId?.let { bluetoothDriverInstance.setSelectedScaleUserId(it) } + + LogManager.d(TAG, "connect: Calling connect() on Java driver instance (${bluetoothDriverInstance.driverName()}) for $deviceAddress.") + try { + bluetoothDriverInstance.connect(deviceAddress) + } catch (e: Exception) { + LogManager.e(TAG, "connect: Exception while calling bluetoothDriverInstance.connect() for $deviceAddress to ${bluetoothDriverInstance.driverName()}", e) + val message = applicationContext.getString(R.string.legacy_adapter_connect_exception, bluetoothDriverInstance.driverName(), e.message) + _eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed(deviceAddress, message)) + cleanupAfterDisconnect() // Ensure state is reset + } + } + } + + override fun disconnect() { + adapterScope.launch { + val deviceNameToLog = currentTargetAddress ?: bluetoothDriverInstance.driverName() + LogManager.i(TAG, "disconnect: REQUEST for $deviceNameToLog") + if (!_isConnected.value && !_isConnecting.value) { + LogManager.d(TAG, "disconnect: Neither connected nor connecting to $deviceNameToLog. No action.") + return@launch + } + bluetoothDriverInstance.disconnect() + // Status flags will be updated by handler events (CONNECTION_DISCONNECT), + // but we can set them here to inform the UI more quickly. + // However, this might lead to premature UI updates if the driver's disconnect is asynchronous + // and fails. Relying on the handler event is safer for final state. + // _isConnected.value = false // Consider removing if handler is reliable + // _isConnecting.value = false // Consider removing if handler is reliable + // cleanupAfterDisconnect() is called by the handler. + } + } + + /** + * Cleans up internal state after a disconnection or connection failure. + * Resets connection flags and clears stored target address and user. + */ + private fun cleanupAfterDisconnect() { + val deviceName = currentTargetAddress ?: bluetoothDriverInstance.driverName() + LogManager.d(TAG, "cleanupAfterDisconnect: Cleaning up for $deviceName (address was $currentTargetAddress)") + _isConnected.value = false + _isConnecting.value = false + currentTargetAddress = null + currentInternalUser = null + LogManager.i(TAG, "cleanupAfterDisconnect: Cleanup completed for ${bluetoothDriverInstance.driverName()}.") + } + + override fun requestMeasurement() { + val deviceNameToLog = currentTargetAddress ?: bluetoothDriverInstance.driverName() + LogManager.d(TAG, "requestMeasurement: CALLED for $deviceNameToLog") + adapterScope.launch { + if (!_isConnected.value || currentTargetAddress == null) { // Explicitly check currentTargetAddress for an active connection + LogManager.w(TAG, "requestMeasurement: Not connected or no active address for measurement request to $deviceNameToLog.") + _eventsFlow.tryEmit(BluetoothEvent.DeviceMessage(applicationContext.getString(R.string.legacy_adapter_request_measurement_not_connected), deviceNameToLog)) + return@launch + } + LogManager.i(TAG, "requestMeasurement: For legacy driver (${bluetoothDriverInstance.driverName()}), measurement is usually triggered automatically. No generic action here.") + _eventsFlow.tryEmit(BluetoothEvent.DeviceMessage(applicationContext.getString(R.string.legacy_adapter_request_measurement_auto), deviceNameToLog)) + } + } + + /** + * Releases resources used by this adapter, including unregistering the callback + * from the Bluetooth driver and canceling the coroutine scope. + * This method should be called when the adapter is no longer needed to prevent memory leaks. + */ + fun release() { + val deviceName = bluetoothDriverInstance.driverName() + LogManager.i(TAG, "release: Adapter for driver $deviceName is being released. Current target address: $currentTargetAddress") + bluetoothDriverInstance.registerCallbackHandler(null) // Important to prevent leaks + // Ensures any ongoing connection is terminated. + if (_isConnected.value || _isConnecting.value) { + // Using a separate launch for disconnect to avoid issues if the scope is cancelling. + // However, the scope will be cancelled immediately after. + // The driver's disconnect should ideally be robust. + CoroutineScope(Dispatchers.IO).launch { // Use a temporary scope for this last operation if needed + bluetoothDriverInstance.disconnect() + } + } + adapterScope.cancel("LegacyScaleAdapter for $deviceName released") + LogManager.i(TAG, "release: AdapterScope for $deviceName cancelled.") + } + + /** + * Informs the legacy driver about the user's selection for a scale user. + * This is typically called in response to a [BluetoothEvent.UserSelectionRequired] event. + * + * @param appUserId The application-specific user ID. + * @param scaleUserIndex The index of the user on the scale. + */ + fun selectLegacyScaleUserIndex(appUserId: Int, scaleUserIndex: Int) { + adapterScope.launch { + LogManager.i(TAG, "selectLegacyScaleUserIndex for ${bluetoothDriverInstance.driverName()}: AppUserID: $appUserId, ScaleUserIndex: $scaleUserIndex") + bluetoothDriverInstance.selectScaleUserIndexForAppUserId(appUserId, scaleUserIndex, driverEventHandler) + } + } + + /** + * Sends the user's consent value to the legacy scale driver. + * This is typically called after the scale requests user consent. + * + * @param appUserId The application-specific user ID. + * @param consentValue The consent value (specific to the driver's protocol). + */ + fun setLegacyScaleUserConsent(appUserId: Int, consentValue: Int) { + adapterScope.launch { + LogManager.i(TAG, "setLegacyScaleUserConsent for ${bluetoothDriverInstance.driverName()}: AppUserID: $appUserId, ConsentValue: $consentValue") + bluetoothDriverInstance.setScaleUserConsent(appUserId, consentValue, driverEventHandler) + } + } + + /** + * Retrieves the name of the managed Bluetooth driver/device. + * Can be used externally if the name is needed and only a reference to the adapter is available. + * + * @return The name of the driver or a fallback class name if an error occurs. + */ + fun getManagedDeviceName(): String { + return try { + bluetoothDriverInstance.driverName() + } catch (e: Exception) { + LogManager.w(TAG, "Error getting driverName() in getManagedDeviceName. Falling back to simple class name.", e) + bluetoothDriverInstance.javaClass.simpleName + } + } + + override fun getEventsFlow(): SharedFlow { + return events + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/BFDeurenberg.java b/android_app/app/src/main/java/com/health/openscale/core/bodymetric/BFDeurenberg.java deleted file mode 100644 index b333f7ed..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/BFDeurenberg.java +++ /dev/null @@ -1,36 +0,0 @@ -/* Copyright (C) 2017 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.core.bodymetric; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; - -public class BFDeurenberg extends EstimatedFatMetric { - @Override - public String getName() { - return "Deurenberg (1992)"; - } - - @Override - public float getFat(ScaleUser user, ScaleMeasurement data) { - final int gender = user.getGender().isMale() ? 1 : 0; - if (user.getAge(data.getDateTime()) >= 16) { - return (1.2f * data.getBMI(user.getBodyHeight())) + (0.23f*user.getAge(data.getDateTime())) - (10.8f * gender) - 5.4f; - } - - return (1.294f * data.getBMI(user.getBodyHeight())) + (0.20f*user.getAge(data.getDateTime())) - (11.4f * gender) - 8.0f; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/BFDeurenbergII.java b/android_app/app/src/main/java/com/health/openscale/core/bodymetric/BFDeurenbergII.java deleted file mode 100644 index c376e00b..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/BFDeurenbergII.java +++ /dev/null @@ -1,35 +0,0 @@ -/* Copyright (C) 2017 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.core.bodymetric; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; - -public class BFDeurenbergII extends EstimatedFatMetric { - @Override - public String getName() { - return "Deurenberg et. al (1991)"; - } - - @Override - public float getFat(ScaleUser user, ScaleMeasurement data) { - if (user.getGender().isMale()) { - return (data.getBMI(user.getBodyHeight()) * 1.2f) + (user.getAge(data.getDateTime()) * 0.23f) - 16.2f; - } - - return (data.getBMI(user.getBodyHeight()) * 1.2f) + (user.getAge(data.getDateTime()) * 0.23f) - 5.4f; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/BFEddy.java b/android_app/app/src/main/java/com/health/openscale/core/bodymetric/BFEddy.java deleted file mode 100644 index ce06b1bf..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/BFEddy.java +++ /dev/null @@ -1,35 +0,0 @@ -/* Copyright (C) 2017 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.core.bodymetric; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; - -public class BFEddy extends EstimatedFatMetric { - @Override - public String getName() { - return "Eddy et. al (1976)"; - } - - @Override - public float getFat(ScaleUser user, ScaleMeasurement data) { - if (user.getGender().isMale()) { - return (1.281f* data.getBMI(user.getBodyHeight())) - 10.13f; - } - - return (1.48f* data.getBMI(user.getBodyHeight())) - 7.0f; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/BFGallagher.java b/android_app/app/src/main/java/com/health/openscale/core/bodymetric/BFGallagher.java deleted file mode 100644 index 6b222a78..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/BFGallagher.java +++ /dev/null @@ -1,37 +0,0 @@ -/* Copyright (C) 2017 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.core.bodymetric; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; - -public class BFGallagher extends EstimatedFatMetric { - @Override - public String getName() { - return "Gallagher et. al [non-asian] (2000)"; - } - - @Override - public float getFat(ScaleUser user, ScaleMeasurement data) { - if (user.getGender().isMale()) { - // non-asian male - return 64.5f - 848.0f * (1.0f / data.getBMI(user.getBodyHeight())) + 0.079f * user.getAge(data.getDateTime()) - 16.4f + 0.05f * user.getAge(data.getDateTime()) + 39.0f * (1.0f / data.getBMI(user.getBodyHeight())); - } - - // non-asian female - return 64.5f - 848.0f * (1.0f / data.getBMI(user.getBodyHeight())) + 0.079f * user.getAge(data.getDateTime()); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/BFGallagherAsian.java b/android_app/app/src/main/java/com/health/openscale/core/bodymetric/BFGallagherAsian.java deleted file mode 100644 index b06cab46..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/BFGallagherAsian.java +++ /dev/null @@ -1,37 +0,0 @@ -/* Copyright (C) 2017 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.core.bodymetric; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; - -public class BFGallagherAsian extends EstimatedFatMetric { - @Override - public String getName() { - return "Gallagher et. al [asian] (2000)"; - } - - @Override - public float getFat(ScaleUser user, ScaleMeasurement data) { - if (user.getGender().isMale()) { - // asian male - return 51.9f - 740.0f * (1.0f / data.getBMI(user.getBodyHeight())) + 0.029f * user.getAge(data.getDateTime()); - } - - // asian female - return 64.8f - 752.0f * (1.0f / data.getBMI(user.getBodyHeight())) + 0.016f * user.getAge(data.getDateTime()); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/EstimatedFatMetric.java b/android_app/app/src/main/java/com/health/openscale/core/bodymetric/EstimatedFatMetric.java deleted file mode 100644 index 6e3c68d6..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/EstimatedFatMetric.java +++ /dev/null @@ -1,44 +0,0 @@ -/* Copyright (C) 2017 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.core.bodymetric; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; - -public abstract class EstimatedFatMetric { - // Don't change enum names, they are stored persistent in preferences - public enum FORMULA { BF_DEURENBERG, BF_DEURENBERG_II, BF_EDDY, BF_GALLAGHER, BF_GALLAGHER_ASIAN } - - public static EstimatedFatMetric getEstimatedMetric(FORMULA metric) { - switch (metric) { - case BF_DEURENBERG: - return new BFDeurenberg(); - case BF_DEURENBERG_II: - return new BFDeurenbergII(); - case BF_EDDY: - return new BFEddy(); - case BF_GALLAGHER: - return new BFGallagher(); - case BF_GALLAGHER_ASIAN: - return new BFGallagherAsian(); - } - - return null; - } - - public abstract String getName(); - public abstract float getFat(ScaleUser user, ScaleMeasurement data); -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/EstimatedLBMMetric.java b/android_app/app/src/main/java/com/health/openscale/core/bodymetric/EstimatedLBMMetric.java deleted file mode 100644 index 334e6175..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/EstimatedLBMMetric.java +++ /dev/null @@ -1,42 +0,0 @@ -/* Copyright (C) 2017 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.core.bodymetric; - -import android.content.Context; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; - -public abstract class EstimatedLBMMetric { - // Don't change enum names, they are stored persistent in preferences - public enum FORMULA { LBW_HUME, LBW_BOER, LBW_WEIGHT_MINUS_FAT } - - public static EstimatedLBMMetric getEstimatedMetric(FORMULA metric) { - switch (metric) { - case LBW_HUME: - return new LBMHume(); - case LBW_BOER: - return new LBMBoer(); - case LBW_WEIGHT_MINUS_FAT: - return new LBMWeightMinusFat(); - } - - return null; - } - - public abstract String getName(Context context); - public abstract float getLBM(ScaleUser user, ScaleMeasurement data); -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/EstimatedWaterMetric.java b/android_app/app/src/main/java/com/health/openscale/core/bodymetric/EstimatedWaterMetric.java deleted file mode 100644 index 2dc5f652..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/EstimatedWaterMetric.java +++ /dev/null @@ -1,42 +0,0 @@ -/* Copyright (C) 2017 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.core.bodymetric; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; - -public abstract class EstimatedWaterMetric { - // Don't change enum names, they are stored persistent in preferences - public enum FORMULA { TBW_BEHNKE, TBW_DELWAIDECRENIER, TBW_HUMEWEYERS, TBW_LEESONGKIM } - - public static EstimatedWaterMetric getEstimatedMetric(FORMULA metric) { - switch (metric) { - case TBW_BEHNKE: - return new TBWBehnke(); - case TBW_DELWAIDECRENIER: - return new TBWDelwaideCrenier(); - case TBW_HUMEWEYERS: - return new TBWHumeWeyers(); - case TBW_LEESONGKIM: - return new TBWLeeSongKim(); - } - - return null; - } - - public abstract String getName(); - public abstract float getWater(ScaleUser user, ScaleMeasurement data); -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/LBMBoer.java b/android_app/app/src/main/java/com/health/openscale/core/bodymetric/LBMBoer.java deleted file mode 100644 index 05be7bda..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/LBMBoer.java +++ /dev/null @@ -1,38 +0,0 @@ -/* Copyright (C) 2017 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.bodymetric; - -import android.content.Context; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; - -public class LBMBoer extends EstimatedLBMMetric { - @Override - public String getName(Context context) { - return "Boer (1984)"; - } - - @Override - public float getLBM(ScaleUser user, ScaleMeasurement data) { - if (user.getGender().isMale()) { - return (0.4071f * data.getWeight()) + (0.267f * user.getBodyHeight()) - 19.2f; - } - - return (0.252f * data.getWeight()) + (0.473f * user.getBodyHeight()) - 48.3f; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/LBMHume.java b/android_app/app/src/main/java/com/health/openscale/core/bodymetric/LBMHume.java deleted file mode 100644 index 0518271c..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/LBMHume.java +++ /dev/null @@ -1,38 +0,0 @@ -/* Copyright (C) 2017 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.bodymetric; - -import android.content.Context; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; - -public class LBMHume extends EstimatedLBMMetric { - @Override - public String getName(Context context) { - return "Hume (1966)"; - } - - @Override - public float getLBM(ScaleUser user, ScaleMeasurement data) { - if (user.getGender().isMale()) { - return (0.32810f * data.getWeight()) + (0.33929f * user.getBodyHeight()) - 29.5336f; - } - - return (0.29569f * data.getWeight()) + (0.41813f * user.getBodyHeight()) - 43.2933f; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/LBMWeightMinusFat.java b/android_app/app/src/main/java/com/health/openscale/core/bodymetric/LBMWeightMinusFat.java deleted file mode 100644 index 90129dba..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/LBMWeightMinusFat.java +++ /dev/null @@ -1,42 +0,0 @@ -/* Copyright (C) 2018 Erik Johansson - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - -package com.health.openscale.core.bodymetric; - -import android.content.Context; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; - -class LBMWeightMinusFat extends EstimatedLBMMetric { - @Override - public String getName(Context context) { - return String.format("%s - %s", - context.getResources().getString(R.string.label_weight), - context.getResources().getString(R.string.label_fat)); - } - - @Override - public float getLBM(ScaleUser user, ScaleMeasurement data) { - if (data.getFat() == 0) { - return 0; - } - - float absFat = data.getWeight() * data.getFat() / 100.0f; - return data.getWeight() - absFat; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/TBWBehnke.java b/android_app/app/src/main/java/com/health/openscale/core/bodymetric/TBWBehnke.java deleted file mode 100644 index 0378d53d..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/TBWBehnke.java +++ /dev/null @@ -1,35 +0,0 @@ -/* Copyright (C) 2017 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.core.bodymetric; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; - -public class TBWBehnke extends EstimatedWaterMetric { - @Override - public String getName() { - return "Behnke (1963)"; - } - - @Override - public float getWater(ScaleUser user, ScaleMeasurement data) { - if (user.getGender().isMale()) { - return 0.72f * (0.204f * user.getBodyHeight() * user.getBodyHeight()) / 100.0f; - } - - return 0.72f * (0.18f * user.getBodyHeight() * user.getBodyHeight()) / 100.0f; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/TBWDelwaideCrenier.java b/android_app/app/src/main/java/com/health/openscale/core/bodymetric/TBWDelwaideCrenier.java deleted file mode 100644 index d0207d7c..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/TBWDelwaideCrenier.java +++ /dev/null @@ -1,31 +0,0 @@ -/* Copyright (C) 2017 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.core.bodymetric; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; - -public class TBWDelwaideCrenier extends EstimatedWaterMetric { - @Override - public String getName() { - return "Delwaide-Crenier et. al (1973)"; - } - - @Override - public float getWater(ScaleUser user, ScaleMeasurement data) { - return 0.72f * (-1.976f + 0.907f * data.getWeight()); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/TBWHumeWeyers.java b/android_app/app/src/main/java/com/health/openscale/core/bodymetric/TBWHumeWeyers.java deleted file mode 100644 index 0f864c0a..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/TBWHumeWeyers.java +++ /dev/null @@ -1,35 +0,0 @@ -/* Copyright (C) 2017 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.core.bodymetric; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; - -public class TBWHumeWeyers extends EstimatedWaterMetric { - @Override - public String getName() { - return "Hume & Weyers (1971)"; - } - - @Override - public float getWater(ScaleUser user, ScaleMeasurement data) { - if (user.getGender().isMale()) { - return (0.194786f * user.getBodyHeight()) + (0.296785f * data.getWeight()) - 14.012934f; - } - - return (0.34454f * user.getBodyHeight()) + (0.183809f * data.getWeight()) - 35.270121f; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/TBWLeeSongKim.java b/android_app/app/src/main/java/com/health/openscale/core/bodymetric/TBWLeeSongKim.java deleted file mode 100644 index 8fc36da8..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/bodymetric/TBWLeeSongKim.java +++ /dev/null @@ -1,35 +0,0 @@ -/* Copyright (C) 2017 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.core.bodymetric; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; - -public class TBWLeeSongKim extends EstimatedWaterMetric { - @Override - public String getName() { - return "Lee, Song, Kim, Lee et. al (2001)"; - } - - @Override - public float getWater(ScaleUser user, ScaleMeasurement data) { - if (user.getGender().isMale()) { - return -28.3497f + (0.243057f * user.getBodyHeight()) + (0.366248f * data.getWeight()); - } - - return -26.6224f + (0.262513f * user.getBodyHeight()) + (0.232948f * data.getWeight()); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt new file mode 100644 index 00000000..bd509bf0 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt @@ -0,0 +1,171 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.data + +import androidx.annotation.StringRes +import com.health.openscale.R +import java.util.Locale + +enum class SupportedLanguage(val code: String, val nativeDisplayName: String) { + ENGLISH("en", "English"), + GERMAN("de", "Deutsch"), + SPANISH("es", "Español"), + FRENCH("fr", "Français"); + + fun toLocale(): Locale { + return Locale.Builder().setLanguage(code).build() + } + + companion object { + fun fromCode(code: String?): SupportedLanguage? { + return entries.find { it.code == code } + } + + fun getDefault(): SupportedLanguage { + val systemLangCode = Locale.getDefault().language + return fromCode(systemLangCode) ?: ENGLISH + } + } +} + +enum class GenderType { + MALE, + FEMALE; + + fun isMale(): Boolean { + return this == MALE} +} + +enum class ActivityLevel { + SEDENTARY, MILD, MODERATE, HEAVY, EXTREME; + + fun toInt(): Int { + when (this) { + SEDENTARY -> return 0 + MILD -> return 1 + MODERATE -> return 2 + HEAVY -> return 3 + EXTREME -> return 4 + } + } + + companion object { + fun fromInt(unit: Int): ActivityLevel { + when (unit) { + 0 -> return SEDENTARY + 1 -> return MILD + 2 -> return MODERATE + 3 -> return HEAVY + 4 -> return EXTREME + } + return SEDENTARY + } + } +} + +enum class WeightUnit { + KG, LB, ST; + + override fun toString(): String { + when (this) { + WeightUnit.LB -> return "lb" + WeightUnit.ST -> return "st" + WeightUnit.KG -> return "kg" + } + + } + + fun toInt(): Int { + when (this) { + WeightUnit.LB -> return 1 + WeightUnit.ST -> return 2 + WeightUnit.KG -> return 0 + } + } + + companion object { + fun fromInt(unit: Int): WeightUnit { + when (unit) { + 1 -> return WeightUnit.LB + 2 -> return WeightUnit.ST + } + return WeightUnit.KG + } + } +} + +enum class MeasurementTypeKey( + val id: Int, + @StringRes val localizedNameResId: Int // Added: Nullable resource ID for the name +) { + WEIGHT(1, R.string.measurement_type_weight), + BMI(2, R.string.measurement_type_bmi), + BODY_FAT(3, R.string.measurement_type_body_fat), + WATER(4, R.string.measurement_type_water), + MUSCLE(5, R.string.measurement_type_muscle), + LBM(6, R.string.measurement_type_lbm), + BONE(7, R.string.measurement_type_bone), + WAIST(8, R.string.measurement_type_waist), + WHR(9, R.string.measurement_type_whr), + WHTR(10, R.string.measurement_type_whtr), + HIPS(11, R.string.measurement_type_hips), + VISCERAL_FAT(12, R.string.measurement_type_visceral_fat), + CHEST(13, R.string.measurement_type_chest), + THIGH(14, R.string.measurement_type_thigh), + BICEPS(15, R.string.measurement_type_biceps), + NECK(16, R.string.measurement_type_neck), + CALIPER_1(17, R.string.measurement_type_caliper1), + CALIPER_2(18, R.string.measurement_type_caliper2), + CALIPER_3(19, R.string.measurement_type_caliper3), + CALIPER(20, R.string.measurement_type_fat_caliper), + BMR(21, R.string.measurement_type_bmr), + TDEE(22, R.string.measurement_type_tdee), + CALORIES(23, R.string.measurement_type_calories), + DATE(24, R.string.measurement_type_date), + TIME(25, R.string.measurement_type_time), + COMMENT(26, R.string.measurement_type_comment), + CUSTOM(99, R.string.measurement_type_custom_default_name); +} + + +enum class UnitType(val displayName: String) { + KG("kg"), + PERCENT("%"), + CM("cm"), + KCAL("kcal"), + NONE("") +} + +enum class InputFieldType { + FLOAT, + INT, + TEXT, + DATE, + TIME +} + +enum class Trend { + UP, DOWN, NONE, NOT_APPLICABLE +} + +enum class TimeRangeFilter(val displayName: String) { + ALL_DAYS("Alle Tage"), + LAST_7_DAYS("Letzte 7 Tage"), + LAST_30_DAYS("Letzte 30 Tage"), + LAST_365_DAYS("Letzte 365 Tage") +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/data/Measurement.kt b/android_app/app/src/main/java/com/health/openscale/core/data/Measurement.kt new file mode 100644 index 00000000..d673fe8b --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/data/Measurement.kt @@ -0,0 +1,37 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.data + +import androidx.room.* + +@Entity( + foreignKeys = [ + ForeignKey( + entity = User::class, + parentColumns = ["id"], + childColumns = ["userId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("userId")] +) +data class Measurement( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + val userId: Int, + val timestamp: Long = System.currentTimeMillis() +) \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/data/MeasurementType.kt b/android_app/app/src/main/java/com/health/openscale/core/data/MeasurementType.kt new file mode 100644 index 00000000..32992e99 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/data/MeasurementType.kt @@ -0,0 +1,61 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.data + +import android.content.Context +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey + +@Entity +data class MeasurementType( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + val key: MeasurementTypeKey = MeasurementTypeKey.CUSTOM, + val name: String? = null, + val color: Int = 0, + val icon : String = "ic_weight", + val unit: UnitType = UnitType.NONE, + val inputType: InputFieldType = InputFieldType.FLOAT, + val displayOrder: Int = 0, + val isDerived: Boolean = false, + val isEnabled : Boolean = true, + val isPinned : Boolean = false +){ + /** + * Gets the appropriate display name for UI purposes. + * If the key points to a predefined type with a localized resource ID, that resource is used + * to ensure the name is displayed in the current device language. + * Otherwise (e.g., for CUSTOM types or if no specific resource ID is set for the key), + * the stored 'name' property is returned. + * + * @param context The context needed to resolve string resources. + * @return The display name for this measurement type. + */ + @Ignore // Room should not try to map this helper function to a DB column + fun getDisplayName(context: Context): String { + return if (key == MeasurementTypeKey.CUSTOM) { + if (!name.isNullOrBlank()) { + name + } else { + context.getString(key.localizedNameResId) + } + } else { + context.getString(key.localizedNameResId) + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/data/MeasurementValue.kt b/android_app/app/src/main/java/com/health/openscale/core/data/MeasurementValue.kt new file mode 100644 index 00000000..85d38e0a --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/data/MeasurementValue.kt @@ -0,0 +1,50 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.data + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + foreignKeys = [ + ForeignKey( + entity = Measurement::class, + parentColumns = ["id"], + childColumns = ["measurementId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = MeasurementType::class, + parentColumns = ["id"], + childColumns = ["typeId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("measurementId"), Index("typeId")] +) +data class MeasurementValue( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + val measurementId: Int, + val typeId: Int, + val floatValue: Float? = null, + val intValue: Int? = null, + val textValue: String? = null, + val dateValue: Long? = null +) \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/data/User.kt b/android_app/app/src/main/java/com/health/openscale/core/data/User.kt new file mode 100644 index 00000000..83cfa587 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/data/User.kt @@ -0,0 +1,31 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class User( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + val name: String, + val birthDate: Long, + val gender: GenderType, + val heightCm: Float? = null, + val activityLevel: ActivityLevel +) \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.java b/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.java deleted file mode 100644 index f15b4d26..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.java +++ /dev/null @@ -1,213 +0,0 @@ -/* Copyright (C) 2018 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.database; - -import androidx.room.Database; -import androidx.room.RoomDatabase; -import androidx.room.TypeConverters; -import androidx.room.migration.Migration; -import androidx.sqlite.db.SupportSQLiteDatabase; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; - -@Database(entities = {ScaleMeasurement.class, ScaleUser.class}, version = 6) -@TypeConverters({Converters.class}) -public abstract class AppDatabase extends RoomDatabase { - public abstract ScaleMeasurementDAO measurementDAO(); - public abstract ScaleUserDAO userDAO(); - - public static final Migration MIGRATION_1_2 = new Migration(1, 2) { - @Override - public void migrate(SupportSQLiteDatabase database) { - database.beginTransaction(); - try { - // Drop old index on datetime only - database.execSQL("DROP INDEX index_scaleMeasurements_datetime"); - - // Rename old table - database.execSQL("ALTER TABLE scaleMeasurements RENAME TO scaleMeasurementsOld"); - - // Create new table with foreign key - database.execSQL("CREATE TABLE scaleMeasurements" - + " (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," - + " userId INTEGER NOT NULL, enabled INTEGER NOT NULL," - + " datetime INTEGER, weight REAL NOT NULL, fat REAL NOT NULL," - + " water REAL NOT NULL, muscle REAL NOT NULL, lbw REAL NOT NULL," - + " waist REAL NOT NULL, hip REAL NOT NULL, bone REAL NOT NULL," - + " comment TEXT, FOREIGN KEY(userId) REFERENCES scaleUsers(id)" - + " ON UPDATE NO ACTION ON DELETE CASCADE)"); - - // Create new index on datetime + userId - database.execSQL("CREATE UNIQUE INDEX index_scaleMeasurements_userId_datetime" - + " ON scaleMeasurements (userId, datetime)"); - - // Copy data from the old table, ignoring those with invalid userId (if any) - database.execSQL("INSERT INTO scaleMeasurements" - + " SELECT * FROM scaleMeasurementsOld" - + " WHERE userId IN (SELECT id from scaleUsers)"); - - // Delete old table - database.execSQL("DROP TABLE scaleMeasurementsOld"); - - database.setTransactionSuccessful(); - } - finally { - database.endTransaction(); - } - } - }; - - public static final Migration MIGRATION_2_3 = new Migration(2, 3) { - @Override - public void migrate(SupportSQLiteDatabase database) { - database.beginTransaction(); - try { - // Drop old index - database.execSQL("DROP INDEX index_scaleMeasurements_userId_datetime"); - - // Rename old table - database.execSQL("ALTER TABLE scaleMeasurements RENAME TO scaleMeasurementsOld"); - database.execSQL("ALTER TABLE scaleUsers RENAME TO scaleUsersOld"); - - // Create new table with foreign key - database.execSQL("CREATE TABLE scaleMeasurements" - + " (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," - + " userId INTEGER NOT NULL, enabled INTEGER NOT NULL," - + " datetime INTEGER, weight REAL NOT NULL, fat REAL NOT NULL," - + " water REAL NOT NULL, muscle REAL NOT NULL, visceralFat REAL NOT NULL," - + " lbm REAL NOT NULL, waist REAL NOT NULL, hip REAL NOT NULL," - + " bone REAL NOT NULL, chest REAL NOT NULL, thigh REAL NOT NULL," - + " biceps REAL NOT NULL, neck REAL NOT NULL, caliper1 REAL NOT NULL," - + " caliper2 REAL NOT NULL, caliper3 REAL NOT NULL, comment TEXT," - + " FOREIGN KEY(userId) REFERENCES scaleUsers(id)" - + " ON UPDATE NO ACTION ON DELETE CASCADE)"); - - database.execSQL("CREATE TABLE scaleUsers " - + "(id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " - + "username TEXT NOT NULL, birthday INTEGER NOT NULL, bodyHeight REAL NOT NULL, " - + "scaleUnit INTEGER NOT NULL, gender INTEGER NOT NULL, initialWeight REAL NOT NULL, " - + "goalWeight REAL NOT NULL, goalDate INTEGER, measureUnit INTEGER NOT NULL, activityLevel INTEGER NOT NULL)"); - - // Create new index on datetime + userId - database.execSQL("CREATE UNIQUE INDEX index_scaleMeasurements_userId_datetime" - + " ON scaleMeasurements (userId, datetime)"); - - // Copy data from the old table - database.execSQL("INSERT INTO scaleMeasurements" - + " SELECT id, userId, enabled, datetime, weight, fat, water, muscle," - + " 0 AS visceralFat, lbw AS lbm, waist, hip, bone, 0 AS chest," - + " 0 as thigh, 0 as biceps, 0 as neck, 0 as caliper1," - + " 0 as caliper2, 0 as caliper3, comment FROM scaleMeasurementsOld"); - - database.execSQL("INSERT INTO scaleUsers" - + " SELECT id, username, birthday, bodyHeight, scaleUnit, gender, initialWeight, goalWeight," - + " goalDate, 0 AS measureUnit, 0 AS activityLevel FROM scaleUsersOld"); - - // Delete old table - database.execSQL("DROP TABLE scaleMeasurementsOld"); - database.execSQL("DROP TABLE scaleUsersOld"); - - database.setTransactionSuccessful(); - } - finally { - database.endTransaction(); - } - } - }; - - public static final Migration MIGRATION_3_4 = new Migration(3, 4) { - @Override - public void migrate(SupportSQLiteDatabase database) { - database.beginTransaction(); - try { - // Drop old index - database.execSQL("DROP INDEX index_scaleMeasurements_userId_datetime"); - - // Rename old table - database.execSQL("ALTER TABLE scaleMeasurements RENAME TO scaleMeasurementsOld"); - - // Create new table with foreign key - database.execSQL("CREATE TABLE scaleMeasurements" - + " (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," - + " userId INTEGER NOT NULL, enabled INTEGER NOT NULL," - + " datetime INTEGER, weight REAL NOT NULL, fat REAL NOT NULL," - + " water REAL NOT NULL, muscle REAL NOT NULL, visceralFat REAL NOT NULL," - + " lbm REAL NOT NULL, waist REAL NOT NULL, hip REAL NOT NULL," - + " bone REAL NOT NULL, chest REAL NOT NULL, thigh REAL NOT NULL," - + " biceps REAL NOT NULL, neck REAL NOT NULL, caliper1 REAL NOT NULL," - + " caliper2 REAL NOT NULL, caliper3 REAL NOT NULL, calories REAL NOT NULL, comment TEXT," - + " FOREIGN KEY(userId) REFERENCES scaleUsers(id)" - + " ON UPDATE NO ACTION ON DELETE CASCADE)"); - - // Create new index on datetime + userId - database.execSQL("CREATE UNIQUE INDEX index_scaleMeasurements_userId_datetime" - + " ON scaleMeasurements (userId, datetime)"); - - // Copy data from the old table - database.execSQL("INSERT INTO scaleMeasurements" - + " SELECT id, userId, enabled, datetime, weight, fat, water, muscle," - + " visceralFat, lbm, waist, hip, bone, chest," - + " thigh, biceps, neck, caliper1," - + " caliper2, caliper3, 0 as calories, comment FROM scaleMeasurementsOld"); - - // Delete old table - database.execSQL("DROP TABLE scaleMeasurementsOld"); - - database.setTransactionSuccessful(); - } - finally { - database.endTransaction(); - } - } - }; - - public static final Migration MIGRATION_4_5 = new Migration(4, 5) { - @Override - public void migrate(SupportSQLiteDatabase database) { - database.beginTransaction(); - try { - // Add assisted weighing and left/right amputation level to table - database.execSQL("ALTER TABLE scaleUsers ADD assistedWeighing INTEGER NOT NULL default 0"); - database.execSQL("ALTER TABLE scaleUsers ADD leftAmputationLevel INTEGER NOT NULL default 0"); - database.execSQL("ALTER TABLE scaleUsers ADD rightAmputationLevel INTEGER NOT NULL default 0"); - - database.setTransactionSuccessful(); - } - finally { - database.endTransaction(); - } - } - }; - - public static final Migration MIGRATION_5_6 = new Migration(5, 6) { - @Override - public void migrate(SupportSQLiteDatabase database) { - database.beginTransaction(); - try { - // Add goal enabled to scale user table - database.execSQL("ALTER TABLE scaleUsers ADD goalEnabled INTEGER NOT NULL default 0"); - - database.setTransactionSuccessful(); - } - finally { - database.endTransaction(); - } - } - }; -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt b/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt new file mode 100644 index 00000000..3b976283 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt @@ -0,0 +1,115 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.health.openscale.core.data.Measurement +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementValue +import com.health.openscale.core.data.User +import com.health.openscale.core.utils.LogManager + +/** + * Main Room database for the application. + * It holds references to all DAOs and manages the database instance. + */ +@Database( + entities = [ + User::class, + Measurement::class, + MeasurementValue::class, + MeasurementType::class + ], + version = 1, // TODO Increment this on schema changes + exportSchema = false // TODO Consider setting to true for production apps to keep schema history +) +@TypeConverters(DatabaseConverters::class) +abstract class AppDatabase : RoomDatabase() { + + abstract fun userDao(): UserDao + abstract fun measurementDao(): MeasurementDao + abstract fun measurementValueDao(): MeasurementValueDao + abstract fun measurementTypeDao(): MeasurementTypeDao + + /** + * Closes the database connection and resets the singleton instance. + * This is typically not needed in normal app operation as Room handles lifecycle. + * Could be useful in specific scenarios like testing or explicit resource cleanup. + */ + fun closeConnection() { + if (isOpen) { + try { + super.close() // Call RoomDatabase's close method + INSTANCE = null + LogManager.i(TAG, "Database connection closed and INSTANCE reset.") + } catch (e: Exception) { + LogManager.e(TAG, "Error closing database connection.", e) + } + } else { + LogManager.w(TAG, "Attempted to close database connection, but it was already closed or not initialized.") + } + } + + companion object { + private const val TAG = "AppDatabase" + const val DATABASE_NAME = "openScaleDB.db" + + @Volatile + private var INSTANCE: AppDatabase? = null + + /** + * Gets the singleton instance of the [AppDatabase]. + * Uses double-checked locking to ensure thread safety. + * + * @param context The application context. + * @return The singleton [AppDatabase] instance. + */ + fun getInstance(context: Context): AppDatabase { + // Double-checked locking pattern + return INSTANCE ?: synchronized(this) { + INSTANCE ?: buildDatabase(context.applicationContext).also { + LogManager.i(TAG, "Database instance created or retrieved.") + INSTANCE = it + } + } + } + + /** + * Builds the Room database instance. + * + * @param appContext The application context. + * @return A new [AppDatabase] instance. + */ + private fun buildDatabase(appContext: Context): AppDatabase { + LogManager.d(TAG, "Building new database instance: $DATABASE_NAME") + return Room.databaseBuilder( + appContext, + AppDatabase::class.java, + DATABASE_NAME + ) + // TODO Destroys and re-creates the database if a migration is needed and not provided. For production, define proper migrations instead. + .fallbackToDestructiveMigration() + // TODO Add any other configurations like .addCallback(), .setQueryExecutor(), etc. here if needed. + .build() + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseConverters.kt b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseConverters.kt new file mode 100644 index 00000000..2928ed8b --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseConverters.kt @@ -0,0 +1,53 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.database + +import androidx.room.TypeConverter +import com.health.openscale.core.data.GenderType +import com.health.openscale.core.data.InputFieldType +import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.UnitType + +class DatabaseConverters { + + @TypeConverter + fun fromTypeKey(value: MeasurementTypeKey): String = value.name + + @TypeConverter + fun toTypeKey(value: String): MeasurementTypeKey = MeasurementTypeKey.valueOf(value) + + // UnitType + @TypeConverter + fun fromUnitType(value: UnitType): String = value.name + + @TypeConverter + fun toUnitType(value: String): UnitType = UnitType.valueOf(value) + + // InputFieldType + @TypeConverter + fun fromInputType(value: InputFieldType): String = value.name + + @TypeConverter + fun toInputType(value: String): InputFieldType = InputFieldType.valueOf(value) + + @TypeConverter + fun fromGender(value: GenderType): String = value.name + + @TypeConverter + fun toGender(value: String): GenderType = GenderType.valueOf(value) +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseRepository.kt b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseRepository.kt new file mode 100644 index 00000000..0b49a90c --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseRepository.kt @@ -0,0 +1,476 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.database + +import com.health.openscale.core.data.ActivityLevel +import com.health.openscale.core.data.GenderType +import com.health.openscale.core.data.Measurement +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.MeasurementValue +import com.health.openscale.core.data.User +import com.health.openscale.core.model.MeasurementWithValues +import com.health.openscale.core.utils.CalculationUtil +import com.health.openscale.core.utils.LogManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +/** + * Repository class for accessing and managing data in the application's database. + * It abstracts the data sources (DAOs) and provides a clean API for data operations. + */ +class DatabaseRepository( + private val database: AppDatabase, + private val userDao: UserDao, + private val measurementDao: MeasurementDao, + private val measurementTypeDao: MeasurementTypeDao, + private val measurementValueDao: MeasurementValueDao +) { + + private val TAG = "DatabaseRepository" + + /** + * Gets the name of the database. + * @return The database name. + */ + fun getDatabaseName(): String { + return AppDatabase.DATABASE_NAME + } + + /** + * Closes the database connection. + */ + fun closeDatabase() { + LogManager.i(TAG, "Attempting to close database connection.") + database.closeConnection() + } + + // --- User Operations --- + fun getAllUsers(): Flow> = userDao.getAllUsers() + fun getUserById(id: Int): Flow = userDao.getById(id) + + suspend fun insertUser(user: User): Long { + LogManager.d(TAG, "Inserting user: ${user.name}") + return userDao.insert(user) + } + + suspend fun updateUser(user: User) { + LogManager.d(TAG, "Updating user with id: ${user.id}") + userDao.update(user) + } + + suspend fun deleteUser(user: User) { + LogManager.d(TAG, "Deleting user with id: ${user.id}") + userDao.delete(user) + } + + // --- Measurement Operations --- + + fun getMeasurementsWithValuesForUser(userId: Int): Flow> = + measurementDao.getMeasurementsWithValuesForUser(userId) + + fun getMeasurementWithValuesById(measurementId: Int): Flow = + measurementDao.getMeasurementWithValuesById(measurementId) + + /** + * Inserts a new measurement and recalculates derived values. + */ + suspend fun insertMeasurement(measurement: Measurement): Long { + LogManager.d(TAG, "Inserting measurement for user id: ${measurement.userId}") + val id = measurementDao.insert(measurement) + LogManager.d(TAG, "New measurement inserted with id: $id. Recalculating derived values.") + recalculateDerivedValuesForMeasurement(id.toInt()) + return id + } + + /** + * Updates an existing measurement and recalculates derived values. + */ + suspend fun updateMeasurement(measurement: Measurement) { + LogManager.d(TAG, "Updating measurement with id: ${measurement.id}. Recalculating derived values.") + measurementDao.update(measurement) + recalculateDerivedValuesForMeasurement(measurement.id) + } + + suspend fun deleteMeasurement(measurement: Measurement) { + LogManager.d(TAG, "Deleting measurement with id: ${measurement.id}") + measurementDao.delete(measurement) + } + + + // --- Measurement Value Operations --- + + /** + * Inserts a new measurement value and recalculates derived values for the associated measurement. + */ + suspend fun insertMeasurementValue(value: MeasurementValue) { + LogManager.d(TAG, "Inserting measurement value for measurement id: ${value.measurementId}, typeId: ${value.typeId}") + measurementValueDao.insert(value) + LogManager.d(TAG, "Recalculating derived values for measurement id: ${value.measurementId}") + recalculateDerivedValuesForMeasurement(value.measurementId) + } + + /** + * Updates an existing measurement value and recalculates derived values for the associated measurement. + */ + suspend fun updateMeasurementValue(value: MeasurementValue) { + LogManager.d(TAG, "Updating measurement value with id: ${value.id}. Recalculating derived values for measurement id: ${value.measurementId}") + measurementValueDao.update(value) + recalculateDerivedValuesForMeasurement(value.measurementId) + } + + /** + * Inserts a list of measurements, each with its associated values. + */ + suspend fun insertMeasurementsWithValues(measurementsData: List>>) { + LogManager.i(TAG, "Attempting to insert ${measurementsData.size} measurements with their values.") + withContext(Dispatchers.IO) { + measurementsData.forEachIndexed { index, (measurement, values) -> + try { + LogManager.d(TAG, "Inserting measurement ${index + 1}/${measurementsData.size}, userId: ${measurement.userId}, with ${values.size} values.") + measurementDao.insertSingleMeasurementWithItsValues(measurement, values) + } catch (e: Exception) { + LogManager.e(TAG, "Failed to insert measurement (userId: ${measurement.userId}, timestamp: ${measurement.timestamp}) and its values. Error: ${e.message}", e) + } + } + } + LogManager.i(TAG, "Finished inserting measurements with values.") + } + + suspend fun deleteMeasurementValueById(valueId: Int) { + LogManager.d(TAG, "Deleting measurement value with id: $valueId") + measurementValueDao.deleteById(valueId) + } + + /** + * Deletes all measurements for a given user. + * @return The number of deleted measurements. + */ + suspend fun deleteAllMeasurementsForUser(userId: Int): Int { + LogManager.i(TAG, "Deleting all measurements for user id: $userId") + return withContext(Dispatchers.IO) { + measurementDao.deleteMeasurementsByUserId(userId).also { count -> + LogManager.i(TAG, "$count measurements deleted for user id: $userId") + } + } + } + + fun getValuesForMeasurement(measurementId: Int): Flow> = + measurementValueDao.getValuesForMeasurement(measurementId) + + // --- Measurement Type Operations --- + + fun getAllMeasurementTypes(): Flow> = measurementTypeDao.getAll() + + suspend fun insertMeasurementType(type: MeasurementType): Long { + LogManager.d(TAG, "Inserting measurement type: ${type.key}") // Logging the key + return measurementTypeDao.insert(type) + } + + suspend fun deleteMeasurementType(type: MeasurementType) { + LogManager.d(TAG, "Deleting measurement type with id: ${type.id}, key: ${type.key}") + measurementTypeDao.delete(type) + } + + suspend fun updateMeasurementType(type: MeasurementType) { + LogManager.d(TAG, "Updating measurement type with id: ${type.id}, key: ${type.key}") + measurementTypeDao.update(type) + } + + + // --- Derived Values Calculation --- + private val DERIVED_VALUES_TAG = "DerivedValues" // Specific tag for this complex logic + + /** + * Recalculates all derived measurement values (like BMI, LBM, etc.) for a given measurement. + * This method fetches the necessary base values and user data, then processes each calculation. + * + * @param measurementId The ID of the measurement for which to recalculate derived values. + */ + private suspend fun recalculateDerivedValuesForMeasurement(measurementId: Int) { + LogManager.i(DERIVED_VALUES_TAG, "Starting recalculation of derived values for measurementId: $measurementId") + + val measurement = measurementDao.getMeasurementById(measurementId) ?: run { + LogManager.w(DERIVED_VALUES_TAG, "Measurement with ID $measurementId not found. Cannot recalculate derived values.") + return + } + val userId = measurement.userId + + val currentMeasurementValues = measurementValueDao.getValuesForMeasurement(measurementId).first() + val allGlobalTypes = measurementTypeDao.getAll().first() + val user = userDao.getById(userId).first() ?: run { + LogManager.w(DERIVED_VALUES_TAG, "User with ID $userId not found for measurement $measurementId. Cannot recalculate derived values.") + return + } + + LogManager.d(DERIVED_VALUES_TAG, "Fetched ${currentMeasurementValues.size} current values, " + + "${allGlobalTypes.size} global types, and user '${user.name}' for measurement $measurementId.") + + val findValue = { key: MeasurementTypeKey -> + val type = allGlobalTypes.find { it.key == key } + if (type == null) { + LogManager.w(DERIVED_VALUES_TAG, "MeasurementType for key '$key' not found in global types list.") + } + val value = currentMeasurementValues.find { it.typeId == type?.id }?.floatValue + LogManager.v(DERIVED_VALUES_TAG, "findValue for $key (typeId: ${type?.id}): ${value ?: "not found"}") + value + } + + val saveOrUpdateDerivedValue: suspend (value: Float?, typeKey: MeasurementTypeKey) -> Unit = + save@{ derivedValue, derivedValueTypeKey -> + val derivedTypeObject = allGlobalTypes.find { it.key == derivedValueTypeKey } + + if (derivedTypeObject == null) { + LogManager.w(DERIVED_VALUES_TAG, "Cannot save/update derived value: Type for key '$derivedValueTypeKey' not found.") + return@save + } + + val existingDerivedValueObject = currentMeasurementValues.find { it.typeId == derivedTypeObject.id } + + if (derivedValue == null) { + if (existingDerivedValueObject != null) { + measurementValueDao.deleteById(existingDerivedValueObject.id) + LogManager.d(DERIVED_VALUES_TAG, "Derived value for key ${derivedTypeObject.key} is null. Deleted existing value (ID: ${existingDerivedValueObject.id}).") + } else { + LogManager.v(DERIVED_VALUES_TAG, "Derived value for key ${derivedTypeObject.key} is null. No existing value to delete.") + } + } else { + val roundedValue = roundTo(derivedValue) + if (existingDerivedValueObject != null) { + if (existingDerivedValueObject.floatValue != roundedValue) { + measurementValueDao.update(existingDerivedValueObject.copy(floatValue = roundedValue)) + LogManager.d(DERIVED_VALUES_TAG, "Derived value for key ${derivedTypeObject.key} updated from ${existingDerivedValueObject.floatValue} to $roundedValue.") + } else { + LogManager.v(DERIVED_VALUES_TAG, "Derived value for key ${derivedTypeObject.key} is $roundedValue (unchanged). No update needed.") + } + } else { + measurementValueDao.insert( + MeasurementValue( + measurementId = measurementId, + typeId = derivedTypeObject.id, + floatValue = roundedValue + ) + ) + LogManager.d(DERIVED_VALUES_TAG, "New derived value for key ${derivedTypeObject.key} inserted: $roundedValue.") + } + } + } + + val weightKg = findValue(MeasurementTypeKey.WEIGHT) + val bodyFatPercentage = findValue(MeasurementTypeKey.BODY_FAT) + val waistCm = findValue(MeasurementTypeKey.WAIST) + val hipsCm = findValue(MeasurementTypeKey.HIPS) + val caliper1Cm = findValue(MeasurementTypeKey.CALIPER_1) + val caliper2Cm = findValue(MeasurementTypeKey.CALIPER_2) + val caliper3Cm = findValue(MeasurementTypeKey.CALIPER_3) + + processBmiCalculation(weightKg, user.heightCm).also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.BMI) } + processLbmCalculation(weightKg, bodyFatPercentage).also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.LBM) } + processWhrCalculation(waistCm, hipsCm).also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.WHR) } + processWhtrCalculation(waistCm, user.heightCm).also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.WHTR) } + processBmrCalculation(weightKg, user).also { bmr -> + saveOrUpdateDerivedValue(bmr, MeasurementTypeKey.BMR) + processTDEECalculation(bmr, user.activityLevel).also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.TDEE) } + } + processFatCaliperCalculation(caliper1Cm, caliper2Cm, caliper3Cm, user) + .also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.CALIPER) } + + LogManager.i(DERIVED_VALUES_TAG, "Finished recalculation of derived values for measurementId: $measurementId") + } + + // --- Private Calculation Helper Functions --- + private val CALC_PROCESS_TAG = "DerivedValuesProcess" + + private fun processBmiCalculation(weightKg: Float?, heightCm: Float?): Float? { + LogManager.v(CALC_PROCESS_TAG, "Processing BMI: weight=$weightKg kg, height=$heightCm cm") + return if (weightKg != null && weightKg > 0f && heightCm != null && heightCm > 0f) { + val heightM = heightCm / 100f + weightKg / (heightM * heightM) + } else { + LogManager.d(CALC_PROCESS_TAG, "BMI calculation skipped: Missing or invalid weight/height.") + null + } + } + + private fun processLbmCalculation(weightKg: Float?, bodyFatPercentage: Float?): Float? { + LogManager.v(CALC_PROCESS_TAG, "Processing LBM: weight=$weightKg kg, bodyFat=$bodyFatPercentage %") + return if (weightKg != null && weightKg > 0f && bodyFatPercentage != null && bodyFatPercentage in 0f..100f) { + val fatMass = weightKg * (bodyFatPercentage / 100f) + weightKg - fatMass + } else { + if (bodyFatPercentage != null && bodyFatPercentage !in 0f..100f) { + LogManager.w(CALC_PROCESS_TAG, "Invalid body fat percentage for LBM calculation: $bodyFatPercentage%. Must be between 0 and 100.") + } else if (weightKg == null || weightKg <= 0f) { + LogManager.d(CALC_PROCESS_TAG, "LBM calculation skipped: Missing or invalid weight.") + } else { + LogManager.d(CALC_PROCESS_TAG, "LBM calculation skipped: Missing body fat percentage.") + } + null + } + } + + private fun processWhrCalculation(waistCm: Float?, hipsCm: Float?): Float? { + LogManager.v(CALC_PROCESS_TAG, "Processing WHR: waist=$waistCm cm, hips=$hipsCm cm") + return if (waistCm != null && waistCm > 0f && hipsCm != null && hipsCm > 0f) { + waistCm / hipsCm + } else { + LogManager.d(CALC_PROCESS_TAG, "WHR calculation skipped: Missing or invalid waist/hips measurements.") + null + } + } + + private fun processWhtrCalculation(waistCm: Float?, bodyHeightCm: Float?): Float? { + LogManager.v(CALC_PROCESS_TAG, "Processing WHTR: waist=$waistCm cm, bodyHeight=$bodyHeightCm cm") + return if (waistCm != null && waistCm > 0f && bodyHeightCm != null && bodyHeightCm > 0f) { + waistCm / bodyHeightCm + } else { + LogManager.d(CALC_PROCESS_TAG, "WHTR calculation skipped: Missing or invalid waist/body height measurements.") + null + } + } + + private fun processBmrCalculation(weightKg: Float?, user: User): Float? { + LogManager.v(CALC_PROCESS_TAG, "Processing BMR for user ${user.id}: weight=$weightKg kg") + val heightCm = user.heightCm + val birthDateTimestamp = user.birthDate + val gender = user.gender + + if (weightKg == null || weightKg <= 0f || + heightCm == null || heightCm <= 0f || + birthDateTimestamp <= 0L || gender == null + ) { + LogManager.d(CALC_PROCESS_TAG, "BMR calculation skipped: Missing or invalid weight, height, birthdate, or gender.") + return null + } + + val ageYears = CalculationUtil.dateToAge(birthDateTimestamp) + LogManager.v(CALC_PROCESS_TAG, "Calculated age for BMR: $ageYears years") + + return if (ageYears in 1..120) { + when (gender) { + GenderType.MALE -> (10.0f * weightKg) + (6.25f * heightCm) - (5.0f * ageYears) + 5.0f + GenderType.FEMALE -> (10.0f * weightKg) + (6.25f * heightCm) - (5.0f * ageYears) - 161.0f + else -> { + LogManager.w(CALC_PROCESS_TAG, "BMR calculation not supported for gender: '$gender'. User ID: ${user.id}") + null + } + } + } else { + LogManager.w(CALC_PROCESS_TAG, "Invalid age for BMR calculation: $ageYears years. User ID: ${user.id}") + null + } + } + + private fun processTDEECalculation(bmr: Float?, activityLevel: ActivityLevel?): Float? { + LogManager.v(CALC_PROCESS_TAG, "Processing TDEE: BMR=$bmr, ActivityLevel=$activityLevel") + if (bmr == null || bmr <= 0f || activityLevel == null) { + LogManager.d(CALC_PROCESS_TAG, "TDEE calculation skipped: Missing or invalid BMR or activity level.") + return null + } + + val activityFactor = when (activityLevel) { + ActivityLevel.SEDENTARY -> 1.2f + ActivityLevel.MILD -> 1.375f + ActivityLevel.MODERATE -> 1.55f + ActivityLevel.HEAVY -> 1.725f + ActivityLevel.EXTREME -> 1.9f + } + return bmr * activityFactor + } + + private fun processFatCaliperCalculation( + caliper1Cm: Float?, + caliper2Cm: Float?, + caliper3Cm: Float?, + user: User + ): Float? { + LogManager.v(CALC_PROCESS_TAG, "Processing Fat Caliper: c1=$caliper1Cm cm, c2=$caliper2Cm cm, c3=$caliper3Cm cm for user ${user.id}") + + if (caliper1Cm == null || caliper1Cm <= 0f || + caliper2Cm == null || caliper2Cm <= 0f || + caliper3Cm == null || caliper3Cm <= 0f + ) { + LogManager.d(CALC_PROCESS_TAG, "Fat Caliper calculation skipped: One or more caliper values are missing or zero.") + return null + } + + val gender = user.gender + val ageYears = CalculationUtil.dateToAge(user.birthDate) + + if (gender == null || ageYears <= 0) { + LogManager.w(CALC_PROCESS_TAG, "Fat Caliper calculation skipped: Invalid gender ($gender) or age ($ageYears years). User ID: ${user.id}") + return null + } + LogManager.v(CALC_PROCESS_TAG, "Calculated age for Fat Caliper: $ageYears years") + + val sumSkinfoldsMm = (caliper1Cm + caliper2Cm + caliper3Cm) * 10.0f + LogManager.v(CALC_PROCESS_TAG, "Sum of skinfolds (S): $sumSkinfoldsMm mm") + + val k0: Float + val k1: Float + val k2: Float + val ka: Float + + when (gender) { + GenderType.MALE -> { + k0 = 1.10938f + k1 = 0.0008267f + k2 = 0.0000016f + ka = 0.0002574f + } + GenderType.FEMALE -> { + k0 = 1.0994921f + k1 = 0.0009929f + k2 = 0.0000023f + ka = 0.0001392f + } + else -> { + LogManager.w(CALC_PROCESS_TAG, "Fat Caliper calculation not supported for gender: '$gender'. User ID: ${user.id}") + return null + } + } + + val bodyDensity = k0 - (k1 * sumSkinfoldsMm) + (k2 * sumSkinfoldsMm * sumSkinfoldsMm) - (ka * ageYears) + LogManager.v(CALC_PROCESS_TAG, "Calculated Body Density (BD): $bodyDensity") + + if (bodyDensity <= 0f) { + LogManager.w(CALC_PROCESS_TAG, "Invalid Body Density calculated: $bodyDensity. Caliper values might be outside the formula's valid range. User ID: ${user.id}") + return null + } + + val fatPercentage = (4.95f / bodyDensity - 4.5f) * 100.0f + LogManager.v(CALC_PROCESS_TAG, "Calculated Fat Percentage from BD: $fatPercentage %") + + return if (fatPercentage in 1.0f..70.0f) { + fatPercentage + } else { + LogManager.w(CALC_PROCESS_TAG, "Calculated Fat Percentage ($fatPercentage%) is outside the expected physiological range (1-70%). User ID: ${user.id}") + fatPercentage + } + } + + /** + * Rounds a float value to two decimal places. + */ + private fun roundTo(value: Float): Float { + return (value * 100).toInt() / 100.0f + } +} + diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/MeasurementDao.kt b/android_app/app/src/main/java/com/health/openscale/core/database/MeasurementDao.kt new file mode 100644 index 00000000..e00cf390 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/database/MeasurementDao.kt @@ -0,0 +1,136 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.database + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import com.health.openscale.core.data.Measurement +import com.health.openscale.core.data.MeasurementValue +import com.health.openscale.core.model.MeasurementValueWithType +import com.health.openscale.core.model.MeasurementWithValues +import kotlinx.coroutines.flow.Flow + +/** + * Data Access Object for Measurement and MeasurementValue entities. + */ +@Dao +interface MeasurementDao { + + /** + * Inserts a measurement. If the measurement already exists based on its primary key, it's replaced. + * @param measurement The measurement to insert. + * @return The row ID of the newly inserted measurement. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(measurement: Measurement): Long + + /** + * Inserts a list of measurement values. + * The `measurementId` in each [MeasurementValue] object MUST be correctly set beforehand. + * Existing values with the same primary key will be replaced. + * + * @param values The list of measurement values to insert. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMeasurementValues(values: List) + + /** + * Inserts a single measurement and its associated values within a transaction. + * This method ensures that the correct `measurementId` is set for each value + * after the main measurement has been inserted and its ID is available. + * + * @param measurement The measurement to insert. + * @param values The list of associated measurement values. + */ + @Transaction + suspend fun insertSingleMeasurementWithItsValues(measurement: Measurement, values: List) { + val measurementId = insert(measurement) // Insert the main measurement to get its ID + + // Update each MeasurementValue with the correct measurementId + val updatedValues = values.map { value -> + // Important: Create a new instance if MeasurementValue is a data class to ensure immutability. + value.copy(measurementId = measurementId.toInt()) + } + + if (updatedValues.isNotEmpty()) { + insertMeasurementValues(updatedValues) // Insert the updated measurement values + } + } + + /** + * Updates an existing measurement. + * @param measurement The measurement to update. + */ + @Update + suspend fun update(measurement: Measurement) + + /** + * Deletes a measurement. + * @param measurement The measurement to delete. + */ + @Delete + suspend fun delete(measurement: Measurement) + + /** + * Deletes all measurements for a specific user. + * @param userId The ID of the user whose measurements are to be deleted. + * @return The number of measurements deleted. + */ + @Query("DELETE FROM Measurement WHERE userId = :userId") + suspend fun deleteMeasurementsByUserId(userId: Int): Int + + /** + * Retrieves all measurements with their associated values for a specific user, ordered by timestamp descending. + * @param userId The ID of the user. + * @return A Flow emitting a list of [MeasurementWithValues]. + */ + @Transaction + @Query("SELECT * FROM Measurement WHERE userId = :userId ORDER BY timestamp DESC") + fun getMeasurementsWithValuesForUser(userId: Int): Flow> + + /** + * Retrieves a specific measurement with its associated values by its ID. + * @param measurementId The ID of the measurement. + * @return A Flow emitting a [MeasurementWithValues] object or null if not found. + */ + @Transaction + @Query("SELECT * FROM Measurement WHERE id = :measurementId") + fun getMeasurementWithValuesById(measurementId: Int): Flow + + /** + * Retrieves all measurement values with their associated type information for a specific measurement. + * @param measurementId The ID of the measurement. + * @return A Flow emitting a list of [MeasurementValueWithType]. + */ + @Transaction + @Query("SELECT * FROM MeasurementValue WHERE measurementId = :measurementId") + fun getValuesWithTypeForMeasurement(measurementId: Int): Flow> + + /** + * Retrieves a specific measurement by its ID, without its associated values. + * @param id The ID of the measurement. + * @return The [Measurement] object or null if not found. + */ + @Query("SELECT * FROM Measurement WHERE id = :id") + suspend fun getMeasurementById(id: Int): Measurement? +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/MeasurementTypeDao.kt b/android_app/app/src/main/java/com/health/openscale/core/database/MeasurementTypeDao.kt new file mode 100644 index 00000000..b59b619a --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/database/MeasurementTypeDao.kt @@ -0,0 +1,45 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.database + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.health.openscale.core.data.MeasurementType +import kotlinx.coroutines.flow.Flow + +@Dao +interface MeasurementTypeDao { + @Insert + suspend fun insert(type: MeasurementType): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(types: List) + + @Update + suspend fun update(type: MeasurementType) + + @Delete + suspend fun delete(type: MeasurementType) + + @Query("SELECT * FROM MeasurementType ORDER BY displayOrder ASC") + fun getAll(): Flow> +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/MeasurementValueDao.kt b/android_app/app/src/main/java/com/health/openscale/core/database/MeasurementValueDao.kt new file mode 100644 index 00000000..6db13b6b --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/database/MeasurementValueDao.kt @@ -0,0 +1,43 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.database + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import com.health.openscale.core.data.MeasurementValue +import kotlinx.coroutines.flow.Flow + +@Dao +interface MeasurementValueDao { + @Insert + suspend fun insert(value: MeasurementValue): Long + + @Update + suspend fun update(value: MeasurementValue) + + @Query("DELETE FROM MeasurementValue WHERE id = :valueId") + suspend fun deleteById(valueId: Int) + + @Insert + suspend fun insertAll(values: List) + + @Query("SELECT * FROM MeasurementValue WHERE measurementId = :measurementId") + fun getValuesForMeasurement(measurementId: Int): Flow> +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/ScaleDatabaseProvider.java b/android_app/app/src/main/java/com/health/openscale/core/database/ScaleDatabaseProvider.java deleted file mode 100644 index 470cf2a0..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/database/ScaleDatabaseProvider.java +++ /dev/null @@ -1,178 +0,0 @@ -/* Copyright (C) 2018 Paul Cowan - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.core.database; - -import android.content.ContentUris; -import android.content.ContentValues; -import android.content.Context; -import android.content.UriMatcher; -import android.database.Cursor; -import android.database.MatrixCursor; -import android.net.Uri; - -import com.health.openscale.BuildConfig; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; - -import java.util.Date; - -import timber.log.Timber; - -/** - * Exposes the user and measurement data from openScale via - * Android - * Content Providers. This allows other apps to access the openScale data for their own purposes - * (e.g. syncing to third-party services like Google Fit, Fitbit API, etc) without openScale itself - * needing to do so or request additional permissions.
- * - * This access is gated by the com.health.openscale.READ_WRITE_DATA permission, which is defined in the - * manifest; it is not accessible to any other app without user confirmation.
- * - * The following URIs are supported: - *

    - *
  • content://com.health.openscale.provider/meta: API and openScale version.
  • - *
  • content://com.health.openscale.provider/users: list all users.
  • - *
  • content://com.health.openscale.provider/measurements/$ID: - * retrieve all measurements for the supplied user ID.
  • - *
- */ -public class ScaleDatabaseProvider extends android.content.ContentProvider { - private static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH); - - private static final int API_VERSION = 1; - - private static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".provider"; - - private static final int MATCH_TYPE_META = 1; - private static final int MATCH_TYPE_USER_LIST = 2; - private static final int MATCH_TYPE_MEASUREMENT_LIST = 3; - - - static { - uriMatcher.addURI(AUTHORITY, "meta", MATCH_TYPE_META); - uriMatcher.addURI(AUTHORITY, "users", MATCH_TYPE_USER_LIST); - uriMatcher.addURI(AUTHORITY, "measurements/#", MATCH_TYPE_MEASUREMENT_LIST); - } - - @Override - public String getType(Uri uri) { - switch (uriMatcher.match(uri)) { - case MATCH_TYPE_META: - return "vnd.android.cursor.item/vnd." + AUTHORITY + ".meta"; - - case MATCH_TYPE_USER_LIST: - return "vnd.android.cursor.dir/vnd." + AUTHORITY + ".user"; - - case MATCH_TYPE_MEASUREMENT_LIST: - return "vnd.android.cursor.dir/vnd." + AUTHORITY + ".measurement"; - } - return null; - } - - @Override - public boolean onCreate() { - // need to create openScale instance for the provider if openScale app is closed - OpenScale.createInstance(getContext().getApplicationContext()); - return true; - } - - @Override - public Cursor query(Uri uri, String[] projection, String selection, - String[] selectionArgs, String sortOrder) { - final Context context = getContext(); - - Cursor cursor; - - switch (uriMatcher.match(uri)) { - case MATCH_TYPE_META: - cursor = new MatrixCursor(new String[]{"apiVersion", "versionCode"}, 1); - ((MatrixCursor) cursor).addRow(new Object[]{API_VERSION, BuildConfig.VERSION_CODE}); - break; - - case MATCH_TYPE_USER_LIST: - cursor = OpenScale.getInstance().getScaleUserListCursor(); - break; - - case MATCH_TYPE_MEASUREMENT_LIST: - cursor = OpenScale.getInstance().getScaleMeasurementListCursor( - ContentUris.parseId(uri)); - break; - - default: - throw new IllegalArgumentException("Unknown URI: " + uri); - } - - cursor.setNotificationUri(context.getContentResolver(), uri); - return cursor; - } - - @Override - public int delete(Uri uri, String selection, String[] selectionArgs) { - throw new UnsupportedOperationException("Not supported"); - } - - @Override - public Uri insert(Uri uri, ContentValues values) { - Date date = new Date(values.getAsLong("datetime")); - float weight = values.getAsFloat("weight"); - int userId = values.getAsInteger("userId"); - - ScaleMeasurement scaleMeasurement = new ScaleMeasurement(); - - scaleMeasurement.setUserId(userId); - scaleMeasurement.setWeight(weight); - scaleMeasurement.setDateTime(date); - - ScaleMeasurementDAO measurementDAO = OpenScale.getInstance().getScaleMeasurementDAO(); - - if (measurementDAO.insert(scaleMeasurement) == -1) { - update(uri, values, "", new String[]{}); - } - - return null; - }; - - @Override - public int update(Uri uri, ContentValues values, String selection, - String[] selectionArgs) { - - Date date = new Date(values.getAsLong("datetime")); - float weight = values.getAsFloat("weight"); - int userId = values.getAsInteger("userId"); - - ScaleMeasurement scaleMeasurement = new ScaleMeasurement(); - - scaleMeasurement.setWeight(weight); - scaleMeasurement.setDateTime(date); - - ScaleMeasurementDAO measurementDAO = OpenScale.getInstance().getScaleMeasurementDAO(); - - ScaleMeasurement databaseMeasurement = measurementDAO.get(date, userId); - - if (databaseMeasurement != null) { - databaseMeasurement.merge(scaleMeasurement); - databaseMeasurement.setEnabled(true); - - measurementDAO.update(databaseMeasurement); - - return 1; - } else { - Timber.e("no measurement for an update found"); - } - - return 0; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/ScaleMeasurementDAO.java b/android_app/app/src/main/java/com/health/openscale/core/database/ScaleMeasurementDAO.java deleted file mode 100644 index e5ecaf4c..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/database/ScaleMeasurementDAO.java +++ /dev/null @@ -1,83 +0,0 @@ -/* Copyright (C) 2018 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.database; - -import android.database.Cursor; - -import androidx.lifecycle.LiveData; -import androidx.room.Dao; -import androidx.room.Insert; -import androidx.room.OnConflictStrategy; -import androidx.room.Query; -import androidx.room.Update; - -import com.health.openscale.core.datatypes.ScaleMeasurement; - -import java.util.Date; -import java.util.List; - -@Dao -public interface ScaleMeasurementDAO { - @Query("SELECT * FROM scaleMeasurements WHERE datetime = :datetime AND userId = :userId") - ScaleMeasurement get(Date datetime, int userId); - - @Query("SELECT * FROM scaleMeasurements WHERE id = :id") - ScaleMeasurement get(int id); - - @Query("SELECT * FROM scaleMeasurements WHERE datetime < (SELECT datetime FROM scaleMeasurements WHERE id = :id) AND userId = :userId AND enabled = 1 ORDER BY datetime DESC LIMIT 0,1") - ScaleMeasurement getPrevious(int id, int userId); - - @Query("SELECT * FROM scaleMeasurements WHERE datetime > (SELECT datetime FROM scaleMeasurements WHERE id = :id) AND userId = :userId AND enabled = 1 LIMIT 0,1") - ScaleMeasurement getNext(int id, int userId); - - @Query("SELECT count(id) FROM scaleMeasurements WHERE userId = :userId AND enabled = 1") - long getCount(int userId); - - @Query("SELECT * FROM scaleMeasurements WHERE userId = :userId AND enabled = 1 ORDER BY datetime DESC") - List getAll(int userId); - - @Query("SELECT * FROM scaleMeasurements WHERE userId = :userId AND enabled = 1 ORDER BY datetime DESC") - LiveData> getAllAsLiveData(int userId); - - @Query("SELECT * FROM scaleMeasurements WHERE datetime >= :startYear AND datetime < :endYear AND userId = :userId AND enabled = 1 ORDER BY datetime DESC") - List getAllInRange(Date startYear, Date endYear, int userId); - - @Query("SELECT * FROM scaleMeasurements WHERE userId = :userId AND enabled = 1 ORDER BY datetime DESC LIMIT 0,1") - ScaleMeasurement getLatest(int userId); - - @Query("SELECT * FROM scaleMeasurements WHERE userId = :userId AND enabled = 1 ORDER BY datetime ASC LIMIT 0,1") - ScaleMeasurement getFirst(int userId); - - @Insert (onConflict = OnConflictStrategy.IGNORE) - long insert(ScaleMeasurement measurement); - - @Insert (onConflict = OnConflictStrategy.IGNORE) - void insertAll(List measurementList); - - @Update - void update(ScaleMeasurement measurement); - - @Query("UPDATE scaleMeasurements SET enabled = 0 WHERE id = :id") - void delete(int id); - - @Query("DELETE FROM scaleMeasurements WHERE userId = :userId") - void deleteAll(int userId); - - // selectAll() is equivalent to getAll(), but returns a Cursor, for exposing via a ContentProvider. - @Query("SELECT id as _ID, datetime, weight, fat, water, muscle FROM scaleMeasurements WHERE userId = :userId AND enabled = 1 ORDER BY datetime DESC") - Cursor selectAll(long userId); -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/ScaleUserDAO.java b/android_app/app/src/main/java/com/health/openscale/core/database/ScaleUserDAO.java deleted file mode 100644 index bb72a366..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/database/ScaleUserDAO.java +++ /dev/null @@ -1,53 +0,0 @@ -/* Copyright (C) 2018 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.database; - -import androidx.room.Dao; -import androidx.room.Delete; -import androidx.room.Insert; -import androidx.room.Query; -import androidx.room.Update; -import android.database.Cursor; - -import com.health.openscale.core.datatypes.ScaleUser; - -import java.util.List; - -@Dao -public interface ScaleUserDAO { - @Query("SELECT * FROM scaleUsers") - List getAll(); - - @Query("SELECT * FROM scaleUsers WHERE id = :id") - ScaleUser get(int id); - - @Insert - long insert(ScaleUser user); - - @Insert - void insertAll(List userList); - - @Update - void update(ScaleUser user); - - @Delete - void delete(ScaleUser user); - - // selectAll() is similar to getAll(), but return a Cursor, for exposing via a ContentProvider. - @Query("SELECT id as _ID, username, birthday, bodyHeight, gender, activityLevel FROM scaleUsers") - Cursor selectAll(); -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/UserDao.kt b/android_app/app/src/main/java/com/health/openscale/core/database/UserDao.kt new file mode 100644 index 00000000..3eb7774f --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/database/UserDao.kt @@ -0,0 +1,44 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.database + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import com.health.openscale.core.data.User +import kotlinx.coroutines.flow.Flow + +@Dao +interface UserDao { + @Insert + suspend fun insert(user: User): Long + + @Update + suspend fun update(user: User) + + @Delete + suspend fun delete(user: User) + + @Query("SELECT * FROM User") + fun getAllUsers(): Flow> + + @Query("SELECT * FROM User WHERE id = :id") + fun getById(id: Int): Flow +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/UserSettingsRepository.kt b/android_app/app/src/main/java/com/health/openscale/core/database/UserSettingsRepository.kt new file mode 100644 index 00000000..752552b2 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/database/UserSettingsRepository.kt @@ -0,0 +1,338 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.database + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.doublePreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.health.openscale.core.utils.LogManager +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import java.io.IOException + +// DataStore instance for user settings +val Context.userSettingsDataStore: DataStore by preferencesDataStore(name = "user_settings") + +/** + * Defines keys for user preferences stored in DataStore. + */ +object UserPreferenceKeys { + // General App Settings + val IS_FILE_LOGGING_ENABLED = booleanPreferencesKey("is_file_logging_enabled") + val IS_FIRST_APP_START = booleanPreferencesKey("is_first_app_start") + val CURRENT_USER_ID = intPreferencesKey("current_user_id") + val APP_LANGUAGE_CODE = stringPreferencesKey("app_language_code") + + // Settings for specific UI components + val SELECTED_TYPES_TABLE = stringSetPreferencesKey("selected_types_table") // IDs of measurement types selected for the data table + + // Saved Bluetooth Scale + val SAVED_BLUETOOTH_SCALE_ADDRESS = stringPreferencesKey("saved_bluetooth_scale_address") + val SAVED_BLUETOOTH_SCALE_NAME = stringPreferencesKey("saved_bluetooth_scale_name") + + // Context strings for screen-specific settings (can be used as prefixes for dynamic keys) + const val OVERVIEW_SCREEN_CONTEXT = "overview_screen" + const val GRAPH_SCREEN_CONTEXT = "graph_screen" + const val STATISTICS_SCREEN_CONTEXT = "statistics_screen" +} + +/** + * Repository interface for accessing and managing user settings. + */ +interface UserSettingsRepository { + // General app settings + val isFileLoggingEnabled: Flow + suspend fun setFileLoggingEnabled(enabled: Boolean) + + val isFirstAppStart: Flow + suspend fun setFirstAppStartCompleted(completed: Boolean) // Renamed for clarity + + val appLanguageCode: Flow + suspend fun setAppLanguageCode(languageCode: String?) + + val currentUserId: Flow + suspend fun setCurrentUserId(userId: Int?) + + // Table settings + val selectedTableTypeIds: Flow> + suspend fun saveSelectedTableTypeIds(typeIds: Set) + + // Bluetooth scale settings + val savedBluetoothScaleAddress: Flow + val savedBluetoothScaleName: Flow + suspend fun saveBluetoothScale(address: String, name: String?) + suspend fun clearSavedBluetoothScale() + + // Generic Settings Accessors + /** + * Observes a setting with the given key name and default value. + * The type T determines the preference key type. + */ + fun observeSetting(keyName: String, defaultValue: T): Flow + + /** + * Saves a setting with the given key name and value. + * The type T determines the preference key type. + */ + suspend fun saveSetting(keyName: String, value: T) +} + +/** + * Implementation of [UserSettingsRepository] using Jetpack DataStore. + */ +class UserSettingsRepositoryImpl(private val context: Context) : UserSettingsRepository { + private val dataStore: DataStore = context.userSettingsDataStore + private val TAG = "UserSettingsRepository" // Tag for logging + + override val isFileLoggingEnabled: Flow = observeSetting( + UserPreferenceKeys.IS_FILE_LOGGING_ENABLED.name, + false + ).catch { exception -> + LogManager.e(TAG, "Error observing isFileLoggingEnabled", exception) + emit(false) // Fallback to default on error + } + + override suspend fun setFileLoggingEnabled(enabled: Boolean) { + LogManager.d(TAG, "Setting file logging enabled to: $enabled") + saveSetting(UserPreferenceKeys.IS_FILE_LOGGING_ENABLED.name, enabled) + } + + override val isFirstAppStart: Flow = observeSetting( + UserPreferenceKeys.IS_FIRST_APP_START.name, + true // Default to true, meaning it IS the first start until explicitly set otherwise + ).catch { exception -> + LogManager.e(TAG, "Error observing isFirstAppStart", exception) + emit(true) // Fallback to default on error + } + + override suspend fun setFirstAppStartCompleted(completed: Boolean) { + LogManager.d(TAG, "Setting first app start completed to: $completed") + saveSetting(UserPreferenceKeys.IS_FIRST_APP_START.name, !completed) + } + + override val appLanguageCode: Flow = dataStore.data + .catch { exception -> + LogManager.e(TAG, "Error reading appLanguageCode from DataStore.", exception) + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + .map { preferences -> + preferences[UserPreferenceKeys.APP_LANGUAGE_CODE] + } + .distinctUntilChanged() + + override suspend fun setAppLanguageCode(languageCode: String?) { + LogManager.d(TAG, "Setting app language code to: $languageCode") + dataStore.edit { preferences -> + if (languageCode != null) { + preferences[UserPreferenceKeys.APP_LANGUAGE_CODE] = languageCode + } else { + preferences.remove(UserPreferenceKeys.APP_LANGUAGE_CODE) + } + } + } + + override val currentUserId: Flow = dataStore.data + .catch { exception -> + LogManager.e(TAG, "Error reading currentUserId from DataStore.", exception) + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + .map { preferences -> + preferences[UserPreferenceKeys.CURRENT_USER_ID] + } + .distinctUntilChanged() + + override suspend fun setCurrentUserId(userId: Int?) { + LogManager.d(TAG, "Setting current user ID to: $userId") + dataStore.edit { preferences -> + if (userId != null) { + preferences[UserPreferenceKeys.CURRENT_USER_ID] = userId + } else { + preferences.remove(UserPreferenceKeys.CURRENT_USER_ID) + } + } + } + + override val selectedTableTypeIds: Flow> = observeSetting( + UserPreferenceKeys.SELECTED_TYPES_TABLE.name, + emptySet() + ).catch { exception -> + LogManager.e(TAG, "Error observing selectedTableTypeIds", exception) + emit(emptySet()) // Fallback to default on error + } + + override suspend fun saveSelectedTableTypeIds(typeIds: Set) { + LogManager.d(TAG, "Saving selected table type IDs: $typeIds") + saveSetting(UserPreferenceKeys.SELECTED_TYPES_TABLE.name, typeIds) + } + + override val savedBluetoothScaleAddress: Flow = dataStore.data + .catch { exception -> + LogManager.e(TAG, "Error reading savedBluetoothScaleAddress from DataStore.", exception) + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + .map { preferences -> + preferences[UserPreferenceKeys.SAVED_BLUETOOTH_SCALE_ADDRESS] + } + .distinctUntilChanged() + + override val savedBluetoothScaleName: Flow = dataStore.data + .catch { exception -> + LogManager.e(TAG, "Error reading savedBluetoothScaleName from DataStore.", exception) + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + .map { preferences -> + preferences[UserPreferenceKeys.SAVED_BLUETOOTH_SCALE_NAME] + } + .distinctUntilChanged() + + override suspend fun saveBluetoothScale(address: String, name: String?) { + LogManager.i(TAG, "Saving Bluetooth scale: Address=$address, Name=$name") + dataStore.edit { preferences -> + preferences[UserPreferenceKeys.SAVED_BLUETOOTH_SCALE_ADDRESS] = address + if (name != null) { + preferences[UserPreferenceKeys.SAVED_BLUETOOTH_SCALE_NAME] = name + } else { + preferences.remove(UserPreferenceKeys.SAVED_BLUETOOTH_SCALE_NAME) + } + } + } + + override suspend fun clearSavedBluetoothScale() { + LogManager.i(TAG, "Clearing saved Bluetooth scale information.") + dataStore.edit { preferences -> + preferences.remove(UserPreferenceKeys.SAVED_BLUETOOTH_SCALE_ADDRESS) + preferences.remove(UserPreferenceKeys.SAVED_BLUETOOTH_SCALE_NAME) + } + } + + @Suppress("UNCHECKED_CAST") + override fun observeSetting(keyName: String, defaultValue: T): Flow { + LogManager.v(TAG, "Observing setting: key='$keyName', type='${defaultValue!!::class.simpleName}'") + return dataStore.data + .catch { exception -> + LogManager.e(TAG, "Error reading setting '$keyName' from DataStore.", exception) + if (exception is IOException) { + // IOExceptions are common if DataStore is corrupted or inaccessible + emit(emptyPreferences()) + } else { + // Rethrow other critical exceptions + throw exception + } + } + .map { preferences -> + val preferenceKey = when (defaultValue) { + is Boolean -> booleanPreferencesKey(keyName) + is Int -> intPreferencesKey(keyName) + is Long -> longPreferencesKey(keyName) + is Float -> floatPreferencesKey(keyName) + is Double -> doublePreferencesKey(keyName) + is String -> stringPreferencesKey(keyName) + is Set<*> -> { + // Ensure all elements in the set are Strings, as DataStore only supports Set + if (defaultValue.all { it is String }) { + stringSetPreferencesKey(keyName) as Preferences.Key + } else { + val errorMsg = "Unsupported Set type for preference: $keyName. Only Set is supported." + LogManager.e(TAG, errorMsg) + throw IllegalArgumentException(errorMsg) + } + } + else -> { + val errorMsg = "Unsupported type for preference: $keyName (Type: ${defaultValue::class.java.name})" + LogManager.e(TAG, errorMsg) + throw IllegalArgumentException(errorMsg) + } + } + preferences[preferenceKey as Preferences.Key] ?: defaultValue.also { + LogManager.v(TAG, "Setting '$keyName' not found, returning default value: $it") + } + } + .distinctUntilChanged() + } + + override suspend fun saveSetting(keyName: String, value: T) { + LogManager.v(TAG, "Saving setting: key='$keyName', value='$value', type='${value!!::class.simpleName}'") + try { + dataStore.edit { preferences -> + when (value) { + is Boolean -> preferences[booleanPreferencesKey(keyName)] = value + is Int -> preferences[intPreferencesKey(keyName)] = value + is Long -> preferences[longPreferencesKey(keyName)] = value + is Float -> preferences[floatPreferencesKey(keyName)] = value + is Double -> preferences[doublePreferencesKey(keyName)] = value + is String -> preferences[stringPreferencesKey(keyName)] = value + is Set<*> -> { + if (value.all { it is String }) { + @Suppress("UNCHECKED_CAST") + preferences[stringSetPreferencesKey(keyName)] = value as Set + } else { + val errorMsg = "Unsupported Set type for preference: $keyName. Only Set is supported." + LogManager.e(TAG, errorMsg) + throw IllegalArgumentException(errorMsg) // This will be caught by the outer try-catch + } + } + else -> { + val errorMsg = "Unsupported type for preference: $keyName (Type: ${value!!::class.java.name})" + LogManager.e(TAG, errorMsg) + throw IllegalArgumentException(errorMsg) // This will be caught by the outer try-catch + } + } + } + LogManager.d(TAG, "Successfully saved setting: key='$keyName'") + } catch (e: Exception) { + LogManager.e(TAG, "Failed to save setting: key='$keyName', value='$value'", e) + // Depending on the app's needs, you might want to rethrow or handle specific exceptions differently. + } + } +} + +/** + * Provides an instance of [UserSettingsRepository]. + * This function should be used for dependency injection. + */ +fun provideUserSettingsRepository(context: Context): UserSettingsRepository { + return UserSettingsRepositoryImpl(context.applicationContext) +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/datatypes/ScaleMeasurement.java b/android_app/app/src/main/java/com/health/openscale/core/datatypes/ScaleMeasurement.java deleted file mode 100644 index ffe30d07..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/datatypes/ScaleMeasurement.java +++ /dev/null @@ -1,525 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.datatypes; - -import androidx.room.ColumnInfo; -import androidx.room.Entity; -import androidx.room.ForeignKey; -import androidx.room.Ignore; -import androidx.room.Index; -import androidx.room.PrimaryKey; - -import com.health.openscale.core.utils.CsvHelper; -import com.j256.simplecsv.common.CsvColumn; - -import java.lang.reflect.Field; -import java.util.Date; - -import timber.log.Timber; - -@Entity(tableName = "scaleMeasurements", - indices = {@Index(value = {"userId", "datetime"}, unique = true)}, - foreignKeys = @ForeignKey( - entity = ScaleUser.class, - parentColumns = "id", - childColumns = "userId", - onDelete = ForeignKey.CASCADE)) -public class ScaleMeasurement implements Cloneable { - - @PrimaryKey(autoGenerate = true) - private int id; - - @ColumnInfo(name = "userId") - private int userId; - @ColumnInfo(name = "enabled") - private boolean enabled; - @CsvColumn(converterClass = CsvHelper.DateTimeConverter.class, format ="yyyy-MM-dd HH:mm", mustNotBeBlank = true) - @ColumnInfo(name = "datetime") - private Date dateTime; - @CsvColumn(mustNotBeBlank = true) - @ColumnInfo(name = "weight") - private float weight; - @CsvColumn(mustBeSupplied = false) - @ColumnInfo(name = "fat") - private float fat; - @CsvColumn(mustBeSupplied = false) - @ColumnInfo(name = "water") - private float water; - @CsvColumn(mustBeSupplied = false) - @ColumnInfo(name = "muscle") - private float muscle; - @CsvColumn(mustBeSupplied = false) - @ColumnInfo(name = "visceralFat") - private float visceralFat; - @CsvColumn(mustBeSupplied = false) - @ColumnInfo(name = "lbm") - private float lbm; - @CsvColumn(mustBeSupplied = false) - @ColumnInfo(name = "waist") - private float waist; - @CsvColumn(mustBeSupplied = false) - @ColumnInfo(name = "hip") - private float hip; - @CsvColumn(mustBeSupplied = false) - @ColumnInfo(name = "bone") - private float bone; - @CsvColumn(mustBeSupplied = false) - @ColumnInfo(name = "chest") - private float chest; - @CsvColumn(mustBeSupplied = false) - @ColumnInfo(name = "thigh") - private float thigh; - @CsvColumn(mustBeSupplied = false) - @ColumnInfo(name = "biceps") - private float biceps; - @CsvColumn(mustBeSupplied = false) - @ColumnInfo(name = "neck") - private float neck; - @CsvColumn(mustBeSupplied = false) - @ColumnInfo(name = "caliper1") - private float caliper1; - @CsvColumn(mustBeSupplied = false) - @ColumnInfo(name = "caliper2") - private float caliper2; - @CsvColumn(mustBeSupplied = false) - @ColumnInfo(name = "caliper3") - private float caliper3; - @CsvColumn(mustBeSupplied = false) - @ColumnInfo(name = "calories") - private float calories; - @CsvColumn(mustBeSupplied = false) - @ColumnInfo(name = "comment") - private String comment; - @Ignore - private int count; - - public ScaleMeasurement() - { - userId = -1; - enabled = true; - dateTime = new Date(); - weight = 0.0f; - fat = 0.0f; - water = 0.0f; - muscle = 0.0f; - lbm = 0.0f; - bone = 0.0f; - waist = 0.0f; - hip = 0.0f; - chest = 0.0f; - thigh = 0.0f; - biceps = 0.0f; - neck = 0.0f; - caliper1 = 0.0f; - caliper2 = 0.0f; - caliper3 = 0.0f; - comment = ""; - count = 1; - } - - @Override - public ScaleMeasurement clone() { - ScaleMeasurement clone; - try { - clone = (ScaleMeasurement) super.clone(); - } - catch (CloneNotSupportedException e) { - throw new RuntimeException("failed to clone ScaleMeasurement", e); - } - clone.dateTime = (Date) dateTime.clone(); - return clone; - } - - public void add(final ScaleMeasurement summand) { - try { - Field[] fields = getClass().getDeclaredFields(); - - for (Field field : fields) { - field.setAccessible(true); - Object value = field.get(this); - - if (value != null && Float.class.isAssignableFrom(value.getClass())) { - field.set(this, (float)value + (float)field.get(summand)); - } - field.setAccessible(false); - } - - count++; - } catch (IllegalAccessException e) { - Timber.e(e); - } - } - - public void add(final float summand) { - try { - Field[] fields = getClass().getDeclaredFields(); - - for (Field field : fields) { - field.setAccessible(true); - Object value = field.get(this); - - if (value != null && Float.class.isAssignableFrom(value.getClass())) { - field.set(this, (float)value + summand); - } - field.setAccessible(false); - } - - } catch (IllegalAccessException e) { - Timber.e(e); - } - } - - public void subtract(final ScaleMeasurement minuend) { - try { - Field[] fields = getClass().getDeclaredFields(); - - for (Field field : fields) { - field.setAccessible(true); - Object value = field.get(this); - if (value != null && Float.class.isAssignableFrom(value.getClass())) { - field.set(this, (float)value - (float)field.get(minuend)); - } - field.setAccessible(false); - } - } catch (IllegalAccessException e) { - Timber.e(e); - } - } - - public void multiply(final float factor) { - try { - Field[] fields = getClass().getDeclaredFields(); - - for (Field field : fields) { - field.setAccessible(true); - Object value = field.get(this); - if (value != null && Float.class.isAssignableFrom(value.getClass())) { - field.set(this, (float)value * factor); - } - field.setAccessible(false); - } - } catch (IllegalAccessException e) { - Timber.e(e); - } - } - - public void divide(final float divisor) { - try { - Field[] fields = getClass().getDeclaredFields(); - - for (Field field : fields) { - field.setAccessible(true); - Object value = field.get(this); - if (value != null && Float.class.isAssignableFrom(value.getClass())) { - field.set(this, (float)value / divisor); - } - field.setAccessible(false); - } - } catch (IllegalAccessException e) { - Timber.e(e); - } - } - - public void merge(ScaleMeasurement measurements) { - try { - Field[] fields = getClass().getDeclaredFields(); - - for (Field field : fields) { - field.setAccessible(true); - Object value = field.get(measurements); - if (value != null && Float.class.isAssignableFrom(value.getClass())) { - if ((float)field.get(this) == 0.0f) { - field.set(this, value); - } - } - field.setAccessible(false); - } - } catch (IllegalAccessException e) { - Timber.e(e); - } - } - - public int count() { return count; } - - public boolean isAverageValue() { return (count > 1); } - - public int getId() { - return id; - } - - public void setId(int id) { - this.id = id; - } - - public int getUserId() { - return userId; - } - - public void setUserId(int user_id) { - this.userId = user_id; - } - - public boolean getEnabled() { - return enabled; - } - - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } - - public Date getDateTime() { - return dateTime; - } - - public void setDateTime(Date date_time) { - this.dateTime = date_time; - } - - public float getWeight() { - return weight; - } - - public void setWeight(float weight) { - this.weight = weight; - } - - public float getFat() { - return fat; - } - - public void setFat(float fat) { - this.fat = fat; - } - - public float getWater() { - return water; - } - - public void setWater(float water) { - this.water = water; - } - - public float getMuscle() { - return muscle; - } - - public void setMuscle(float muscle) { - this.muscle = muscle; - } - - public float getVisceralFat() { - return visceralFat; - } - - public void setVisceralFat(float visceralFat) { - this.visceralFat = visceralFat; - } - - public float getLbm() { - return lbm; - } - - public void setLbm(float lbm) { - this.lbm = lbm; - } - - public float getWaist() { - return waist; - } - - public void setWaist(float waist) { - this.waist = waist; - } - - public float getHip() { - return hip; - } - - public void setHip(float hip) { - this.hip = hip; - } - - public float getBone() { return bone; } - - public void setBone(float bone) {this.bone = bone; } - - public float getChest() { - return chest; - } - - public void setChest(float chest) { - this.chest = chest; - } - - public float getThigh() { - return thigh; - } - - public void setThigh(float thigh) { - this.thigh = thigh; - } - - public float getBiceps() { - return biceps; - } - - public void setBiceps(float biceps) { - this.biceps = biceps; - } - - public float getNeck() { - return neck; - } - - public void setNeck(float neck) { - this.neck = neck; - } - - public float getCaliper1() { - return caliper1; - } - - public void setCaliper1(float caliper1) { - this.caliper1 = caliper1; - } - - public float getCaliper2() { - return caliper2; - } - - public void setCaliper2(float caliper2) { - this.caliper2 = caliper2; - } - - public float getCaliper3() { - return caliper3; - } - - public void setCaliper3(float caliper3) { - this.caliper3 = caliper3; - } - - public float getCalories() { return calories; } - - public void setCalories(float calories) { this.calories = calories; } - - public String getComment() { - return comment; - } - - public void setComment(String comment) { - if (comment == null) { - this.comment = ""; - } - else { - this.comment = comment; - } - } - - public float getBMI(float body_height) { - return weight / ((body_height / 100.0f)*(body_height / 100.0f)); - } - - public float getBMR(ScaleUser scaleUser) { - float bmr; - - // BMR Harris-Benedict equation - if (scaleUser.getGender().isMale()) { - bmr = 66.4730f + (13.7516f * weight) + (5.0033f * scaleUser.getBodyHeight()) - (6.7550f * scaleUser.getAge(dateTime)); - } else { - bmr = 655.0955f + (9.5634f * weight) + (1.8496f * scaleUser.getBodyHeight()) - (4.6756f * scaleUser.getAge(dateTime)); - } - - return bmr; // kCal / day - } - - public float getTDEE(ScaleUser scaleUser) { - float factor = 1.0f; - - switch (scaleUser.getActivityLevel()) { - case SEDENTARY: - factor = 1.2f; - break; - case MILD: - factor = 1.375f; - break; - case MODERATE: - factor = 1.55f; - break; - case HEAVY: - factor = 1.725f; - break; - case EXTREME: - factor = 1.9f; - break; - } - - return factor * getBMR(scaleUser); - } - - public float getWHtR(float body_height) { - return waist / body_height; - } - - public float getWHR() { - if (hip == 0) { - return 0; - } - - return waist / hip; - } - - public float getFatCaliper(ScaleUser scaleUser) { - float fat_caliper; - - float k0, k1, k2, ka; - - if (caliper1 == 0.0f || caliper2 == 0.0f || caliper3 == 0.0f){ - return 0.0f; - } - - float s = (caliper1 + caliper2 + caliper3) * 10.0f; // cm to mm - - if (scaleUser.getGender().isMale()) { - k0 = 1.10938f; - k1 = 0.0008267f; - k2 = 0.0000016f; - ka = 0.0002574f; - } else { - k0 = 1.0994921f; - k1 = 0.0009929f; - k2 = 0.0000023f; - ka = 0.0001392f; - } - - // calipometrie formula by Jackson, Pollock: Generalized equations for predicting body density of women. In: British Journal of Nutrition. Nr.40, Oktober 1978, S.497–504 - fat_caliper = ((4.95f / (k0 - (k1*s) + (k2 * s*s) - (ka*scaleUser.getAge()))) - 4.5f) * 100.0f; - - return fat_caliper; - } - - @Override - public String toString() - { - return String.format( - "ID: %d, USER_ID: %d, DATE_TIME: %s, WEIGHT: %.2f, FAT: %.2f, WATER: %.2f, " + - "MUSCLE: %.2f, LBM: %.2f, WAIST: %.2f, HIP: %.2f, BONE: %.2f, CHEST: %.2f, " + - "THIGH: %.2f, ARM: %.2f, NECK: %.2f, CALIPER1: %.2f, CALIPER2: %.2f, CALIPER3: %.2f, COMMENT: %s", - id, userId, dateTime.toString(), weight, fat, water, - muscle, lbm, waist, hip, bone, chest, thigh, biceps, neck, caliper1, caliper2, caliper3, comment); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/datatypes/ScaleUser.java b/android_app/app/src/main/java/com/health/openscale/core/datatypes/ScaleUser.java deleted file mode 100644 index 5be6d42e..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/datatypes/ScaleUser.java +++ /dev/null @@ -1,298 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.datatypes; - -import androidx.annotation.NonNull; -import androidx.room.ColumnInfo; -import androidx.room.Entity; -import androidx.room.PrimaryKey; - -import com.health.openscale.core.utils.Converters; -import com.health.openscale.core.utils.DateTimeHelpers; - -import java.util.Calendar; -import java.util.Date; - -@Entity(tableName = "scaleUsers") -public class ScaleUser { - @PrimaryKey(autoGenerate = true) - private int id; - - @NonNull - @ColumnInfo(name = "username") - private String userName; - @NonNull - @ColumnInfo(name = "birthday") - private Date birthday; - @ColumnInfo(name = "bodyHeight") - private float bodyHeight; - @ColumnInfo(name = "scaleUnit") - @NonNull - private Converters.WeightUnit scaleUnit; - @ColumnInfo(name = "gender") - @NonNull - private Converters.Gender gender; - @ColumnInfo(name = "goalEnabled") - private boolean goalEnabled; - @ColumnInfo(name = "initialWeight") - private float initialWeight; - @ColumnInfo(name = "goalWeight") - private float goalWeight; - @ColumnInfo(name = "goalDate") - private Date goalDate; - @NonNull - @ColumnInfo(name = "measureUnit") - private Converters.MeasureUnit measureUnit; - @NonNull - @ColumnInfo(name = "activityLevel") - private Converters.ActivityLevel activityLevel; - @ColumnInfo(name = "assistedWeighing") - private boolean assistedWeighing; - @NonNull - @ColumnInfo(name = "leftAmputationLevel") - private Converters.AmputationLevel leftAmputationLevel; - @NonNull - @ColumnInfo(name = "rightAmputationLevel") - private Converters.AmputationLevel rightAmputationLevel; - - public ScaleUser() { - userName = ""; - birthday = new Date(); - bodyHeight = -1; - scaleUnit = Converters.WeightUnit.KG; - gender = Converters.Gender.MALE; - initialWeight = -1; - goalEnabled = false; - goalWeight = -1; - goalDate = new Date(); - measureUnit = Converters.MeasureUnit.CM; - activityLevel = Converters.ActivityLevel.SEDENTARY; - assistedWeighing = false; - leftAmputationLevel = Converters.AmputationLevel.NONE; - rightAmputationLevel = Converters.AmputationLevel.NONE; - } - - public int getId() { - return id; - } - - public void setId(int id) { - this.id = id; - } - - public String getUserName() { - return userName; - } - - public void setUserName(String userName) { - this.userName = userName; - } - - public Date getBirthday() { - return birthday; - } - - public void setBirthday(Date birthday) { - this.birthday = birthday; - } - - public float getBodyHeight() { - return bodyHeight; - } - - public void setBodyHeight(float bodyHeight) { - this.bodyHeight = bodyHeight; - } - - public Converters.WeightUnit getScaleUnit() { - return scaleUnit; - } - - public void setScaleUnit(Converters.WeightUnit scaleUnit) { - this.scaleUnit = scaleUnit; - } - - public Converters.Gender getGender() { - return gender; - } - - public void setGender(Converters.Gender gender) { - this.gender = gender; - } - - public boolean isGoalEnabled() { - return goalEnabled; - } - - public void setGoalEnabled(boolean goalEnabled) { - this.goalEnabled = goalEnabled; - } - - public float getGoalWeight() { - return goalWeight; - } - - public void setGoalWeight(float goalWeight) { - this.goalWeight = goalWeight; - } - - public Date getGoalDate() { - return goalDate; - } - - public void setGoalDate(Date goalDate) { - this.goalDate = goalDate; - } - - public int getAge(Date todayDate) { - Calendar calToday = Calendar.getInstance(); - if (todayDate != null) { - calToday.setTime(todayDate); - } - - Calendar calBirthday = Calendar.getInstance(); - calBirthday.setTime(birthday); - - return DateTimeHelpers.yearsBetween(calBirthday, calToday); - } - - public int getAge() { - return getAge(null); - } - - public void setInitialWeight(float weight) { - this.initialWeight = weight; - } - - public float getInitialWeight() { - return initialWeight; - } - - public void setMeasureUnit(Converters.MeasureUnit unit) { - measureUnit = unit; - } - - public Converters.MeasureUnit getMeasureUnit() { - return measureUnit; - } - - public void setActivityLevel(Converters.ActivityLevel level) { - activityLevel = level; - } - - public Converters.ActivityLevel getActivityLevel() { - return activityLevel; - } - - public boolean isAssistedWeighing() { - return assistedWeighing; - } - - public void setAssistedWeighing(boolean assistedWeighing) { - this.assistedWeighing = assistedWeighing; - } - - @NonNull - public Converters.AmputationLevel getLeftAmputationLevel() { - return leftAmputationLevel; - } - - public void setLeftAmputationLevel(@NonNull Converters.AmputationLevel leftAmputationLevel) { - this.leftAmputationLevel = leftAmputationLevel; - } - - @NonNull - public Converters.AmputationLevel getRightAmputationLevel() { - return rightAmputationLevel; - } - - public void setRightAmputationLevel(@NonNull Converters.AmputationLevel rightAmputationLevel) { - this.rightAmputationLevel = rightAmputationLevel; - } - - public float getAmputationCorrectionFactor() { - float correctionFactor = 100.0f; - - switch (rightAmputationLevel) { - case NONE: - break; - case HAND: - correctionFactor -= 0.8f; - break; - case FOREARM_HAND: - correctionFactor -= 3.0f; - break; - case ARM: - correctionFactor -= 11.5f; - break; - case FOOT: - correctionFactor -= 1.8f; - break; - case LOWER_LEG_FOOT: - correctionFactor -= 7.1f; - break; - case LEG: - correctionFactor -= 18.7f; - break; - } - - switch (leftAmputationLevel) { - case NONE: - break; - case HAND: - correctionFactor -= 0.8f; - break; - case FOREARM_HAND: - correctionFactor -= 3.0f; - break; - case ARM: - correctionFactor -= 11.5f; - break; - case FOOT: - correctionFactor -= 1.8f; - break; - case LOWER_LEG_FOOT: - correctionFactor -= 7.1f; - break; - case LEG: - correctionFactor -= 18.7f; - break; - } - - return correctionFactor; - } - - public static String getPreferenceKey(int userId, String key) { - return String.format("user.%d.%s", userId, key); - } - - public String getPreferenceKey(String key) { - return getPreferenceKey(getId(), key); - } - - @Override - public String toString() - { - return String.format( - "id(%d) name(%s) birthday(%s) age(%d) body height(%.2f) scale unit(%s) " + - "gender(%s) initial weight(%.2f) goal enabled(%b) goal weight(%.2f) goal date(%s) " + - "measure unt(%s) activity level(%d) assisted weighing(%b)", - id, userName, birthday.toString(), getAge(), bodyHeight, scaleUnit.toString(), - gender.toString().toLowerCase(), initialWeight, goalEnabled, goalWeight, goalDate.toString(), - measureUnit.toString(), activityLevel.toInt(), assistedWeighing); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/evaluation/EvaluationResult.java b/android_app/app/src/main/java/com/health/openscale/core/evaluation/EvaluationResult.java deleted file mode 100644 index 0470217e..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/evaluation/EvaluationResult.java +++ /dev/null @@ -1,34 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.evaluation; - -public class EvaluationResult { - public enum EVAL_STATE {LOW, NORMAL, HIGH, UNDEFINED} - - public float value; - public float lowLimit; - public float highLimit; - public EVAL_STATE eval_state; - - public EvaluationResult(float value, float lowLimit, float highLimit, EVAL_STATE eval_state) - { - this.value = value; - this.lowLimit = lowLimit; - this.highLimit = highLimit; - this.eval_state = eval_state; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/evaluation/EvaluationSheet.java b/android_app/app/src/main/java/com/health/openscale/core/evaluation/EvaluationSheet.java deleted file mode 100644 index b02286a7..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/evaluation/EvaluationSheet.java +++ /dev/null @@ -1,325 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.core.evaluation; - -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; - -import java.util.ArrayList; -import java.util.Date; -import java.util.List; - -public class EvaluationSheet { - - private ScaleUser evalUser; - private int userAge; - - private List fatEvaluateSheet_Man; - private List fatEvaluateSheet_Woman; - - private List waterEvaluateSheet_Man; - private List waterEvaluateSheet_Woman; - - private List muscleEvaluateSheet_Man; - private List muscleEvaluateSheet_Woman; - - private List bmiEvaluateSheet_Man; - private List bmiEvaluateSheet_Woman; - - private List lbmEvaluateSheet_Man; - private List lbmEvaluateSheet_Woman; - - private List waistEvaluateSheet_Man; - private List waistEvaluateSheet_Woman; - - private List whrtEvaluateSheet; - - private List whrEvaluateSheet_Man; - private List whrEvaluateSheet_Woman; - - private List visceralFatEvaluateSheet; - - private class sheetEntry { - public sheetEntry(int lowAge, int maxAge, float lowLimit, float highLimit) - { - this.lowAge = lowAge; - this.maxAge = maxAge; - this.lowLimit = lowLimit; - this.highLimit = highLimit; - } - - public int lowAge; - public int maxAge; - public float lowLimit; - public float highLimit; - } - - - public EvaluationSheet(ScaleUser user, Date dateTime) { - evalUser = user; - userAge = user.getAge(dateTime); - - fatEvaluateSheet_Man = new ArrayList<>(); - fatEvaluateSheet_Woman = new ArrayList<>(); - - waterEvaluateSheet_Man = new ArrayList<>(); - waterEvaluateSheet_Woman = new ArrayList<>(); - - muscleEvaluateSheet_Man = new ArrayList<>(); - muscleEvaluateSheet_Woman = new ArrayList<>(); - - bmiEvaluateSheet_Man = new ArrayList<>(); - bmiEvaluateSheet_Woman = new ArrayList<>(); - - waistEvaluateSheet_Man = new ArrayList<>(); - waistEvaluateSheet_Woman = new ArrayList<>(); - - whrtEvaluateSheet = new ArrayList<>(); - - whrEvaluateSheet_Man = new ArrayList<>(); - whrEvaluateSheet_Woman = new ArrayList<>(); - - visceralFatEvaluateSheet = new ArrayList<>(); - - lbmEvaluateSheet_Man = new ArrayList<>(); - lbmEvaluateSheet_Woman = new ArrayList<>(); - - initEvaluationSheets(); - } - - private void initEvaluationSheets() - { - fatEvaluateSheet_Man.add(new sheetEntry(10, 14, 11, 16)); - fatEvaluateSheet_Man.add(new sheetEntry(15, 19, 12, 17)); - fatEvaluateSheet_Man.add(new sheetEntry(20, 29, 13, 18)); - fatEvaluateSheet_Man.add(new sheetEntry(30, 39, 14, 19)); - fatEvaluateSheet_Man.add(new sheetEntry(40, 49, 15, 20)); - fatEvaluateSheet_Man.add(new sheetEntry(50, 59, 16, 21)); - fatEvaluateSheet_Man.add(new sheetEntry(60, 69, 17, 22)); - fatEvaluateSheet_Man.add(new sheetEntry(70, 1000, 18, 23)); - - - fatEvaluateSheet_Woman.add(new sheetEntry(10, 14, 16, 21)); - fatEvaluateSheet_Woman.add(new sheetEntry(15, 19, 17, 22)); - fatEvaluateSheet_Woman.add(new sheetEntry(20, 29, 18, 23)); - fatEvaluateSheet_Woman.add(new sheetEntry(30, 39, 19, 24)); - fatEvaluateSheet_Woman.add(new sheetEntry(40, 49, 20, 25)); - fatEvaluateSheet_Woman.add(new sheetEntry(50, 59, 21, 26)); - fatEvaluateSheet_Woman.add(new sheetEntry(60, 69, 22, 27)); - fatEvaluateSheet_Woman.add(new sheetEntry(70, 1000, 23, 28)); - - waterEvaluateSheet_Man.add(new sheetEntry(10, 1000, 50, 65)); - - waterEvaluateSheet_Woman.add(new sheetEntry(10, 1000, 45, 60)); - - // Muscle Reference: "Skeletal muscle mass and distribution in 468 men and women aged 18–88 yr" by IAN JANSSEN, STEVEN B. HEYMSFIELD, ZIMIAN WANG, and ROBERT ROS in J Appl Physiol89: 81–88, 2000 - muscleEvaluateSheet_Man.add(new sheetEntry(18, 29, 37.9f, 46.7f)); - muscleEvaluateSheet_Man.add(new sheetEntry(30, 39, 34.1f, 44.1f)); - muscleEvaluateSheet_Man.add(new sheetEntry(40, 49, 33.1f, 41.1f)); - muscleEvaluateSheet_Man.add(new sheetEntry(50, 59, 31.7f, 38.5f)); - muscleEvaluateSheet_Man.add(new sheetEntry(60, 69, 29.9f, 37.7f)); - muscleEvaluateSheet_Man.add(new sheetEntry(70, 1000, 28.7f, 43.3f)); - - muscleEvaluateSheet_Woman.add(new sheetEntry(18, 29, 28.4f, 39.8f)); - muscleEvaluateSheet_Woman.add(new sheetEntry(30, 39, 25.0f, 36.2f)); - muscleEvaluateSheet_Woman.add(new sheetEntry(40, 49, 24.2f, 34.2f)); - muscleEvaluateSheet_Woman.add(new sheetEntry(50, 59, 24.7f, 33.5f)); - muscleEvaluateSheet_Woman.add(new sheetEntry(60, 69, 22.7f, 31.9f)); - muscleEvaluateSheet_Woman.add(new sheetEntry(70, 1000, 25.5f, 34.9f)); - - bmiEvaluateSheet_Man.add(new sheetEntry(16, 24, 20, 25)); - bmiEvaluateSheet_Man.add(new sheetEntry(25, 34, 21, 26)); - bmiEvaluateSheet_Man.add(new sheetEntry(35, 44, 22, 27)); - bmiEvaluateSheet_Man.add(new sheetEntry(45, 54, 23, 28)); - bmiEvaluateSheet_Man.add(new sheetEntry(55, 64, 24, 29)); - bmiEvaluateSheet_Man.add(new sheetEntry(65, 90, 25, 30)); - - bmiEvaluateSheet_Woman.add(new sheetEntry(16, 24, 19, 24)); - bmiEvaluateSheet_Woman.add(new sheetEntry(25, 34, 20, 25)); - bmiEvaluateSheet_Woman.add(new sheetEntry(35, 44, 21, 26)); - bmiEvaluateSheet_Woman.add(new sheetEntry(45, 54, 22, 27)); - bmiEvaluateSheet_Woman.add(new sheetEntry(55, 64, 23, 28)); - bmiEvaluateSheet_Woman.add(new sheetEntry(65, 90, 24, 29)); - - waistEvaluateSheet_Man.add(new sheetEntry(18, 90, -1, Converters.fromCentimeter(94, evalUser.getMeasureUnit()))); - waistEvaluateSheet_Woman.add(new sheetEntry(18, 90, -1, Converters.fromCentimeter(80, evalUser.getMeasureUnit()))); - - whrtEvaluateSheet.add(new sheetEntry(15, 40, 0.4f, 0.5f)); - whrtEvaluateSheet.add(new sheetEntry(41, 42, 0.4f, 0.51f)); - whrtEvaluateSheet.add(new sheetEntry(43, 44, 0.4f, 0.53f)); - whrtEvaluateSheet.add(new sheetEntry(45, 46, 0.4f, 0.55f)); - whrtEvaluateSheet.add(new sheetEntry(47, 48, 0.4f, 0.57f)); - whrtEvaluateSheet.add(new sheetEntry(49, 50, 0.4f, 0.59f)); - whrtEvaluateSheet.add(new sheetEntry(51, 90, 0.4f, 0.6f)); - - whrEvaluateSheet_Man.add(new sheetEntry(18, 90, 0.8f, 0.9f)); - whrEvaluateSheet_Woman.add(new sheetEntry(18, 90, 0.7f, 0.8f)); - - visceralFatEvaluateSheet.add(new sheetEntry(18, 90, -1, 12)); - // Lean body mass reference: "Lean body mass: reference values for Italian population between 18 to 88 years old" DOI: 10.26355/eurrev_201811_16415 - // assuming low limits as P25 and upper limit as P75 - lbmEvaluateSheet_Man.add(new sheetEntry(18, 24, 52.90f, 62.70f)); - lbmEvaluateSheet_Man.add(new sheetEntry(25, 34, 53.10f, 64.80f)); - lbmEvaluateSheet_Man.add(new sheetEntry(35, 44, 53.83f, 65.60f)); - lbmEvaluateSheet_Man.add(new sheetEntry(45, 54, 53.60f, 65.20f)); - lbmEvaluateSheet_Man.add(new sheetEntry(55, 64, 51.63f, 61.10f)); - lbmEvaluateSheet_Man.add(new sheetEntry(65, 74, 48.48f, 58.20f)); - lbmEvaluateSheet_Man.add(new sheetEntry(75, 88, 43.35f, 60.23f)); - lbmEvaluateSheet_Woman.add(new sheetEntry(18, 24, 34.30f, 41.90f)); - lbmEvaluateSheet_Woman.add(new sheetEntry(25, 34, 35.20f, 43.70f)); - lbmEvaluateSheet_Woman.add(new sheetEntry(35, 44, 35.60f, 47.10f)); - lbmEvaluateSheet_Woman.add(new sheetEntry(45, 54, 36.10f, 44.90f)); - lbmEvaluateSheet_Woman.add(new sheetEntry(55, 64, 35.15f, 43.95f)); - lbmEvaluateSheet_Woman.add(new sheetEntry(65, 74, 34.10f, 42.05f)); - lbmEvaluateSheet_Woman.add(new sheetEntry(75, 88, 33.80f, 40.40f)); - } - - - public EvaluationResult evaluateWeight(float weight) { - float body_height_squared = (evalUser.getBodyHeight() / 100.0f) * (evalUser.getBodyHeight() / 100.0f); - float lowLimit; - float highLimit; - - if (evalUser.getGender().isMale()) { - lowLimit = body_height_squared * 20.0f; - highLimit = body_height_squared * 25.0f; - } else { - lowLimit = body_height_squared * 19.0f; - highLimit = body_height_squared * 24.0f; - } - - if (weight < lowLimit) { // low - return new EvaluationResult(weight, Converters.fromKilogram(Math.round(lowLimit), evalUser.getScaleUnit()), Converters.fromKilogram(Math.round(highLimit), evalUser.getScaleUnit()), EvaluationResult.EVAL_STATE.LOW); - } else if (weight >= lowLimit && weight <= highLimit) { // normal - return new EvaluationResult(weight, Converters.fromKilogram(Math.round(lowLimit), evalUser.getScaleUnit()), Converters.fromKilogram(Math.round(highLimit), evalUser.getScaleUnit()), EvaluationResult.EVAL_STATE.NORMAL); - } else if (weight > highLimit) { //high - return new EvaluationResult(weight, Converters.fromKilogram(Math.round(lowLimit), evalUser.getScaleUnit()), Converters.fromKilogram(Math.round(highLimit), evalUser.getScaleUnit()), EvaluationResult.EVAL_STATE.HIGH); - } - - return new EvaluationResult(0, -1, -1, EvaluationResult.EVAL_STATE.UNDEFINED); - } - - - public EvaluationResult evaluateBodyFat(float fat) { - List bodyEvaluateSheet; - - if (evalUser.getGender().isMale()) { - bodyEvaluateSheet = fatEvaluateSheet_Man; - } else { - bodyEvaluateSheet = fatEvaluateSheet_Woman; - } - - return evaluateSheet(fat, bodyEvaluateSheet); - } - - public EvaluationResult evaluateBodyWater(float water) { - List bodyEvaluateSheet; - - if (evalUser.getGender().isMale()) { - bodyEvaluateSheet = waterEvaluateSheet_Man; - } else { - bodyEvaluateSheet = waterEvaluateSheet_Woman; - } - - return evaluateSheet(water, bodyEvaluateSheet); - } - - public EvaluationResult evaluateBodyMuscle(float muscle) { - List bodyEvaluateSheet; - - if (evalUser.getGender().isMale()) { - bodyEvaluateSheet = muscleEvaluateSheet_Man; - } else { - bodyEvaluateSheet = muscleEvaluateSheet_Woman; - } - - return evaluateSheet(muscle, bodyEvaluateSheet); - } - - public EvaluationResult evaluateBMI(float bmi) { - List bodyEvaluateSheet; - - if (evalUser.getGender().isMale()) { - bodyEvaluateSheet = bmiEvaluateSheet_Man; - } else { - bodyEvaluateSheet = bmiEvaluateSheet_Woman; - } - - return evaluateSheet(bmi, bodyEvaluateSheet); - } - - public EvaluationResult evaluateLBM(float lbm) { - List bodyEvaluateSheet; - - if (evalUser.getGender().isMale()) { - bodyEvaluateSheet = lbmEvaluateSheet_Man; - } else { - bodyEvaluateSheet = lbmEvaluateSheet_Woman; - } - - return evaluateSheet(lbm, bodyEvaluateSheet); - } - - public EvaluationResult evaluateWaist(float waist) { - List bodyEvaluateSheet; - - if (evalUser.getGender().isMale()) { - bodyEvaluateSheet = waistEvaluateSheet_Man; - } else { - bodyEvaluateSheet = waistEvaluateSheet_Woman; - } - - return evaluateSheet(waist, bodyEvaluateSheet); - } - - public EvaluationResult evaluateWHtR(float whrt) { - return evaluateSheet(whrt, whrtEvaluateSheet); - } - - public EvaluationResult evaluateWHR(float whr) { - List bodyEvaluateSheet; - - if (evalUser.getGender().isMale()) { - bodyEvaluateSheet = whrEvaluateSheet_Man; - } else { - bodyEvaluateSheet = whrEvaluateSheet_Woman; - } - - return evaluateSheet(whr, bodyEvaluateSheet); - } - - public EvaluationResult evaluateVisceralFat(float visceralFat) { - return evaluateSheet(visceralFat, visceralFatEvaluateSheet); - } - - private EvaluationResult evaluateSheet(float value, List sheet) { - for (int i=0; i < sheet.size(); i++) { - sheetEntry curEntry = sheet.get(i); - - if (curEntry.lowAge <= userAge && curEntry.maxAge >= userAge) { - if (value < curEntry.lowLimit) { // low - return new EvaluationResult(value, curEntry.lowLimit, curEntry.highLimit, EvaluationResult.EVAL_STATE.LOW); - } else if (value >= curEntry.lowLimit && value <= curEntry.highLimit) { // normal - return new EvaluationResult(value, curEntry.lowLimit, curEntry.highLimit, EvaluationResult.EVAL_STATE.NORMAL); - } else if (value > curEntry.highLimit) { //high - return new EvaluationResult(value, curEntry.lowLimit, curEntry.highLimit, EvaluationResult.EVAL_STATE.HIGH); - } - } - } - - return new EvaluationResult(0, -1, -1, EvaluationResult.EVAL_STATE.UNDEFINED); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/model/MeasurementModel.kt b/android_app/app/src/main/java/com/health/openscale/core/model/MeasurementModel.kt new file mode 100644 index 00000000..2b57f827 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/model/MeasurementModel.kt @@ -0,0 +1,43 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.model + +import androidx.room.Embedded +import androidx.room.Relation +import com.health.openscale.core.data.Measurement +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementValue + +data class MeasurementWithValues( + @Embedded val measurement: Measurement, + @Relation( + parentColumn = "id", + entityColumn = "measurementId", + entity = MeasurementValue::class + ) + val values: List +) + +data class MeasurementValueWithType( + @Embedded val value: MeasurementValue, + @Relation( + parentColumn = "typeId", + entityColumn = "id" + ) + val type: MeasurementType +) \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/utils/Converters.java b/android_app/app/src/main/java/com/health/openscale/core/utils/Converters.java deleted file mode 100644 index d401e395..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/utils/Converters.java +++ /dev/null @@ -1,407 +0,0 @@ -/* Copyright (C) 2018 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.utils; - -import androidx.room.TypeConverter; - -import java.util.Date; - -public class Converters { - public enum MeasureUnit { - CM, INCH; - - public String toString() { - switch (this) { - case CM: - return "cm"; - case INCH: - return "in"; - } - - return ""; - } - - public static MeasureUnit fromInt(int unit) { - switch (unit) { - case 0: - return CM; - case 1: - return INCH; - } - return CM; - } - - public int toInt() { - switch (this) { - case CM: - return 0; - case INCH: - return 1; - } - - return 0; - } - } - - public enum WeightUnit { - KG, LB, ST; - - public String toString() { - switch (this) { - case LB: - return "lb"; - case ST: - return "st"; - } - return "kg"; - } - - public static WeightUnit fromInt(int unit) { - switch (unit) { - case 1: - return LB; - case 2: - return ST; - } - return KG; - } - - public int toInt() { - switch (this) { - case LB: - return 1; - case ST: - return 2; - } - return 0; - } - } - - public enum Gender { - MALE, FEMALE; - - public boolean isMale() { - return this == MALE; - } - - public static Gender fromInt(int gender) { - return gender == 0 ? MALE : FEMALE; - } - - public int toInt() { - return this == MALE ? 0 : 1; - } - } - - public enum ActivityLevel { - SEDENTARY, MILD, MODERATE, HEAVY, EXTREME; - - public static ActivityLevel fromInt(int unit) { - switch (unit) { - case 0: - return SEDENTARY; - case 1: - return MILD; - case 2: - return MODERATE; - case 3: - return HEAVY; - case 4: - return EXTREME; - } - - return SEDENTARY; - } - - public int toInt() { - switch (this) { - case SEDENTARY: - return 0; - case MILD: - return 1; - case MODERATE: - return 2; - case HEAVY: - return 3; - case EXTREME: - return 4; - } - - return 0; - } - } - - public enum AmputationLevel { - NONE, HAND, FOREARM_HAND, ARM, FOOT, LOWER_LEG_FOOT, LEG; - - public static AmputationLevel fromInt(int unit) { - switch (unit) { - case 0: - return NONE; - case 1: - return HAND; - case 2: - return FOREARM_HAND; - case 3: - return ARM; - case 4: - return FOOT; - case 5: - return LOWER_LEG_FOOT; - case 6: - return LEG; - } - - return NONE; - } - - public int toInt() { - switch (this) { - case NONE: - return 0; - case HAND: - return 1; - case FOREARM_HAND: - return 2; - case ARM: - return 3; - case FOOT: - return 4; - case LOWER_LEG_FOOT: - return 5; - case LEG: - return 6; - } - - return 0; - } - } - - private static final float KG_LB = 2.20462f; - private static final float KG_ST = 0.157473f; - private static final float CM_IN = 0.393701f; - - @TypeConverter - public static Date fromTimestamp(Long value) { - return value == null ? null : new Date(value); - } - - @TypeConverter - public static Long dateToTimestamp(Date date) { - return date == null ? null : date.getTime(); - } - - @TypeConverter - public static MeasureUnit fromMeasureUnitInt(int unit) { - return MeasureUnit.fromInt(unit); - } - - @TypeConverter - public static int toMeasureUnitInt(MeasureUnit unit) { - return unit.toInt(); - } - - @TypeConverter - public static WeightUnit fromWeightUnitInt(int unit) { - return WeightUnit.fromInt(unit); - } - - @TypeConverter - public static int toWeightUnitInt(WeightUnit unit) { - return unit.toInt(); - } - - @TypeConverter - public static Gender fromGenderInt(int gender) { - return Gender.fromInt(gender); - } - - @TypeConverter - public static int toGenderInt(Gender gender) { - return gender.toInt(); - } - - @TypeConverter - public static ActivityLevel fromActivityLevelInt(int level) { - return ActivityLevel.fromInt(level); - } - - @TypeConverter - public static int toActivityLevelInt(ActivityLevel level) { - return level.toInt(); - } - - @TypeConverter - public static AmputationLevel fromAmputationLevelInt(int level) { - return AmputationLevel.fromInt(level); - } - - @TypeConverter - public static int toAmputationLevelInt(AmputationLevel level) { - return level.toInt(); - } - - public static float toCentimeter(float value, MeasureUnit unit) { - switch (unit) { - case INCH: - return value / CM_IN; - } - return value; - } - - public static float fromCentimeter(float cm, MeasureUnit unit) { - switch (unit) { - case INCH: - return cm * CM_IN; - } - return cm; - } - - public static float toKilogram(float value, WeightUnit unit) { - switch (unit) { - case LB: - return value / KG_LB; - case ST: - return value / KG_ST; - } - return value; - } - - public static float fromKilogram(float kg, WeightUnit unit) { - switch (unit) { - case LB: - return kg * KG_LB; - case ST: - return kg * KG_ST; - } - return kg; - } - - public static int fromSignedInt16Le(byte[] data, int offset) { - int value = data[offset + 1] << 8; - value += data[offset] & 0xFF; - return value; - } - - public static int fromSignedInt16Be(byte[] data, int offset) { - int value = data[offset] << 8; - value += data[offset + 1] & 0xFF; - return value; - } - - public static int fromUnsignedInt16Le(byte[] data, int offset) { - return fromSignedInt16Le(data, offset) & 0xFFFF; - } - - public static int fromUnsignedInt16Be(byte[] data, int offset) { - return fromSignedInt16Be(data, offset) & 0xFFFF; - } - - public static void toInt16Le(byte[] data, int offset, int value) { - data[offset + 0] = (byte) (value & 0xFF); - data[offset + 1] = (byte) ((value >> 8) & 0xFF); - } - - public static void toInt16Be(byte[] data, int offset, int value) { - data[offset + 0] = (byte) ((value >> 8) & 0xFF); - data[offset + 1] = (byte) (value & 0xFF); - } - - public static byte[] toInt16Le(int value) { - byte[] data = new byte[2]; - toInt16Le(data, 0, value); - return data; - } - - public static byte[] toInt16Be(int value) { - byte[] data = new byte[2]; - toInt16Be(data, 0, value); - return data; - } - - public static int fromSignedInt24Le(byte[] data, int offset) { - int value = data[offset + 2] << 16; - value += (data[offset + 1] & 0xFF) << 8; - value += data[offset] & 0xFF; - return value; - } - - public static int fromSignedInt24Be(byte[] data, int offset) { - int value = data[offset] << 16; - value += (data[offset + 1] & 0xFF) << 8; - value += data[offset + 2] & 0xFF; - return value; - } - - public static int fromUnsignedInt24Le(byte[] data, int offset) { - return fromSignedInt24Le(data, offset) & 0xFFFFFF; - } - - public static int fromUnsignedInt24Be(byte[] data, int offset) { - return fromSignedInt24Be(data, offset) & 0xFFFFFF; - } - - public static int fromSignedInt32Le(byte[] data, int offset) { - int value = data[offset + 3] << 24; - value += (data[offset + 2] & 0xFF) << 16; - value += (data[offset + 1] & 0xFF) << 8; - value += data[offset] & 0xFF; - return value; - } - - public static int fromSignedInt32Be(byte[] data, int offset) { - int value = data[offset] << 24; - value += (data[offset + 1] & 0xFF) << 16; - value += (data[offset + 2] & 0xFF) << 8; - value += data[offset + 3] & 0xFF; - return value; - } - - public static long fromUnsignedInt32Le(byte[] data, int offset) { - return (long) fromSignedInt32Le(data, offset) & 0xFFFFFFFFL; - } - - public static long fromUnsignedInt32Be(byte[] data, int offset) { - return (long) fromSignedInt32Be(data, offset) & 0xFFFFFFFFL; - } - - public static void toInt32Le(byte[] data, int offset, long value) { - data[offset + 3] = (byte) ((value >> 24) & 0xFF); - data[offset + 2] = (byte) ((value >> 16) & 0xFF); - data[offset + 1] = (byte) ((value >> 8) & 0xFF); - data[offset + 0] = (byte) (value & 0xFF); - } - - public static void toInt32Be(byte[] data, int offset, long value) { - data[offset + 0] = (byte) ((value >> 24) & 0xFF); - data[offset + 1] = (byte) ((value >> 16) & 0xFF); - data[offset + 2] = (byte) ((value >> 8) & 0xFF); - data[offset + 3] = (byte) (value & 0xFF); - } - - public static byte[] toInt32Le(long value) { - byte[] data = new byte[4]; - toInt32Le(data, 0, value); - return data; - } - - public static byte[] toInt32Be(long value) { - byte[] data = new byte[4]; - toInt32Be(data, 0, value); - return data; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/utils/Converters.kt b/android_app/app/src/main/java/com/health/openscale/core/utils/Converters.kt new file mode 100644 index 00000000..17edd4f7 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/utils/Converters.kt @@ -0,0 +1,135 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.utils + +object Converters { + @JvmStatic + fun fromSignedInt16Le(data: ByteArray, offset: Int): Int { + var value = data[offset + 1].toInt() shl 8 + value += data[offset].toInt() and 0xFF + return value + } + @JvmStatic + fun fromSignedInt16Be(data: ByteArray, offset: Int): Int { + var value = data[offset].toInt() shl 8 + value += data[offset + 1].toInt() and 0xFF + return value + } + @JvmStatic + fun fromUnsignedInt16Le(data: ByteArray, offset: Int): Int { + return fromSignedInt16Le(data, offset) and 0xFFFF + } + @JvmStatic + fun fromUnsignedInt16Be(data: ByteArray, offset: Int): Int { + return fromSignedInt16Be(data, offset) and 0xFFFF + } + @JvmStatic + fun toInt16Le(data: ByteArray, offset: Int, value: Int) { + data[offset + 0] = (value and 0xFF).toByte() + data[offset + 1] = ((value shr 8) and 0xFF).toByte() + } + @JvmStatic + fun toInt16Be(data: ByteArray, offset: Int, value: Int) { + data[offset + 0] = ((value shr 8) and 0xFF).toByte() + data[offset + 1] = (value and 0xFF).toByte() + } + @JvmStatic + fun toInt16Le(value: Int): ByteArray { + val data = ByteArray(2) + toInt16Le(data, 0, value) + return data + } + @JvmStatic + fun toInt16Be(value: Int): ByteArray { + val data = ByteArray(2) + toInt16Be(data, 0, value) + return data + } + @JvmStatic + fun fromSignedInt24Le(data: ByteArray, offset: Int): Int { + var value = data[offset + 2].toInt() shl 16 + value += (data[offset + 1].toInt() and 0xFF) shl 8 + value += data[offset].toInt() and 0xFF + return value + } + @JvmStatic + fun fromSignedInt24Be(data: ByteArray, offset: Int): Int { + var value = data[offset].toInt() shl 16 + value += (data[offset + 1].toInt() and 0xFF) shl 8 + value += data[offset + 2].toInt() and 0xFF + return value + } + @JvmStatic + fun fromUnsignedInt24Le(data: ByteArray, offset: Int): Int { + return fromSignedInt24Le(data, offset) and 0xFFFFFF + } + @JvmStatic + fun fromUnsignedInt24Be(data: ByteArray, offset: Int): Int { + return fromSignedInt24Be(data, offset) and 0xFFFFFF + } + @JvmStatic + fun fromSignedInt32Le(data: ByteArray, offset: Int): Int { + var value = data[offset + 3].toInt() shl 24 + value += (data[offset + 2].toInt() and 0xFF) shl 16 + value += (data[offset + 1].toInt() and 0xFF) shl 8 + value += data[offset].toInt() and 0xFF + return value + } + @JvmStatic + fun fromSignedInt32Be(data: ByteArray, offset: Int): Int { + var value = data[offset].toInt() shl 24 + value += (data[offset + 1].toInt() and 0xFF) shl 16 + value += (data[offset + 2].toInt() and 0xFF) shl 8 + value += data[offset + 3].toInt() and 0xFF + return value + } + @JvmStatic + fun fromUnsignedInt32Le(data: ByteArray, offset: Int): Long { + return fromSignedInt32Le(data, offset).toLong() and 0xFFFFFFFFL + } + @JvmStatic + fun fromUnsignedInt32Be(data: ByteArray, offset: Int): Long { + return fromSignedInt32Be(data, offset).toLong() and 0xFFFFFFFFL + } + @JvmStatic + fun toInt32Le(data: ByteArray, offset: Int, value: Long) { + data[offset + 3] = ((value shr 24) and 0xFFL).toByte() + data[offset + 2] = ((value shr 16) and 0xFFL).toByte() + data[offset + 1] = ((value shr 8) and 0xFFL).toByte() + data[offset + 0] = (value and 0xFFL).toByte() + } + @JvmStatic + fun toInt32Be(data: ByteArray, offset: Int, value: Long) { + data[offset + 0] = ((value shr 24) and 0xFFL).toByte() + data[offset + 1] = ((value shr 16) and 0xFFL).toByte() + data[offset + 2] = ((value shr 8) and 0xFFL).toByte() + data[offset + 3] = (value and 0xFFL).toByte() + } + @JvmStatic + fun toInt32Le(value: Long): ByteArray { + val data = ByteArray(4) + toInt32Le(data, 0, value) + return data + } + @JvmStatic + fun toInt32Be(value: Long): ByteArray { + val data = ByteArray(4) + toInt32Be(data, 0, value) + return data + } +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/utils/CsvHelper.java b/android_app/app/src/main/java/com/health/openscale/core/utils/CsvHelper.java deleted file mode 100644 index 2e17d45a..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/utils/CsvHelper.java +++ /dev/null @@ -1,134 +0,0 @@ -/* Copyright (C) 2018 Erik Johansson -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.utils; - -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.j256.simplecsv.converter.DateConverter; -import com.j256.simplecsv.processor.ColumnInfo; -import com.j256.simplecsv.processor.ColumnNameMatcher; -import com.j256.simplecsv.processor.CsvProcessor; -import com.j256.simplecsv.processor.ParseError; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.Writer; -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.List; - -public class CsvHelper { - - public static void exportTo(Writer writer, List measurements) throws IOException { - CsvProcessor csvProcessor = new CsvProcessor<>(ScaleMeasurement.class); - csvProcessor.writeAll(writer, measurements, true); - } - - private static String[] getOldStyleHeaders(String sampleLine) { - if (sampleLine == null) { - return null; - } - - final String[] fields = sampleLine.split(",", -1); - - // Return an array with header fields that match the guessed version. - if (fields.length == 10) { - // From version 1.6 up to 1.7 - return new String[]{"dateTime", "weight", "fat", "water", "muscle", "lbm", - "bone", "waist", "hip", "comment"}; - } - else if (fields.length == 9) { - // From version 1.5.5 - return new String[]{"dateTime", "weight", "fat", "water", "muscle", "bone", - "waist", "hip", "comment"}; - } - else if (fields.length == 8) { - // From version 1.3 - return new String[]{"dateTime", "weight", "fat", "water", "muscle", "waist", - "hip", "comment"}; - } - else if (fields.length == 6) { - // From version 1.2 - return new String[]{"dateTime", "weight", "fat", "water", "muscle", "comment"}; - } - else if (fields.length == 5) { - // From version 1.0 - return new String[]{"dateTime", "weight", "fat", "water", "comment"}; - } - - // Unknown input data format - return null; - } - - public static List importFrom(BufferedReader reader) - throws IOException, ParseException { - CsvProcessor csvProcessor = - new CsvProcessor<>(ScaleMeasurement.class) - .withHeaderValidation(true) - .withFlexibleOrder(true) - .withAlwaysTrimInput(true) - .withAllowPartialLines(true); - - csvProcessor.setColumnNameMatcher(new ColumnNameMatcher() { - @Override - public boolean matchesColumnName(String definitionName, String csvName) { - return definitionName.equals(csvName) - || (definitionName.equals("lbm") && csvName.equals("lbw")); - } - }); - - reader.mark(1000); - try { - csvProcessor.readHeader(reader, null); - } - catch (ParseException ex) { - // Try to import it as an old style CSV export - reader.reset(); - final String sampleLine = reader.readLine(); - reader.reset(); - - final String[] header = getOldStyleHeaders(sampleLine); - - if (header == null) { - // Don't know what to do with this, let Simple CSV error out - return csvProcessor.readAll(reader, null); - } - - csvProcessor.validateHeaderColumns(header, null); - } - - return csvProcessor.readRows(reader, null); - } - - // backward compatible for openScale version >= 2.1.2 to support old date format dd.MM.yyyy, see issue #506 - public static class DateTimeConverter extends DateConverter { - private static final SimpleDateFormat srcDateFormat = new SimpleDateFormat("dd.MM.yyyy HH:mm"); - private static final SimpleDateFormat dstDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm"); - - @Override - public Date stringToJava(String line, int lineNumber, int linePos, ColumnInfo columnInfo, String value, ParseError parseError) throws ParseException{ - try { - Date srcDate = srcDateFormat.parse(value); - value = dstDateFormat.format(srcDate); - } catch (ParseException ex) { - // ignore - } - - return super.stringToJava(line, lineNumber, linePos, columnInfo, value, parseError); - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/utils/DateTimeHelpers.java b/android_app/app/src/main/java/com/health/openscale/core/utils/DateTimeHelpers.java deleted file mode 100644 index 97c35c87..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/utils/DateTimeHelpers.java +++ /dev/null @@ -1,55 +0,0 @@ -/* Copyright (C) 2017-2018 Erik Johansson -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.core.utils; - -import java.util.Calendar; - -public final class DateTimeHelpers { - static public int daysBetween(Calendar start, Calendar end) { - if (start.after(end)) { - return -daysBetween(end, start); - } - - int days = 0; - - Calendar current = (Calendar)start.clone(); - while (current.get(Calendar.YEAR) < end.get(Calendar.YEAR)) { - final int daysInYear = - current.getActualMaximum(Calendar.DAY_OF_YEAR) - - current.get(Calendar.DAY_OF_YEAR) + 1; - days += daysInYear; - current.add(Calendar.DAY_OF_YEAR, daysInYear); - } - - days += end.get(Calendar.DAY_OF_YEAR) - current.get(Calendar.DAY_OF_YEAR); - - return days; - } - - static public int yearsBetween(Calendar start, Calendar end) { - int years = end.get(Calendar.YEAR) - start.get(Calendar.YEAR); - - final int startMonth = start.get(Calendar.MONTH); - final int endMonth = end.get(Calendar.MONTH); - if (endMonth < startMonth - || (endMonth == startMonth - && end.get(Calendar.DAY_OF_MONTH) < start.get(Calendar.DAY_OF_MONTH))) { - years -= 1; - } - return years; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/utils/LogManager.kt b/android_app/app/src/main/java/com/health/openscale/core/utils/LogManager.kt new file mode 100644 index 00000000..d270fa6e --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/utils/LogManager.kt @@ -0,0 +1,387 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.utils + +import android.content.Context +import android.os.Build +import android.util.Log +import com.health.openscale.BuildConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import java.io.File +import java.io.FileWriter +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Manages logging for the application, providing methods to log messages + * to Logcat and optionally to a file. + */ +object LogManager { + + private const val DEFAULT_TAG = "openScaleLog" + private const val LOG_SUB_DIRECTORY = "logs" + private const val CURRENT_LOG_FILE_NAME_BASE = "openScale_current_log" + private const val LOG_FILE_EXTENSION = ".txt" + private const val MAX_LOG_SIZE_BYTES = 5 * 1024 * 1024 // 5 MB + + private var isInitialized = false + private var logToFileEnabled = false + private lateinit var appContext: Context + private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) + + /** + * Initializes the LogManager. Must be called once, typically in Application.onCreate(). + * @param context The application context. + * @param enableLoggingToFile True to enable logging to a file, false otherwise. + */ + fun init(context: Context, enableLoggingToFile: Boolean) { + if (isInitialized) { + // Log a warning if already initialized, but don't re-initialize. + Log.w(DEFAULT_TAG, "LogManager already initialized. Ignoring subsequent init call.") + return + } + + appContext = context.applicationContext + logToFileEnabled = enableLoggingToFile + isInitialized = true + + // Log initialization status. + if (logToFileEnabled) { + coroutineScope.launch { + resetLogFileOnStartup() + i(DEFAULT_TAG, "LogManager initialized. Logging to file: enabled. Log directory: ${getLogDirectory().absolutePath}") + } + } else { + i(DEFAULT_TAG, "LogManager initialized. Logging to file: disabled.") + } + } + + /** + * Deletes the current log file if it exists and writes initial headers. + * This is called on startup if file logging is enabled. + */ + private suspend fun resetLogFileOnStartup() { + val logDir = getLogDirectory() + val currentLogFile = File(logDir, "$CURRENT_LOG_FILE_NAME_BASE$LOG_FILE_EXTENSION") + + if (currentLogFile.exists()) { + if (currentLogFile.delete()) { + d(DEFAULT_TAG, "Previous log file deleted on startup: ${currentLogFile.name}") + } else { + w(DEFAULT_TAG, "Failed to delete previous log file on startup: ${currentLogFile.name}") + } + } + // Always attempt to write headers, ensures file is created if it didn't exist. + writeInitialLogHeaders(currentLogFile) + } + + /** + * Updates the preference for logging to a file. + * If logging is enabled and was previously disabled, it ensures log headers are written. + * @param enabled True to enable file logging, false to disable. + */ + fun updateLoggingPreference(enabled: Boolean) { + val oldState = logToFileEnabled + if (oldState == enabled) { + d(DEFAULT_TAG, "File logging preference is already set to: $enabled. No change.") + return + } + logToFileEnabled = enabled + i(DEFAULT_TAG, "File logging preference updated to: $logToFileEnabled (was: $oldState)") + if (logToFileEnabled) { // Only act if newly enabled + coroutineScope.launch { + val currentLogFile = File(getLogDirectory(), "$CURRENT_LOG_FILE_NAME_BASE$LOG_FILE_EXTENSION") + if (!currentLogFile.exists() || currentLogFile.length() == 0L) { + d(DEFAULT_TAG, "Log file missing or empty after enabling file logging. Writing headers.") + writeInitialLogHeaders(currentLogFile) + } + } + } + } + + /** + * Retrieves the directory for storing log files. + * It prioritizes external storage and falls back to internal storage if external is not available. + * Creates the directory if it doesn't exist. + * @return The File object representing the log directory. + */ + private fun getLogDirectory(): File { + val externalLogDir = appContext.getExternalFilesDir(LOG_SUB_DIRECTORY) + if (externalLogDir != null) { + if (!externalLogDir.exists()) { + if (!externalLogDir.mkdirs()) { + w(DEFAULT_TAG, "Failed to create external log directory: ${externalLogDir.absolutePath}. Attempting internal storage.") + // Fall through to internal storage if mkdirs fails + } else { + d(DEFAULT_TAG, "External log directory created: ${externalLogDir.absolutePath}") + return externalLogDir + } + } + return externalLogDir + } + + // Fallback to internal storage + val internalLogDir = File(appContext.filesDir, LOG_SUB_DIRECTORY) + if (!internalLogDir.exists()) { + if (!internalLogDir.mkdirs()) { + // If internal storage also fails, this is a more serious issue. + e(DEFAULT_TAG, "Failed to create internal log directory: ${internalLogDir.absolutePath}. Logging to file may not work.") + } else { + d(DEFAULT_TAG, "Internal log directory created: ${internalLogDir.absolutePath}") + } + } + // Log this fallback case if externalLogDir was null initially + if (externalLogDir == null) { + w(DEFAULT_TAG, "External storage not available. Using internal storage for logs: ${internalLogDir.absolutePath}") + } + return internalLogDir + } + + @JvmStatic + fun v(tag: String?, message: String) { log(Log.VERBOSE, tag, message) } + @JvmStatic + fun d(tag: String?, message: String) { log(Log.DEBUG, tag, message) } + @JvmStatic + fun i(tag: String?, message: String) { log(Log.INFO, tag, message) } + @JvmStatic + fun w(tag: String?, message: String, throwable: Throwable? = null) { log(Log.WARN, tag, message, throwable) } + @JvmStatic + fun e(tag: String?, message: String, throwable: Throwable? = null) { log(Log.ERROR, tag, message, throwable) } + + /** + * Core logging function. Logs to Logcat and, if enabled, to a file. + * @param priority The log priority (e.g., Log.VERBOSE, Log.ERROR). + * @param tag The tag for the log message. Defaults to DEFAULT_TAG if null. + * @param message The message to log. + * @param throwable An optional throwable to log with its stack trace. + */ + private fun log(priority: Int, tag: String?, message: String, throwable: Throwable? = null) { + if (!isInitialized) { + // Use Android's Log directly if LogManager isn't initialized. + // This ensures critical early errors or misconfigurations are visible. + val initErrorMsg = "LogManager not initialized! Attempted to log: [${tag ?: DEFAULT_TAG}] $message" + Log.e("LogManager_NotInit", initErrorMsg, throwable) + // Optionally, print to System.err as a last resort if Logcat is also problematic + // System.err.println("$initErrorMsg ${throwable?.let { Log.getStackTraceString(it) }}") + return + } + + val currentTag = tag ?: DEFAULT_TAG + + // Log to Android's Logcat + when (priority) { + Log.VERBOSE -> Log.v(currentTag, message, throwable) + Log.DEBUG -> Log.d(currentTag, message, throwable) + Log.INFO -> Log.i(currentTag, message, throwable) + Log.WARN -> Log.w(currentTag, message, throwable) + Log.ERROR -> Log.e(currentTag, message, throwable) + // Default case for custom priorities, though less common with this setup. + else -> Log.println(priority, currentTag, message + if (throwable != null) "\n${Log.getStackTraceString(throwable)}" else "") + } + + // Log to file if enabled + if (logToFileEnabled) { + val formattedMessageForFile = formatMessageForFile(priority, currentTag, message, throwable) + coroutineScope.launch { + try { + val currentLogFile = File(getLogDirectory(), "$CURRENT_LOG_FILE_NAME_BASE$LOG_FILE_EXTENSION") + + // Ensure the log file's parent directory exists. + // This is a safeguard, though getLogDirectory should handle it. + currentLogFile.parentFile?.mkdirs() + + // Check if file is missing or empty, then write headers. + // This can happen if the file was cleared or logging was just enabled. + if (!currentLogFile.exists() || currentLogFile.length() == 0L) { + d(DEFAULT_TAG, "Log file missing or empty, writing headers: ${currentLogFile.absolutePath}") + writeInitialLogHeaders(currentLogFile) // This will create/overwrite with headers + } + + checkAndRotateLog(currentLogFile) // Rotate log if it exceeds max size + + // Append the log message + FileWriter(currentLogFile, true).use { writer -> + writer.append(formattedMessageForFile) + writer.append("\n") + } + } catch (e: IOException) { + // Log error related to file writing to Logcat only, to avoid recursive file logging issues. + Log.e(DEFAULT_TAG, "Error writing to log file: ${e.message}", e) + } + } + } + } + + /** + * Writes initial header information to the specified log file. + * Includes session start time, application info, and device info. + * This method will create the file if it doesn't exist, or overwrite it if it does. + * @param logFile The file to write the headers to. + */ + private fun writeInitialLogHeaders(logFile: File) { + try { + // Ensure the directory exists before attempting to write. + logFile.parentFile?.mkdirs() + + FileWriter(logFile, false).use { writer -> // false for append means overwrite + val separator = "============================================================" + val sessionStartTime = dateFormat.format(Date()) + + writer.append("$separator\n") + writer.append(" LOG SESSION STARTED\n") + writer.append(" -------------------\n") + writer.append(" Time : $sessionStartTime\n") + writer.append("\n") + + writer.append(" APPLICATION INFO\n") + writer.append(" ----------------\n") + writer.append(" App Name : openScale\n") // Consider making this dynamic if needed + writer.append(" Version : ${BuildConfig.VERSION_NAME}\n") + writer.append(" Version Code : ${BuildConfig.VERSION_CODE}\n") + writer.append(" Package ID : ${BuildConfig.APPLICATION_ID}\n") + writer.append(" Build Type : ${BuildConfig.BUILD_TYPE}\n") + writer.append("\n") + + writer.append(" DEVICE INFO\n") + writer.append(" -----------\n") + writer.append(" Manufacturer : ${Build.MANUFACTURER}\n") + writer.append(" Model : ${Build.MODEL}\n") + writer.append(" Android Version : ${Build.VERSION.RELEASE}\n") + writer.append(" API Level : ${Build.VERSION.SDK_INT}\n") + writer.append(" System Build ID : ${Build.DISPLAY}\n") + + writer.append("$separator\n\n") + } + d(DEFAULT_TAG, "Initial log headers written to: ${logFile.absolutePath}") + } catch (e: IOException) { + Log.e(DEFAULT_TAG, "Error writing initial log headers to ${logFile.absolutePath}", e) + } + } + + /** + * Formats a log message for file output. + * Includes timestamp, priority character, tag, message, and stack trace if available. + * @param priority The log priority. + * @param tag The log tag. + * @param message The log message. + * @param throwable An optional throwable. + * @return The formatted log string. + */ + private fun formatMessageForFile(priority: Int, tag: String, message: String, throwable: Throwable?): String { + val priorityChar = when (priority) { + Log.VERBOSE -> "V"; Log.DEBUG -> "D"; Log.INFO -> "I"; Log.WARN -> "W"; Log.ERROR -> "E"; else -> "?" + } + val timestamp = dateFormat.format(Date()) // Generate timestamp at the moment of formatting + val builder = StringBuilder() + builder.append("$timestamp $priorityChar/$tag: $message") + throwable?.let { + builder.append("\n").append(Log.getStackTraceString(it)) // Append stack trace if present + } + return builder.toString() + } + + /** + * Checks if the current log file exceeds the maximum allowed size and rotates it if necessary. + * Rotation currently means deleting the oversized log file and starting a new one + * by writing the initial log headers. + * @param currentLogFile The current log file. + */ + private fun checkAndRotateLog(currentLogFile: File) { + if (currentLogFile.exists() && currentLogFile.length() > MAX_LOG_SIZE_BYTES) { + val oldFileSize = currentLogFile.length() + i(DEFAULT_TAG, "Log file '${currentLogFile.name}' (size: $oldFileSize bytes) exceeds limit ($MAX_LOG_SIZE_BYTES bytes). Rotating.") + + // Delete the oversized log file + if (currentLogFile.delete()) { + i(DEFAULT_TAG, "Oversized log file deleted: ${currentLogFile.name}. A new log file will be started with headers.") + // The writeInitialLogHeaders will be called to prepare the new (now non-existent or empty) file. + // It is important that this happens *before* the next log message is written. + // The main log() function's check for existence/emptiness and subsequent call to writeInitialLogHeaders + // will handle this. Alternatively, we can explicitly call it here. + // For clarity and ensuring headers are written immediately after rotation: + writeInitialLogHeaders(currentLogFile) + } else { + e(DEFAULT_TAG, "Failed to delete oversized log file '${currentLogFile.name}' for rotation. Current log may continue to grow or writes may fail.") + } + } + } + + /** + * Gets the current log file. + * @return The File object for the current log file, or null if LogManager is not initialized + * or if the log file does not exist (and file logging is expected). + */ + fun getLogFile(): File? { + if (!isInitialized) { + w(DEFAULT_TAG, "getLogFile() called before LogManager was initialized.") + return null + } + val logFile = File(getLogDirectory(), "$CURRENT_LOG_FILE_NAME_BASE$LOG_FILE_EXTENSION") + return if (logFile.exists()) { + logFile + } else { + // Log file might not exist if logging to file is disabled or no logs written yet. + d(DEFAULT_TAG, "Queried log file does not currently exist at path: ${logFile.absolutePath}") + null + } + } + + /** + * Clears the current log file(s). + * If file logging is enabled, it then writes the initial log headers to the new empty log file. + */ + fun clearLogFiles() { + if (!isInitialized) { + w(DEFAULT_TAG, "clearLogFiles() called before LogManager was initialized.") + return + } + coroutineScope.launch { + val logDir = getLogDirectory() // Get directory first + val currentLogFile = File(logDir, "$CURRENT_LOG_FILE_NAME_BASE$LOG_FILE_EXTENSION") + + try { + if (currentLogFile.exists()) { + if (currentLogFile.delete()) { + i(DEFAULT_TAG, "Log file cleared: ${currentLogFile.absolutePath}") + } else { + e(DEFAULT_TAG, "Failed to clear log file: ${currentLogFile.absolutePath}") + // If deletion fails, do not proceed to write headers to a potentially problematic file. + return@launch + } + } else { + i(DEFAULT_TAG, "Log file already cleared or did not exist: ${currentLogFile.absolutePath}") + } + + // If file logging is enabled, a new log session effectively starts, so write headers. + if (logToFileEnabled) { + d(DEFAULT_TAG, "File logging is enabled, writing initial headers after clearing.") + writeInitialLogHeaders(currentLogFile) + } + } catch (e: Exception) { + // Catch any unexpected exception during file operations. + Log.e(DEFAULT_TAG, "Error during clearLogFiles operation for ${currentLogFile.absolutePath}", e) + } + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/utils/PolynomialFitter.java b/android_app/app/src/main/java/com/health/openscale/core/utils/PolynomialFitter.java deleted file mode 100644 index eadb54af..00000000 --- a/android_app/app/src/main/java/com/health/openscale/core/utils/PolynomialFitter.java +++ /dev/null @@ -1,215 +0,0 @@ -package com.health.openscale.core.utils; - -/*************************************************************************** - * Copyright (C) 2009 by Paul Lutus, Ian Clarke * - * lutusp@arachnoid.com, ian.clarke@gmail.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 2 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, write to the * - * Free Software Foundation, Inc., * - * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * - ***************************************************************************/ - -import java.util.ArrayList; -import java.util.List; - -/** - * A class to fit a polynomial to a (potentially very large) dataset. - * - * @author Paul Lutus - * @author Ian Clarke - * - */ - -/* - * Changelog: - * 20100130: Add note about relicensing - * 20091114: Modify so that points can be added after the curve is - * created, also some other minor fixes - * 20091113: Extensively modified by Ian Clarke, main changes: - * - Should now be able to handle extremely large datasets - * - Use generic Java collections classes and interfaces - * where possible - * - Data can be fed to the fitter as it is available, rather - * than all at once - * - * The code that this is based on was obtained from: http://arachnoid.com/polysolve - * - * Note: I (Ian Clarke) am happy to release this code under a more liberal - * license such as the LGPL, however Paul Lutus (the primary author) refuses - * to do this on the grounds that the LGPL is not an open source license. - * If you want to try to explain to him that the LGPL is indeed an open - * source license, good luck - it's like talking to a brick wall. - */ -public class PolynomialFitter { - - private final int p, rs; - - private long n = 0; - - private final double[][] m; - - private final double[] mpc; - /** - * @param degree - * The degree of the polynomial to be fit to the data - */ - public PolynomialFitter(final int degree) { - assert degree > 0; - p = degree + 1; - rs = 2 * p - 1; - m = new double[p][p + 1]; - mpc = new double[rs]; - } - - /** - * Add a point to the set of points that the polynomial must be fit to - * - * @param x - * The x coordinate of the point - * @param y - * The y coordinate of the point - */ - public void addPoint(final double x, final double y) { - assert !Double.isInfinite(x) && !Double.isNaN(x); - assert !Double.isInfinite(y) && !Double.isNaN(y); - n++; - // process precalculation array - for (int r = 1; r < rs; r++) { - mpc[r] += Math.pow(x, r); - } - // process RH column cells - m[0][p] += y; - for (int r = 1; r < p; r++) { - m[r][p] += Math.pow(x, r) * y; - } - } - - /** - * Returns a polynomial that seeks to minimize the square of the total - * distance between the set of points and the polynomial. - * - * @return A polynomial - */ - public Polynomial getBestFit() { - final double[] mpcClone = mpc.clone(); - final double[][] mClone = new double[m.length][]; - for (int x = 0; x < mClone.length; x++) { - mClone[x] = m[x].clone(); - } - - mpcClone[0] += n; - // populate square matrix section - for (int r = 0; r < p; r++) { - for (int c = 0; c < p; c++) { - mClone[r][c] = mpcClone[r + c]; - } - } - gj_echelonize(mClone); - final Polynomial result = new Polynomial(p); - for (int j = 0; j < p; j++) { - result.add(j, mClone[j][p]); - } - return result; - } - private double fx(final double x, final List terms) { - double a = 0; - int e = 0; - for (final double i : terms) { - a += i * Math.pow(x, e); - e++; - } - return a; - } - private void gj_divide(final double[][] A, final int i, final int j, final int m) { - for (int q = j + 1; q < m; q++) { - A[i][q] /= A[i][j]; - } - A[i][j] = 1; - } - - private void gj_echelonize(final double[][] A) { - final int n = A.length; - final int m = A[0].length; - int i = 0; - int j = 0; - while (i < n && j < m) { - // look for a non-zero entry in col j at or below row i - int k = i; - while (k < n && A[k][j] == 0) { - k++; - } - // if such an entry is found at row k - if (k < n) { - // if k is not i, then swap row i with row k - if (k != i) { - gj_swap(A, i, j); - } - // if A[i][j] is not 1, then divide row i by A[i][j] - if (A[i][j] != 1) { - gj_divide(A, i, j, m); - } - // eliminate all other non-zero entries from col j by - // subtracting from each - // row (other than i) an appropriate multiple of row i - gj_eliminate(A, i, j, n, m); - i++; - } - j++; - } - } - - private void gj_eliminate(final double[][] A, final int i, final int j, final int n, final int m) { - for (int k = 0; k < n; k++) { - if (k != i && A[k][j] != 0) { - for (int q = j + 1; q < m; q++) { - A[k][q] -= A[k][j] * A[i][q]; - } - A[k][j] = 0; - } - } - } - - private void gj_swap(final double[][] A, final int i, final int j) { - double temp[]; - temp = A[i]; - A[i] = A[j]; - A[j] = temp; - } - - - public static class Polynomial extends ArrayList { - private static final long serialVersionUID = 1692843494322684190L; - - public Polynomial(final int p) { - super(p); - } - - public double getY(final double x) { - double ret = 0; - for (int p=0; p -1; x--) { - ret.append(get(x) + (x > 0 ? "x^" + x + " + " : "")); - } - return ret.toString(); - } - } -} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/utils/Utils.kt b/android_app/app/src/main/java/com/health/openscale/core/utils/Utils.kt new file mode 100644 index 00000000..e99273c0 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/utils/Utils.kt @@ -0,0 +1,120 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.utils + +import android.app.LocaleManager +import android.content.res.Configuration +import android.os.Build +import android.os.LocaleList +import androidx.activity.ComponentActivity +import com.health.openscale.core.data.SupportedLanguage +import java.time.Instant +import java.time.LocalDate +import java.time.Period +import java.time.ZoneId +import java.util.Locale + +object CalculationUtil { + fun dateToAge(birthDateMillis: Long): Int { + val birthDate = Instant.ofEpochMilli(birthDateMillis) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + + val today = LocalDate.now() + + return Period.between(birthDate, today).years + } +} + +/** + * Utility object for language-related operations within the application. + * Includes functions for changing the app's language and retrieving + * supported languages. + */ +object LanguageUtil { + + private const val TAG = "LanguageUtil" + + /** + * Updates the application's locale for the given activity. + * The change is made persistent through the system (depending on the API version) + * and typically requires a `recreate()` of the activity to take effect. + * + * @param activity The ComponentActivity whose locale is to be updated. + * @param languageCode The language code (e.g., "en", "de") of the target language. + * If null, the default system language will be used. + */ + fun updateAppLocale(activity: ComponentActivity, languageCode: String?) { + val targetLanguageEnum = SupportedLanguage.fromCode(languageCode) + ?: SupportedLanguage.getDefault() // Fallback to the default language defined in the enum + + val effectiveLanguageCode = targetLanguageEnum.code + + if (effectiveLanguageCode.isBlank()) { + LogManager.w(TAG, "Language code is blank, cannot update locale.") + return + } + + LogManager.d(TAG, "Attempting to set app locale to: $effectiveLanguageCode for Activity: ${activity::class.java.simpleName}") + val newLocale = targetLanguageEnum.toLocale() // Use the toLocale() method of the enum + val newLocaleList = LocaleList(newLocale) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + try { + val localeManager = activity.getSystemService(LocaleManager::class.java) + if (localeManager != null) { + LogManager.i(TAG, "Using LocaleManager to set application locales to: ${newLocale.toLanguageTag()}") + localeManager.applicationLocales = newLocaleList + } else { + LogManager.w(TAG, "LocaleManager is null on API ${Build.VERSION.SDK_INT}, falling back to older method.") + applyConfigurationToActivity(activity, newLocale, newLocaleList) + } + } catch (e: Exception) { + LogManager.e(TAG, "Error using LocaleManager", e) + applyConfigurationToActivity(activity, newLocale, newLocaleList) // Fallback on error + } + } else { + LogManager.i(TAG, "Using applyOverrideConfiguration for API ${Build.VERSION.SDK_INT} to set locale: ${newLocale.toLanguageTag()}") + applyConfigurationToActivity(activity, newLocale, newLocaleList) + } + } + + /** + * Applies the new locale configuration to the given activity. + * This is the fallback method for older API versions or when LocaleManager is not available. + */ + private fun applyConfigurationToActivity(activity: ComponentActivity, newLocale: Locale, newLocaleList: LocaleList) { + val currentActivityConfiguration = activity.resources.configuration + val currentActivityLocale = currentActivityConfiguration.locales.get(0) + + // Only apply if the language or country actually changes, + // to avoid unnecessary configuration changes. + if (currentActivityLocale.language != newLocale.language || + (newLocale.country.isNotBlank() && currentActivityLocale.country != newLocale.country)) { + + val newConfiguration = Configuration(currentActivityConfiguration) + newConfiguration.setLocale(newLocale) + newConfiguration.setLocales(newLocaleList) // Important for a consistent locale list + + activity.applyOverrideConfiguration(newConfiguration) + LogManager.i(TAG, "Applied override configuration to activity for locale: ${newLocale.toLanguageTag()}.") + } else { + LogManager.d(TAG, "Activity locale is already set to: ${newLocale.toLanguageTag()}. No configuration override needed.") + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/MainActivity.java b/android_app/app/src/main/java/com/health/openscale/gui/MainActivity.java deleted file mode 100644 index ffef525c..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/MainActivity.java +++ /dev/null @@ -1,1078 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.gui; - -import android.Manifest; -import android.app.AlertDialog; -import android.app.Dialog; -import android.bluetooth.BluetoothAdapter; -import android.bluetooth.BluetoothManager; -import android.content.ActivityNotFoundException; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.content.res.Configuration; -import android.graphics.Typeface; -import android.location.LocationManager; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.Message; -import android.preference.PreferenceManager; -import android.provider.Settings; -import android.text.Editable; -import android.text.Html; -import android.text.InputFilter; -import android.text.InputType; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.text.method.LinkMovementMethod; -import android.util.Pair; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.widget.EditText; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.app.AppCompatDelegate; -import androidx.appcompat.widget.Toolbar; -import androidx.core.content.ContextCompat; -import androidx.core.content.FileProvider; -import androidx.core.view.GravityCompat; -import androidx.drawerlayout.widget.DrawerLayout; -import androidx.navigation.NavController; -import androidx.navigation.Navigation; -import androidx.navigation.fragment.NavHostFragment; -import androidx.navigation.ui.AppBarConfiguration; -import androidx.navigation.ui.NavigationUI; - -import com.google.android.material.bottomnavigation.BottomNavigationView; -import com.google.android.material.navigation.NavigationView; -import com.health.openscale.BuildConfig; -import com.health.openscale.MobileNavigationDirections; -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.bluetooth.BluetoothCommunication; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; -import com.health.openscale.gui.measurement.MeasurementEntryFragment; -import com.health.openscale.gui.preferences.BluetoothSettingsFragment; -import com.health.openscale.gui.preferences.UserSettingsFragment; -import com.health.openscale.gui.slides.AppIntroActivity; - -import java.io.File; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.GregorianCalendar; -import java.util.List; -import java.util.Locale; - -import cat.ereza.customactivityoncrash.config.CaocConfig; -import timber.log.Timber; - -public class MainActivity extends AppCompatActivity - implements SharedPreferences.OnSharedPreferenceChangeListener{ - public static final String PREFERENCE_LANGUAGE = "language"; - private static Locale systemDefaultLocale = null; - private SharedPreferences prefs; - private static boolean firstAppStart = true; - private static boolean valueOfCountModified = false; - private static int bluetoothStatusIcon = R.drawable.ic_bluetooth_disabled; - private static MenuItem bluetoothStatus; - - private static final int IMPORT_DATA_REQUEST = 100; - private static final int EXPORT_DATA_REQUEST = 101; - private static final int APPINTRO_REQUEST = 103; - - private AppBarConfiguration mAppBarConfiguration; - private DrawerLayout drawerLayout; - private NavController navController; - private NavigationView navigationView; - private BottomNavigationView navigationBottomView; - - private boolean settingsActivityRunning = false; - - public static Context createBaseContext(Context context) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - String language = prefs.getString(PREFERENCE_LANGUAGE, ""); - if (language.isEmpty() || language.equals("default")) { - if (systemDefaultLocale != null) { - Locale.setDefault(systemDefaultLocale); - systemDefaultLocale = null; - } - return context; - } - - if (systemDefaultLocale == null) { - systemDefaultLocale = Locale.getDefault(); - } - - Locale locale; - String[] localeParts = TextUtils.split(language, "-"); - if (localeParts.length == 2) { - locale = new Locale(localeParts[0], localeParts[1]); - } - else { - locale = new Locale(localeParts[0]); - } - Locale.setDefault(locale); - - Configuration config = context.getResources().getConfiguration(); - config.setLocale(locale); - - return context.createConfigurationContext(config); - } - - @Override - protected void attachBaseContext(Context context) { - super.attachBaseContext(createBaseContext(context)); - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - prefs = PreferenceManager.getDefaultSharedPreferences(this); - prefs.registerOnSharedPreferenceChangeListener(this); - - String prefTheme = prefs.getString("app_theme", "Light"); - - if (prefTheme.equals("Dark")) { - if (Build.VERSION.SDK_INT >= 29) { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); - } else { - setTheme(R.style.AppTheme); - } - } - - super.onCreate(savedInstanceState); - - CaocConfig.Builder.create() - .trackActivities(false) - .apply(); - - setContentView(R.layout.activity_main); - - // Set a Toolbar to replace the ActionBar. - Toolbar toolbar = findViewById(R.id.toolbar); - setSupportActionBar(toolbar); - - toolbar.setNavigationOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - onSupportNavigateUp(); - } - }); - - // Find our drawer view - drawerLayout = findViewById(R.id.drawer_layout); - - // Find our drawer view - navigationView = findViewById(R.id.navigation_view); - navigationBottomView = findViewById(R.id.navigation_bottom_view); - - // Passing each menu ID as a set of Ids because each - // menu should be considered as top level destinations. - mAppBarConfiguration = new AppBarConfiguration.Builder( - R.id.nav_overview, R.id.nav_graph, R.id.nav_table, R.id.nav_statistic, R.id.nav_main_preferences) - .setOpenableLayout(drawerLayout) - .build(); - navController = ((NavHostFragment)getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment)).getNavController(); - NavigationUI.setupActionBarWithNavController(this, navController, mAppBarConfiguration); - NavigationUI.setupWithNavController(navigationView, navController); - NavigationUI.setupWithNavController(navigationBottomView, navController); - - navigationView.setNavigationItemSelectedListener(new NavigationView.OnNavigationItemSelectedListener() { - @Override - public boolean onNavigationItemSelected(@NonNull MenuItem item) { - switch (item.getItemId()) { - case R.id.nav_donation: - startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=H5KSTQA6TKTE4&source=url"))); - drawerLayout.closeDrawers(); - return true; - case R.id.nav_help: - startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/oliexdev/openScale/wiki"))); - drawerLayout.closeDrawers(); - return true; - } - - prefs.edit().putInt("lastFragmentId", item.getItemId()).apply(); - NavigationUI.onNavDestinationSelected(item, navController); - - // Close the navigation drawer - drawerLayout.closeDrawers(); - - return true; - } - }); - - navigationBottomView.setOnNavigationItemSelectedListener(new BottomNavigationView.OnNavigationItemSelectedListener() { - @Override - public boolean onNavigationItemSelected(@NonNull MenuItem item) { - prefs.edit().putInt("lastFragmentId", item.getItemId()).apply(); - NavigationUI.onNavDestinationSelected(item, navController); - return true; - } - }); - - navigationBottomView.setSelectedItemId(prefs.getInt("lastFragmentId", R.id.nav_overview)); - - if (prefs.getBoolean("firstStart", true)) { - Intent appIntroIntent = new Intent(this, AppIntroActivity.class); - startActivityForResult(appIntroIntent, APPINTRO_REQUEST); - - prefs.edit().putBoolean("firstStart", false).apply(); - } - - if (prefs.getBoolean("resetLaunchCountForVersion2.0", true)) { - prefs.edit().putInt("launchCount", 0).commit(); - - prefs.edit().putBoolean("resetLaunchCountForVersion2.0", false).apply(); - } - - if(!valueOfCountModified){ - int launchCount = prefs.getInt("launchCount", 0); - - if(prefs.edit().putInt("launchCount", ++launchCount).commit()){ - valueOfCountModified = true; - - // ask the user once for feedback on the 15th app launch - if(launchCount == 15){ - AlertDialog.Builder builder = new AlertDialog.Builder(this); - - builder.setMessage(R.string.label_feedback_message_enjoying) - .setPositiveButton(R.string.label_feedback_message_yes, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.dismiss(); - positiveFeedbackDialog(); - } - }) - .setNegativeButton(R.string.label_feedback_message_no, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.dismiss(); - negativeFeedbackDialog(); - } - }); - - AlertDialog dialog = builder.create(); - dialog.show(); - } - } - } - } - - @Override - public boolean onSupportNavigateUp() { - NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment); - return NavigationUI.navigateUp(navController, mAppBarConfiguration) - || super.onSupportNavigateUp(); - } - - @Override - public void onResume() { - super.onResume(); - settingsActivityRunning = false; - } - - @Override - public void onDestroy() { - prefs.unregisterOnSharedPreferenceChangeListener(this); - super.onDestroy(); - } - - @Override - public void onSharedPreferenceChanged(SharedPreferences preferences, String key) { - if (settingsActivityRunning) { - recreate(); - OpenScale.getInstance().triggerWidgetUpdate(); - } - } - - private void positiveFeedbackDialog() { - AlertDialog.Builder builder = new AlertDialog.Builder(this); - - builder.setMessage(R.string.label_feedback_message_rate_app) - .setPositiveButton(R.string.label_feedback_message_positive, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.dismiss(); - Uri uri = Uri.parse("market://details?id=" + getPackageName()); - Intent goToMarket = new Intent(Intent.ACTION_VIEW, uri); - // To count with Play market back stack, After pressing back button, - // to taken back to our application, we need to add following flags to intent. - goToMarket.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY | - Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET | - Intent.FLAG_ACTIVITY_MULTIPLE_TASK); - try { - startActivity(goToMarket); - } catch (ActivityNotFoundException e) { - startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("http://play.google.com/store/apps/details?id=" + getPackageName()))); - } - } - }) - .setNegativeButton(R.string.label_feedback_message_negative, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.dismiss(); - } - }); - - AlertDialog dialog = builder.create(); - dialog.show(); - } - - private void negativeFeedbackDialog() { - AlertDialog.Builder builder = new AlertDialog.Builder(this); - - builder.setMessage(R.string.label_feedback_message_issue) - .setPositiveButton(R.string.label_feedback_message_positive, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.dismiss(); - startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/oliexdev/openScale/issues"))); - } - }) - .setNegativeButton(R.string.label_feedback_message_negative, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.dismiss(); - } - }); - - AlertDialog dialog = builder.create(); - dialog.show(); - } - - private void showNoSelectedUserDialog() { - AlertDialog.Builder infoDialog = new AlertDialog.Builder(this); - - infoDialog.setMessage(getResources().getString(R.string.info_no_selected_user)); - infoDialog.setPositiveButton(getResources().getString(R.string.label_ok), null); - infoDialog.show(); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - drawerLayout.openDrawer(GravityCompat.START); - return true; - case R.id.action_add_measurement: - if (OpenScale.getInstance().getSelectedScaleUserId() == -1) { - showNoSelectedUserDialog(); - return true; - } - - if (OpenScale.getInstance().getSelectedScaleUser().isAssistedWeighing()) { - showAssistedWeighingDialog(true); - } else { - MobileNavigationDirections.ActionNavMobileNavigationToNavDataentry action = MobileNavigationDirections.actionNavMobileNavigationToNavDataentry(); - action.setMode(MeasurementEntryFragment.DATA_ENTRY_MODE.ADD); - action.setTitle(getString(R.string.label_add_measurement)); - Navigation.findNavController(this, R.id.nav_host_fragment).navigate(action); - } - return true; - case R.id.action_bluetooth_status: - if (OpenScale.getInstance().disconnectFromBluetoothDevice()) { - setBluetoothStatusIcon(R.drawable.ic_bluetooth_disabled); - } - else { - if (OpenScale.getInstance().getSelectedScaleUserId() == -1) { - showNoSelectedUserDialog(); - return true; - } - - if (OpenScale.getInstance().getSelectedScaleUser().isAssistedWeighing()) { - showAssistedWeighingDialog(false); - } else { - invokeConnectToBluetoothDevice(); - } - } - return true; - case R.id.importData: - importCsvFile(); - return true; - case R.id.exportData: - exportCsvFile(); - return true; - case R.id.shareData: - shareCsvFile(); - return true; - } - - return super.onOptionsItemSelected(item); - } - - private void showAssistedWeighingDialog(boolean manuelEntry) { - AlertDialog.Builder builder = new AlertDialog.Builder(this); - - LinearLayout linearLayout = new LinearLayout(this); - linearLayout.setOrientation(LinearLayout.VERTICAL); - linearLayout.setPadding(50, 50, 0, 0); - TextView title = new TextView(this); - title.setText(R.string.label_assisted_weighing); - title.setTextSize(24); - title.setTypeface(null, Typeface.BOLD); - - TextView description = new TextView(this); - description.setPadding(0, 20, 0, 0); - description.setText(R.string.info_assisted_weighing_choose_reference_user); - linearLayout.addView(title); - linearLayout.addView(description); - - builder.setCustomTitle(linearLayout); - - List scaleUserList = OpenScale.getInstance().getScaleUserList(); - ArrayList infoTexts = new ArrayList<>(); - ArrayList userIds = new ArrayList<>(); - - int assistedWeighingRefUserId = prefs.getInt("assistedWeighingRefUserId", -1); - int checkedItem = 0; - - for (ScaleUser scaleUser : scaleUserList) { - String singleInfoText = scaleUser.getUserName(); - - if (!scaleUser.isAssistedWeighing()) { - ScaleMeasurement lastRefScaleMeasurement = OpenScale.getInstance().getLastScaleMeasurement(scaleUser.getId()); - - if (lastRefScaleMeasurement != null) { - singleInfoText += " [" + Converters.fromKilogram(lastRefScaleMeasurement.getWeight(), scaleUser.getScaleUnit()) + scaleUser.getScaleUnit().toString() + "]"; - } else { - singleInfoText += " [" + getString(R.string.label_empty) + "]"; - } - - infoTexts.add(singleInfoText); - userIds.add(scaleUser.getId()); - } - - if (scaleUser.getId() == assistedWeighingRefUserId) { - checkedItem = infoTexts.indexOf(singleInfoText); - } - } - - if (!infoTexts.isEmpty()) { - builder.setSingleChoiceItems(infoTexts.toArray(new CharSequence[infoTexts.size()]), checkedItem, null); - } else { - builder.setMessage(getString(R.string.info_assisted_weighing_no_reference_user)); - } - - builder.setNegativeButton(R.string.label_cancel, null); - builder.setPositiveButton(R.string.label_ok, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - if (userIds.isEmpty()) { - Toast.makeText(getApplicationContext(), getString(R.string.info_assisted_weighing_no_reference_user), Toast.LENGTH_LONG).show(); - return; - } - - int selectedPosition = ((AlertDialog)dialog).getListView().getCheckedItemPosition(); - prefs.edit().putInt("assistedWeighingRefUserId", userIds.get(selectedPosition)).commit(); - - ScaleMeasurement lastRefScaleMeasurement = OpenScale.getInstance().getLastScaleMeasurement(userIds.get(selectedPosition)); - - if (lastRefScaleMeasurement != null) { - Calendar calMinusOneDay = Calendar.getInstance(); - calMinusOneDay.add(Calendar.DAY_OF_YEAR, -1); - - if (calMinusOneDay.getTime().after(lastRefScaleMeasurement.getDateTime())) { - Toast.makeText(getApplicationContext(), getString(R.string.info_assisted_weighing_old_reference_measurement), Toast.LENGTH_LONG).show(); - } - } else { - Toast.makeText(getApplicationContext(), getString(R.string.info_assisted_weighing_no_reference_measurements), Toast.LENGTH_LONG).show(); - return; - } - - if (manuelEntry) { - MobileNavigationDirections.ActionNavMobileNavigationToNavDataentry action = MobileNavigationDirections.actionNavMobileNavigationToNavDataentry(); - action.setMode(MeasurementEntryFragment.DATA_ENTRY_MODE.ADD); - action.setTitle(getString(R.string.label_add_measurement)); - navController.navigate(action); - } else { - invokeConnectToBluetoothDevice(); - } - } - }); - - AlertDialog dialog = builder.create(); - dialog.show(); - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - // Inflate the menu; this adds items to the action bar if it is present. - getMenuInflater().inflate(R.menu.action_menu, menu); - - bluetoothStatus = menu.findItem(R.id.action_bluetooth_status); - - BluetoothManager bluetoothManager = (BluetoothManager) getSystemService(BLUETOOTH_SERVICE); - boolean hasBluetooth = bluetoothManager.getAdapter() != null; - - if (!hasBluetooth) { - setBluetoothStatusIcon(R.drawable.ic_bluetooth_disabled); - } - // Just search for a bluetooth device just once at the start of the app and if start preference enabled - else if (firstAppStart && prefs.getBoolean("btEnable", false)) { - invokeConnectToBluetoothDevice(); - firstAppStart = false; - } - else { - // Set current bluetooth status icon while e.g. orientation changes - setBluetoothStatusIcon(bluetoothStatusIcon); - } - - return super.onCreateOptionsMenu(menu); - } - - private void invokeConnectToBluetoothDevice() { - final OpenScale openScale = OpenScale.getInstance(); - - if (openScale.getSelectedScaleUserId() == -1) { - showNoSelectedUserDialog(); - return; - } - - Timber.d("Main Activity Bluetooth permission check"); - - int targetSdkVersion = getApplicationInfo().targetSdkVersion; - - final BluetoothManager bluetoothManager = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE); - BluetoothAdapter btAdapter = bluetoothManager.getAdapter(); - - // Check if Bluetooth is enabled - if (btAdapter == null || !btAdapter.isEnabled()) { - Timber.d("Bluetooth is not enabled"); - Toast.makeText(this, "Bluetooth " + getResources().getString(R.string.info_is_not_enable), Toast.LENGTH_SHORT).show(); - setBluetoothStatusIcon(R.drawable.ic_bluetooth_disabled); - return; - } - - // Check if Bluetooth 4.x is available - if (!getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) { - Timber.d("No Bluetooth 4.x available"); - Toast.makeText(this, "Bluetooth 4.x " + getResources().getString(R.string.info_is_not_available), Toast.LENGTH_SHORT).show(); - setBluetoothStatusIcon(R.drawable.ic_bluetooth_disabled); - return; - } - - // Check if GPS or Network location service is enabled - LocationManager locationManager = (LocationManager) getSystemService(LOCATION_SERVICE); - if (!(locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER))) { - Timber.d("No GPS or Network location service is enabled, ask user for permission"); - - AlertDialog.Builder builder = new AlertDialog.Builder(this); - builder.setTitle(R.string.permission_bluetooth_info_title); - builder.setIcon(R.drawable.ic_preferences_about); - builder.setMessage(R.string.permission_location_service_info); - builder.setPositiveButton(R.string.label_ok, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialogInterface, int i) { - // Show location settings when the user acknowledges the alert dialog - Intent intent = new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS); - startActivity(intent); - } - }); - - Dialog alertDialog = builder.create(); - alertDialog.setCanceledOnTouchOutside(false); - alertDialog.show(); - - setBluetoothStatusIcon(R.drawable.ic_bluetooth_disabled); - return; - } - - String deviceName = prefs.getString( - BluetoothSettingsFragment.PREFERENCE_KEY_BLUETOOTH_DEVICE_NAME, ""); - String hwAddress = prefs.getString( - BluetoothSettingsFragment.PREFERENCE_KEY_BLUETOOTH_HW_ADDRESS, ""); - - if (!BluetoothAdapter.checkBluetoothAddress(hwAddress)) { - setBluetoothStatusIcon(R.drawable.ic_bluetooth_connection_lost); - Toast.makeText(getApplicationContext(), R.string.info_bluetooth_no_device_set, Toast.LENGTH_SHORT).show(); - return; - } - - String[] requiredPermissions; - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && targetSdkVersion >= Build.VERSION_CODES.S) { - Timber.d("SDK >= 31 request for Bluetooth Scan and Bluetooth connect permissions"); - requiredPermissions = new String[]{Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT}; - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && targetSdkVersion >= Build.VERSION_CODES.Q) { - Timber.d("SDK >= 29 request for Access fine location permission"); - requiredPermissions = new String[]{Manifest.permission.ACCESS_FINE_LOCATION}; - } else { - Timber.d("SDK < 29 request for coarse location permission"); - requiredPermissions = new String[]{Manifest.permission.ACCESS_FINE_LOCATION}; - } - - if (hasPermissions(requiredPermissions)) { - connectToBluetooth(); - } else if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)) { - AlertDialog.Builder builder = new AlertDialog.Builder(this); - Timber.d("No access fine location permission granted"); - - builder.setMessage(R.string.permission_bluetooth_info) - .setTitle(R.string.permission_bluetooth_info_title) - .setIcon(R.drawable.ic_preferences_about) - .setPositiveButton(R.string.label_ok, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.dismiss(); - requestPermissionBluetoothLauncher.launch(requiredPermissions); - } - }); - - Dialog alertDialog = builder.create(); - alertDialog.setCanceledOnTouchOutside(false); - alertDialog.show(); - } else if (shouldShowRequestPermissionRationale(Manifest.permission.BLUETOOTH_SCAN)) { - AlertDialog.Builder builder = new AlertDialog.Builder(this); - Timber.d("No access Bluetooth scan permission granted"); - - builder.setMessage(R.string.permission_bluetooth_info) - .setTitle(R.string.permission_bluetooth_info_title) - .setIcon(R.drawable.ic_preferences_about) - .setPositiveButton(R.string.label_ok, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.dismiss(); - requestPermissionBluetoothLauncher.launch(requiredPermissions); - } - }); - - Dialog alertDialog = builder.create(); - alertDialog.setCanceledOnTouchOutside(false); - alertDialog.show(); - } else { - requestPermissionBluetoothLauncher.launch(requiredPermissions); - } - } - - private void connectToBluetooth() { - String deviceName = prefs.getString( - BluetoothSettingsFragment.PREFERENCE_KEY_BLUETOOTH_DEVICE_NAME, ""); - String hwAddress = prefs.getString( - BluetoothSettingsFragment.PREFERENCE_KEY_BLUETOOTH_HW_ADDRESS, ""); - - Toast.makeText(getApplicationContext(), getResources().getString(R.string.info_bluetooth_try_connection) + " " + deviceName, Toast.LENGTH_SHORT).show(); - setBluetoothStatusIcon(R.drawable.ic_bluetooth_searching); - - if (!OpenScale.getInstance().connectToBluetoothDevice(deviceName, hwAddress, callbackBtHandler)) { - setBluetoothStatusIcon(R.drawable.ic_bluetooth_connection_lost); - Toast.makeText(getApplicationContext(), deviceName + " " + getResources().getString(R.string.label_bt_device_no_support), Toast.LENGTH_SHORT).show(); - } - } - - private final Handler callbackBtHandler = new Handler() { - @Override - public void handleMessage(Message msg) { - - BluetoothCommunication.BT_STATUS btStatus = BluetoothCommunication.BT_STATUS.values()[msg.what]; - - switch (btStatus) { - case RETRIEVE_SCALE_DATA: - setBluetoothStatusIcon(R.drawable.ic_bluetooth_connection_success); - ScaleMeasurement scaleBtData = (ScaleMeasurement) msg.obj; - - OpenScale openScale = OpenScale.getInstance(); - - if (prefs.getBoolean("mergeWithLastMeasurement", true)) { - if (!openScale.isScaleMeasurementListEmpty()) { - ScaleMeasurement lastMeasurement = openScale.getLastScaleMeasurement(); - scaleBtData.merge(lastMeasurement); - } - } - - openScale.addScaleMeasurement(scaleBtData, true); - break; - case INIT_PROCESS: - setBluetoothStatusIcon(R.drawable.ic_bluetooth_connection_success); - Toast.makeText(getApplicationContext(), getResources().getString(R.string.info_bluetooth_init), Toast.LENGTH_SHORT).show(); - Timber.d("Bluetooth initializing"); - break; - case CONNECTION_LOST: - setBluetoothStatusIcon(R.drawable.ic_bluetooth_connection_lost); - Toast.makeText(getApplicationContext(), getResources().getString(R.string.info_bluetooth_connection_lost), Toast.LENGTH_SHORT).show(); - Timber.d("Bluetooth connection lost"); - break; - case NO_DEVICE_FOUND: - setBluetoothStatusIcon(R.drawable.ic_bluetooth_connection_lost); - Toast.makeText(getApplicationContext(), getResources().getString(R.string.info_bluetooth_no_device), Toast.LENGTH_SHORT).show(); - Timber.e("No Bluetooth device found"); - break; - case CONNECTION_RETRYING: - setBluetoothStatusIcon(R.drawable.ic_bluetooth_searching); - Toast.makeText(getApplicationContext(), getResources().getString(R.string.info_bluetooth_no_device_retrying), Toast.LENGTH_SHORT).show(); - Timber.e("No Bluetooth device found retrying"); - break; - case CONNECTION_ESTABLISHED: - setBluetoothStatusIcon(R.drawable.ic_bluetooth_connection_success); - Toast.makeText(getApplicationContext(), getResources().getString(R.string.info_bluetooth_connection_successful), Toast.LENGTH_SHORT).show(); - Timber.d("Bluetooth connection successful established"); - break; - case CONNECTION_DISCONNECT: - setBluetoothStatusIcon(R.drawable.ic_bluetooth_connection_lost); - Toast.makeText(getApplicationContext(), getResources().getString(R.string.info_bluetooth_connection_disconnected), Toast.LENGTH_SHORT).show(); - Timber.d("Bluetooth connection successful disconnected"); - break; - case UNEXPECTED_ERROR: - setBluetoothStatusIcon(R.drawable.ic_bluetooth_connection_lost); - Toast.makeText(getApplicationContext(), getResources().getString(R.string.info_bluetooth_connection_error) + ": " + msg.obj, Toast.LENGTH_SHORT).show(); - Timber.e("Bluetooth unexpected error: %s", msg.obj); - break; - case SCALE_MESSAGE: - try { - String toastMessage = String.format(getResources().getString(msg.arg1), msg.obj); - Toast.makeText(getApplicationContext(), toastMessage, Toast.LENGTH_LONG).show(); - Timber.d("Bluetooth scale message: " + toastMessage); - } catch (Exception ex) { - Timber.e("Bluetooth scale message error: " + ex); - } - break; - case CHOOSE_SCALE_USER: - chooseScaleUser(msg); - break; - case ENTER_SCALE_USER_CONSENT: - enterScaleUserConsent(msg); - break; - } - } - }; - - private void chooseScaleUser(Message msg) { - AlertDialog.Builder mBuilder = new AlertDialog.Builder(MainActivity.this); - Pair choices = (Pair)msg.obj; - - mBuilder.setTitle(getResources().getString(R.string.info_select_scale_user)); - mBuilder.setSingleChoiceItems(choices.first, -1 , new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialoginterface, int i) { - Timber.d("UI selected " + i + ": " + choices.first[i] + " P-0" + choices.second[i]); - OpenScale.getInstance().setBluetoothDeviceUserIndex(OpenScale.getInstance().getSelectedScaleUser().getId(), choices.second[i], callbackBtHandler); - dialoginterface.dismiss(); - } - }); - mBuilder.setNegativeButton(getResources().getString(R.string.label_cancel), new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialoginterface, int i) { - dialoginterface.dismiss(); - } - }); - - AlertDialog mDialog = mBuilder.create(); - mDialog.show(); - } - - private void enterScaleUserConsent(Message msg) { - final int appUserId = msg.arg1; - final int scaleUserIndex = msg.arg2; - final int[] consentCode = {-1}; - - AlertDialog.Builder mBuilder = new AlertDialog.Builder(MainActivity.this); - mBuilder.setTitle(getResources().getString(R.string.info_enter_consent_code_for_scale_user, Integer.toString(scaleUserIndex))); - - final EditText input = new EditText(this); - input.setInputType(InputType.TYPE_CLASS_NUMBER); - InputFilter[] filterArray = new InputFilter[1]; - filterArray[0] = new InputFilter.LengthFilter(4); - input.setFilters(filterArray); - mBuilder.setView(input); - - mBuilder.setPositiveButton(getResources().getString(R.string.label_ok), new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialoginterface, int i) { - OpenScale.getInstance().setBluetoothDeviceUserConsent(appUserId, consentCode[0], callbackBtHandler); - dialoginterface.dismiss(); - } - }); - mBuilder.setNegativeButton(getResources().getString(R.string.label_cancel), new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialoginterface, int i) { - OpenScale.getInstance().setBluetoothDeviceUserConsent(appUserId, -1, callbackBtHandler); - dialoginterface.dismiss(); - } - }); - - AlertDialog mDialog = mBuilder.create(); - mDialog.show(); - - mDialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false); - input.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - } - @Override - public void afterTextChanged(Editable s) { - try { - consentCode[0] = Integer.parseInt(s.toString()); - Timber.d("consent code set to " + consentCode[0] + "(" + s.toString() + ")"); - mDialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(true); - } catch(NumberFormatException nfe) { - Timber.d("Could not parse " + nfe); - mDialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false); - } - } - }); - } - - private void setBluetoothStatusIcon(int iconResource) { - bluetoothStatusIcon = iconResource; - bluetoothStatus.setIcon(getResources().getDrawable(bluetoothStatusIcon)); - } - - private void importCsvFile() { - int selectedUserId = OpenScale.getInstance().getSelectedScaleUserId(); - - if (selectedUserId == -1) { - AlertDialog.Builder infoDialog = new AlertDialog.Builder(this); - - infoDialog.setMessage(getResources().getString(R.string.info_no_selected_user)); - infoDialog.setPositiveButton(getResources().getString(R.string.label_ok), null); - - infoDialog.show(); - } - else { - Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.addCategory(Intent.CATEGORY_OPENABLE); - intent.setType("*/*"); - - startActivityForResult( - Intent.createChooser(intent, getResources().getString(R.string.label_import)), - IMPORT_DATA_REQUEST); - } - } - - private String getExportFilename(ScaleUser selectedScaleUser) { - return String.format("openScale %s.csv", selectedScaleUser.getUserName()); - } - - private void startActionCreateDocumentForExportIntent() { - OpenScale openScale = OpenScale.getInstance(); - ScaleUser selectedScaleUser = openScale.getSelectedScaleUser(); - - Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); - intent.addCategory(Intent.CATEGORY_OPENABLE); - intent.setType("text/csv"); - intent.putExtra(Intent.EXTRA_TITLE, getExportFilename(selectedScaleUser)); - - startActivityForResult(intent, EXPORT_DATA_REQUEST); - } - - private boolean doExportData(Uri uri) { - OpenScale openScale = OpenScale.getInstance(); - if (openScale.exportData(uri)) { - String filename = openScale.getFilenameFromUri(uri); - Toast.makeText(this, - getResources().getString(R.string.info_data_exported) + " " + filename, - Toast.LENGTH_SHORT).show(); - return true; - } - return false; - } - - private String getExportPreferenceKey(ScaleUser selectedScaleUser) { - return selectedScaleUser.getPreferenceKey("exportUri"); - } - - private void exportCsvFile() { - OpenScale openScale = OpenScale.getInstance(); - final ScaleUser selectedScaleUser = openScale.getSelectedScaleUser(); - - Uri uri; - try { - String exportUri = prefs.getString(getExportPreferenceKey(selectedScaleUser), ""); - uri = Uri.parse(exportUri); - - // Verify that the file still exists and that we have write permission - getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - openScale.getFilenameFromUriMayThrow(uri); - } - catch (Exception ex) { - uri = null; - } - - if (uri == null) { - startActionCreateDocumentForExportIntent(); - return; - } - - AlertDialog.Builder exportDialog = new AlertDialog.Builder(this); - exportDialog.setTitle(R.string.label_export); - exportDialog.setMessage(getResources().getString(R.string.label_export_overwrite, - openScale.getFilenameFromUri(uri))); - - final Uri exportUri = uri; - exportDialog.setPositiveButton(R.string.label_yes, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - if (!doExportData(exportUri)) { - prefs.edit().remove(getExportPreferenceKey(selectedScaleUser)).apply(); - } - } - }); - exportDialog.setNegativeButton(R.string.label_no, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - startActionCreateDocumentForExportIntent(); - } - }); - exportDialog.setNeutralButton(R.string.label_cancel, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - dialog.dismiss(); - } - }); - - exportDialog.show(); - } - - private void shareCsvFile() { - final ScaleUser selectedScaleUser = OpenScale.getInstance().getSelectedScaleUser(); - - File shareFile = new File(getApplicationContext().getCacheDir(), - getExportFilename(selectedScaleUser)); - if (!OpenScale.getInstance().exportData(Uri.fromFile(shareFile))) { - return; - } - - Intent intent = new Intent(Intent.ACTION_SEND); - intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - intent.setType("text/csv"); - - final Uri uri = FileProvider.getUriForFile( - getApplicationContext(), BuildConfig.APPLICATION_ID + ".fileprovider", shareFile); - intent.putExtra(Intent.EXTRA_STREAM, uri); - - intent.putExtra(Intent.EXTRA_SUBJECT, - getResources().getString(R.string.label_share_subject, selectedScaleUser.getUserName())); - - startActivity(Intent.createChooser(intent, getResources().getString(R.string.label_share))); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - - OpenScale openScale = OpenScale.getInstance(); - - if (requestCode == APPINTRO_REQUEST) { - if (openScale.getSelectedScaleUserId() == -1) { - MobileNavigationDirections.ActionNavMobileNavigationToNavUsersettings action = MobileNavigationDirections.actionNavMobileNavigationToNavUsersettings(); - action.setMode(UserSettingsFragment.USER_SETTING_MODE.ADD); - action.setTitle(getString(R.string.label_add_user)); - Navigation.findNavController(this, R.id.nav_host_fragment).navigate(action); - } - } - - if (resultCode != RESULT_OK || data == null) { - return; - } - - - switch (requestCode) { - case IMPORT_DATA_REQUEST: - openScale.importData(data.getData()); - break; - case EXPORT_DATA_REQUEST: - if (doExportData(data.getData())) { - SharedPreferences.Editor editor = prefs.edit(); - - String key = getExportPreferenceKey(openScale.getSelectedScaleUser()); - - // Remove any old persistable permission and export uri - try { - getContentResolver().releasePersistableUriPermission( - Uri.parse(prefs.getString(key, "")), - Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - editor.remove(key); - } - catch (Exception ex) { - // Ignore - } - - // Take persistable permission and save export uri - try { - getContentResolver().takePersistableUriPermission( - data.getData(), Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - editor.putString(key, data.getData().toString()); - } - catch (Exception ex) { - // Ignore - } - - editor.apply(); - } - break; - } - } - - private boolean hasPermissions(String[] permissions) { - if (permissions != null) { - for (String permission : permissions) { - if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { - Timber.d("Permission is not granted: " + permission); - return false; - } - Timber.d("Permission already granted: " + permission); - } - return true; - } - return false; - } - - private ActivityResultLauncher requestPermissionBluetoothLauncher = - registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), isGranted -> { - if (isGranted.containsValue(false)) { - Timber.d("At least one Bluetooth permission was not granted"); - Toast.makeText(this, getString(R.string.label_bluetooth_title) + ": " + getString(R.string.permission_not_granted), Toast.LENGTH_SHORT).show(); - } - else { - connectToBluetooth(); - } - }); - - // Generate random dummy measurements - ONLY FOR TESTING PURPOSE - private void generateDummyMeasurements(int measurementCount) { - for (int i=0; i -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.gui.graph; - -import android.app.AlertDialog; -import android.content.DialogInterface; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.view.LayoutInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.PopupMenu; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.activity.OnBackPressedCallback; -import androidx.fragment.app.Fragment; -import androidx.lifecycle.Observer; -import androidx.navigation.Navigation; - -import com.github.mikephil.charting.charts.BarChart; -import com.github.mikephil.charting.components.AxisBase; -import com.github.mikephil.charting.components.XAxis; -import com.github.mikephil.charting.data.BarData; -import com.github.mikephil.charting.data.BarDataSet; -import com.github.mikephil.charting.data.BarEntry; -import com.github.mikephil.charting.data.Entry; -import com.github.mikephil.charting.formatter.StackedValueFormatter; -import com.github.mikephil.charting.formatter.ValueFormatter; -import com.github.mikephil.charting.highlight.Highlight; -import com.github.mikephil.charting.interfaces.datasets.IBarDataSet; -import com.github.mikephil.charting.listener.OnChartValueSelectedListener; -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.google.android.material.shape.ShapeAppearanceModel; -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.gui.measurement.ChartActionBarView; -import com.health.openscale.gui.measurement.ChartMeasurementView; -import com.health.openscale.gui.measurement.MeasurementEntryFragment; -import com.health.openscale.gui.utils.ColorUtil; - -import java.text.SimpleDateFormat; -import java.time.LocalDate; -import java.time.ZoneId; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.List; -import java.util.Locale; - -public class GraphFragment extends Fragment { - private View graphView; - private ChartMeasurementView chartView; - private ChartActionBarView chartActionBarView; - private BarChart chartTop; - private TextView txtYear; - private Button btnLeftYear; - private Button btnRightYear; - private PopupMenu popup; - private FloatingActionButton showMenu; - private FloatingActionButton editMenu; - private FloatingActionButton deleteMenu; - private SharedPreferences prefs; - - private OpenScale openScale; - - private LocalDate calYears; - private LocalDate calLastSelected; - - private ScaleMeasurement markedMeasurement; - - private static final String CAL_YEARS_KEY = "calYears"; - private static final String CAL_LAST_SELECTED_KEY = "calLastSelected"; - - public GraphFragment() { - calYears = LocalDate.now(); - calLastSelected = LocalDate.now(); - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) - { - openScale = OpenScale.getInstance(); - - if (savedInstanceState == null) { - if (!openScale.isScaleMeasurementListEmpty()) { - calYears = openScale.getLastScaleMeasurement().getDateTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDate();; - calLastSelected = openScale.getLastScaleMeasurement().getDateTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); - } - } - else { - calYears = LocalDate.ofEpochDay(savedInstanceState.getLong(CAL_YEARS_KEY)); - calLastSelected = LocalDate.ofEpochDay(savedInstanceState.getLong(CAL_LAST_SELECTED_KEY)); - } - - graphView = inflater.inflate(R.layout.fragment_graph, container, false); - - prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); - - chartView = graphView.findViewById(R.id.chartView); - chartView.setOnChartValueSelectedListener(new onChartValueSelectedListener()); - chartView.setProgressBar(graphView.findViewById(R.id.progressBar)); - - chartTop = graphView.findViewById(R.id.chart_top); - chartTop.setDoubleTapToZoomEnabled(false); - chartTop.setDrawGridBackground(false); - chartTop.getLegend().setEnabled(false); - chartTop.getAxisLeft().setEnabled(false); - chartTop.getAxisRight().setEnabled(false); - chartTop.getDescription().setEnabled(false); - chartTop.setOnChartValueSelectedListener(new chartTopValueTouchListener()); - - XAxis chartTopxAxis = chartTop.getXAxis(); - chartTopxAxis.setPosition(XAxis.XAxisPosition.BOTTOM); - chartTopxAxis.setDrawGridLines(false); - chartTopxAxis.setTextColor(ColorUtil.getTintColor(graphView.getContext())); - chartTopxAxis.setValueFormatter(new ValueFormatter() { - - private final SimpleDateFormat mFormat = new SimpleDateFormat("MMM", Locale.getDefault()); - - @Override - public String getAxisLabel(float value, AxisBase axis) { - Calendar calendar = Calendar.getInstance(); - calendar.set(Calendar.MONTH, (int)value); - return mFormat.format(calendar.getTime()); - } - }); - - txtYear = graphView.findViewById(R.id.txtYear); - txtYear.setText(Integer.toString(calYears.getYear())); - - chartActionBarView = graphView.findViewById(R.id.chartActionBar); - chartActionBarView.setOnActionClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - generateGraphs(); - } - }); - - ImageView optionMenu = graphView.findViewById(R.id.optionMenu); - optionMenu.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - popup.show(); - } - }); - - btnLeftYear = graphView.findViewById(R.id.btnLeftYear); - btnLeftYear.setOnClickListener(new View.OnClickListener() { - public void onClick(View view) { - calYears = calYears.minusYears(1); - txtYear.setText(Integer.toString(calYears.getYear())); - - generateGraphs(); - } - }); - - btnRightYear = graphView.findViewById(R.id.btnRightYear); - btnRightYear.setOnClickListener(new View.OnClickListener() { - public void onClick(View view) { - calYears = calYears.plusYears(1); - txtYear.setText(Integer.toString(calYears.getYear())); - - generateGraphs(); - } - }); - - popup = new PopupMenu(getContext(), optionMenu); - popup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { - @Override - public boolean onMenuItemClick(MenuItem item) { - - switch (item.getItemId()) { - case R.id.enableChartActionBar: - if (item.isChecked()) { - item.setChecked(false); - prefs.edit().putBoolean("enableGraphChartActionBar", false).apply(); - chartActionBarView.setVisibility(View.GONE); - } else { - item.setChecked(true); - prefs.edit().putBoolean("enableGraphChartActionBar", true).apply(); - chartActionBarView.setVisibility(View.VISIBLE); - } - return true; - case R.id.enableMonth: - if (item.isChecked()) { - item.setChecked(false); - prefs.edit().putBoolean("showMonth", false).apply(); - } else { - item.setChecked(true); - prefs.edit().putBoolean("showMonth", true).apply(); - } - - getActivity().recreate(); // TODO HACK to refresh graph; graph.invalidate and notfiydatachange is not enough!? - - generateGraphs(); - return true; - case R.id.enableWeek: - if (item.isChecked()) { - item.setChecked(false); - prefs.edit().putBoolean("showWeek", false).apply(); - } else { - item.setChecked(true); - prefs.edit().putBoolean("showWeek", true).apply(); - } - - getActivity().recreate(); // TODO HACK to refresh graph; graph.invalidate and notfiydatachange is not enough!? - - generateGraphs(); - return true; - default: - return false; - } - } - }); - popup.getMenuInflater().inflate(R.menu.graph_menu, popup.getMenu()); - - MenuItem enableMonth = popup.getMenu().findItem(R.id.enableMonth); - enableMonth.setChecked(prefs.getBoolean("showMonth", true)); - - MenuItem enableWeek = popup.getMenu().findItem(R.id.enableWeek); - enableWeek.setChecked(prefs.getBoolean("showWeek", false)); - - MenuItem enableMeasurementBar = popup.getMenu().findItem(R.id.enableChartActionBar); - enableMeasurementBar.setChecked(prefs.getBoolean("enableGraphChartActionBar", true)); - - if (enableMeasurementBar.isChecked()) { - chartActionBarView.setVisibility(View.VISIBLE); - } else { - chartActionBarView.setVisibility(View.GONE); - } - - showMenu = graphView.findViewById(R.id.showMenu); - showMenu.setShapeAppearanceModel(ShapeAppearanceModel.builder().setAllCornerSizes(1000).build()); - showMenu.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - GraphFragmentDirections.ActionNavGraphToNavDataentry action = GraphFragmentDirections.actionNavGraphToNavDataentry(); - action.setMeasurementId(markedMeasurement.getId()); - action.setMode(MeasurementEntryFragment.DATA_ENTRY_MODE.VIEW); - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigate(action); - } - }); - - editMenu = graphView.findViewById(R.id.editMenu); - editMenu.setShapeAppearanceModel(ShapeAppearanceModel.builder().setAllCornerSizes(1000).build()); - editMenu.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - GraphFragmentDirections.ActionNavGraphToNavDataentry action = GraphFragmentDirections.actionNavGraphToNavDataentry(); - action.setMeasurementId(markedMeasurement.getId()); - action.setMode(MeasurementEntryFragment.DATA_ENTRY_MODE.EDIT); - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigate(action); - } - }); - deleteMenu = graphView.findViewById(R.id.deleteMenu); - deleteMenu.setShapeAppearanceModel(ShapeAppearanceModel.builder().setAllCornerSizes(1000).build()); - deleteMenu.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - deleteMeasurement(); - } - }); - - OpenScale.getInstance().getScaleMeasurementsLiveData().observe(getViewLifecycleOwner(), new Observer>() { - @Override - public void onChanged(List scaleMeasurements) { - chartView.updateMeasurementList(scaleMeasurements); - generateGraphs(); - } - }); - - OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) { - @Override - public void handleOnBackPressed() { - requireActivity().finish(); - } - }; - - requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), onBackPressedCallback); - - return graphView; - } - - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - - outState.putLong(CAL_YEARS_KEY, calYears.toEpochDay()); - outState.putLong(CAL_LAST_SELECTED_KEY, calLastSelected.toEpochDay()); - } - - private void generateColumnData() - { - int[] numOfMonth = openScale.getCountsOfMonth(calYears.getYear()); - - LocalDate calMonths = LocalDate.of(calYears.getYear(), 1, 1); - - List dataSets = new ArrayList<>(); - - for (int i=0; i<12; i++) { - List entries = new ArrayList<>(); - - entries.add(new BarEntry(calMonths.getMonthValue()-1, numOfMonth[i])); - - calMonths = calMonths.plusMonths(1); - - BarDataSet set = new BarDataSet(entries, "month "+i); - set.setColor(ColorUtil.COLORS[i % 4]); - set.setDrawValues(false); - set.setValueFormatter(new StackedValueFormatter(true, "", 0)); - dataSets.add(set); - } - - BarData data = new BarData(dataSets); - - chartTop.setData(data); - chartTop.setFitBars(true); - chartTop.invalidate(); - } - - private void generateGraphs() { - final int selectedYear = calYears.getYear(); - - int firstYear = selectedYear; - int lastYear = selectedYear; - - if (!openScale.isScaleMeasurementListEmpty()) { - Calendar cal = Calendar.getInstance(); - - cal.setTime(openScale.getFirstScaleMeasurement().getDateTime()); - firstYear = cal.get(Calendar.YEAR); - - cal.setTime(openScale.getLastScaleMeasurement().getDateTime()); - lastYear = cal.get(Calendar.YEAR); - } - btnLeftYear.setEnabled(selectedYear > firstYear); - btnRightYear.setEnabled(selectedYear < lastYear); - - if (selectedYear == firstYear && selectedYear == lastYear) { - btnLeftYear.setVisibility(View.GONE); - btnRightYear.setVisibility(View.GONE); - } else { - btnLeftYear.setVisibility(View.VISIBLE); - btnRightYear.setVisibility(View.VISIBLE); - } - - // show monthly diagram - if (prefs.getBoolean("showMonth", true)) { - chartTop.setVisibility(View.VISIBLE); - chartView.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 0.7f)); - - generateColumnData(); - - if (prefs.getBoolean("showWeek", false)) { - chartView.setViewRange(selectedYear, calLastSelected.getMonthValue(), ChartMeasurementView.ViewMode.WEEK_OF_MONTH); - } else { - chartView.setViewRange(selectedYear, calLastSelected.getMonthValue(), ChartMeasurementView.ViewMode.DAY_OF_MONTH); - } - } else { // show only yearly diagram and hide monthly diagram - chartTop.setVisibility(View.GONE); - chartView.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 0.9f)); - - if (prefs.getBoolean("showWeek", false)) { - chartView.setViewRange(selectedYear, ChartMeasurementView.ViewMode.WEEK_OF_YEAR); - } else { - chartView.setViewRange(selectedYear, ChartMeasurementView.ViewMode.MONTH_OF_YEAR); - } - } - chartView.refreshMeasurementList(); - } - - private class chartTopValueTouchListener implements OnChartValueSelectedListener { - @Override - public void onValueSelected(Entry e, Highlight h) { - calLastSelected = calLastSelected.withMonth((int)e.getX()+1); - - generateGraphs(); - - showMenu.setVisibility(View.GONE); - editMenu.setVisibility(View.GONE); - deleteMenu.setVisibility(View.GONE); - } - - @Override - public void onNothingSelected() { - - } - } - - private class onChartValueSelectedListener implements OnChartValueSelectedListener { - @Override - public void onValueSelected(Entry e, Highlight h) { - Object[] extraData = (Object[])e.getData(); - - if (extraData == null) { - return; - } - - markedMeasurement = (ScaleMeasurement)extraData[0]; - //MeasurementView measurementView = (MeasurementView)extraData[1]; - - showMenu.setVisibility(View.VISIBLE); - editMenu.setVisibility(View.VISIBLE); - deleteMenu.setVisibility(View.VISIBLE); - } - - @Override - public void onNothingSelected() { - showMenu.setVisibility(View.GONE); - editMenu.setVisibility(View.GONE); - deleteMenu.setVisibility(View.GONE); - } - } - - private void deleteMeasurement() { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(graphView.getContext()); - boolean deleteConfirmationEnable = prefs.getBoolean("deleteConfirmationEnable", true); - - if (deleteConfirmationEnable) { - AlertDialog.Builder deleteAllDialog = new AlertDialog.Builder(graphView.getContext()); - deleteAllDialog.setMessage(getResources().getString(R.string.question_really_delete)); - - deleteAllDialog.setPositiveButton(getResources().getString(R.string.label_yes), new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - doDeleteMeasurement(); - } - }); - - deleteAllDialog.setNegativeButton(getResources().getString(R.string.label_no), new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.dismiss(); - } - }); - - deleteAllDialog.show(); - } - else { - doDeleteMeasurement(); - } - } - - private void doDeleteMeasurement() { - OpenScale.getInstance().deleteScaleMeasurement(markedMeasurement.getId()); - Toast.makeText(graphView.getContext(), getResources().getString(R.string.info_data_deleted), Toast.LENGTH_SHORT).show(); - - showMenu.setVisibility(View.GONE); - editMenu.setVisibility(View.GONE); - deleteMenu.setVisibility(View.GONE); - - chartTop.invalidate(); - chartView.invalidate(); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/BMIMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/BMIMeasurementView.java deleted file mode 100644 index f1f60dab..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/BMIMeasurementView.java +++ /dev/null @@ -1,73 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; - -public class BMIMeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "bmi"; - - public BMIMeasurementView(Context context) { - super(context, R.string.label_bmi, R.drawable.ic_bmi); - } - - @Override - public String getKey() { - return KEY; - } - - @Override - public boolean isEditable() { - return false; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return measurement.getBMI(getScaleUser().getBodyHeight()); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - // Empty - } - - @Override - public String getUnit() { - return ""; - } - - @Override - protected float getMaxValue() { - return 125; - } - - @Override - public int getColor() { - return Color.parseColor("#EC407A"); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return evalSheet.evaluateBMI(value); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/BMRMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/BMRMeasurementView.java deleted file mode 100644 index 56e0e187..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/BMRMeasurementView.java +++ /dev/null @@ -1,78 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; - -public class BMRMeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "bmr"; - - public BMRMeasurementView(Context context) { - super(context, R.string.label_bmr, R.drawable.ic_bmr); - } - - @Override - public String getKey() { - return KEY; - } - - @Override - public boolean isEditable() { - return false; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return measurement.getBMR(getScaleUser()); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - // Empty - } - - @Override - public String getUnit() { - return "kCal"; - } - - @Override - protected float getMaxValue() { - return 5000; - } - - @Override - protected int getDecimalPlaces() { - return 0; - } - - @Override - public int getColor() { - return Color.parseColor("#26A69A"); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return null; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/BicepsMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/BicepsMeasurementView.java deleted file mode 100644 index 9175fd0a..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/BicepsMeasurementView.java +++ /dev/null @@ -1,69 +0,0 @@ -/* Copyright (C) 2018 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; -import com.health.openscale.core.utils.Converters; - -public class BicepsMeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "biceps"; - - public BicepsMeasurementView(Context context) { - super(context, R.string.label_biceps, R.drawable.ic_biceps); - } - - @Override - public String getKey() { - return KEY; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return Converters.fromCentimeter(measurement.getBiceps(), getScaleUser().getMeasureUnit()); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - measurement.setBiceps(Converters.toCentimeter(value, getScaleUser().getMeasureUnit())); - } - - @Override - public String getUnit() { - return getScaleUser().getMeasureUnit().toString(); - } - - @Override - protected float getMaxValue() { - return 500; - } - - @Override - public int getColor() { - return Color.parseColor("#c0ca33"); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return null; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/BoneMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/BoneMeasurementView.java deleted file mode 100644 index eb6f8ab8..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/BoneMeasurementView.java +++ /dev/null @@ -1,79 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; -import com.health.openscale.core.utils.Converters; - -public class BoneMeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "bone"; - - public BoneMeasurementView(Context context) { - super(context, R.string.label_bone, R.drawable.ic_bone); - } - - @Override - public String getKey() { - return KEY; - } - - @Override - protected boolean supportsAbsoluteWeightToPercentageConversion() { - return true; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return Converters.fromKilogram(measurement.getBone(), getScaleUser().getScaleUnit()); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - measurement.setBone(Converters.toKilogram(value, getScaleUser().getScaleUnit())); - } - - @Override - public String getUnit() { - if (shouldConvertAbsoluteWeightToPercentage()) { - return "%"; - } - - return getScaleUser().getScaleUnit().toString(); - } - - @Override - protected float getMaxValue() { - return maybeConvertAbsoluteWeightToPercentage( - Converters.fromKilogram(50, getScaleUser().getScaleUnit())); - } - - @Override - public int getColor() { - return Color.parseColor("#4FC3F7"); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return null; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/Caliper1MeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/Caliper1MeasurementView.java deleted file mode 100644 index 3dbe35dc..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/Caliper1MeasurementView.java +++ /dev/null @@ -1,73 +0,0 @@ -/* Copyright (C) 2018 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; -import com.health.openscale.core.utils.Converters; - -public class Caliper1MeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "caliper1"; - - public Caliper1MeasurementView(Context context) { - super(context, R.string.label_caliper1_female, R.drawable.ic_caliper1); - - if (getScaleUser().getGender().isMale()) { - setName(R.string.label_caliper1_male); - } - } - - @Override - public String getKey() { - return KEY; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return Converters.fromCentimeter(measurement.getCaliper1(), getScaleUser().getMeasureUnit()); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - measurement.setCaliper1(Converters.toCentimeter(value, getScaleUser().getMeasureUnit())); - } - - @Override - public String getUnit() { - return getScaleUser().getMeasureUnit().toString(); - } - - @Override - protected float getMaxValue() { - return 500; - } - - @Override - public int getColor() { - return Color.parseColor("#ba68c8"); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return null; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/Caliper2MeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/Caliper2MeasurementView.java deleted file mode 100644 index 16bb9451..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/Caliper2MeasurementView.java +++ /dev/null @@ -1,73 +0,0 @@ -/* Copyright (C) 2018 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; -import com.health.openscale.core.utils.Converters; - -public class Caliper2MeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "caliper2"; - - public Caliper2MeasurementView(Context context) { - super(context, R.string.label_caliper2_female, R.drawable.ic_caliper2); - - if (getScaleUser().getGender().isMale()) { - setName(R.string.label_caliper2_male); - } - } - - @Override - public String getKey() { - return KEY; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return Converters.fromCentimeter(measurement.getCaliper2(), getScaleUser().getMeasureUnit()); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - measurement.setCaliper2(Converters.toCentimeter(value, getScaleUser().getMeasureUnit())); - } - - @Override - public String getUnit() { - return getScaleUser().getMeasureUnit().toString(); - } - - @Override - protected float getMaxValue() { - return 500; - } - - @Override - public int getColor() { - return Color.parseColor("#ce93d8"); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return null; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/Caliper3MeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/Caliper3MeasurementView.java deleted file mode 100644 index 482feaba..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/Caliper3MeasurementView.java +++ /dev/null @@ -1,73 +0,0 @@ -/* Copyright (C) 2018 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; -import com.health.openscale.core.utils.Converters; - -public class Caliper3MeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "caliper3"; - - public Caliper3MeasurementView(Context context) { - super(context, R.string.label_caliper3_female, R.drawable.ic_caliper3); - - if (getScaleUser().getGender().isMale()) { - setName(R.string.label_caliper3_male); - } - } - - @Override - public String getKey() { - return KEY; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return Converters.fromCentimeter(measurement.getCaliper3(), getScaleUser().getMeasureUnit()); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - measurement.setCaliper3(Converters.toCentimeter(value, getScaleUser().getMeasureUnit())); - } - - @Override - public String getUnit() { - return getScaleUser().getMeasureUnit().toString(); - } - - @Override - protected float getMaxValue() { - return 500; - } - - @Override - public int getColor() { - return Color.parseColor("#e1bee7"); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return null; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/CaloriesMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/CaloriesMeasurementView.java deleted file mode 100644 index 50ad2cc3..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/CaloriesMeasurementView.java +++ /dev/null @@ -1,73 +0,0 @@ -/* Copyright (C) 2019 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; - -public class CaloriesMeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "calories"; - - public CaloriesMeasurementView(Context context) { - super(context, R.string.label_calories, R.drawable.ic_calories); - } - - @Override - public String getKey() { - return KEY; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return measurement.getCalories(); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - measurement.setCalories(value); - } - - @Override - public String getUnit() { - return "kCal"; - } - - @Override - protected float getMaxValue() { - return 100000; - } - - @Override - protected int getDecimalPlaces() { - return 0; - } - - @Override - public int getColor() { - return Color.parseColor("#e533ff"); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return null; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/ChartActionBarView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/ChartActionBarView.java deleted file mode 100644 index 4e2281ab..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/ChartActionBarView.java +++ /dev/null @@ -1,149 +0,0 @@ -/* Copyright (C) 2019 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.content.SharedPreferences; -import android.content.res.ColorStateList; -import android.graphics.Color; -import android.preference.PreferenceManager; -import android.util.AttributeSet; -import android.view.View; -import android.view.ViewGroup; -import android.widget.HorizontalScrollView; -import android.widget.LinearLayout; -import android.widget.RelativeLayout; - -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.google.android.material.shape.ShapeAppearanceModel; -import com.health.openscale.gui.utils.ColorUtil; - -import java.util.List; - -public class ChartActionBarView extends HorizontalScrollView { - - private LinearLayout actionBarView; - private List measurementViews; - private View.OnClickListener onActionClickListener; - private boolean isInGraphKey; - - public ChartActionBarView(Context context) { - super(context); - init(); - } - - public ChartActionBarView(Context context, AttributeSet attrs) { - super(context, attrs); - init(); - } - - public ChartActionBarView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - init(); - } - - private void init() { - if (isInEditMode()) { - return; - } - - actionBarView = new LinearLayout(getContext()); - actionBarView.setOrientation(LinearLayout.HORIZONTAL); - - measurementViews = MeasurementView.getMeasurementList( - getContext(), MeasurementView.DateTimeOrder.NONE); - - isInGraphKey = true; - onActionClickListener = null; - - addView(actionBarView); - refreshFloatingActionsButtons(); - } - - public void setOnActionClickListener(View.OnClickListener listener) { - onActionClickListener = listener; - } - - public void setIsInGraphKey(boolean status) { - isInGraphKey = status; - refreshFloatingActionsButtons(); - } - - private void refreshFloatingActionsButtons() { - actionBarView.removeAllViews(); - - for (MeasurementView view : measurementViews) { - if (view instanceof FloatMeasurementView) { - final FloatMeasurementView measurementView = (FloatMeasurementView) view; - - if (measurementView.isVisible()) { - addActionButton(measurementView); - } - } - } - } - - private void addActionButton(FloatMeasurementView measurementView) { - FloatingActionButton actionButton = new FloatingActionButton(getContext()); - - actionButton.setTag(measurementView.getKey()); - actionButton.setColorFilter(Color.parseColor("#000000")); - actionButton.setImageDrawable(measurementView.getIcon()); - actionButton.setClickable(true); - actionButton.setSize(FloatingActionButton.SIZE_MINI); - actionButton.setShapeAppearanceModel(ShapeAppearanceModel.builder().setAllCornerSizes(1000).build()); - RelativeLayout.LayoutParams lay = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); - lay.setMargins(0,5,20,10); - actionButton.setLayoutParams(lay); - actionButton.setOnClickListener(new onActionClickListener()); - - if (isInGraphKey) { - int color = measurementView.getSettings().isInGraph() - ? measurementView.getColor() : ColorUtil.COLOR_GRAY; - actionButton.setBackgroundTintList(ColorStateList.valueOf(color)); - - } else { - int color = measurementView.getSettings().isInOverviewGraph() - ? measurementView.getColor() : ColorUtil.COLOR_GRAY; - actionButton.setBackgroundTintList(ColorStateList.valueOf(color)); - } - - actionBarView.addView(actionButton); - } - - private class onActionClickListener implements View.OnClickListener { - @Override - public void onClick(View v) { - FloatingActionButton actionButton = (FloatingActionButton) v; - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); - - String key = String.valueOf(actionButton.getTag()); - MeasurementViewSettings settings = new MeasurementViewSettings(prefs, key); - if (isInGraphKey) { - prefs.edit().putBoolean(settings.getInGraphKey(), !settings.isInGraph()).apply(); - } else { - prefs.edit().putBoolean(settings.getInOverviewGraphKey(), !settings.isInOverviewGraph()).apply(); - } - - refreshFloatingActionsButtons(); - - if (onActionClickListener != null) { - onActionClickListener.onClick(v); - } - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/ChartMarkerView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/ChartMarkerView.java deleted file mode 100644 index 36828ab7..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/ChartMarkerView.java +++ /dev/null @@ -1,93 +0,0 @@ -/* Copyright (C) 2018 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.gui.measurement; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.text.Layout; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.style.AlignmentSpan; -import android.text.style.ForegroundColorSpan; -import android.text.style.RelativeSizeSpan; -import android.widget.TextView; - -import com.github.mikephil.charting.components.MarkerView; -import com.github.mikephil.charting.data.Entry; -import com.github.mikephil.charting.highlight.Highlight; -import com.github.mikephil.charting.utils.MPPointF; -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.gui.utils.ColorUtil; - -import java.text.DateFormat; - -@SuppressLint("ViewConstructor") -public class ChartMarkerView extends MarkerView { - private final TextView markerTextField; - - public ChartMarkerView(Context context, int layoutResource) { - super(context, layoutResource); - - markerTextField = findViewById(R.id.markerTextField); - } - - @Override - public void refreshContent(Entry e, Highlight highlight) { - Object[] extraData = (Object[])e.getData(); - ScaleMeasurement measurement = (ScaleMeasurement)extraData[0]; - ScaleMeasurement prevMeasurement = (ScaleMeasurement)extraData[1]; - FloatMeasurementView measurementView = (FloatMeasurementView)extraData[2]; - - SpannableStringBuilder markerText = new SpannableStringBuilder(); - - if (measurement != null) { - measurementView.loadFrom(measurement, prevMeasurement); - DateFormat dateFormat = DateFormat.getDateInstance(); - markerText.append(dateFormat.format(measurement.getDateTime())); - markerText.setSpan(new RelativeSizeSpan(0.8f), 0, markerText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - markerText.append("\n"); - - if (measurement.isAverageValue()) { - markerText.append(getContext().getString(R.string.label_trend) + " "); - } - } - - markerText.append(measurementView.getValueAsString(true)); - - if (prevMeasurement != null) { - markerText.append("\n"); - int textPosAfterSymbol = markerText.length() + 1; - - measurementView.appendDiffValue(markerText, false); - - // set color diff value to text color - if (markerText.length() > textPosAfterSymbol) { - markerText.setSpan(new ForegroundColorSpan(ColorUtil.COLOR_WHITE), textPosAfterSymbol, markerText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } - } - - markerText.setSpan(new AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER),0, markerText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - markerTextField.setText(markerText); - - super.refreshContent(e, highlight); - } - - @Override - public MPPointF getOffset() { - return new MPPointF(-(getWidth() / 2f), -getHeight() - 5f); - } -} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/ChartMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/ChartMeasurementView.java deleted file mode 100644 index a9ae993a..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/ChartMeasurementView.java +++ /dev/null @@ -1,708 +0,0 @@ -/* Copyright (C) 2019 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - -package com.health.openscale.gui.measurement; - -import static java.time.temporal.ChronoUnit.DAYS; - -import android.content.Context; -import android.content.SharedPreferences; -import android.graphics.Color; -import android.graphics.RectF; -import android.preference.PreferenceManager; -import android.util.AttributeSet; -import android.widget.ProgressBar; - -import com.github.mikephil.charting.charts.LineChart; -import com.github.mikephil.charting.components.AxisBase; -import com.github.mikephil.charting.components.Legend; -import com.github.mikephil.charting.components.XAxis; -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.github.mikephil.charting.formatter.ValueFormatter; -import com.github.mikephil.charting.interfaces.datasets.ILineDataSet; -import com.github.mikephil.charting.utils.Utils; -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; -import com.health.openscale.core.utils.PolynomialFitter; -import com.health.openscale.gui.utils.ColorUtil; - -import java.time.LocalDate; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.List; -import java.util.Stack; - -public class ChartMeasurementView extends LineChart { - public static final String COMPUTATION_METHOD_SIMPLE_MOVING_AVERAGE = "SimpleMovingAverage"; - public static final String COMPUTATION_METHOD_EXPONENTIALLY_SMOOTHED_MOVING_AVERAGE = "ExponentiallySmoothedMovingAverage"; - - public enum ViewMode { - DAY_OF_MONTH, - WEEK_OF_MONTH, - WEEK_OF_YEAR, - MONTH_OF_YEAR, - DAY_OF_YEAR, - DAY_OF_ALL, - WEEK_OF_ALL, - MONTH_OF_ALL, - YEAR_OF_ALL - } - - private OpenScale openScale; - private SharedPreferences prefs; - private List measurementViews; - private List scaleMeasurementList; - private ViewMode viewMode; - private boolean isInGraphKey; - private ProgressBar progressBar; - - private interface TrendlineComputationInterface { - public List processMeasurements(List measurementList); - } - - public ChartMeasurementView(Context context) { - super(context); - initChart(); - } - - public ChartMeasurementView(Context context, AttributeSet attrs) { - super(context, attrs); - initChart(); - } - - public ChartMeasurementView(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - initChart(); - } - - public void setViewRange(final ViewMode mode) { - viewMode = mode; - - setGranularityAndRange(1980, 1); - setXValueFormat(viewMode); - - if (openScale.getLastScaleMeasurement() != null) { - moveViewToX(convertDateToInt(openScale.getLastScaleMeasurement().getDateTime())); - } - } - - public void setViewRange(int year, final ViewMode mode) { - viewMode = mode; - - setGranularityAndRange(year, 1); - setXValueFormat(viewMode); - - LocalDate startDate = LocalDate.of(year, 1, 1); - - moveViewToX(convertDateToInt(startDate)); - } - - public void setViewRange(int year, int month, final ViewMode mode) { - viewMode = mode; - - setGranularityAndRange(year, month); - setXValueFormat(viewMode); - - LocalDate startDate = LocalDate.of(year, month, 1); - - moveViewToX(convertDateToInt(startDate)); - } - - private void setGranularityAndRange(int year, int month) { - LocalDate startDate = LocalDate.of(year, month, 1); - LocalDate endDate = LocalDate.of(year, month, 1); - - int range = 0; - int granularity = 0; - - switch (viewMode) { - case DAY_OF_MONTH: - endDate = startDate.plusMonths(1); - range = (int)DAYS.between(startDate, endDate); - granularity = 1; - break; - case WEEK_OF_MONTH: - endDate = startDate.plusMonths(1); - range = (int)DAYS.between(startDate, endDate); - granularity = 7; - break; - case WEEK_OF_YEAR: - endDate = startDate.plusYears(1); - range = (int)DAYS.between(startDate, endDate); - granularity = 7; - break; - case MONTH_OF_YEAR: - endDate = startDate.plusYears(1); - range = (int)DAYS.between(startDate, endDate); - granularity = 30; - break; - case DAY_OF_YEAR: - endDate = startDate.plusYears(1); - range = (int)DAYS.between(startDate, endDate); - granularity = 1; - break; - case DAY_OF_ALL: - endDate = startDate.plusMonths(1); - range = (int)DAYS.between(startDate, endDate); - granularity = 1; - break; - case WEEK_OF_ALL: - endDate = startDate.plusMonths(1); - range = (int)DAYS.between(startDate, endDate); - granularity = 7; - break; - case MONTH_OF_ALL: - endDate = startDate.plusMonths(3); - range = (int)DAYS.between(startDate, endDate); - granularity = 30; - break; - case YEAR_OF_ALL: - endDate = startDate.plusYears(1); - range = (int)DAYS.between(startDate, endDate); - granularity = 365; - break; - default: - throw new IllegalArgumentException("view mode not implemented"); - } - - getXAxis().setGranularity(granularity); - setVisibleXRangeMaximum(range); - setCustomViewPortOffsets(); // set custom viewPortOffsets to avoid jitter on translating while auto scale is on - } - - public void setIsInGraphKey(boolean status) { - isInGraphKey = status; - } - - public void setProgressBar(ProgressBar bar) { - progressBar = bar; - } - - private void initChart() { - if (isInEditMode()) { - return; - } - - prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); - openScale = OpenScale.getInstance(); - measurementViews = MeasurementView.getMeasurementList(getContext(), MeasurementView.DateTimeOrder.NONE); - isInGraphKey = true; - progressBar = null; - - setHardwareAccelerationEnabled(true); - setAutoScaleMinMaxEnabled(true); - setMarker(new ChartMarkerView(getContext(), R.layout.chart_markerview)); - setDoubleTapToZoomEnabled(false); - setHighlightPerTapEnabled(true); - getLegend().setEnabled(prefs.getBoolean("legendEnable", true)); - getLegend().setWordWrapEnabled(true); - getLegend().setHorizontalAlignment(Legend.LegendHorizontalAlignment.CENTER); - getLegend().setTextColor(ColorUtil.getTintColor(getContext())); - getDescription().setEnabled(false); - getAxisLeft().setEnabled(true); - getAxisRight().setEnabled(true); - getAxisLeft().setTextColor(ColorUtil.getTintColor(getContext())); - getAxisRight().setTextColor(ColorUtil.getTintColor(getContext())); - getXAxis().setPosition(XAxis.XAxisPosition.BOTTOM); - getXAxis().setTextColor(ColorUtil.getTintColor(getContext())); - getXAxis().setGranularityEnabled(true); - } - - private int convertDateToInt(LocalDate date) { - return (int)date.toEpochDay(); - } - - private int convertDateToInt(Date date) { - LocalDate localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); - return (int)localDate.toEpochDay(); - } - - private LocalDate convertIntToDate(int shortDate) { - return LocalDate.ofEpochDay(shortDate); - } - - private void setXValueFormat(final ViewMode mode) { - getXAxis().setValueFormatter(new ValueFormatter() { - - @Override - public String getAxisLabel(float value, AxisBase axis) { - DateTimeFormatter formatter; - - switch (mode) { - case DAY_OF_MONTH: - formatter = DateTimeFormatter.ofPattern("dd"); - break; - case WEEK_OF_MONTH: - formatter = DateTimeFormatter.ofPattern("'W'W"); - break; - case WEEK_OF_YEAR: - formatter = DateTimeFormatter.ofPattern("'W'w"); - break; - case MONTH_OF_YEAR: - formatter = DateTimeFormatter.ofPattern("MMM"); - break; - case DAY_OF_YEAR: - formatter = DateTimeFormatter.ofPattern("D"); - break; - case DAY_OF_ALL: - formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT); - break; - case WEEK_OF_ALL: - formatter = DateTimeFormatter.ofPattern("'W'w yyyy"); - break; - case MONTH_OF_ALL: - formatter = DateTimeFormatter.ofPattern("MMM yyyy"); - break; - case YEAR_OF_ALL: - formatter = DateTimeFormatter.ofPattern("yyyy"); - break; - default: - throw new IllegalArgumentException("view mode not implemented"); - } - - return formatter.format(convertIntToDate((int)value)); - } - }); - } - - private void setCustomViewPortOffsets() { - float offsetLeft = 0f, offsetRight = 0f, offsetTop = 0f, offsetBottom = 0f; - - RectF mOffsetsBuffer = new RectF(); - calculateLegendOffsets(mOffsetsBuffer); - - offsetLeft += mOffsetsBuffer.left; - offsetTop += mOffsetsBuffer.top; - offsetRight += mOffsetsBuffer.right; - offsetBottom += Math.max(70f, mOffsetsBuffer.bottom); - - // offsets for y-labels - - // add one symbol worth of offset to avoid cutting decimal places - float additionalWidth = (float) Utils.calcTextWidth(mAxisRendererLeft - .getPaintAxisLabels(), "1"); - - if (mAxisLeft.needsOffset()) { - offsetLeft += mAxisLeft.getRequiredWidthSpace(mAxisRendererLeft - .getPaintAxisLabels()) + additionalWidth; - } - - if (mAxisRight.needsOffset()) { - offsetRight += mAxisRight.getRequiredWidthSpace(mAxisRendererRight - .getPaintAxisLabels()) + additionalWidth; - } - - if (mXAxis.isEnabled() && mXAxis.isDrawLabelsEnabled()) { - - float xLabelHeight = mXAxis.mLabelRotatedHeight + mXAxis.getYOffset(); - - // offsets for x-labels - if (mXAxis.getPosition() == XAxis.XAxisPosition.BOTTOM) { - - offsetBottom += xLabelHeight; - - } else if (mXAxis.getPosition() == XAxis.XAxisPosition.TOP) { - - offsetTop += xLabelHeight; - - } else if (mXAxis.getPosition() == XAxis.XAxisPosition.BOTH_SIDED) { - - offsetBottom += xLabelHeight; - offsetTop += xLabelHeight; - } - } - - offsetTop += getExtraTopOffset(); - offsetRight += getExtraRightOffset(); - offsetBottom += getExtraBottomOffset(); - offsetLeft += getExtraLeftOffset(); - - float minOffset = Utils.convertDpToPixel(mMinOffset); - - setViewPortOffsets( - Math.max(minOffset, offsetLeft), - Math.max(minOffset, offsetTop), - Math.max(minOffset, offsetRight), - Math.max(minOffset, offsetBottom)); - } - - public void updateMeasurementList(final List scaleMeasurementList) { - clear(); - - if (scaleMeasurementList.isEmpty()) { - progressBar.setVisibility(GONE); - return; - } - - Collections.reverse(scaleMeasurementList); - - this.scaleMeasurementList = scaleMeasurementList; - refreshMeasurementList(); - } - - public void refreshMeasurementList() { - highlightValue(null, false); // deselect any highlighted value - - if (scaleMeasurementList == null) { - progressBar.setVisibility(GONE); - return; - } - - progressBar.setVisibility(VISIBLE); - - List lineDataSets; - lineDataSets = new ArrayList<>(); - - for (MeasurementView view : measurementViews) { - if (view instanceof FloatMeasurementView && view.isVisible()) { - final FloatMeasurementView measurementView = (FloatMeasurementView) view; - - final List lineEntries = new ArrayList<>(); - - for (int i=0; i lineDataSets) { - addTrendLine(lineDataSets, this::getExponentiallySmoothedMovingAverageOfScaleMeasurements); - } - - private void addSimpleMovingAverage(List lineDataSets) { - addTrendLine(lineDataSets, this::getSimpleMovingAverageOfScaleMeasurements); - } - - private void addMeasurementLine(List lineDataSets, List lineEntries, FloatMeasurementView measurementView) { - LineDataSet measurementLine = new LineDataSet(lineEntries, measurementView.getName().toString()); - measurementLine.setLineWidth(1.5f); - measurementLine.setValueTextSize(10.0f); - measurementLine.setColor(measurementView.getColor()); - measurementLine.setValueTextColor(ColorUtil.getTintColor(getContext())); - measurementLine.setCircleColor(measurementView.getColor()); - measurementLine.setCircleHoleColor(measurementView.getColor()); - measurementLine.setAxisDependency(measurementView.getSettings().isOnRightAxis() ? YAxis.AxisDependency.RIGHT : YAxis.AxisDependency.LEFT); - measurementLine.setHighlightEnabled(true); - measurementLine.setDrawHighlightIndicators(true); - measurementLine.setHighlightLineWidth(1.5f); - measurementLine.setDrawHorizontalHighlightIndicator(false); - measurementLine.setHighLightColor(Color.RED); - measurementLine.setDrawCircles(prefs.getBoolean("pointsEnable", true)); - measurementLine.setDrawValues(prefs.getBoolean("labelsEnable", false)); - measurementLine.setMode(LineDataSet.Mode.HORIZONTAL_BEZIER); - if (prefs.getBoolean("trendLine", false)) { - // show only data points if trend line or simple moving average is enabled - measurementLine.enableDashedLine(0, 1, 0); - } - - if (measurementView.isVisible() && !lineEntries.isEmpty()) { - if (isInGraphKey) { - if (measurementView.getSettings().isInGraph()) { - lineDataSets.add(measurementLine); - } - } else { - if (measurementView.getSettings().isInOverviewGraph()) { - lineDataSets.add(measurementLine); - } - } - } - } - - private void addGoalLine(List lineDataSets) { - List valuesGoalLine = new Stack<>(); - - ScaleUser user = OpenScale.getInstance().getSelectedScaleUser(); - float goalWeight = Converters.fromKilogram(user.getGoalWeight(), user.getScaleUnit()); - - valuesGoalLine.add(new Entry(getXChartMin(), goalWeight)); - valuesGoalLine.add(new Entry(getXChartMax(), goalWeight)); - - LineDataSet goalLine = new LineDataSet(valuesGoalLine, getContext().getString(R.string.label_goal_line)); - goalLine.setLineWidth(1.5f); - goalLine.setColor(ColorUtil.COLOR_GREEN); - goalLine.setAxisDependency(prefs.getBoolean("weightOnRightAxis", true) ? YAxis.AxisDependency.RIGHT : YAxis.AxisDependency.LEFT); - goalLine.setDrawValues(false); - goalLine.setDrawCircles(false); - goalLine.setHighlightEnabled(false); - goalLine.enableDashedLine(10, 30, 0); - - lineDataSets.add(goalLine); - } - - private List getExponentiallySmoothedMovingAverageOfScaleMeasurements(List measurementList) { - List trendlineList = new ArrayList<>(); - - // exponentially smoothed moving average with 10% smoothing - trendlineList.add(measurementList.get(0)); - - for (int i = 1; i < measurementList.size(); i++) { - ScaleMeasurement entry = measurementList.get(i).clone(); - ScaleMeasurement trendPreviousEntry = trendlineList.get(i - 1); - - entry.subtract(trendPreviousEntry); - entry.multiply(0.1f); - entry.add(trendPreviousEntry); - - trendlineList.add(entry); - } - - return trendlineList; - } - - private List getSimpleMovingAverageOfScaleMeasurements(List measurementList) { - final long NUMBER_OF_MS_IN_A_DAY = 1000 * 60 * 60 * 24; - List movingAverageList = new ArrayList<>(); - - int samplingWidth = prefs.getInt("simpleMovingAverageNumDays", 7); - - // simple moving average of the last samplingWidth days - movingAverageList.add(measurementList.get(0)); - - for (int i = 1; i < measurementList.size(); i++) { - ScaleMeasurement entry = measurementList.get(i).clone(); - int numberOfMeasurementsToAverageOut = 0; - - for (int k = i-1; k >= 0; k--){ - ScaleMeasurement previousMeasurement = measurementList.get(i - k - 1); - - if (entry.getDateTime().getTime() - previousMeasurement.getDateTime().getTime() < samplingWidth * NUMBER_OF_MS_IN_A_DAY) { - numberOfMeasurementsToAverageOut += 1; - entry.add(previousMeasurement); - } - } - - entry.multiply(1.0f/(numberOfMeasurementsToAverageOut+1)); - - movingAverageList.add(entry); - } - - return movingAverageList; - } - - private ArrayList getNonZeroScaleMeasurementsList(FloatMeasurementView measurementView) { - ArrayList nonZeroScaleMeasurementList = new ArrayList<>(); - - // filter first all zero measurements out, so that the follow-up trendline calculations are not based on them - for (int i=0; i lineDataSets, TrendlineComputationInterface trendlineComputation) { - - for (MeasurementView view : measurementViews) { - if (view instanceof FloatMeasurementView && view.isVisible()) { - final FloatMeasurementView measurementView = (FloatMeasurementView) view; - - ArrayList nonZeroScaleMeasurementList = getNonZeroScaleMeasurementsList(measurementView); - // check if we have some data left otherwise skip the measurement - if (nonZeroScaleMeasurementList.isEmpty()) { - continue; - } - - // calculate the trendline from the non-zero scale measurement list - List scaleMeasurementsAsTrendlineList = trendlineComputation.processMeasurements(nonZeroScaleMeasurementList); - - final List lineEntries = convertMeasurementsToLineEntries(measurementView, scaleMeasurementsAsTrendlineList); - - addMeasurementLineTrend(lineDataSets, lineEntries, measurementView, getContext().getString(R.string.label_trend_line)); - - // add the future entries - if (prefs.getBoolean("trendlineFuture", true)) { - addPredictionLine(lineDataSets, lineEntries, measurementView); - } - } - } - } - - private List convertMeasurementsToLineEntries(FloatMeasurementView measurementView, List measurementsList) { - List lineEntries = new ArrayList<>(); - for (int i = 0; i< measurementsList.size(); i++) { - ScaleMeasurement measurement = measurementsList.get(i); - float value = measurementView.getConvertedMeasurementValue(measurement); - - Entry entry = new Entry(); - entry.setX(convertDateToInt(measurement.getDateTime())); - entry.setY(value); - Object[] extraData = new Object[3]; - extraData[0] = measurement; - extraData[1] = (i == 0) ? null : measurementsList.get(i-1); - extraData[2] = measurementView; - entry.setData(extraData); - - lineEntries.add(entry); - } - - return lineEntries; - } - - private void addPredictionLine(List lineDataSets, List lineEntries, FloatMeasurementView measurementView) { - if (lineEntries.size() < 2) { - return; - } - - PolynomialFitter polyFitter = new PolynomialFitter(lineEntries.size() == 2 ? 2 : 3); - - // add last point to polynomial fitter first - int lastPos = lineEntries.size() - 1; - Entry lastEntry = lineEntries.get(lastPos); - polyFitter.addPoint((double) lastEntry.getX(), (double) lastEntry.getY()); - - // use only the last 30 values for the polynomial fitter - for (int i=2; i<30; i++) { - int pos = lineEntries.size() - i; - - if (pos >= 0) { - Entry entry = lineEntries.get(pos); - Entry prevEntry = lineEntries.get(pos+1); - - // check if x position is different otherwise that point is useless for the polynomial calculation. - if (entry.getX() != prevEntry.getX()) { - polyFitter.addPoint((double) entry.getX(), (double) entry.getY()); - } - } - } - - PolynomialFitter.Polynomial polynomial = polyFitter.getBestFit(); - - int maxX = (int) lastEntry.getX()+1; - List predictionValues = new Stack<>(); - - predictionValues.add(lastEntry); - - // predict 30 days into the future - for (int i = maxX; i < maxX + 30; i++) { - double yPredictionValue = polynomial.getY(i); - predictionValues.add(new Entry((float) i, (float) yPredictionValue)); - } - - LineDataSet predictionLine = new LineDataSet(predictionValues, measurementView.getName().toString() + "-" + getContext().getString(R.string.label_prediction)); - predictionLine.setLineWidth(1.5f); - predictionLine.setColor(measurementView.getColor()); - predictionLine.setAxisDependency(measurementView.getSettings().isOnRightAxis() ? YAxis.AxisDependency.RIGHT : YAxis.AxisDependency.LEFT); - predictionLine.setDrawValues(false); - predictionLine.setDrawCircles(false); - predictionLine.setHighlightEnabled(false); - predictionLine.enableDashedLine(10, 30, 0); - - if (measurementView.isVisible()) { - if (isInGraphKey) { - if (measurementView.getSettings().isInGraph()) { - lineDataSets.add(predictionLine); - } - } else { - if (measurementView.getSettings().isInOverviewGraph()) { - lineDataSets.add(predictionLine); - } - } - } - } - - private void addMeasurementLineTrend(List lineDataSets, List lineEntries, FloatMeasurementView measurementView, String name) { - LineDataSet measurementLine = new LineDataSet(lineEntries, measurementView.getName().toString() + "-" + name); - measurementLine.setLineWidth(1.5f); - measurementLine.setValueTextSize(10.0f); - measurementLine.setColor(measurementView.getColor()); - measurementLine.setValueTextColor(ColorUtil.getTintColor(getContext())); - measurementLine.setCircleColor(measurementView.getColor()); - measurementLine.setCircleHoleColor(measurementView.getColor()); - measurementLine.setAxisDependency(measurementView.getSettings().isOnRightAxis() ? YAxis.AxisDependency.RIGHT : YAxis.AxisDependency.LEFT); - measurementLine.setHighlightEnabled(true); - measurementLine.setDrawHighlightIndicators(true); - measurementLine.setHighlightLineWidth(1.5f); - measurementLine.setDrawHorizontalHighlightIndicator(false); - measurementLine.setHighLightColor(Color.RED); - measurementLine.setDrawCircles(false);//prefs.getBoolean("pointsEnable", true)); - measurementLine.setDrawValues(prefs.getBoolean("labelsEnable", false)); - - if (measurementView.isVisible()) { - if (isInGraphKey) { - if (measurementView.getSettings().isInGraph()) { - lineDataSets.add(measurementLine); - } - } else { - if (measurementView.getSettings().isInOverviewGraph()) { - lineDataSets.add(measurementLine); - } - } - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/ChestMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/ChestMeasurementView.java deleted file mode 100644 index 6575219d..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/ChestMeasurementView.java +++ /dev/null @@ -1,69 +0,0 @@ -/* Copyright (C) 2018 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; -import com.health.openscale.core.utils.Converters; - -public class ChestMeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "chest"; - - public ChestMeasurementView(Context context) { - super(context, R.string.label_chest, R.drawable.ic_chest); - } - - @Override - public String getKey() { - return KEY; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return Converters.fromCentimeter(measurement.getChest(), getScaleUser().getMeasureUnit()); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - measurement.setChest(Converters.toCentimeter(value, getScaleUser().getMeasureUnit())); - } - - @Override - public String getUnit() { - return getScaleUser().getMeasureUnit().toString(); - } - - @Override - protected float getMaxValue() { - return 500; - } - - @Override - public int getColor() { - return Color.parseColor("#1e88e5"); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return null; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/CommentMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/CommentMeasurementView.java deleted file mode 100644 index dae0c9eb..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/CommentMeasurementView.java +++ /dev/null @@ -1,103 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.os.Bundle; -import android.text.InputType; -import android.view.View; -import android.widget.EditText; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.gui.utils.ColorUtil; - -public class CommentMeasurementView extends MeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "comment"; - - private String comment; - - public CommentMeasurementView(Context context) { - super(context, R.string.label_comment, R.drawable.ic_comment); - } - - @Override - public String getKey() { - return KEY; - } - - private void setValue(String newComment, boolean callListener) { - if (!newComment.equals(comment)) { - comment = newComment; - setValueView(comment, callListener); - } - } - - @Override - public void loadFrom(ScaleMeasurement measurement, ScaleMeasurement previousMeasurement) { - setValue(measurement.getComment(), false); - } - - @Override - public void saveTo(ScaleMeasurement measurement) { - measurement.setComment(comment); - } - - @Override - public void clearIn(ScaleMeasurement measurement) { - measurement.setComment(""); - } - - @Override - public void restoreState(Bundle state) { - setValue(state.getString(getKey()), true); - } - - @Override - public void saveState(Bundle state) { - state.putString(getKey(), comment); - } - - @Override - public int getColor() { return ColorUtil.COLOR_GRAY; }; - - @Override - public String getValueAsString(boolean withUnit) { - return comment; - } - - @Override - protected View getInputView() { - EditText input = new EditText(getContext()); - - input.setInputType(InputType.TYPE_CLASS_TEXT - | InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE - | InputType.TYPE_TEXT_FLAG_MULTI_LINE); - input.setHint(R.string.info_enter_comment); - input.setText(getValueAsString(false)); - input.setSelectAllOnFocus(true); - - return input; - } - - @Override - protected boolean validateAndSetInput(View view) { - EditText editText = (EditText) view; - setValue(editText.getText().toString(), true); - return true; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/DateMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/DateMeasurementView.java deleted file mode 100644 index 0843b1d1..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/DateMeasurementView.java +++ /dev/null @@ -1,126 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.app.DatePickerDialog; -import android.content.Context; -import android.os.Bundle; -import android.view.View; -import android.widget.DatePicker; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.gui.utils.ColorUtil; - -import java.text.DateFormat; -import java.util.Calendar; -import java.util.Date; - -public class DateMeasurementView extends MeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "date"; - - private final DateFormat dateFormat; - private Date date; - - public DateMeasurementView(Context context) { - super(context, R.string.label_date, R.drawable.ic_lastmonth); - dateFormat = DateFormat.getDateInstance(); - } - - @Override - public String getKey() { - return KEY; - } - - private void setValue(Date newDate, boolean callListener) { - if (!newDate.equals(date)) { - date = newDate; - if (getUpdateViews()) { - setValueView(dateFormat.format(date), callListener); - } - } - } - - @Override - public void loadFrom(ScaleMeasurement measurement, ScaleMeasurement previousMeasurement) { - setValue(measurement.getDateTime(), false); - } - - @Override - public void saveTo(ScaleMeasurement measurement) { - Calendar target = Calendar.getInstance(); - target.setTime(measurement.getDateTime()); - - Calendar source = Calendar.getInstance(); - source.setTime(date); - - target.set(source.get(Calendar.YEAR), source.get(Calendar.MONTH), - source.get(Calendar.DAY_OF_MONTH)); - - measurement.setDateTime(target.getTime()); - } - - @Override - public void clearIn(ScaleMeasurement measurement) { - // Ignore - } - - @Override - public void restoreState(Bundle state) { - setValue(new Date(state.getLong(getKey())), true); - } - - @Override - public void saveState(Bundle state) { - state.putLong(getKey(), date.getTime()); - } - - @Override - public int getColor() { return ColorUtil.COLOR_GRAY; }; - - @Override - public String getValueAsString(boolean withUnit) { - return dateFormat.format(date); - } - - @Override - protected View getInputView() { - Calendar cal = Calendar.getInstance(); - cal.setTime(date); - - DatePickerDialog datePickerDialog = new DatePickerDialog( - getContext(), - null, - cal.get(Calendar.YEAR), - cal.get(Calendar.MONTH), - cal.get(Calendar.DAY_OF_MONTH)); - - return datePickerDialog.getDatePicker(); - } - - @Override - protected boolean validateAndSetInput(View view) { - DatePicker datePicker = (DatePicker) view; - - Calendar cal = Calendar.getInstance(); - cal.setTime(date); - cal.set(datePicker.getYear(), datePicker.getMonth(), datePicker.getDayOfMonth()); - setValue(cal.getTime(), true); - - return true; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/FatCaliperMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/FatCaliperMeasurementView.java deleted file mode 100644 index cb5d31d8..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/FatCaliperMeasurementView.java +++ /dev/null @@ -1,82 +0,0 @@ -/* Copyright (C) 2018 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; - -public class FatCaliperMeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "fat_caliper"; - - public FatCaliperMeasurementView(Context context) { - super(context, R.string.label_fat_caliper, R.drawable.ic_fat_caliper); - } - - @Override - public String getKey() { - return KEY; - } - - @Override - public boolean isEditable() { - return false; - } - - @Override - protected boolean supportsPercentageToAbsoluteWeightConversion() { - return true; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return measurement.getFatCaliper(getScaleUser()); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - - } - - @Override - public String getUnit() { - if (shouldConvertPercentageToAbsoluteWeight()) { - return getScaleUser().getScaleUnit().toString(); - } - - return "%"; - } - - @Override - protected float getMaxValue() { - return maybeConvertPercentageToAbsoluteWeight(80); - } - - @Override - public int getColor() { - return Color.parseColor("#f3e5f5"); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return evalSheet.evaluateBodyFat(value); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/FatMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/FatMeasurementView.java deleted file mode 100644 index 45ccd5a2..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/FatMeasurementView.java +++ /dev/null @@ -1,99 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import androidx.preference.ListPreference; - -import com.health.openscale.R; -import com.health.openscale.core.bodymetric.EstimatedFatMetric; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; - -public class FatMeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "fat"; - - public FatMeasurementView(Context context) { - super(context, R.string.label_fat, R.drawable.ic_fat); - } - - @Override - public String getKey() { - return KEY; - } - - @Override - protected boolean supportsPercentageToAbsoluteWeightConversion() { - return true; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return measurement.getFat(); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - measurement.setFat(value); - } - - @Override - public String getUnit() { - if (shouldConvertPercentageToAbsoluteWeight()) { - return getScaleUser().getScaleUnit().toString(); - } - - return "%"; - } - - @Override - protected float getMaxValue() { - return maybeConvertPercentageToAbsoluteWeight(80); - } - - @Override - public int getColor() { - return Color.parseColor("#FFBB33"); - } - - @Override - protected boolean isEstimationSupported() { return true; } - - @Override - protected void prepareEstimationFormulaPreference(ListPreference preference) { - String[] entries = new String[EstimatedFatMetric.FORMULA.values().length]; - String[] values = new String[entries.length]; - - int idx = 0; - for (EstimatedFatMetric.FORMULA formula : EstimatedFatMetric.FORMULA.values()) { - entries[idx] = EstimatedFatMetric.getEstimatedMetric(formula).getName(); - values[idx] = formula.name(); - ++idx; - } - - preference.setEntries(entries); - preference.setEntryValues(values); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return evalSheet.evaluateBodyFat(value); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/FloatMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/FloatMeasurementView.java deleted file mode 100644 index 88b0e442..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/FloatMeasurementView.java +++ /dev/null @@ -1,793 +0,0 @@ -/* Copyright (C) 2018 Erik Johansson -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.content.Intent; -import android.content.res.Resources; -import android.graphics.Color; -import android.net.Uri; -import android.os.Bundle; -import android.os.Handler; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.style.ForegroundColorSpan; -import android.text.style.RelativeSizeSpan; -import android.view.LayoutInflater; -import android.view.MotionEvent; -import android.view.View; -import android.widget.Button; -import android.widget.EditText; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.TableRow; -import android.widget.TextView; - -import androidx.preference.CheckBoxPreference; -import androidx.preference.ListPreference; -import androidx.preference.Preference; -import androidx.preference.PreferenceScreen; -import androidx.preference.PreferenceViewHolder; -import androidx.preference.SwitchPreference; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; -import com.health.openscale.core.utils.Converters; -import com.health.openscale.gui.utils.ColorUtil; - -import java.util.Date; -import java.util.Locale; - -public abstract class FloatMeasurementView extends MeasurementView { - private static final char SYMBOL_UP = '\u279a'; - private static final char SYMBOL_NEUTRAL = '\u2799'; - private static final char SYMBOL_DOWN = '\u2798'; - - private static final float NO_VALUE = -1.0f; - private static final float AUTO_VALUE = -2.0f; - private static float INC_DEC_DELTA = 0.1f; - - private Date dateTime; - private float value = NO_VALUE; - private float previousValue = NO_VALUE; - private float userConvertedWeight; - private EvaluationResult evaluationResult; - - private String nameText; - - private Button incButton; - private Button decButton; - - public FloatMeasurementView(Context context, int textId, int iconId) { - super(context, textId, iconId); - initView(context); - - nameText = getResources().getString(textId); - } - - private void initView(Context context) { - setBackgroundIconColor(getColor()); - - incButton = new Button(context); - decButton = new Button(context); - - LinearLayout incDecLayout = getIncDecLayout(); - incDecLayout.addView(incButton); - incDecLayout.addView(decButton); - - incButton.setText("+"); - incButton.setBackgroundColor(Color.TRANSPARENT); - incButton.setPadding(0,0,0,0); - incButton.setLayoutParams(new TableRow.LayoutParams(LayoutParams.MATCH_PARENT, 0, 0.50f)); - incButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - incValue(); - } - }); - incButton.setOnTouchListener(new RepeatListener(400, 100, new OnClickListener() { - @Override - public void onClick(View view) { - incValue(); - } - })); - incButton.setVisibility(View.GONE); - - decButton.setText("-"); - decButton.setBackgroundColor(Color.TRANSPARENT); - decButton.setPadding(0,0,0,0); - decButton.setLayoutParams(new TableRow.LayoutParams(LayoutParams.MATCH_PARENT, 0, 0.50f)); - decButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - decValue(); - } - }); - - decButton.setOnTouchListener(new RepeatListener(400, 100, new OnClickListener() { - @Override - public void onClick(View view) { - decValue(); - } - })); - decButton.setVisibility(View.GONE); - } - - private float clampValue(float value) { - return Math.max(0.0f, Math.min(getMaxValue(), value)); - } - - private float roundValue(float value) { - final float factor = (float) Math.pow(10, getDecimalPlaces()); - return Math.round(value * factor) / factor; - } - - private void setValueInner(float newValue, boolean callListener) { - value = newValue; - evaluationResult = null; - - if (!getUpdateViews()) { - return; - } - - if (value == AUTO_VALUE) { - setValueView(getContext().getString(R.string.label_automatic), false); - } - else { - setValueView(formatValue(value, true), callListener); - - if (getMeasurementMode() != MeasurementViewMode.ADD) { - final float evalValue = maybeConvertToOriginalValue(value); - - EvaluationSheet evalSheet = new EvaluationSheet(getScaleUser(), dateTime); - evaluationResult = evaluateSheet(evalSheet, evalValue); - - if (evaluationResult != null) { - evaluationResult.value = value; - evaluationResult.lowLimit = maybeConvertValue(evaluationResult.lowLimit); - evaluationResult.highLimit = maybeConvertValue(evaluationResult.highLimit); - } - } - } - setEvaluationView(evaluationResult); - } - - private void setPreviousValueInner(float newPreviousValue) { - previousValue = newPreviousValue; - - if (!getUpdateViews()) { - return; - } - - if (previousValue >= 0.0f) { - final float diff = value - previousValue; - - char symbol; - - if (diff > 0.0) { - symbol = SYMBOL_UP; - } else if (diff < 0.0) { - symbol = SYMBOL_DOWN; - } else { - symbol = SYMBOL_NEUTRAL; - } - - SpannableStringBuilder text = new SpannableStringBuilder(nameText); - text.append("\n"); - - int start = text.length(); - text.append(symbol); - text.setSpan(new ForegroundColorSpan(Color.GRAY), start, text.length(), - Spanned.SPAN_EXCLUSIVE_INCLUSIVE); - - start = text.length(); - text.append(' '); - text.append(formatValue(diff, true)); - text.setSpan(new RelativeSizeSpan(0.8f), start, text.length(), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - - setNameView(text); - } - else { - setNameView(nameText); - } - } - - private void setValue(float newValue, float newPreviousValue, boolean callListener) { - final boolean valueChanged = newValue != value; - final boolean previousValueChanged = newPreviousValue != previousValue; - - if (valueChanged) { - setValueInner(newValue, callListener); - } - - if (valueChanged || previousValueChanged) { - setPreviousValueInner(newPreviousValue); - } - } - - private void incValue() { - setValue(clampValue(value + INC_DEC_DELTA), previousValue, true); - } - private void decValue() { - setValue(clampValue(value - INC_DEC_DELTA), previousValue, true); - } - - public String formatValue(float value, boolean withUnit) { - final String format = String.format(Locale.getDefault(), "%%.%df%s", - getDecimalPlaces(), withUnit && !getUnit().isEmpty() ? " %s" : ""); - return String.format(Locale.getDefault(), format, value, getUnit()); - } - - protected String formatValue(float value) { - return formatValue(value, false); - } - - protected abstract float getMeasurementValue(ScaleMeasurement measurement); - protected abstract void setMeasurementValue(float value, ScaleMeasurement measurement); - - public float getConvertedMeasurementValue(ScaleMeasurement measurement) { - updateUserConvertedWeight(measurement); - - float convertedValue = getMeasurementValue(measurement); - convertedValue = maybeConvertValue(convertedValue); - convertedValue = clampValue(convertedValue); - convertedValue = roundValue(convertedValue); - - return convertedValue; - } - - public abstract String getUnit(); - protected abstract float getMaxValue(); - protected int getDecimalPlaces() { - return 2; - } - - public abstract int getColor(); - - protected boolean isEstimationSupported() { return false; } - protected void prepareEstimationFormulaPreference(ListPreference preference) {} - - protected abstract EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value); - - private boolean useAutoValue() { - return isEstimationSupported() - && getSettings().isEstimationEnabled() - && getMeasurementMode() == MeasurementViewMode.ADD; - } - - // Only one of these can return true - protected boolean supportsAbsoluteWeightToPercentageConversion() { return false; } - protected boolean supportsPercentageToAbsoluteWeightConversion() { return false; } - - private boolean supportsConversion() { - return supportsAbsoluteWeightToPercentageConversion() - || supportsPercentageToAbsoluteWeightConversion(); - } - - protected boolean shouldConvertAbsoluteWeightToPercentage() { - return supportsAbsoluteWeightToPercentageConversion() - && getSettings().isPercentageEnabled(); - } - - protected boolean shouldConvertPercentageToAbsoluteWeight() { - return supportsPercentageToAbsoluteWeightConversion() - && !getSettings().isPercentageEnabled(); - } - - private boolean shouldConvert() { - return shouldConvertAbsoluteWeightToPercentage() - || shouldConvertPercentageToAbsoluteWeight(); - } - - private float makeAbsoluteWeight(float percentage) { - return userConvertedWeight / 100.0f * percentage; - } - - private float makeRelativeWeight(float absolute) { - return 100.0f / userConvertedWeight * absolute; - } - - protected float maybeConvertAbsoluteWeightToPercentage(float value) { - if (shouldConvertAbsoluteWeightToPercentage()) { - return makeRelativeWeight(value); - } - - return value; - } - - protected float maybeConvertPercentageToAbsoluteWeight(float value) { - if (shouldConvertPercentageToAbsoluteWeight()) { - return makeAbsoluteWeight(value); - } - - return value; - } - - private float maybeConvertValue(float value) { - if (shouldConvertAbsoluteWeightToPercentage()) { - return makeRelativeWeight(value); - } - if (shouldConvertPercentageToAbsoluteWeight()) { - return makeAbsoluteWeight(value); - } - - return value; - } - - private float maybeConvertToOriginalValue(float value) { - if (shouldConvertAbsoluteWeightToPercentage()){ - return makeAbsoluteWeight(value); - } - if (shouldConvertPercentageToAbsoluteWeight()) { - return makeRelativeWeight(value); - } - - return value; - } - - private void updateUserConvertedWeight(ScaleMeasurement measurement) { - if (shouldConvert()) { - // Make sure weight is never 0 to avoid division by 0 - userConvertedWeight = Math.max(1.0f, - Converters.fromKilogram(measurement.getWeight(), getScaleUser().getScaleUnit())); - } - else { - // Only valid when a conversion is enabled - userConvertedWeight = -1.0f; - } - } - - @Override - public void loadFrom(ScaleMeasurement measurement, ScaleMeasurement previousMeasurement) { - dateTime = measurement.getDateTime(); - - float newValue = AUTO_VALUE; - float newPreviousValue = NO_VALUE; - - if (!useAutoValue()) { - newValue = getConvertedMeasurementValue(measurement); - - if (previousMeasurement != null) { - float saveUserConvertedWeight = userConvertedWeight; - - newPreviousValue = getConvertedMeasurementValue(previousMeasurement); - - userConvertedWeight = saveUserConvertedWeight; - } - } - - setValue(newValue, newPreviousValue, false); - } - - @Override - public void saveTo(ScaleMeasurement measurement) { - if (!useAutoValue()) { - if (shouldConvert()) { - // Make sure to use the current weight to get a correct value - updateUserConvertedWeight(measurement); - } - - // May need to convert back to original value before saving - setMeasurementValue(maybeConvertToOriginalValue(value), measurement); - } - } - - @Override - public void clearIn(ScaleMeasurement measurement) { - setMeasurementValue(0.0f, measurement); - } - - @Override - public void restoreState(Bundle state) { - setValue(state.getFloat(getKey()), previousValue, true); - } - - @Override - public void saveState(Bundle state) { - state.putFloat(getKey(), value); - } - - @Override - public String getValueAsString(boolean withUnit) { - if (useAutoValue()) { - return getContext().getString(R.string.label_automatic); - } - return formatValue(value, withUnit); - } - - public float getValue() { - return value; - } - - @Override - public CharSequence getName() { - return nameText; - } - - protected void setName(int textId) { - nameText = getResources().getString(textId); - setNameView(nameText); - } - - @Override - public void appendDiffValue(final SpannableStringBuilder text, boolean newLine, boolean isEvalOn) { - if (previousValue < 0.0f) { - return; - } - - char symbol; - int color; - - final float diff = value - previousValue; - if (diff > 0.0f) { - symbol = SYMBOL_UP; - color = Color.GREEN; - } else if (diff < 0.0f) { - symbol = SYMBOL_DOWN; - color = Color.RED; - } else { - symbol = SYMBOL_NEUTRAL; - color = Color.GRAY; - } - - // skip evaluation to speed the calculation up (e.g. not needed for table view) - if (isEvalOn) { - // change color depending on if you are going towards or away from your weight goal - if (this instanceof WeightMeasurementView) { - if (diff > 0.0f) { - color = (value > getScaleUser().getGoalWeight()) ? Color.RED : Color.GREEN; - } else if (diff < 0.0f) { - color = (value < getScaleUser().getGoalWeight()) ? Color.RED : Color.GREEN; - } - } - - final float evalValue = maybeConvertToOriginalValue(value); - - EvaluationSheet evalSheet = new EvaluationSheet(getScaleUser(), dateTime); - evaluationResult = evaluateSheet(evalSheet, evalValue); - - if (evaluationResult != null) { - switch (evaluationResult.eval_state) { - case LOW: - color = (diff > 0.0f) ? Color.GREEN : Color.RED; - break; - case HIGH: - color = (diff < 0.0f) ? Color.GREEN : Color.RED; - break; - case NORMAL: - color = Color.GREEN; - break; - } - } - } - - if (newLine) { - text.append('\n'); - } - int start = text.length(); - text.append(symbol); - text.setSpan(new ForegroundColorSpan(color), start, text.length(), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - - text.append(' '); - - start = text.length(); - text.append(formatValue(diff)); - text.setSpan(new RelativeSizeSpan(0.8f), start, text.length(), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } - - - @Override - public void appendDiffValue(final SpannableStringBuilder text, boolean newLine) { - appendDiffValue(text, newLine, true); - } - - @Override - protected boolean isEditable() { - if (useAutoValue()) { - return false; - } - return true; - } - - @Override - public void setEditMode(MeasurementViewMode mode) { - super.setEditMode(mode); - - if (mode == MeasurementViewMode.VIEW || !isEditable()) { - incButton.setVisibility(View.GONE); - decButton.setVisibility(View.GONE); - } - else { - incButton.setVisibility(View.VISIBLE); - decButton.setVisibility(View.VISIBLE); - } - } - - @Override - public void setExpand(boolean state) { - final boolean show = state && isVisible() && evaluationResult != null; - showEvaluatorRow(show); - } - - @Override - public String getPreferenceSummary() { - MeasurementViewSettings settings = getSettings(); - Resources res = getResources(); - - final String separator = ", "; - String summary = ""; - if (supportsConversion() && settings.isPercentageEnabled()) { - summary += res.getString(R.string.label_percent) + separator; - } - if (isEstimationSupported() && settings.isEstimationEnabled()) { - summary += res.getString(R.string.label_estimated) + separator; - } - - if (!summary.isEmpty()) { - return summary.substring(0, summary.length() - separator.length()); - } - - return ""; - } - - private class ListPreferenceWithNeutralButton extends ListPreference { - ListPreferenceWithNeutralButton(Context context) { - super(context); - - setWidgetLayoutResource(R.layout.preference_info); - } - - @Override - public void onBindViewHolder(PreferenceViewHolder holder) { - super.onBindViewHolder(holder); - - ImageView helpView = (ImageView)holder.findViewById(R.id.helpView); - - helpView.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - getContext().startActivity(new Intent( - Intent.ACTION_VIEW, - Uri.parse("https://github.com/oliexdev/openScale/wiki/Body-metric-estimations"))); - } - }); - } - } - - @Override - public void prepareExtraPreferencesScreen(PreferenceScreen screen) { - super.prepareExtraPreferencesScreen(screen); - MeasurementViewSettings settings = getSettings(); - - CheckBoxPreference rightAxis = new CheckBoxPreference(screen.getContext()); - rightAxis.setKey(settings.getOnRightAxisKey()); - rightAxis.setTitle(R.string.label_is_on_right_axis); - rightAxis.setPersistent(true); - rightAxis.setDefaultValue(settings.isOnRightAxis()); - screen.addPreference(rightAxis); - - if (supportsConversion()) { - SwitchPreference percentage = new SwitchPreference(screen.getContext()); - percentage.setKey(settings.getPercentageEnabledKey()); - percentage.setTitle(R.string.label_measurement_in_percent); - percentage.setPersistent(true); - percentage.setDefaultValue(settings.isPercentageEnabled()); - screen.addPreference(percentage); - } - - if (isEstimationSupported()) { - final CheckBoxPreference estimate = new CheckBoxPreference(screen.getContext()); - estimate.setKey(settings.getEstimationEnabledKey()); - estimate.setTitle(R.string.label_estimate_measurement); - estimate.setSummary(R.string.label_estimate_measurement_summary); - estimate.setPersistent(true); - estimate.setDefaultValue(settings.isEstimationEnabled()); - screen.addPreference(estimate); - - final ListPreference formula = new ListPreferenceWithNeutralButton(screen.getContext()); - formula.setKey(settings.getEstimationFormulaKey()); - formula.setTitle(R.string.label_estimation_formula); - formula.setPersistent(true); - formula.setDefaultValue(settings.getEstimationFormula()); - prepareEstimationFormulaPreference(formula); - formula.setEnabled(estimate.isChecked()); - formula.setSummary(formula.getEntries()[formula.findIndexOfValue(settings.getEstimationFormula())]); - formula.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - ListPreference list = (ListPreference) preference; - int idx = list.findIndexOfValue((String) newValue); - if (idx == -1) { - return false; - } - preference.setSummary(list.getEntries()[idx]); - return true; - } - }); - - estimate.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - if ((Boolean)newValue == true) { - formula.setEnabled(true); - } else { - formula.setEnabled(false); - } - return true; - } - }); - - screen.addPreference(formula); - } - } - - private float validateAndGetInput(View view) { - EditText editText = view.findViewById(R.id.float_input); - String text = editText.getText().toString(); - - float newValue = -1; - if (text.isEmpty()) { - editText.setError(getResources().getString(R.string.error_value_required)); - return newValue; - } - - try { - newValue = Float.valueOf(text.replace(',', '.')); - } - catch (NumberFormatException ex) { - newValue = -1; - } - - if (newValue < 0 || newValue > getMaxValue()) { - editText.setError(getResources().getString(R.string.error_value_range)); - newValue = -1; - } - - return newValue; - } - - @Override - protected View getInputView() { - final LinearLayout view = (LinearLayout) LayoutInflater.from(getContext()) - .inflate(R.layout.float_input_view, null); - - final EditText input = view.findViewById(R.id.float_input); - input.setText(formatValue(value)); - - final TextView unit = view.findViewById(R.id.float_input_unit); - unit.setText(getUnit()); - - if (getDecimalPlaces() == 0) { - INC_DEC_DELTA = 10.0f; - } else { - INC_DEC_DELTA = 0.1f; - } - - View.OnClickListener onClickListener = new View.OnClickListener() { - @Override - public void onClick(View button) { - float newValue = validateAndGetInput(view); - if (newValue < 0) { - return; - } - - if (button.getId() == R.id.btn_inc) { - newValue += INC_DEC_DELTA; - } - else { - newValue -= INC_DEC_DELTA; - } - - input.setText(formatValue(clampValue(newValue))); - input.selectAll(); - } - }; - - RepeatListener repeatListener = - new RepeatListener(400, 100, onClickListener); - - final Button inc = view.findViewById(R.id.btn_inc); - inc.setText("\u25b2 +" + formatValue(INC_DEC_DELTA)); - inc.setOnClickListener(onClickListener); - inc.setTextColor(ColorUtil.getPrimaryColor(getContext())); - inc.setOnTouchListener(repeatListener); - - final Button dec = view.findViewById(R.id.btn_dec); - dec.setText("\u25bc -" + formatValue(INC_DEC_DELTA)); - dec.setTextColor(ColorUtil.getPrimaryColor(getContext())); - dec.setOnClickListener(onClickListener); - dec.setOnTouchListener(repeatListener); - - return view; - } - - @Override - protected boolean validateAndSetInput(View view) { - float newValue = validateAndGetInput(view); - if (newValue >= 0) { - setValue(newValue, previousValue, true); - return true; - } - - return false; - } - - private class RepeatListener implements OnTouchListener { - private final Handler handler = new Handler(); - - private int initialInterval; - private final int normalInterval; - private final OnClickListener clickListener; - - private final Runnable handlerRunnable = new Runnable() { - @Override - public void run() { - handler.postDelayed(this, normalInterval); - clickListener.onClick(downView); - } - }; - - private View downView; - - /** - * RepeatListener cyclically runs a clickListener, emulating keyboard-like behaviour. First - * click is fired immediately, next one after the initialInterval, and subsequent ones after the normalInterval. - * - * @param initialInterval The interval after first click event - * @param normalInterval The interval after second and subsequent click events - * @param clickListener The OnClickListener, that will be called periodically - */ - public RepeatListener(int initialInterval, int normalInterval, - OnClickListener clickListener) { - if (clickListener == null) { - throw new IllegalArgumentException("null runnable"); - } - if (initialInterval < 0 || normalInterval < 0) { - throw new IllegalArgumentException("negative interval"); - } - - this.initialInterval = initialInterval; - this.normalInterval = normalInterval; - this.clickListener = clickListener; - } - - public boolean onTouch(View view, MotionEvent motionEvent) { - switch (motionEvent.getAction()) { - case MotionEvent.ACTION_DOWN: - handler.removeCallbacks(handlerRunnable); - handler.postDelayed(handlerRunnable, initialInterval); - downView = view; - downView.setPressed(true); - clickListener.onClick(view); - return true; - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: - handler.removeCallbacks(handlerRunnable); - downView.setPressed(false); - downView = null; - return true; - } - - return false; - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/HipMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/HipMeasurementView.java deleted file mode 100644 index c79f8bb2..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/HipMeasurementView.java +++ /dev/null @@ -1,69 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; -import com.health.openscale.core.utils.Converters; - -public class HipMeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "hip"; - - public HipMeasurementView(Context context) { - super(context, R.string.label_hip, R.drawable.ic_hip); - } - - @Override - public String getKey() { - return KEY; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return Converters.fromCentimeter(measurement.getHip(), getScaleUser().getMeasureUnit()); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - measurement.setHip(Converters.toCentimeter(value, getScaleUser().getMeasureUnit())); - } - - @Override - public String getUnit() { - return getScaleUser().getMeasureUnit().toString(); - } - - @Override - protected float getMaxValue() { - return 500; - } - - @Override - public int getColor() { - return Color.parseColor("#FFEE58"); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return null; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/LBMMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/LBMMeasurementView.java deleted file mode 100644 index c1d39729..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/LBMMeasurementView.java +++ /dev/null @@ -1,91 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import androidx.preference.ListPreference; - -import com.health.openscale.R; -import com.health.openscale.core.bodymetric.EstimatedLBMMetric; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; -import com.health.openscale.core.utils.Converters; - -public class LBMMeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "lbw"; - - public LBMMeasurementView(Context context) { - super(context, R.string.label_lbm, R.drawable.ic_lbm); - } - - @Override - public String getKey() { - return KEY; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return Converters.fromKilogram(measurement.getLbm(), getScaleUser().getScaleUnit()); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - measurement.setLbm(Converters.toKilogram(value, getScaleUser().getScaleUnit())); - } - - @Override - public String getUnit() { - return getScaleUser().getScaleUnit().toString(); - } - - @Override - protected float getMaxValue() { - return Converters.fromKilogram(300, getScaleUser().getScaleUnit()); - } - - @Override - public int getColor() { - return Color.parseColor("#5C6BC0"); - } - - @Override - protected boolean isEstimationSupported() { return true; } - - @Override - protected void prepareEstimationFormulaPreference(ListPreference preference) { - String[] entries = new String[EstimatedLBMMetric.FORMULA.values().length]; - String[] values = new String[entries.length]; - - int idx = 0; - for (EstimatedLBMMetric.FORMULA formula : EstimatedLBMMetric.FORMULA.values()) { - entries[idx] = EstimatedLBMMetric.getEstimatedMetric(formula).getName(getContext()); - values[idx] = formula.name(); - ++idx; - } - - preference.setEntries(entries); - preference.setEntryValues(values); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return evalSheet.evaluateLBM(value); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/LinearGaugeView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/LinearGaugeView.java deleted file mode 100644 index 866505f4..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/LinearGaugeView.java +++ /dev/null @@ -1,262 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Path; -import android.graphics.Rect; -import android.graphics.RectF; -import android.util.AttributeSet; -import android.view.View; - -import com.health.openscale.R; - -import java.util.Locale; - - -public class LinearGaugeView extends View { - - private static final int COLOR_BLUE = Color.parseColor("#33B5E5"); - private static final int COLOR_GREEN = Color.parseColor("#99CC00"); - private static final int COLOR_RED = Color.parseColor("#FF4444"); - - private static final float barHeight = 10; - private static final float textOffset = 10.0f; - private final RectF limitRect = new RectF(0, 0, barHeight / 2, barHeight * 2); - - // Pre-created rect to avoid creating object in onDraw - private final Rect bounds = new Rect(); - - private Paint rectPaintLow; - private Paint rectPaintNormal; - private Paint rectPaintHigh; - private Paint textPaint; - private Paint indicatorPaint; - private Paint infoTextPaint; - - private float value; - private float firstLimit = -1.0f; - private float secondLimit = -1.0f; - - public LinearGaugeView(Context context) { - super(context); - init(); - } - - public LinearGaugeView(Context context, AttributeSet attrs) { - super(context, attrs); - init(); - } - - public LinearGaugeView(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - init(); - } - - private void init() { - rectPaintLow = new Paint(Paint.ANTI_ALIAS_FLAG); - rectPaintLow.setColor(COLOR_BLUE); - - rectPaintNormal = new Paint(Paint.ANTI_ALIAS_FLAG); - rectPaintNormal.setColor(COLOR_GREEN); - - rectPaintHigh = new Paint(Paint.ANTI_ALIAS_FLAG); - rectPaintHigh.setColor(COLOR_RED); - - textPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - textPaint.setColor(Color.GRAY); - textPaint.setTextSize(30); - - indicatorPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - indicatorPaint.setColor(Color.GRAY); - indicatorPaint.setTextSize(30); - - infoTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - infoTextPaint.setColor(Color.GRAY); - infoTextPaint.setTextSize(40); - infoTextPaint.setTextAlign(Paint.Align.CENTER); - } - - private float valueToPosition(float value, float minValue, float maxValue) { - final float percent = (value - minValue) / (maxValue - minValue) * 100.0f; - return getWidth() / 100.0f * percent; - } - - private void drawCenteredText(Canvas canvas, String text, float centerX, float y, Paint paint) { - final float textWidth = paint.measureText(text); - float x = Math.max(0.0f, centerX - textWidth / 2.0f); - x = Math.min(x, getWidth() - textWidth); - canvas.drawText(text, x, y, paint); - } - - private String toText(float value) { - return String.format(Locale.getDefault(), "%.1f", value); - } - - @Override - protected void onDraw(Canvas canvas) { - super.onDraw(canvas); - - if (firstLimit < 0 && secondLimit < 0) { - float textX = getWidth() / 2.0f; - float textY = getHeight() / 2.0f; - canvas.drawText(getResources().getString(R.string.info_no_evaluation_available), textX, textY, infoTextPaint); - return; - } - - final boolean hasFirstLimit = firstLimit >= 0; - - // Calculate how much bar to show to the left and right of the "normal" span - // (or just the second limit if there is no first limit). - float span = hasFirstLimit ? (secondLimit - firstLimit) / 2.0f : 0.3f * secondLimit; - - // Add some extra margin to avoid having the indicator too far towards an edge - final float margin = 0.05f * span; - - // Adjust the span if needed to make the value fit inside of it - if (hasFirstLimit && value - margin < firstLimit - span) { - span = firstLimit - value + margin; - } else if (!hasFirstLimit && value - margin < secondLimit - span) { - span = secondLimit - value + margin; - } else if (value + margin > secondLimit + span) { - span = value - secondLimit + margin; - } - - // Round span to some nice value - if (span <= 1.0f) { - span = (float)Math.ceil(span * 10.0) / 10.0f; - } else if (span <= 10.0f) { - span = (float)Math.ceil(span); - } else { - span = 5.0f * (float)Math.ceil(span / 5.0); - } - - final float minValue = Math.max(0.0f, (hasFirstLimit ? firstLimit : secondLimit) - span); - final float maxValue = secondLimit + span; - - final float firstPos = valueToPosition(firstLimit, minValue, maxValue); - final float secondPos = valueToPosition(secondLimit, minValue, maxValue); - final float valuePos = valueToPosition(value, minValue, maxValue); - - // Bar - final float barTop = getHeight() / 2.0f - barHeight / 2.0f; - final float barBottom = barTop + barHeight; - - if (firstLimit > 0) { - canvas.drawRect(0, barTop, firstPos, barBottom, rectPaintLow); - canvas.drawRect(firstPos, barTop, secondPos, barBottom, rectPaintNormal); - } else { - canvas.drawRect(0, barTop, secondPos, barBottom, rectPaintNormal); - } - canvas.drawRect(secondPos, barTop, getWidth(), barBottom, rectPaintHigh); - - // Limit Lines - limitRect.offsetTo(0, getHeight() / 2.0f - limitRect.height() / 2.0f); - canvas.drawRect(limitRect, textPaint); - if (firstLimit > 0) { - limitRect.offsetTo(firstPos - limitRect.width() / 2.0f, limitRect.top); - canvas.drawRect(limitRect, textPaint); - } - limitRect.offsetTo(secondPos - limitRect.width() / 2.0f, limitRect.top); - canvas.drawRect(limitRect, textPaint); - limitRect.offsetTo(getWidth() - limitRect.width(), limitRect.top); - canvas.drawRect(limitRect, textPaint); - - // Text - final float textY = barTop - textOffset; - canvas.drawText(toText(minValue), 0.0f, textY, textPaint); - if (firstLimit > 0) { - drawCenteredText(canvas, toText(firstLimit), firstPos, textY, textPaint); - } - drawCenteredText(canvas, toText(secondLimit), secondPos, textY, textPaint); - drawCenteredText(canvas, toText(maxValue), getWidth(), textY, textPaint); - - // Indicator - final float indicatorBottom = limitRect.bottom + 15.0f; - Path path = new Path(); - path.setFillType(Path.FillType.EVEN_ODD); - path.moveTo(valuePos, barBottom); - path.lineTo(valuePos + 10.0f, indicatorBottom); - path.lineTo(valuePos - 10.0f, indicatorBottom); - path.close(); - - canvas.drawPath(path, indicatorPaint); - - // Value text - final String valueStr = String.format(Locale.getDefault(), "%.2f", value); - indicatorPaint.getTextBounds(valueStr, 0, valueStr.length(), bounds); - drawCenteredText(canvas, valueStr, valuePos, - indicatorBottom + bounds.height() + 2, indicatorPaint); - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - - int desiredWidth = 100; - int desiredHeight = 120; - - int widthMode = MeasureSpec.getMode(widthMeasureSpec); - int widthSize = MeasureSpec.getSize(widthMeasureSpec); - int heightMode = MeasureSpec.getMode(heightMeasureSpec); - int heightSize = MeasureSpec.getSize(heightMeasureSpec); - - int width; - int height; - - //Measure Width - if (widthMode == MeasureSpec.EXACTLY) { - //Must be this size - width = widthSize; - } else if (widthMode == MeasureSpec.AT_MOST) { - //Can't be bigger than... - width = Math.min(desiredWidth, widthSize); - } else { - //Be whatever you want - width = desiredWidth; - } - - //Measure Height - if (heightMode == MeasureSpec.EXACTLY) { - //Must be this size - height = heightSize; - } else if (heightMode == MeasureSpec.AT_MOST) { - //Can't be bigger than... - height = Math.min(desiredHeight, heightSize); - } else { - //Be whatever you want - height = desiredHeight; - } - - //MUST CALL THIS - setMeasuredDimension(width, height); - } - - public void setLimits(float first, float second) { - firstLimit = first; - secondLimit = second; - invalidate(); - requestLayout(); - } - - public void setValue(float value) { - this.value = value; - invalidate(); - requestLayout(); - } -} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/MeasurementEntryFragment.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/MeasurementEntryFragment.java deleted file mode 100644 index eb6ef489..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/MeasurementEntryFragment.java +++ /dev/null @@ -1,439 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.app.AlertDialog; -import android.content.Context; -import android.content.DialogInterface; -import android.content.SharedPreferences; -import android.graphics.Color; -import android.graphics.drawable.Drawable; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.LinearLayout; -import android.widget.TableLayout; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.core.graphics.drawable.DrawableCompat; -import androidx.fragment.app.Fragment; -import androidx.navigation.Navigation; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; - -import java.text.DateFormat; -import java.util.Date; -import java.util.List; - -public class MeasurementEntryFragment extends Fragment { - public enum DATA_ENTRY_MODE {ADD, EDIT, VIEW}; - private static final String PREF_EXPAND = "expandEvaluator"; - - private DATA_ENTRY_MODE mode = DATA_ENTRY_MODE.ADD; - - private MeasurementView.MeasurementViewMode measurementViewMode; - - private List dataEntryMeasurements; - - private TextView txtDataNr; - private Button btnLeft; - private Button btnRight; - - private MenuItem saveButton; - private MenuItem editButton; - private MenuItem expandButton; - private MenuItem deleteButton; - - private ScaleMeasurement scaleMeasurement; - private ScaleMeasurement previousMeasurement; - private ScaleMeasurement nextMeasurement; - private boolean isDirty; - - private Context context; - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View root = inflater.inflate(R.layout.fragment_dataentry, container, false); - - setHasOptionsMenu(true); - - context = getContext(); - - TableLayout tableLayoutDataEntry = root.findViewById(R.id.tableLayoutDataEntry); - - dataEntryMeasurements = MeasurementView.getMeasurementList( - context, MeasurementView.DateTimeOrder.LAST); - - txtDataNr = root.findViewById(R.id.txtDataNr); - btnLeft = root.findViewById(R.id.btnLeft); - btnRight = root.findViewById(R.id.btnRight); - - btnLeft.setVisibility(View.INVISIBLE); - btnRight.setVisibility(View.INVISIBLE); - - btnLeft.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - moveLeft(); - } - }); - btnRight.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - moveRight(); - } - }); - - MeasurementView.MeasurementViewMode measurementMode = MeasurementView.MeasurementViewMode.ADD; - - mode = MeasurementEntryFragmentArgs.fromBundle(getArguments()).getMode(); - - switch (mode) { - case ADD: - measurementMode = MeasurementView.MeasurementViewMode.ADD; - break; - case EDIT: - break; - case VIEW: - measurementMode = MeasurementView.MeasurementViewMode.VIEW; - break; - } - - for (MeasurementView measurement : dataEntryMeasurements) { - measurement.setEditMode(measurementMode); - } - - int id = MeasurementEntryFragmentArgs.fromBundle(getArguments()).getMeasurementId(); - - updateOnView(id); - - onMeasurementViewUpdateListener updateListener = new onMeasurementViewUpdateListener(); - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - final boolean expand = mode == DATA_ENTRY_MODE.ADD - ? false : prefs.getBoolean(PREF_EXPAND, false); - - for (MeasurementView measurement : dataEntryMeasurements) { - tableLayoutDataEntry.addView(measurement); - measurement.setOnUpdateListener(updateListener); - measurement.setExpand(expand); - } - - return root; - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - menu.clear(); - inflater.inflate(R.menu.dataentry_menu, menu); - - // Apply a tint to all icons in the toolbar - for (int i = 0; i < menu.size(); ++i) { - MenuItem item = menu.getItem(i); - final Drawable drawable = item.getIcon(); - if (drawable == null) { - continue; - } - - final Drawable wrapped = DrawableCompat.wrap(drawable.mutate()); - - if (item.getItemId() == R.id.saveButton) { - DrawableCompat.setTint(wrapped, Color.parseColor("#FFFFFF")); - } else if (item.getItemId() == R.id.editButton) { - DrawableCompat.setTint(wrapped, Color.parseColor("#99CC00")); - } else if (item.getItemId() == R.id.expandButton) { - DrawableCompat.setTint(wrapped, Color.parseColor("#FFBB33")); - } else if (item.getItemId() == R.id.deleteButton) { - DrawableCompat.setTint(wrapped, Color.parseColor("#FF4444")); - } - - item.setIcon(wrapped); - } - - saveButton = menu.findItem(R.id.saveButton); - editButton = menu.findItem(R.id.editButton); - expandButton = menu.findItem(R.id.expandButton); - deleteButton = menu.findItem(R.id.deleteButton); - - // Hide/show icons as appropriate for the view mode - switch (mode) { - case ADD: - setViewMode(MeasurementView.MeasurementViewMode.ADD); - break; - case EDIT: - setViewMode(MeasurementView.MeasurementViewMode.EDIT); - break; - case VIEW: - setViewMode(MeasurementView.MeasurementViewMode.VIEW); - break; - } - - super.onCreateOptionsMenu(menu, inflater); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.saveButton: - final boolean isEdit = scaleMeasurement.getId() > 0; - saveScaleData(); - if (isEdit) { - setViewMode(MeasurementView.MeasurementViewMode.VIEW); - } - else { - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigateUp(); - } - return true; - - case R.id.expandButton: - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - final boolean expand = !prefs.getBoolean(PREF_EXPAND, false); - prefs.edit().putBoolean(PREF_EXPAND, expand).apply(); - - for (MeasurementView measurement : dataEntryMeasurements) { - measurement.setExpand(expand); - } - return true; - - case R.id.editButton: - setViewMode(MeasurementView.MeasurementViewMode.EDIT); - return true; - - case R.id.deleteButton: - deleteMeasurement(); - return true; - } - return super.onOptionsItemSelected(item); - } - - private void updateOnView(int id) { - if (scaleMeasurement == null || scaleMeasurement.getId() != id) { - isDirty = false; - scaleMeasurement = null; - previousMeasurement = null; - nextMeasurement = null; - } - - OpenScale openScale = OpenScale.getInstance(); - - if (id > 0) { - // Show selected scale data - if (scaleMeasurement == null) { - ScaleMeasurement[] tupleScaleData = openScale.getTupleOfScaleMeasurement(id); - previousMeasurement = tupleScaleData[0]; - scaleMeasurement = tupleScaleData[1].clone(); - nextMeasurement = tupleScaleData[2]; - - btnLeft.setEnabled(previousMeasurement != null); - btnRight.setEnabled(nextMeasurement != null); - } - } else { - if (openScale.isScaleMeasurementListEmpty()) { - // Show default values - scaleMeasurement = new ScaleMeasurement(); - scaleMeasurement.setWeight(openScale.getSelectedScaleUser().getInitialWeight()); - } - else { - // Show the last scale data as default - scaleMeasurement = openScale.getLastScaleMeasurement().clone(); - scaleMeasurement.setId(0); - scaleMeasurement.setDateTime(new Date()); - scaleMeasurement.setComment(""); - } - - isDirty = true; - - // Measurements that aren't visible should not store any value. Since we use values from - // the previous measurement there might be values for entries not shown. The loop below - // clears these values. - for (MeasurementView measurement : dataEntryMeasurements) { - if (!measurement.isVisible()) { - measurement.clearIn(scaleMeasurement); - } - } - } - - for (MeasurementView measurement : dataEntryMeasurements) { - measurement.loadFrom(scaleMeasurement, previousMeasurement); - } - - txtDataNr.setMinWidth(txtDataNr.getWidth()); - txtDataNr.setText(DateFormat.getDateTimeInstance( - DateFormat.LONG, DateFormat.SHORT).format(scaleMeasurement.getDateTime())); - } - - private void setViewMode(MeasurementView.MeasurementViewMode viewMode) { - measurementViewMode = viewMode; - int dateTimeVisibility = View.VISIBLE; - - switch (viewMode) { - case VIEW: - saveButton.setVisible(false); - editButton.setVisible(true); - expandButton.setVisible(true); - deleteButton.setVisible(true); - - ((LinearLayout)txtDataNr.getParent()).setVisibility(View.VISIBLE); - btnLeft.setVisibility(View.VISIBLE); - btnRight.setVisibility(View.VISIBLE); - btnLeft.setEnabled(previousMeasurement != null); - btnRight.setEnabled(nextMeasurement != null); - - dateTimeVisibility = View.GONE; - break; - case EDIT: - saveButton.setVisible(true); - editButton.setVisible(false); - expandButton.setVisible(true); - deleteButton.setVisible(true); - - ((LinearLayout)txtDataNr.getParent()).setVisibility(View.VISIBLE); - btnLeft.setVisibility(View.VISIBLE); - btnRight.setVisibility(View.VISIBLE); - btnLeft.setEnabled(false); - btnRight.setEnabled(false); - break; - case ADD: - saveButton.setVisible(true); - editButton.setVisible(false); - expandButton.setVisible(false); - deleteButton.setVisible(false); - - ((LinearLayout)txtDataNr.getParent()).setVisibility(View.GONE); - break; - } - - for (MeasurementView measurement : dataEntryMeasurements) { - if (measurement instanceof DateMeasurementView || measurement instanceof TimeMeasurementView || measurement instanceof UserMeasurementView) { - measurement.setVisibility(dateTimeVisibility); - } - measurement.setEditMode(viewMode); - } - } - - private void saveScaleData() { - if (!isDirty) { - return; - } - - OpenScale openScale = OpenScale.getInstance(); - if (openScale.getSelectedScaleUserId() == -1) { - return; - } - - if (scaleMeasurement.getId() > 0) { - openScale.updateScaleMeasurement(scaleMeasurement); - } - else { - openScale.addScaleMeasurement(scaleMeasurement); - } - isDirty = false; - } - - private void deleteMeasurement() { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - boolean deleteConfirmationEnable = prefs.getBoolean("deleteConfirmationEnable", true); - - if (deleteConfirmationEnable) { - AlertDialog.Builder deleteAllDialog = new AlertDialog.Builder(context); - deleteAllDialog.setMessage(getResources().getString(R.string.question_really_delete)); - - deleteAllDialog.setPositiveButton(getResources().getString(R.string.label_yes), new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - doDeleteMeasurement(); - } - }); - - deleteAllDialog.setNegativeButton(getResources().getString(R.string.label_no), new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.dismiss(); - } - }); - - deleteAllDialog.show(); - } - else { - doDeleteMeasurement(); - } - } - - private void doDeleteMeasurement() { - OpenScale.getInstance().deleteScaleMeasurement(scaleMeasurement.getId()); - Toast.makeText(context, getResources().getString(R.string.info_data_deleted), Toast.LENGTH_SHORT).show(); - - final boolean hasNext = moveLeft() || moveRight(); - if (!hasNext) { - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigateUp(); - } - else if (measurementViewMode == MeasurementView.MeasurementViewMode.EDIT) { - setViewMode(MeasurementView.MeasurementViewMode.VIEW); - } - } - - private boolean moveLeft() { - if (previousMeasurement != null) { - updateOnView(previousMeasurement.getId()); - return true; - } - - return false; - } - - private boolean moveRight() { - if (nextMeasurement != null) { - updateOnView(nextMeasurement.getId()); - return true; - } - - return false; - } - - private class onMeasurementViewUpdateListener implements MeasurementViewUpdateListener { - @Override - public void onMeasurementViewUpdate(MeasurementView view) { - view.saveTo(scaleMeasurement); - isDirty = true; - - // When weight is updated we may need to re-save some values that are stored - // as percentages, but that the user may have set up to be shown as absolute. - // Otherwise that measurement (e.g. fat) may change when weight is updated. - if (view instanceof WeightMeasurementView) { - for (MeasurementView measurement : dataEntryMeasurements) { - if (measurement != view) { - measurement.saveTo(scaleMeasurement); - } - } - } - - txtDataNr.setText(DateFormat.getDateTimeInstance( - DateFormat.LONG, DateFormat.SHORT).format(scaleMeasurement.getDateTime())); - - for (MeasurementView measurement : dataEntryMeasurements) { - if (measurement != view) { - measurement.loadFrom(scaleMeasurement, previousMeasurement); - } - } - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/MeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/MeasurementView.java deleted file mode 100644 index 70127ea8..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/MeasurementView.java +++ /dev/null @@ -1,545 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import static com.health.openscale.gui.measurement.MeasurementView.MeasurementViewMode.ADD; -import static com.health.openscale.gui.measurement.MeasurementView.MeasurementViewMode.EDIT; -import static com.health.openscale.gui.measurement.MeasurementView.MeasurementViewMode.STATISTIC; -import static com.health.openscale.gui.measurement.MeasurementView.MeasurementViewMode.VIEW; - -import android.content.Context; -import android.content.DialogInterface; -import android.content.SharedPreferences; -import android.graphics.Color; -import android.graphics.PorterDuff; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.GradientDrawable; -import android.os.Bundle; -import android.text.SpannableStringBuilder; -import android.text.TextUtils; -import android.util.TypedValue; -import android.view.Gravity; -import android.view.View; -import android.view.ViewGroup; -import android.widget.EditText; -import android.widget.FrameLayout; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.Space; -import android.widget.TableLayout; -import android.widget.TableRow; -import android.widget.TextView; - -import androidx.appcompat.app.AlertDialog; -import androidx.core.content.ContextCompat; -import androidx.preference.CheckBoxPreference; -import androidx.preference.PreferenceManager; -import androidx.preference.PreferenceScreen; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.gui.utils.ColorUtil; - -import java.util.ArrayList; -import java.util.List; - -public abstract class MeasurementView extends TableLayout { - public enum MeasurementViewMode {VIEW, EDIT, ADD, STATISTIC} - - public static final String PREF_MEASUREMENT_ORDER = "measurementOrder"; - - private MeasurementViewSettings settings; - - private TableRow measurementRow; - private ImageView iconView; - private GradientDrawable iconViewBackground; - private int iconId; - private TextView nameView; - private TextView valueView; - private LinearLayout incDecLayout; - private ImageView editModeView; - private ImageView indicatorView; - - private TableRow evaluatorRow; - private LinearGaugeView evaluatorView; - - private MeasurementViewUpdateListener updateListener = null; - private MeasurementViewMode measurementMode = VIEW; - - private boolean updateViews = true; - - public MeasurementView(Context context, int textId, int iconId) { - super(context); - this.iconId = iconId; - - initView(context); - - nameView.setText(textId); - } - - public enum DateTimeOrder { FIRST, LAST, NONE } - - public static List getMeasurementList( - Context context, DateTimeOrder dateTimeOrder) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - final List sorted = new ArrayList<>(); - if (dateTimeOrder == DateTimeOrder.FIRST) { - sorted.add(new DateMeasurementView(context)); - sorted.add(new TimeMeasurementView(context)); - } - - { - final List unsorted = new ArrayList<>(); - - unsorted.add(new WeightMeasurementView(context)); - unsorted.add(new BMIMeasurementView(context)); - unsorted.add(new WaterMeasurementView(context)); - unsorted.add(new MuscleMeasurementView(context)); - unsorted.add(new LBMMeasurementView(context)); - unsorted.add(new FatMeasurementView(context)); - unsorted.add(new BoneMeasurementView(context)); - unsorted.add(new VisceralFatMeasurementView(context)); - unsorted.add(new WaistMeasurementView(context)); - unsorted.add(new WHtRMeasurementView(context)); - unsorted.add(new HipMeasurementView(context)); - unsorted.add(new WHRMeasurementView(context)); - unsorted.add(new ChestMeasurementView(context)); - unsorted.add(new ThighMeasurementView(context)); - unsorted.add(new BicepsMeasurementView(context)); - unsorted.add(new NeckMeasurementView(context)); - unsorted.add(new FatCaliperMeasurementView(context)); - unsorted.add(new Caliper1MeasurementView(context)); - unsorted.add(new Caliper2MeasurementView(context)); - unsorted.add(new Caliper3MeasurementView(context)); - unsorted.add(new BMRMeasurementView(context)); - unsorted.add(new TDEEMeasurementView(context)); - unsorted.add(new CaloriesMeasurementView(context)); - unsorted.add(new CommentMeasurementView(context)); - unsorted.add(new UserMeasurementView(context)); - - // Get sort order - final String[] sortOrder = TextUtils.split( - prefs.getString(PREF_MEASUREMENT_ORDER, ""), ","); - - // Move views from unsorted to sorted in the correct order - for (String key : sortOrder) { - for (MeasurementView measurement : unsorted) { - if (key.equals(measurement.getKey())) { - sorted.add(measurement); - unsorted.remove(measurement); - break; - } - } - } - - // Any new views end up at the end - sorted.addAll(unsorted); - } - - if (dateTimeOrder == DateTimeOrder.LAST) { - sorted.add(new DateMeasurementView(context)); - sorted.add(new TimeMeasurementView(context)); - } - - for (MeasurementView measurement : sorted) { - measurement.setVisible(measurement.getSettings().isEnabled()); - } - - return sorted; - } - - public static void saveMeasurementViewsOrder(Context context, List measurementViews) { - ArrayList order = new ArrayList<>(); - for (MeasurementView measurement : measurementViews) { - order.add(measurement.getKey()); - } - PreferenceManager.getDefaultSharedPreferences(context).edit() - .putString(PREF_MEASUREMENT_ORDER, TextUtils.join(",", order)) - .apply(); - } - - private void initView(Context context) { - measurementRow = new TableRow(context); - - iconView = new ImageView(context); - iconViewBackground = new GradientDrawable(); - nameView = new TextView(context); - valueView = new TextView(context); - editModeView = new ImageView(context); - indicatorView = new ImageView(context); - - evaluatorRow = new TableRow(context); - evaluatorView = new LinearGaugeView(context); - - incDecLayout = new LinearLayout(context); - - measurementRow.setLayoutParams(new TableRow.LayoutParams(LayoutParams.MATCH_PARENT,LayoutParams.WRAP_CONTENT, 1.0f)); - measurementRow.setGravity(Gravity.CENTER); - measurementRow.addView(iconView); - measurementRow.addView(nameView); - measurementRow.addView(valueView); - measurementRow.addView(incDecLayout); - measurementRow.addView(editModeView); - measurementRow.addView(indicatorView); - - addView(measurementRow); - addView(evaluatorRow); - - iconViewBackground.setColor(ColorUtil.COLOR_GRAY); - iconViewBackground.setShape(GradientDrawable.OVAL); - iconViewBackground.setGradientRadius(iconView.getWidth()); - - iconView.setImageResource(iconId); - iconView.setScaleType(ImageView.ScaleType.CENTER_INSIDE); - iconView.setPadding(15,15,15,15); - - iconView.setColorFilter(ColorUtil.COLOR_BLACK); - iconView.setBackground(iconViewBackground); - - TableRow.LayoutParams iconLayout = new TableRow.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); - iconLayout.setMargins(10, 5, 10, 5); - iconView.setLayoutParams(iconLayout); - - nameView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15); - nameView.setLines(2); - nameView.setLayoutParams(new TableRow.LayoutParams(0, LayoutParams.WRAP_CONTENT, 0.55f)); - - valueView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15); - valueView.setGravity(Gravity.RIGHT | Gravity.CENTER); - valueView.setPadding(0,0,20,0); - valueView.setLayoutParams(new TableRow.LayoutParams(0, LayoutParams.WRAP_CONTENT, 0.29f)); - - incDecLayout.setOrientation(VERTICAL); - incDecLayout.setVisibility(View.GONE); - incDecLayout.setPadding(0,0,0,0); - incDecLayout.setLayoutParams(new TableRow.LayoutParams(0, LayoutParams.MATCH_PARENT, 0.05f)); - - editModeView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_editable)); - editModeView.setScaleType(ImageView.ScaleType.CENTER_INSIDE); - editModeView.setVisibility(View.GONE); - editModeView.setColorFilter(getForegroundColor()); - - indicatorView.setLayoutParams(new TableRow.LayoutParams(0, LayoutParams.MATCH_PARENT, 0.01f)); - indicatorView.setBackgroundColor(Color.GRAY); - - evaluatorRow.setLayoutParams(new TableRow.LayoutParams(LayoutParams.MATCH_PARENT,LayoutParams.WRAP_CONTENT, 1.0f)); - evaluatorRow.addView(new Space(context)); - evaluatorRow.addView(evaluatorView); - Space spaceAfterEvaluatorView = new Space(context); - evaluatorRow.addView(spaceAfterEvaluatorView); - evaluatorRow.setVisibility(View.GONE); - - evaluatorView.setLayoutParams(new TableRow.LayoutParams(0, LayoutParams.WRAP_CONTENT, 0.99f)); - spaceAfterEvaluatorView.setLayoutParams(new TableRow.LayoutParams(0, LayoutParams.WRAP_CONTENT, 0.01f)); - - setOnClickListener(new onClickListenerEvaluation()); - } - - protected LinearLayout getIncDecLayout() { - return incDecLayout; - } - - public void setOnUpdateListener(MeasurementViewUpdateListener listener) { - updateListener = listener; - } - - public void setUpdateViews(boolean update) { - updateViews = update; - } - protected boolean getUpdateViews() { - return updateViews; - } - - public abstract String getKey(); - - public MeasurementViewSettings getSettings() { - if (settings == null) { - settings = new MeasurementViewSettings( - PreferenceManager.getDefaultSharedPreferences(getContext()), getKey()); - } - return settings; - } - - public abstract void loadFrom(ScaleMeasurement measurement, ScaleMeasurement previousMeasurement); - public abstract void saveTo(ScaleMeasurement measurement); - public abstract void clearIn(ScaleMeasurement measurement); - - public abstract void restoreState(Bundle state); - public abstract void saveState(Bundle state); - - public CharSequence getName() { return nameView.getText(); } - public abstract String getValueAsString(boolean withUnit); - public void appendDiffValue(final SpannableStringBuilder builder, boolean newLine, boolean isEvalOn) { } - public void appendDiffValue(final SpannableStringBuilder builder, boolean newLine) { } - public Drawable getIcon() { return iconView.getDrawable(); } - public int getIconResource() { return iconId; } - public void setBackgroundIconColor(int color) { - iconViewBackground.setColor(color); - } - - protected boolean isEditable() { - return true; - } - - public void setEditMode(MeasurementViewMode mode) { - measurementMode = mode; - - nameView.setGravity(Gravity.LEFT | (mode == ADD ? Gravity.CENTER : Gravity.TOP)); - valueView.setGravity(Gravity.CENTER | (mode == STATISTIC ? 0 : Gravity.RIGHT)); - - switch (mode) { - case VIEW: - indicatorView.setVisibility(View.VISIBLE); - editModeView.setVisibility(View.GONE); - incDecLayout.setVisibility(View.GONE); - nameView.setVisibility(View.VISIBLE); - break; - case EDIT: - case ADD: - indicatorView.setVisibility(View.GONE); - editModeView.setVisibility(View.VISIBLE); - incDecLayout.setVisibility(View.VISIBLE); - nameView.setVisibility(View.VISIBLE); - - if (!isEditable()) { - editModeView.setVisibility(View.INVISIBLE); - } - break; - case STATISTIC: - indicatorView.setVisibility(View.GONE); - incDecLayout.setVisibility(View.GONE); - editModeView.setVisibility(View.GONE); - nameView.setVisibility(View.GONE); - break; - } - } - - protected MeasurementViewMode getMeasurementMode() { - return measurementMode; - } - - protected void setValueView(String text, boolean callListener) { - if (updateViews) { - valueView.setText(text); - } - if (callListener && updateListener != null) { - updateListener.onMeasurementViewUpdate(this); - } - } - - protected void setNameView(CharSequence text) { - if (updateViews) { - nameView.setText(text); - } - } - - public int getForegroundColor() { - return ColorUtil.getTintColor(getContext()); - } - - public int getIndicatorColor() { - ColorDrawable background = (ColorDrawable)indicatorView.getBackground(); - return background.getColor(); - } - - abstract public int getColor(); - - protected void showEvaluatorRow(boolean show) { - if (show) { - evaluatorRow.setVisibility(View.VISIBLE); - } - else { - evaluatorRow.setVisibility(View.GONE); - } - } - - public void setExpand(boolean state) { - showEvaluatorRow(false); - } - - public void setVisible(boolean isVisible) { - if (isVisible) { - measurementRow.setVisibility(View.VISIBLE); - } else { - measurementRow.setVisibility(View.GONE); - } - } - - public boolean isVisible() { - if (measurementRow.getVisibility() == View.GONE) { - return false; - } - - return true; - } - - protected void setEvaluationView(EvaluationResult evalResult) { - if (!updateViews) { - return; - } - - if (evalResult == null) { - evaluatorView.setLimits(-1.0f, -1.0f); - indicatorView.setBackgroundColor(Color.GRAY); - return; - } - - evaluatorView.setLimits(evalResult.lowLimit, evalResult.highLimit); - evaluatorView.setValue(evalResult.value); - - switch (evalResult.eval_state) { - case LOW: - indicatorView.setBackgroundColor(ColorUtil.COLOR_BLUE); - break; - case NORMAL: - indicatorView.setBackgroundColor(ColorUtil.COLOR_GREEN); - break; - case HIGH: - indicatorView.setBackgroundColor(ColorUtil.COLOR_RED); - break; - case UNDEFINED: - indicatorView.setBackgroundColor(Color.GRAY); - break; - } - } - - protected ScaleUser getScaleUser() { - OpenScale openScale = OpenScale.getInstance(); - - return openScale.getSelectedScaleUser(); - } - - public String getPreferenceSummary() { return ""; } - public void prepareExtraPreferencesScreen(PreferenceScreen screen) { - MeasurementViewSettings settings = getSettings(); - - CheckBoxPreference isSticky = new CheckBoxPreference(screen.getContext()); - isSticky.setKey(settings.getIsStickyGraphKey()); - isSticky.setTitle(R.string.label_is_sticky); - isSticky.setPersistent(true); - isSticky.setDefaultValue(settings.isSticky()); - screen.addPreference(isSticky); - } - - protected abstract View getInputView(); - protected abstract boolean validateAndSetInput(View view); - - private MeasurementView getNextView() { - ViewGroup parent = (ViewGroup) getParent(); - for (int i = parent.indexOfChild(this) + 1; i < parent.getChildCount(); ++i) { - MeasurementView next = (MeasurementView) parent.getChildAt(i); - if (next.isVisible() && next.isEditable()) { - return next; - } - } - return null; - } - - private void prepareInputDialog(final AlertDialog dialog) { - dialog.setTitle(getName()); - getIcon().setColorFilter(ColorUtil.getTintColor(getContext()), PorterDuff.Mode.SRC_IN); - dialog.setIcon(getIcon()); - - final View input = getInputView(); - - FrameLayout fl = dialog.findViewById(R.id.custom); - fl.removeAllViews(); - fl.addView(input, new LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT)); - - View.OnClickListener clickListener = new View.OnClickListener() { - @Override - public void onClick(View view) { - if (view == dialog.getButton(DialogInterface.BUTTON_POSITIVE) - && !validateAndSetInput(input)) { - return; - } - dialog.dismiss(); - } - }; - - dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener); - dialog.getButton(DialogInterface.BUTTON_POSITIVE).setTextColor(ColorUtil.getPrimaryColor(getContext())); - dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener(clickListener); - dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setTextColor(ColorUtil.getPrimaryColor(getContext())); - - final MeasurementView next = getNextView(); - if (next != null) { - dialog.getButton(DialogInterface.BUTTON_NEUTRAL).setTextColor(ColorUtil.getPrimaryColor(getContext())); - dialog.getButton(DialogInterface.BUTTON_NEUTRAL).setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - if (validateAndSetInput(input)) { - next.prepareInputDialog(dialog); - } - } - }); - } - else { - dialog.getButton(DialogInterface.BUTTON_NEUTRAL).setVisibility(GONE); - } - } - - private void showInputDialog() { - MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getContext()); - - builder.setTitle(getName()); - builder.setIcon(getIcon()); - - // Dummy view to have the "custom" frame layout being created and show - // the soft input (if needed). - builder.setView(new EditText(getContext())); - - builder.setPositiveButton(R.string.label_ok, null); - builder.setNegativeButton(R.string.label_cancel, null); - builder.setNeutralButton(R.string.label_next, null); - - final AlertDialog dialog = builder.create(); - - dialog.setOnShowListener(new DialogInterface.OnShowListener() { - @Override - public void onShow(DialogInterface dialogInterface) { - prepareInputDialog(dialog); - } - }); - - dialog.show(); - } - - private class onClickListenerEvaluation implements View.OnClickListener { - @Override - public void onClick(View v) { - if (getMeasurementMode() == STATISTIC) { - return; - } - - if (getMeasurementMode() == EDIT || getMeasurementMode() == ADD) { - if (isEditable()) { - showInputDialog(); - } - return; - } - - setExpand(evaluatorRow.getVisibility() != View.VISIBLE); - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/MeasurementViewSettings.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/MeasurementViewSettings.java deleted file mode 100644 index a8df3f46..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/MeasurementViewSettings.java +++ /dev/null @@ -1,253 +0,0 @@ -/* Copyright (C) 2018 Erik Johansson -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.gui.measurement; - -import android.content.SharedPreferences; - -import com.health.openscale.core.bodymetric.EstimatedFatMetric; -import com.health.openscale.core.bodymetric.EstimatedLBMMetric; -import com.health.openscale.core.bodymetric.EstimatedWaterMetric; - -public class MeasurementViewSettings { - private final SharedPreferences preferences; - private final String key; - - private static final String PREFERENCE_SUFFIX_ENABLE = "Enable"; - private static final String PREFERENCE_SUFFIX_IS_STICKY = "IsSticky"; - private static final String PREFERENCE_SUFFIX_IN_OVERVIEW_GRAPH = "InOverviewGraph"; - private static final String PREFERENCE_SUFFIX_ON_RIGHT_AXIS = "OnRightAxis"; - private static final String PREFERENCE_SUFFIX_IN_GRAPH = "InGraph"; - private static final String PREFERENCE_SUFFIX_PERCENTAGE_ENABLE = "PercentageEnable"; - private static final String PREFERENCE_SUFFIX_ESTIMATE_ENABLE = "EstimateEnable"; - private static final String PREFERENCE_SUFFIX_ESTIMATE_FORMULA = "EstimateFormula"; - - public MeasurementViewSettings(SharedPreferences prefs, String key) { - preferences = prefs; - this.key = key; - } - - private String getPreferenceKey(String suffix) { - return key + suffix; - } - - public String getEnabledKey() { - return getPreferenceKey(PREFERENCE_SUFFIX_ENABLE); - } - - public boolean isEnabledIgnoringDependencies() { - boolean defaultValue; - switch (key) { - case WeightMeasurementView.KEY: - // Weight can't be disabled - return true; - case VisceralFatMeasurementView.KEY: - case LBMMeasurementView.KEY: - case BoneMeasurementView.KEY: - case WaistMeasurementView.KEY: - case HipMeasurementView.KEY: - case ChestMeasurementView.KEY: - case BicepsMeasurementView.KEY: - case ThighMeasurementView.KEY: - case NeckMeasurementView.KEY: - case Caliper1MeasurementView.KEY: - case Caliper2MeasurementView.KEY: - case Caliper3MeasurementView.KEY: - case CaloriesMeasurementView.KEY: - case UserMeasurementView.KEY: - defaultValue = false; - break; - default: - defaultValue = true; - break; - } - return preferences.getBoolean(getEnabledKey(), defaultValue); - } - - private boolean isDependencyEnabled(String dependencyKey) { - // Weight can't be disabled - if (dependencyKey.equals(WeightMeasurementView.KEY)) { - return true; - } - - return (new MeasurementViewSettings(preferences, dependencyKey)).isEnabled(); - } - - public boolean areDependenciesEnabled() { - switch (key) { - case FatCaliperMeasurementView.KEY: - return isDependencyEnabled(Caliper1MeasurementView.KEY) - && isDependencyEnabled(Caliper2MeasurementView.KEY) - && isDependencyEnabled(Caliper3MeasurementView.KEY); - - case BMIMeasurementView.KEY: - case BMRMeasurementView.KEY: - return isDependencyEnabled(WeightMeasurementView.KEY); - - // Requires weight as they are stored as percentage of it - case FatMeasurementView.KEY: - case MuscleMeasurementView.KEY: - case WaterMeasurementView.KEY: - return isDependencyEnabled(WeightMeasurementView.KEY); - - case WHRMeasurementView.KEY: - return isDependencyEnabled(HipMeasurementView.KEY) - && isDependencyEnabled(WaistMeasurementView.KEY); - - case WHtRMeasurementView.KEY: - return isDependencyEnabled(WaistMeasurementView.KEY); - } - return true; - } - - public boolean isEnabled() { - return isEnabledIgnoringDependencies() && areDependenciesEnabled(); - } - - public boolean isSticky() { - boolean defaultValue; - switch (key) { - case WeightMeasurementView.KEY: - case WaterMeasurementView.KEY: - case MuscleMeasurementView.KEY: - case FatMeasurementView.KEY: - defaultValue = true; - break; - default: - defaultValue = false; - break; - } - return preferences.getBoolean(getIsStickyGraphKey(), defaultValue); - } - - public String getIsStickyGraphKey() { - return getPreferenceKey(PREFERENCE_SUFFIX_IS_STICKY); - } - - public String getInOverviewGraphKey() { - return getPreferenceKey(PREFERENCE_SUFFIX_IN_OVERVIEW_GRAPH); - } - - public boolean isInOverviewGraph() { - boolean defaultValue; - switch (key) { - case WeightMeasurementView.KEY: - case WaterMeasurementView.KEY: - case MuscleMeasurementView.KEY: - case FatMeasurementView.KEY: - defaultValue = true; - break; - default: - defaultValue = false; - break; - } - return preferences.getBoolean(getInOverviewGraphKey(), defaultValue); - } - - public String getInGraphKey() { - return getPreferenceKey(PREFERENCE_SUFFIX_IN_GRAPH); - } - - public boolean isOnRightAxis() { - boolean defaultValue; - switch (key) { - case WeightMeasurementView.KEY: - case BMRMeasurementView.KEY: - case TDEEMeasurementView.KEY: - case CaloriesMeasurementView.KEY: - defaultValue = true; - break; - default: - defaultValue = false; - break; - } - - return preferences.getBoolean(getOnRightAxisKey(), defaultValue); - } - - public String getOnRightAxisKey() { - return getPreferenceKey(PREFERENCE_SUFFIX_ON_RIGHT_AXIS); - } - - public boolean isInGraph() { - return preferences.getBoolean(getInGraphKey(), true); - } - - public String getPercentageEnabledKey() { - return getPreferenceKey(PREFERENCE_SUFFIX_PERCENTAGE_ENABLE); - } - - public boolean isPercentageEnabled() { - boolean defaultValue; - switch (key) { - case BoneMeasurementView.KEY: - defaultValue = false; - break; - default: - defaultValue = true; - break; - } - return preferences.getBoolean(getPercentageEnabledKey(), defaultValue); - } - - public String getEstimationEnabledKey() { - switch (key) { - case FatMeasurementView.KEY: - return "estimateFatEnable"; - case LBMMeasurementView.KEY: - return "estimateLBWEnable"; - case WaterMeasurementView.KEY: - return "estimateWaterEnable"; - } - return getPreferenceKey(PREFERENCE_SUFFIX_ESTIMATE_ENABLE); - } - - public boolean isEstimationEnabled() { - return preferences.getBoolean(getEstimationEnabledKey(), false); - } - - public String getEstimationFormulaKey() { - switch (key) { - case FatMeasurementView.KEY: - return "estimateFatFormula"; - case LBMMeasurementView.KEY: - return "estimateLBWFormula"; - case WaterMeasurementView.KEY: - return "estimateWaterFormula"; - } - return getPreferenceKey(PREFERENCE_SUFFIX_ESTIMATE_FORMULA); - } - - public String getEstimationFormula() { - String defaultValue; - switch (key) { - case FatMeasurementView.KEY: - defaultValue = EstimatedFatMetric.FORMULA.BF_GALLAGHER.name(); - break; - case LBMMeasurementView.KEY: - defaultValue = EstimatedLBMMetric.FORMULA.LBW_HUME.name(); - break; - case WaterMeasurementView.KEY: - defaultValue = EstimatedWaterMetric.FORMULA.TBW_LEESONGKIM.name(); - break; - default: - defaultValue = ""; - break; - } - - return preferences.getString(getEstimationFormulaKey(), defaultValue); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/MeasurementViewUpdateListener.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/MeasurementViewUpdateListener.java deleted file mode 100644 index c5807302..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/MeasurementViewUpdateListener.java +++ /dev/null @@ -1,20 +0,0 @@ -/* Copyright (C) 2017 Erik Johansson -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -public interface MeasurementViewUpdateListener { - void onMeasurementViewUpdate(MeasurementView view); -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/MuscleMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/MuscleMeasurementView.java deleted file mode 100644 index 08a72951..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/MuscleMeasurementView.java +++ /dev/null @@ -1,77 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; - -public class MuscleMeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "muscle"; - - public MuscleMeasurementView(Context context) { - super(context, R.string.label_muscle, R.drawable.ic_muscle); - } - - @Override - public String getKey() { - return KEY; - } - - @Override - protected boolean supportsPercentageToAbsoluteWeightConversion() { - return true; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return measurement.getMuscle(); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - measurement.setMuscle(value); - } - - @Override - public String getUnit() { - if (shouldConvertPercentageToAbsoluteWeight()) { - return getScaleUser().getScaleUnit().toString(); - } - - return "%"; - } - - @Override - protected float getMaxValue() { - return maybeConvertPercentageToAbsoluteWeight(100); - } - - @Override - public int getColor() { - return Color.parseColor("#99CC00"); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return evalSheet.evaluateBodyMuscle(value); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/NeckMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/NeckMeasurementView.java deleted file mode 100644 index 7ca9ab4d..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/NeckMeasurementView.java +++ /dev/null @@ -1,69 +0,0 @@ -/* Copyright (C) 2018 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; -import com.health.openscale.core.utils.Converters; - -public class NeckMeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "neck"; - - public NeckMeasurementView(Context context) { - super(context, R.string.label_neck, R.drawable.ic_neck); - } - - @Override - public String getKey() { - return KEY; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return Converters.fromCentimeter(measurement.getNeck(), getScaleUser().getMeasureUnit()); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - measurement.setNeck(Converters.toCentimeter(value, getScaleUser().getMeasureUnit())); - } - - @Override - public String getUnit() { - return getScaleUser().getMeasureUnit().toString(); - } - - @Override - protected float getMaxValue() { - return 500; - } - - @Override - public int getColor() { - return Color.parseColor("#00acc1"); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return null; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/TDEEMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/TDEEMeasurementView.java deleted file mode 100644 index a9f5072b..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/TDEEMeasurementView.java +++ /dev/null @@ -1,78 +0,0 @@ -/* Copyright (C) 2019 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; - -public class TDEEMeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "tdee"; - - public TDEEMeasurementView(Context context) { - super(context, R.string.label_tdee, R.drawable.ic_tdee); - } - - @Override - public String getKey() { - return KEY; - } - - @Override - public boolean isEditable() { - return false; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return measurement.getTDEE(getScaleUser()); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - // Empty - } - - @Override - public String getUnit() { - return "kCal"; - } - - @Override - protected float getMaxValue() { - return 10000; - } - - @Override - protected int getDecimalPlaces() { - return 0; - } - - @Override - public int getColor() { - return Color.parseColor("#6ea626"); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return null; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/ThighMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/ThighMeasurementView.java deleted file mode 100644 index b401bdb0..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/ThighMeasurementView.java +++ /dev/null @@ -1,69 +0,0 @@ -/* Copyright (C) 2018 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; -import com.health.openscale.core.utils.Converters; - -public class ThighMeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "thigh"; - - public ThighMeasurementView(Context context) { - super(context, R.string.label_thigh, R.drawable.ic_thigh); - } - - @Override - public String getKey() { - return KEY; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return Converters.fromCentimeter(measurement.getThigh(), getScaleUser().getMeasureUnit()); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - measurement.setThigh(Converters.toCentimeter(value, getScaleUser().getMeasureUnit())); - } - - @Override - public String getUnit() { - return getScaleUser().getMeasureUnit().toString(); - } - - @Override - protected float getMaxValue() { - return 500; - } - - @Override - public int getColor() { - return Color.parseColor("#f4511e"); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return null; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/TimeMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/TimeMeasurementView.java deleted file mode 100644 index c1dd00be..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/TimeMeasurementView.java +++ /dev/null @@ -1,131 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.os.Bundle; -import android.view.View; -import android.widget.TimePicker; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.gui.utils.ColorUtil; - -import java.text.DateFormat; -import java.util.Calendar; -import java.util.Date; - -public class TimeMeasurementView extends MeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "time"; - - private final DateFormat timeFormat; - private Date time; - - public TimeMeasurementView(Context context) { - super(context, R.string.label_time, R.drawable.ic_daysleft); - timeFormat = android.text.format.DateFormat.getTimeFormat(context); - } - - @Override - public String getKey() { - return KEY; - } - - private void setValue(Date newTime, boolean callListener) { - if (!newTime.equals(time)) { - time = newTime; - if (getUpdateViews()) { - setValueView(timeFormat.format(time), callListener); - } - } - } - - @Override - public void loadFrom(ScaleMeasurement measurement, ScaleMeasurement previousMeasurement) { - setValue(measurement.getDateTime(), false); - } - - @Override - public void saveTo(ScaleMeasurement measurement) { - Calendar target = Calendar.getInstance(); - target.setTime(measurement.getDateTime()); - - Calendar source = Calendar.getInstance(); - source.setTime(time); - - target.set(Calendar.HOUR_OF_DAY, source.get(Calendar.HOUR_OF_DAY)); - target.set(Calendar.MINUTE, source.get(Calendar.MINUTE)); - target.set(Calendar.SECOND, 0); - target.set(Calendar.MILLISECOND, 0); - - measurement.setDateTime(target.getTime()); - } - - @Override - public void clearIn(ScaleMeasurement measurement) { - // Ignore - } - - @Override - public void restoreState(Bundle state) { - setValue(new Date(state.getLong(getKey())), true); - } - - @Override - public void saveState(Bundle state) { - state.putLong(getKey(), time.getTime()); - } - - @Override - public int getColor() { return ColorUtil.COLOR_GRAY; }; - - @Override - public String getValueAsString(boolean withUnit) { - return timeFormat.format(time); - } - - @Override - protected View getInputView() { - TimePicker timePicker = new TimePicker(getContext()); - timePicker.setPadding(0, 15, 0, 0); - - Calendar cal = Calendar.getInstance(); - cal.setTime(time); - - timePicker.setCurrentHour(cal.get(Calendar.HOUR_OF_DAY)); - timePicker.setCurrentMinute(cal.get(Calendar.MINUTE)); - timePicker.setIs24HourView(android.text.format.DateFormat.is24HourFormat(getContext())); - - return timePicker; - } - - @Override - protected boolean validateAndSetInput(View view) { - TimePicker timePicker = (TimePicker) view; - - Calendar cal = Calendar.getInstance(); - cal.setTime(time); - cal.set(Calendar.HOUR_OF_DAY, timePicker.getCurrentHour()); - cal.set(Calendar.MINUTE, timePicker.getCurrentMinute()); - cal.set(Calendar.SECOND, 0); - cal.set(Calendar.MILLISECOND, 0); - - setValue(cal.getTime(), true); - - return true; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/UserMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/UserMeasurementView.java deleted file mode 100644 index 4ec9c7db..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/UserMeasurementView.java +++ /dev/null @@ -1,123 +0,0 @@ -/* Copyright (C) 2019 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.os.Bundle; -import android.view.View; -import android.widget.ArrayAdapter; -import android.widget.Spinner; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.gui.utils.ColorUtil; - -import java.util.ArrayList; - -public class UserMeasurementView extends MeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "user"; - - private OpenScale openScale = OpenScale.getInstance(); - private int userId; - - public UserMeasurementView(Context context) { - super(context, R.string.label_user_name, R.drawable.ic_user); - userId = -1; - } - - @Override - public String getKey() { - return KEY; - } - - private void setValue(int newUserId, boolean callListener) { - if (newUserId == -1) { - setValueView(openScale.getSelectedScaleUser().getUserName(), callListener); - } else if (userId != newUserId) { - userId = newUserId; - - setValueView(openScale.getScaleUser(userId).getUserName(), callListener); - } - } - - @Override - public void loadFrom(ScaleMeasurement measurement, ScaleMeasurement previousMeasurement) { - setValue(measurement.getUserId(), false); - } - - @Override - public void saveTo(ScaleMeasurement measurement) { - measurement.setUserId(userId); - } - - @Override - public void clearIn(ScaleMeasurement measurement) { - // ignore - } - - @Override - public void restoreState(Bundle state) { - setValue(state.getInt(getKey()), true); - } - - @Override - public void saveState(Bundle state) { - state.putInt(getKey(), userId); - } - - @Override - public int getColor() { return ColorUtil.COLOR_GRAY; }; - - @Override - public String getValueAsString(boolean withUnit) { - return openScale.getScaleUser(userId).getUserName(); - } - - @Override - protected View getInputView() { - Spinner spinScaleUer = new Spinner(getContext()); - ArrayAdapter spinScaleUserAdapter = new ArrayAdapter<>(getContext(), R.layout.support_simple_spinner_dropdown_item, new ArrayList<>()); - - spinScaleUer.setAdapter(spinScaleUserAdapter); - - int spinPos = 0; - - for (ScaleUser scaleUser : openScale.getScaleUserList()) { - spinScaleUserAdapter.add(scaleUser.getUserName()); - - if (scaleUser.getId() == userId) { - spinPos = spinScaleUserAdapter.getCount() - 1; - } - } - - spinScaleUer.setSelection(spinPos); - - return spinScaleUer; - } - - @Override - protected boolean validateAndSetInput(View view) { - Spinner spinScaleUser = (Spinner)view; - - int pos = spinScaleUser.getSelectedItemPosition(); - setValue(openScale.getScaleUserList().get(pos).getId(), true); - - return true; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/VisceralFatMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/VisceralFatMeasurementView.java deleted file mode 100644 index 02635e35..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/VisceralFatMeasurementView.java +++ /dev/null @@ -1,68 +0,0 @@ -/* Copyright (C) 2018 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; - -public class VisceralFatMeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "visceralFat"; - - public VisceralFatMeasurementView(Context context) { - super(context, R.string.label_visceral_fat, R.drawable.ic_visceral_fat); - } - - @Override - public String getKey() { - return KEY; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return measurement.getVisceralFat(); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - measurement.setVisceralFat(value); - } - - @Override - public String getUnit() { - return ""; - } - - @Override - protected float getMaxValue() { - return 100; - } - - @Override - public int getColor() { - return Color.parseColor("#00bfa5"); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return evalSheet.evaluateVisceralFat(value); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/WHRMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/WHRMeasurementView.java deleted file mode 100644 index c413be28..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/WHRMeasurementView.java +++ /dev/null @@ -1,73 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; - -public class WHRMeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "whr"; - - public WHRMeasurementView(Context context) { - super(context, R.string.label_whr, R.drawable.ic_whr); - } - - @Override - public String getKey() { - return KEY; - } - - @Override - public boolean isEditable() { - return false; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return measurement.getWHR(); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - // Empty - } - - @Override - public String getUnit() { - return ""; - } - - @Override - protected float getMaxValue() { - return 1.5f; - } - - @Override - public int getColor() { - return Color.parseColor("#FFA726"); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return evalSheet.evaluateWHR(value); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/WHtRMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/WHtRMeasurementView.java deleted file mode 100644 index ac5cb9c6..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/WHtRMeasurementView.java +++ /dev/null @@ -1,73 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; - -public class WHtRMeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "whtr"; - - public WHtRMeasurementView(Context context) { - super(context, R.string.label_whtr, R.drawable.ic_whtr); - } - - @Override - public String getKey() { - return KEY; - } - - @Override - public boolean isEditable() { - return false; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return measurement.getWHtR(getScaleUser().getBodyHeight()); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - // Empty - } - - @Override - public String getUnit() { - return ""; - } - - @Override - protected float getMaxValue() { - return 1; - } - - @Override - public int getColor() { - return Color.parseColor("#9CCC65"); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return evalSheet.evaluateWHtR(value); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/WaistMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/WaistMeasurementView.java deleted file mode 100644 index 2da45796..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/WaistMeasurementView.java +++ /dev/null @@ -1,69 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; -import com.health.openscale.core.utils.Converters; - -public class WaistMeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "waist"; - - public WaistMeasurementView(Context context) { - super(context, R.string.label_waist, R.drawable.ic_waist); - } - - @Override - public String getKey() { - return KEY; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return Converters.fromCentimeter(measurement.getWaist(), getScaleUser().getMeasureUnit()); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - measurement.setWaist(Converters.toCentimeter(value, getScaleUser().getMeasureUnit())); - } - - @Override - public String getUnit() { - return getScaleUser().getMeasureUnit().toString(); - } - - @Override - protected float getMaxValue() { - return 200; - } - - @Override - public int getColor() { - return Color.parseColor("#FF7043"); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return evalSheet.evaluateWaist(value); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/WaterMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/WaterMeasurementView.java deleted file mode 100644 index be3b4e00..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/WaterMeasurementView.java +++ /dev/null @@ -1,99 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import androidx.preference.ListPreference; - -import com.health.openscale.R; -import com.health.openscale.core.bodymetric.EstimatedWaterMetric; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; - -public class WaterMeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "water"; - - public WaterMeasurementView(Context context) { - super(context, R.string.label_water, R.drawable.ic_water); - } - - @Override - public String getKey() { - return KEY; - } - - @Override - protected boolean supportsPercentageToAbsoluteWeightConversion() { - return true; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return measurement.getWater(); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - measurement.setWater(value); - } - - @Override - public String getUnit() { - if (shouldConvertPercentageToAbsoluteWeight()) { - return getScaleUser().getScaleUnit().toString(); - } - - return "%"; - } - - @Override - protected float getMaxValue() { - return maybeConvertPercentageToAbsoluteWeight(80); - } - - @Override - public int getColor() { - return Color.parseColor("#33B5E5"); - } - - @Override - protected boolean isEstimationSupported() { return true; } - - @Override - protected void prepareEstimationFormulaPreference(ListPreference preference) { - String[] entries = new String[EstimatedWaterMetric.FORMULA.values().length]; - String[] values = new String[entries.length]; - - int idx = 0; - for (EstimatedWaterMetric.FORMULA formula : EstimatedWaterMetric.FORMULA.values()) { - entries[idx] = EstimatedWaterMetric.getEstimatedMetric(formula).getName(); - values[idx] = formula.name(); - ++idx; - } - - preference.setEntries(entries); - preference.setEntryValues(values); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return evalSheet.evaluateBodyWater(value); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/measurement/WeightMeasurementView.java b/android_app/app/src/main/java/com/health/openscale/gui/measurement/WeightMeasurementView.java deleted file mode 100644 index cad45f5b..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/measurement/WeightMeasurementView.java +++ /dev/null @@ -1,69 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.measurement; - -import android.content.Context; -import android.graphics.Color; - -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.evaluation.EvaluationResult; -import com.health.openscale.core.evaluation.EvaluationSheet; -import com.health.openscale.core.utils.Converters; - -public class WeightMeasurementView extends FloatMeasurementView { - // Don't change key value, it may be stored persistent in preferences - public static final String KEY = "weight"; - - public WeightMeasurementView(Context context) { - super(context, R.string.label_weight, R.drawable.ic_weight); - } - - @Override - public String getKey() { - return KEY; - } - - @Override - protected float getMeasurementValue(ScaleMeasurement measurement) { - return Converters.fromKilogram(measurement.getWeight(), getScaleUser().getScaleUnit()); - } - - @Override - protected void setMeasurementValue(float value, ScaleMeasurement measurement) { - measurement.setWeight(Converters.toKilogram(value, getScaleUser().getScaleUnit())); - } - - @Override - public String getUnit() { - return getScaleUser().getScaleUnit().toString(); - } - - @Override - protected float getMaxValue() { - return Converters.fromKilogram(300.0f, getScaleUser().getScaleUnit()); - } - - @Override - public int getColor() { - return Color.parseColor("#AA66CC"); - } - - @Override - protected EvaluationResult evaluateSheet(EvaluationSheet evalSheet, float value) { - return evalSheet.evaluateWeight(Converters.toKilogram(value, getScaleUser().getScaleUnit())); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/overview/OverviewAdapter.java b/android_app/app/src/main/java/com/health/openscale/gui/overview/OverviewAdapter.java deleted file mode 100644 index 80256bfe..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/overview/OverviewAdapter.java +++ /dev/null @@ -1,217 +0,0 @@ -/* Copyright (C) 2023 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.gui.overview; - -import android.app.Activity; -import android.app.AlertDialog; -import android.content.DialogInterface; -import android.content.SharedPreferences; -import android.preference.PreferenceManager; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TableLayout; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; -import androidx.navigation.Navigation; -import androidx.recyclerview.widget.RecyclerView; -import androidx.transition.AutoTransition; -import androidx.transition.TransitionManager; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.gui.measurement.DateMeasurementView; -import com.health.openscale.gui.measurement.MeasurementEntryFragment; -import com.health.openscale.gui.measurement.MeasurementView; -import com.health.openscale.gui.measurement.TimeMeasurementView; -import com.health.openscale.gui.measurement.UserMeasurementView; - -import java.text.DateFormat; -import java.text.SimpleDateFormat; -import java.util.List; - -class OverviewAdapter extends RecyclerView.Adapter { - private Activity activity; - private List scaleMeasurementList; - - public OverviewAdapter(Activity activity, List scaleMeasurementList) { - this.activity = activity; - this.scaleMeasurementList = scaleMeasurementList; - } - - private void deleteMeasurement(int measurementId) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); - boolean deleteConfirmationEnable = prefs.getBoolean("deleteConfirmationEnable", true); - - if (deleteConfirmationEnable) { - AlertDialog.Builder deleteAllDialog = new AlertDialog.Builder(activity); - deleteAllDialog.setMessage(activity.getResources().getString(R.string.question_really_delete)); - - deleteAllDialog.setPositiveButton(activity.getResources().getString(R.string.label_yes), new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - doDeleteMeasurement(measurementId); - } - }); - - deleteAllDialog.setNegativeButton(activity.getResources().getString(R.string.label_no), new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.dismiss(); - } - }); - - deleteAllDialog.show(); - } - else { - doDeleteMeasurement(measurementId); - } - } - - private void doDeleteMeasurement(int measurementId) { - OpenScale.getInstance().deleteScaleMeasurement(measurementId); - Toast.makeText(activity, activity.getResources().getString(R.string.info_data_deleted), Toast.LENGTH_SHORT).show(); - } - - @Override - public OverviewAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_overview, parent, false); - - ViewHolder viewHolder = new ViewHolder(view); - - return viewHolder; - } - - @Override - public void onBindViewHolder(@NonNull OverviewAdapter.ViewHolder holder, int position) { - holder.measurementHighlightViews.removeAllViews(); - holder.measurementViews.removeAllViews(); - - ScaleMeasurement scaleMeasurement = scaleMeasurementList.get(position); - ScaleMeasurement prevScaleMeasurement; - - // for the first measurement no previous measurement are available, use standard measurement instead - if (position == 0) { - prevScaleMeasurement = new ScaleMeasurement(); - } else { - prevScaleMeasurement = scaleMeasurementList.get(position - 1); - } - - holder.showEntry.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - OverviewFragmentDirections.ActionNavOverviewToNavDataentry action = OverviewFragmentDirections.actionNavOverviewToNavDataentry(); - action.setMeasurementId(scaleMeasurement.getId()); - action.setMode(MeasurementEntryFragment.DATA_ENTRY_MODE.VIEW); - Navigation.findNavController(activity, R.id.nav_host_fragment).navigate(action); - } - }); - - holder.editEntry.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - OverviewFragmentDirections.ActionNavOverviewToNavDataentry action = OverviewFragmentDirections.actionNavOverviewToNavDataentry(); - action.setMeasurementId(scaleMeasurement.getId()); - action.setMode(MeasurementEntryFragment.DATA_ENTRY_MODE.EDIT); - Navigation.findNavController(activity, R.id.nav_host_fragment).navigate(action); - } - }); - holder.deleteEntry.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - deleteMeasurement(scaleMeasurement.getId()); - } - }); - - holder.expandMeasurementView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - TransitionManager.beginDelayedTransition(holder.measurementViews, new AutoTransition()); - - if (holder.measurementViews.getVisibility() == View.VISIBLE) { - holder.measurementViews.setVisibility(View.GONE); - holder.expandMeasurementView.setImageDrawable(ContextCompat.getDrawable(activity, R.drawable.ic_expand_more)); - } else { - holder.measurementViews.setVisibility(View.VISIBLE); - holder.expandMeasurementView.setImageDrawable(ContextCompat.getDrawable(activity, R.drawable.ic_expand_less)); - } - } - }); - - holder.dateView.setText(DateFormat.getDateInstance(DateFormat.MEDIUM).format(scaleMeasurement.getDateTime()) + - " (" + new SimpleDateFormat("EE").format(scaleMeasurement.getDateTime()) + ") "+ - DateFormat.getTimeInstance(DateFormat.SHORT).format(scaleMeasurement.getDateTime())); - - List measurementViewList = MeasurementView.getMeasurementList(activity, MeasurementView.DateTimeOrder.LAST); - - for (MeasurementView measurementView : measurementViewList) { - if (measurementView instanceof DateMeasurementView || measurementView instanceof TimeMeasurementView || measurementView instanceof UserMeasurementView) { - measurementView.setVisible(false); - } - else if (measurementView.isVisible()) { - measurementView.loadFrom(scaleMeasurement, prevScaleMeasurement); - - if (measurementView.getSettings().isSticky()) { - holder.measurementHighlightViews.addView(measurementView); - } else{ - holder.measurementViews.addView(measurementView); - } - } - } - - if (holder.measurementViews.getChildCount() == 0) { - holder.expandMeasurementView.setVisibility(View.GONE); - } else { - holder.expandMeasurementView.setVisibility(View.VISIBLE); - } - } - - @Override - public long getItemId(int position) { - return scaleMeasurementList.get(position).getId(); - } - - @Override - public int getItemCount() { - return scaleMeasurementList.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - TextView dateView; - ImageView showEntry; - ImageView editEntry; - ImageView deleteEntry; - TableLayout measurementHighlightViews; - ImageView expandMeasurementView; - TableLayout measurementViews; - - public ViewHolder(@NonNull View itemView) { - super(itemView); - - dateView = itemView.findViewById(R.id.dateView); - showEntry = itemView.findViewById(R.id.showEntry); - editEntry = itemView.findViewById(R.id.editEntry); - deleteEntry = itemView.findViewById(R.id.deleteEntry); - measurementHighlightViews = itemView.findViewById(R.id.measurementHighlightViews); - expandMeasurementView = itemView.findViewById(R.id.expandMoreView); - measurementViews = itemView.findViewById(R.id.measurementViews); - measurementViews.setVisibility(View.GONE); - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/overview/OverviewFragment.java b/android_app/app/src/main/java/com/health/openscale/gui/overview/OverviewFragment.java deleted file mode 100644 index a6eeb07a..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/overview/OverviewFragment.java +++ /dev/null @@ -1,410 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.overview; - -import android.content.SharedPreferences; -import android.graphics.Color; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.style.RelativeSizeSpan; -import android.text.style.StyleSpan; -import android.view.LayoutInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.AdapterView; -import android.widget.ArrayAdapter; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.PopupMenu; -import android.widget.Spinner; -import android.widget.TextView; - -import androidx.activity.OnBackPressedCallback; -import androidx.fragment.app.Fragment; -import androidx.lifecycle.Observer; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.transition.ChangeScroll; -import androidx.transition.TransitionManager; - -import com.github.mikephil.charting.data.Entry; -import com.github.mikephil.charting.highlight.Highlight; -import com.github.mikephil.charting.listener.OnChartValueSelectedListener; -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.DateTimeHelpers; -import com.health.openscale.gui.measurement.ChartActionBarView; -import com.health.openscale.gui.measurement.ChartMeasurementView; -import com.health.openscale.gui.measurement.WeightMeasurementView; - -import java.text.DateFormat; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.List; - -public class OverviewFragment extends Fragment { - private View overviewView; - - private TextView txtTitleUser; - - private RecyclerView recyclerView; - private OverviewAdapter overviewAdapter; - private ChartMeasurementView chartView; - private ChartActionBarView chartActionBarView; - - private Spinner spinUser; - - private PopupMenu rangePopupMenu; - - private LinearLayout rowGoal; - private TextView differenceWeightView; - private TextView initialWeightView; - private TextView goalWeightView; - - private ScaleUser currentScaleUser; - - private ArrayAdapter spinUserAdapter; - - private SharedPreferences prefs; - - private List scaleMeasurementList; - private ScaleMeasurement markedMeasurement; - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - overviewView = inflater.inflate(R.layout.fragment_overview, container, false); - - prefs = PreferenceManager.getDefaultSharedPreferences(overviewView.getContext()); - - rowGoal = overviewView.findViewById(R.id.rowGoal); - differenceWeightView = overviewView.findViewById(R.id.differenceWeightView); - initialWeightView = overviewView.findViewById(R.id.initialWeightView); - goalWeightView = overviewView.findViewById(R.id.goalWeightView); - - chartView = overviewView.findViewById(R.id.chartView); - chartView.setOnChartValueSelectedListener(new onChartSelectedListener()); - chartView.setProgressBar(overviewView.findViewById(R.id.progressBar)); - chartView.setIsInGraphKey(false); - chartView.getLegend().setEnabled(false); - - setYAxisVisibility(prefs.getBoolean("enableYAxis", false)); - - chartActionBarView = overviewView.findViewById(R.id.chartActionBar); - chartActionBarView.setIsInGraphKey(false); - chartActionBarView.setOnActionClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - chartView.refreshMeasurementList(); - updateChartView(); - } - }); - - spinUser = overviewView.findViewById(R.id.spinUser); - - ImageView optionMenu = overviewView.findViewById(R.id.rangeOptionMenu); - optionMenu.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - rangePopupMenu.show(); - } - }); - - rangePopupMenu = new PopupMenu(getContext(), optionMenu); - rangePopupMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { - @Override - public boolean onMenuItemClick(MenuItem item) { - - switch (item.getItemId()) { - case R.id.enableChartActionBar: - if (item.isChecked()) { - item.setChecked(false); - prefs.edit().putBoolean("enableOverviewChartActionBar", false).apply(); - chartActionBarView.setVisibility(View.GONE); - } else { - item.setChecked(true); - prefs.edit().putBoolean("enableOverviewChartActionBar", true).apply(); - chartActionBarView.setVisibility(View.VISIBLE); - } - return true; - case R.id.enableYAxis: - boolean checked = item.isChecked(); - item.setChecked(!checked); - prefs.edit().putBoolean("enableYAxis", !checked).apply(); - setYAxisVisibility(!checked); - updateChartView(); - return true; - case R.id.menu_range_day: - prefs.edit().putInt("selectRangeMode", ChartMeasurementView.ViewMode.DAY_OF_ALL.ordinal()).commit(); - break; - case R.id.menu_range_week: - prefs.edit().putInt("selectRangeMode", ChartMeasurementView.ViewMode.WEEK_OF_ALL.ordinal()).commit(); - break; - case R.id.menu_range_month: - prefs.edit().putInt("selectRangeMode", ChartMeasurementView.ViewMode.MONTH_OF_ALL.ordinal()).commit(); - break; - case R.id.menu_range_year: - prefs.edit().putInt("selectRangeMode", ChartMeasurementView.ViewMode.YEAR_OF_ALL.ordinal()).commit(); - } - - item.setChecked(true); - - getActivity().recreate(); // TODO HACK to refresh graph; graph.invalidate and notfiydatachange is not enough!? - - return true; - } - }); - rangePopupMenu.getMenuInflater().inflate(R.menu.overview_menu, rangePopupMenu.getMenu()); - ChartMeasurementView.ViewMode selectedRangePos = ChartMeasurementView.ViewMode.values()[prefs.getInt("selectRangeMode", ChartMeasurementView.ViewMode.DAY_OF_ALL.ordinal())]; - - switch (selectedRangePos) { - case DAY_OF_ALL: - rangePopupMenu.getMenu().findItem(R.id.menu_range_day).setChecked(true); - break; - case WEEK_OF_ALL: - rangePopupMenu.getMenu().findItem(R.id.menu_range_week).setChecked(true); - break; - case MONTH_OF_ALL: - rangePopupMenu.getMenu().findItem(R.id.menu_range_month).setChecked(true); - break; - case YEAR_OF_ALL: - rangePopupMenu.getMenu().findItem(R.id.menu_range_year).setChecked(true); - break; - } - - MenuItem enableMeasurementBar = rangePopupMenu.getMenu().findItem(R.id.enableChartActionBar); - enableMeasurementBar.setChecked(prefs.getBoolean("enableOverviewChartActionBar", false)); - - if (enableMeasurementBar.isChecked()) { - chartActionBarView.setVisibility(View.VISIBLE); - } else { - chartActionBarView.setVisibility(View.GONE); - } - - MenuItem enableYAxis = rangePopupMenu.getMenu().findItem(R.id.enableYAxis); - enableYAxis.setChecked(prefs.getBoolean("enableYAxis", false)); - - recyclerView = overviewView.findViewById(R.id.recyclerView); - LinearLayoutManager layoutManager = new LinearLayoutManager(getContext()); - layoutManager.setInitialPrefetchItemCount(5); - layoutManager.setReverseLayout(true); - layoutManager.setStackFromEnd(true); - recyclerView.setLayoutManager(layoutManager); - - spinUserAdapter = new ArrayAdapter<>(overviewView.getContext(), R.layout.spinner_item, new ArrayList()); - spinUser.setAdapter(spinUserAdapter); - - // Set item select listener after spinner is created because otherwise item listener fires a lot!?!? - spinUser.post(new Runnable() { - public void run() { - spinUser.setOnItemSelectedListener(new spinUserSelectionListener()); - updateUserSelection(); - } - }); - - chartView.animateY(700); - - OpenScale.getInstance().getScaleMeasurementsLiveData().observe(getViewLifecycleOwner(), new Observer>() { - @Override - public void onChanged(List scaleMeasurements) { - updateOnView(scaleMeasurements); - } - }); - - OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) { - @Override - public void handleOnBackPressed() { - requireActivity().finish(); - } - }; - - requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), onBackPressedCallback); - - return overviewView; - } - - protected void setYAxisVisibility(boolean visible) { - chartView.getAxisRight().setDrawLabels(visible); - chartView.getAxisRight().setDrawGridLines(visible); - chartView.getAxisRight().setDrawAxisLine(visible); - - chartView.getAxisLeft().setDrawGridLines(visible); - chartView.getAxisLeft().setDrawLabels(visible); - chartView.getAxisLeft().setDrawAxisLine(visible); - - chartView.getXAxis().setDrawGridLines(visible); - } - - public void updateOnView(List scaleMeasurementList) { - this.scaleMeasurementList = scaleMeasurementList; - - overviewAdapter = new OverviewAdapter(getActivity(), scaleMeasurementList); - recyclerView.setAdapter(overviewAdapter); - - updateUserSelection(); - chartView.updateMeasurementList(scaleMeasurementList); - updateChartView(); - } - - private void updateChartView() { - ChartMeasurementView.ViewMode selectedRangeMode = ChartMeasurementView.ViewMode.values()[prefs.getInt("selectRangeMode", ChartMeasurementView.ViewMode.DAY_OF_ALL.ordinal())]; - chartView.setViewRange(selectedRangeMode); - } - - private void updateUserSelection() { - currentScaleUser = OpenScale.getInstance().getSelectedScaleUser(); - - spinUserAdapter.clear(); - List scaleUserList = OpenScale.getInstance().getScaleUserList(); - - int posUser = 0; - - for (ScaleUser scaleUser : scaleUserList) { - spinUserAdapter.add(scaleUser.getUserName()); - - if (scaleUser.getId() == currentScaleUser.getId()) { - posUser = spinUserAdapter.getCount() - 1; - } - } - - spinUser.setSelection(posUser, true); - - // Hide user selector when there is only one user - int visibility = spinUserAdapter.getCount() < 2 ? View.GONE : View.VISIBLE; - spinUser.setVisibility(visibility); - - if (currentScaleUser.isGoalEnabled()) { - rowGoal.setVisibility(View.VISIBLE); - - WeightMeasurementView weightMeasurementView = new WeightMeasurementView(getContext()); - ScaleMeasurement initialWeightMeasurement = OpenScale.getInstance().getLastScaleMeasurement(); - - if (initialWeightMeasurement == null) { - initialWeightMeasurement = new ScaleMeasurement(); - } - - initialWeightMeasurement.setWeight(initialWeightMeasurement.getWeight()); - weightMeasurementView.loadFrom(initialWeightMeasurement, null); - - SpannableStringBuilder initialWeightValue = new SpannableStringBuilder(); - initialWeightValue.append(getResources().getString(R.string.label_weight)); - initialWeightValue.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), 0, initialWeightValue.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - initialWeightValue.append("\n"); - initialWeightValue.append(weightMeasurementView.getValueAsString(true)); - initialWeightValue.append(("\n")); - int start = initialWeightValue.length(); - initialWeightValue.append(DateFormat.getDateInstance(DateFormat.MEDIUM).format(initialWeightMeasurement.getDateTime())); - initialWeightValue.setSpan(new RelativeSizeSpan(0.8f), start, initialWeightValue.length(), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - - initialWeightView.setText(initialWeightValue); - - ScaleMeasurement goalWeightMeasurement = new ScaleMeasurement(); - goalWeightMeasurement.setWeight(currentScaleUser.getGoalWeight()); - weightMeasurementView.loadFrom(goalWeightMeasurement, null); - - SpannableStringBuilder goalWeightValue = new SpannableStringBuilder(); - goalWeightValue.append(getResources().getString(R.string.label_goal_weight)); - goalWeightValue.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), 0, goalWeightValue.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - goalWeightValue.append("\n"); - goalWeightValue.append(weightMeasurementView.getValueAsString(true)); - goalWeightValue.append(("\n")); - start = goalWeightValue.length(); - goalWeightValue.append(DateFormat.getDateInstance(DateFormat.MEDIUM).format(currentScaleUser.getGoalDate())); - goalWeightValue.setSpan(new RelativeSizeSpan(0.8f), start, goalWeightValue.length(), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - - goalWeightView.setText(goalWeightValue); - - ScaleMeasurement differenceWeightMeasurement = new ScaleMeasurement(); - if (initialWeightMeasurement.getWeight() > goalWeightMeasurement.getWeight()) { - differenceWeightMeasurement.setWeight(initialWeightMeasurement.getWeight() - goalWeightMeasurement.getWeight()); - } else { - differenceWeightMeasurement.setWeight(goalWeightMeasurement.getWeight() - initialWeightMeasurement.getWeight()); - } - weightMeasurementView.loadFrom(differenceWeightMeasurement, null); - - Calendar initialCalendar = Calendar.getInstance(); - initialCalendar.setTime(initialWeightMeasurement.getDateTime()); - Calendar goalCalendar = Calendar.getInstance(); - goalCalendar.setTime(currentScaleUser.getGoalDate()); - int daysBetween = Math.max(0, DateTimeHelpers.daysBetween(initialCalendar, goalCalendar)); - - SpannableStringBuilder differenceWeightValue = new SpannableStringBuilder(); - differenceWeightValue.append(getResources().getString(R.string.label_weight_difference)); - differenceWeightValue.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), 0, differenceWeightValue.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - differenceWeightValue.append("\n"); - differenceWeightValue.append(weightMeasurementView.getValueAsString(true)); - differenceWeightValue.append(("\n")); - start = differenceWeightValue.length(); - differenceWeightValue.append(daysBetween + " " + getString(R.string.label_days_left)); - differenceWeightValue.setSpan(new RelativeSizeSpan(0.8f), start, differenceWeightValue.length(), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - - differenceWeightView.setText(differenceWeightValue); - } else { - rowGoal.setVisibility(View.GONE); - } - } - - private class onChartSelectedListener implements OnChartValueSelectedListener { - - @Override - public void onValueSelected(Entry e, Highlight h) { - Object[] extraData = (Object[])e.getData(); - - markedMeasurement = (ScaleMeasurement)extraData[0]; - //MeasurementView measurementView = (MeasurementView)extraData[1]; - - if (scaleMeasurementList.contains(markedMeasurement)) { - TransitionManager.beginDelayedTransition(recyclerView, new ChangeScroll()); - recyclerView.scrollToPosition(scaleMeasurementList.indexOf(markedMeasurement)); - } - } - - @Override - public void onNothingSelected() { - // empty - } - } - - private class spinUserSelectionListener implements AdapterView.OnItemSelectedListener { - - @Override - public void onItemSelected(AdapterView parent, View view, int position, long id) { - if (parent.getChildCount() > 0) { - ((TextView) parent.getChildAt(0)).setTextColor(Color.GRAY); - - OpenScale openScale = OpenScale.getInstance(); - - List scaleUserList = openScale.getScaleUserList(); - ScaleUser scaleUser = scaleUserList.get(position); - - openScale.selectScaleUser(scaleUser.getId()); - updateOnView(openScale.getScaleMeasurementList()); - } - } - - @Override - public void onNothingSelected(AdapterView parent) { - - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/preferences/AboutPreferences.java b/android_app/app/src/main/java/com/health/openscale/gui/preferences/AboutPreferences.java deleted file mode 100644 index 9fd2bdd0..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/preferences/AboutPreferences.java +++ /dev/null @@ -1,171 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.preferences; - -import android.content.Intent; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.util.Log; -import android.view.Menu; -import android.view.MenuInflater; - -import androidx.preference.CheckBoxPreference; -import androidx.preference.Preference; -import androidx.preference.PreferenceFragmentCompat; - -import com.health.openscale.BuildConfig; -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; - -import java.io.IOException; -import java.io.OutputStream; -import java.io.PrintWriter; -import java.text.DateFormat; -import java.text.SimpleDateFormat; -import java.util.Date; - -import timber.log.Timber; - -import static android.app.Activity.RESULT_OK; - -public class AboutPreferences extends PreferenceFragmentCompat { - private static final String KEY_APP_VERSION = "pref_app_version"; - private static final String KEY_DEBUG_LOG = "debug_log"; - - private static final int DEBUG_LOG_REQUEST = 100; - - private CheckBoxPreference debugLog; - - class FileDebugTree extends Timber.DebugTree { - PrintWriter writer; - DateFormat format; - - FileDebugTree(OutputStream output) { - writer = new PrintWriter(output, true); - format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"); - } - - void close() { - writer.close(); - } - - private String priorityToString(int priority) { - switch (priority) { - case Log.ASSERT: - return "Assert"; - case Log.ERROR: - return "Error"; - case Log.WARN: - return "Warning"; - case Log.INFO: - return "Info"; - case Log.DEBUG: - return "Debug"; - case Log.VERBOSE: - return "Verbose"; - } - return String.format("Unknown (%d)", priority); - } - - @Override - protected synchronized void log(int priority, String tag, String message, Throwable t) { - final long id = Thread.currentThread().getId(); - writer.printf("%s %s [%d] %s: %s\r\n", - format.format(new Date()), priorityToString(priority), id, tag, message); - } - } - - private FileDebugTree getEnabledFileDebugTree() { - for (Timber.Tree tree : Timber.forest()) { - if (tree instanceof FileDebugTree) { - return (FileDebugTree) tree; - } - } - return null; - } - - @Override - public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { - setPreferencesFromResource(R.xml.about_preferences, rootKey); - - setHasOptionsMenu(true); - - findPreference(KEY_APP_VERSION).setSummary( - String.format("v%s (%d)", BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE)); - - debugLog = (CheckBoxPreference)findPreference(KEY_DEBUG_LOG); - debugLog.setChecked(getEnabledFileDebugTree() != null); - debugLog.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - if (debugLog.isChecked()) { - DateFormat format = new SimpleDateFormat("yyyy-MM-dd_HH-mm"); - String fileName = String.format("openScale_%s.txt", format.format(new Date())); - - Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); - intent.addCategory(Intent.CATEGORY_OPENABLE); - intent.setType("text/plain"); - intent.putExtra(Intent.EXTRA_TITLE, fileName); - - startActivityForResult(intent, DEBUG_LOG_REQUEST); - } else { - FileDebugTree tree = getEnabledFileDebugTree(); - if (tree != null) { - Timber.d("Debug log disabled"); - Timber.uproot(tree); - tree.close(); - OpenScale.DEBUG_MODE = false; - } - } - - return true; - } - }); - } - - private void startLogTo(Uri uri) { - try { - OutputStream output = getActivity().getContentResolver().openOutputStream(uri); - Timber.plant(new FileDebugTree(output)); - OpenScale.DEBUG_MODE = true; - Timber.d("Debug log enabled, %s v%s (%d), SDK %d, %s %s", - getResources().getString(R.string.app_name), - BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE, - Build.VERSION.SDK_INT, Build.MANUFACTURER, Build.MODEL); - Timber.d("Selected user " + OpenScale.getInstance().getSelectedScaleUser()); - } - catch (IOException ex) { - Timber.e(ex, "Failed to open debug log %s", uri.toString()); - } - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - - if (requestCode == DEBUG_LOG_REQUEST && resultCode == RESULT_OK && data != null) { - startLogTo(data.getData()); - } - - debugLog.setChecked(getEnabledFileDebugTree() != null); - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - menu.clear(); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/preferences/BackupPreferences.java b/android_app/app/src/main/java/com/health/openscale/gui/preferences/BackupPreferences.java deleted file mode 100644 index 7fd39eb1..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/preferences/BackupPreferences.java +++ /dev/null @@ -1,321 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.preferences; - -import static android.app.Activity.RESULT_OK; - -import android.Manifest; -import android.content.ComponentName; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.view.Menu; -import android.view.MenuInflater; -import android.widget.Toast; - -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; -import androidx.fragment.app.Fragment; -import androidx.preference.CheckBoxPreference; -import androidx.preference.Preference; -import androidx.preference.PreferenceFragmentCompat; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.alarm.AlarmBackupHandler; -import com.health.openscale.core.alarm.ReminderBootReceiver; - -import java.io.IOException; - -import timber.log.Timber; - -public class BackupPreferences extends PreferenceFragmentCompat implements SharedPreferences.OnSharedPreferenceChangeListener { - private static final String PREFERENCE_KEY_IMPORT_BACKUP = "importBackup"; - private static final String PREFERENCE_KEY_EXPORT_BACKUP = "exportBackup"; - private static final String PREFERENCE_KEY_AUTO_BACKUP = "autoBackup"; - private static final String PREFERENCE_KEY_AUTO_BACKUP_DIR = "backupDir"; - - private static final int IMPORT_DATA_REQUEST = 100; - private static final int EXPORT_DATA_REQUEST = 101; - - private Preference importBackup; - private Preference exportBackup; - private Preference autoBackupDir; - - private CheckBoxPreference autoBackup; - - private boolean isAutoBackupAskForPermission; - - private SharedPreferences prefs; - - private Fragment fragment; - - @Override - public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { - setPreferencesFromResource(R.xml.backup_preferences, rootKey); - - setHasOptionsMenu(true); - - fragment = this; - - prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); - - importBackup = (Preference) findPreference(PREFERENCE_KEY_IMPORT_BACKUP); - importBackup.setOnPreferenceClickListener(new onClickListenerImportBackup()); - - exportBackup = (Preference) findPreference(PREFERENCE_KEY_EXPORT_BACKUP); - exportBackup.setOnPreferenceClickListener(new onClickListenerExportBackup()); - - autoBackup = (CheckBoxPreference) findPreference(PREFERENCE_KEY_AUTO_BACKUP); - autoBackup.setOnPreferenceClickListener(new onClickListenerAutoBackup()); - - // Auto backup preference - autoBackupDir = (Preference) findPreference(PREFERENCE_KEY_AUTO_BACKUP_DIR); - autoBackupDir.setOnPreferenceClickListener(new onClickListenerAutoBackupDir()); - // Setting auto backup preference's summary to location or message that none is selected - String autoBackupDirString = prefs.getString("backupDir", null); - autoBackupDir.setSummary(autoBackupDirString != null ? Uri.parse(autoBackupDirString).getLastPathSegment() : getString(R.string.label_auto_backup_lacation)); - - updateBackupPreferences(); - } - - void updateBackupPreferences() { - ComponentName receiver = new ComponentName(getActivity().getApplicationContext(), ReminderBootReceiver.class); - PackageManager pm = getActivity().getApplicationContext().getPackageManager(); - - AlarmBackupHandler alarmBackupHandler = new AlarmBackupHandler(); - - isAutoBackupAskForPermission = false; - - if (autoBackup.isChecked()) { - Timber.d("Auto-Backup enabled"); - alarmBackupHandler.scheduleAlarms(getActivity()); - - pm.setComponentEnabledSetting(receiver, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, - PackageManager.DONT_KILL_APP); - } else { - Timber.d("Auto-Backup disabled"); - alarmBackupHandler.disableAlarm(getActivity()); - - pm.setComponentEnabledSetting(receiver, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, - PackageManager.DONT_KILL_APP); - } - } - - @Override - public void onResume() - { - super.onResume(); - getPreferenceManager().getSharedPreferences().registerOnSharedPreferenceChangeListener(this); - } - - @Override - public void onPause() - { - getPreferenceManager().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(this); - super.onPause(); - } - - @Override - public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) - { - updateBackupPreferences(); - } - - - private class onClickListenerAutoBackup implements Preference.OnPreferenceClickListener { - @Override - public boolean onPreferenceClick(Preference preference) { - if (autoBackup.isChecked()) { - autoBackup.setChecked(true); - // If backupDir location already saved user won't be prompted to select a new location - if (prefs.getString("backupDir", null) == null) { - Toast.makeText(getContext(), R.string.info_select_auto_backup_export_dir, Toast.LENGTH_SHORT).show(); - selectAutoBackupDir.launch(null); - } - } else { - autoBackup.setChecked(false); - } - return true; - } - } - - /** - * Function for "Export directory" setting - */ - private class onClickListenerAutoBackupDir implements Preference.OnPreferenceClickListener { - @Override - public boolean onPreferenceClick(@NonNull Preference preference) { - selectAutoBackupDir.launch(null); - return true; - } - } - - /** - * Launches Android File Picker to choose a directory where automatic backups should be saved - * If user exits File Picker without selecting an directory - * and previously none where select a "Auto backup" checkbox is removed - */ - ActivityResultLauncher selectAutoBackupDir = registerForActivityResult(new ActivityResultContracts.OpenDocumentTree(), result -> { - if (result != null) { - getActivity().getContentResolver().takePersistableUriPermission(result, Intent.FLAG_GRANT_READ_URI_PERMISSION|Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - autoBackupDir.setSummary(result.getLastPathSegment()); - SharedPreferences.Editor editor = prefs.edit(); - editor.putString("backupDir", result.toString()); - editor.commit(); - } else { - if (prefs.getString("backupDir", null) == null) { - this.autoBackup.setChecked(false); - } - } - }); - - private class onClickListenerImportBackup implements Preference.OnPreferenceClickListener { - @Override - public boolean onPreferenceClick(Preference preference) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - importBackup(); - } else { - if (ContextCompat.checkSelfPermission(getContext(), Manifest.permission.READ_EXTERNAL_STORAGE) - == PackageManager.PERMISSION_GRANTED) { - importBackup(); - } else { - requestPermissionImportLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE); - } - } - return true; - } - } - - private class onClickListenerExportBackup implements Preference.OnPreferenceClickListener { - @Override - public boolean onPreferenceClick(Preference preference) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - exportBackup(); - } else { - if (ContextCompat.checkSelfPermission(getContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) - == PackageManager.PERMISSION_GRANTED) { - exportBackup(); - } else { - requestPermissionExportLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE); - } - } - - return true; - } - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - - if (resultCode != RESULT_OK || data == null) { - return; - } - - OpenScale openScale = OpenScale.getInstance(); - - switch (requestCode) { - case IMPORT_DATA_REQUEST: - Uri importURI = data.getData(); - - try { - openScale.importDatabase(importURI); - Toast.makeText(getActivity().getApplicationContext(), getResources().getString(R.string.info_data_imported) + " " + importURI.getPath(), Toast.LENGTH_SHORT).show(); - } catch (IOException e) { - Toast.makeText(getActivity().getApplicationContext(), getResources().getString(R.string.error_importing) + " " + e.getMessage(), Toast.LENGTH_LONG).show(); - return; - } - break; - - case EXPORT_DATA_REQUEST: - Uri exportURI = data.getData(); - - try { - openScale.exportDatabase(exportURI); - Toast.makeText(getActivity().getApplicationContext(), getResources().getString(R.string.info_data_exported) + " " + exportURI.getPath(), Toast.LENGTH_SHORT).show(); - } catch (IOException e) { - Toast.makeText(getActivity().getApplicationContext(), getResources().getString(R.string.error_exporting) + " " + e.getMessage(), Toast.LENGTH_LONG).show(); - return; - } - break; - } - } - - private boolean importBackup() { - Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.addCategory(Intent.CATEGORY_OPENABLE); - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - intent.setType("*/*"); - - startActivityForResult( - Intent.createChooser(intent, getResources().getString(R.string.label_import)), - IMPORT_DATA_REQUEST); - - return true; - } - - private boolean exportBackup() { - Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); - intent.addCategory(Intent.CATEGORY_OPENABLE); - intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - intent.putExtra(Intent.EXTRA_TITLE, "openScale.db"); - intent.setType("*/*"); - - startActivityForResult(intent, EXPORT_DATA_REQUEST); - - return true; - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - menu.clear(); - } - - private ActivityResultLauncher requestPermissionImportLauncher = - registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> { - if (isGranted) { - importBackup(); - } - else { - Toast.makeText(getContext(), getResources().getString(R.string.permission_not_granted), Toast.LENGTH_SHORT).show(); - } - }); - - private ActivityResultLauncher requestPermissionExportLauncher = - registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> { - if (isGranted) { - if (isAutoBackupAskForPermission) { - autoBackup.setChecked(true); - } else { - exportBackup(); - } - } - else { - if (isAutoBackupAskForPermission) { - autoBackup.setChecked(false); - } - - Toast.makeText(getContext(), getResources().getString(R.string.permission_not_granted), Toast.LENGTH_SHORT).show(); - } - }); -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/preferences/BluetoothPreferences.java b/android_app/app/src/main/java/com/health/openscale/gui/preferences/BluetoothPreferences.java deleted file mode 100644 index 1fecb99b..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/preferences/BluetoothPreferences.java +++ /dev/null @@ -1,104 +0,0 @@ -/* Copyright (C) 2014 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.gui.preferences; - -import android.content.SharedPreferences; -import android.os.Bundle; -import android.text.TextUtils; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.lifecycle.Observer; -import androidx.navigation.NavDirections; -import androidx.navigation.Navigation; -import androidx.navigation.fragment.NavHostFragment; -import androidx.preference.Preference; -import androidx.preference.PreferenceFragmentCompat; - -import com.health.openscale.R; - - -public class BluetoothPreferences extends PreferenceFragmentCompat { - private static final String PREFERENCE_KEY_BLUETOOTH_SCANNER = "btScanner"; - - private Preference btScanner; - - private static final String formatDeviceName(String name, String address) { - if (TextUtils.isEmpty(name) && !address.isEmpty()) { - return String.format("[%s]", address); - } - if (name.isEmpty() || address.isEmpty()) { - return "-"; - } - return String.format("%s [%s]", name, address); - } - - private String getCurrentDeviceName() { - SharedPreferences prefs = getPreferenceManager().getSharedPreferences(); - return formatDeviceName( - prefs.getString(BluetoothSettingsFragment.PREFERENCE_KEY_BLUETOOTH_DEVICE_NAME, ""), - prefs.getString(BluetoothSettingsFragment.PREFERENCE_KEY_BLUETOOTH_HW_ADDRESS, "")); - } - - @Override - public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { - setPreferencesFromResource(R.xml.bluetooth_preferences, rootKey); - - setHasOptionsMenu(true); - - btScanner = (Preference) findPreference(PREFERENCE_KEY_BLUETOOTH_SCANNER); - - btScanner.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - NavDirections action = BluetoothPreferencesDirections.actionNavBluetoothPreferencesToNavBluetoothSettings(); - Navigation.findNavController(requireActivity(), R.id.nav_host_fragment).navigate(action); - return true; - } - }); - - btScanner.setSummary(getCurrentDeviceName()); - } - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - - View view = super.onCreateView(inflater, container, savedInstanceState); - - NavHostFragment navHostFragment = (NavHostFragment) getActivity().getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment); - navHostFragment.getNavController().getCurrentBackStackEntry().getSavedStateHandle().getLiveData("update", false).observe(getViewLifecycleOwner(), new Observer() { - @Override - public void onChanged(Boolean aBoolean) { - if (aBoolean) { - btScanner.setSummary(getCurrentDeviceName()); - } - } - }); - - return view; - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - menu.clear(); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/preferences/BluetoothSettingsFragment.java b/android_app/app/src/main/java/com/health/openscale/gui/preferences/BluetoothSettingsFragment.java deleted file mode 100644 index 8a4ee30a..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/preferences/BluetoothSettingsFragment.java +++ /dev/null @@ -1,546 +0,0 @@ -/* Copyright (C) 2019 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.preferences; - -import static android.content.Context.LOCATION_SERVICE; - -import android.Manifest; -import android.app.AlertDialog; -import android.app.Dialog; -import android.bluetooth.BluetoothAdapter; -import android.bluetooth.BluetoothDevice; -import android.bluetooth.BluetoothManager; -import android.bluetooth.le.ScanResult; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.graphics.Color; -import android.graphics.PorterDuff; -import android.location.LocationManager; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.os.Message; -import android.preference.PreferenceManager; -import android.provider.Settings; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.TextUtils; -import android.text.style.ForegroundColorSpan; -import android.text.style.RelativeSizeSpan; -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.ProgressBar; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.core.content.ContextCompat; -import androidx.fragment.app.Fragment; -import androidx.navigation.Navigation; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.bluetooth.BluetoothCommunication; -import com.health.openscale.core.bluetooth.BluetoothFactory; -import com.health.openscale.gui.utils.ColorUtil; -import com.welie.blessed.BluetoothCentralManager; -import com.welie.blessed.BluetoothCentralManagerCallback; -import com.welie.blessed.BluetoothPeripheral; - -import java.util.HashMap; -import java.util.Map; - -import timber.log.Timber; - -public class BluetoothSettingsFragment extends Fragment { - public static final String PREFERENCE_KEY_BLUETOOTH_DEVICE_NAME = "btDeviceName"; - public static final String PREFERENCE_KEY_BLUETOOTH_HW_ADDRESS = "btHwAddress"; - - private Map foundDevices = new HashMap<>(); - - private LinearLayout deviceListView; - private TextView txtSearching; - private ProgressBar progressBar; - private Handler progressHandler; - private BluetoothCentralManager central; - private Context context; - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View root = inflater.inflate(R.layout.fragment_bluetoothsettings, container, false); - - setHasOptionsMenu(true); - - deviceListView = root.findViewById(R.id.deviceListView); - txtSearching = root.findViewById(R.id.txtSearching); - progressBar = root.findViewById(R.id.progressBar); - - context = root.getContext(); - - return root; - } - - @Override - public void onPause() { - stopBluetoothDiscovery(); - super.onPause(); - } - - @Override - public void onResume() { - super.onResume(); - - Timber.d("Bluetooth settings Bluetooth permission check"); - - int targetSdkVersion = context.getApplicationInfo().targetSdkVersion; - - final BluetoothManager bluetoothManager = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE); - BluetoothAdapter btAdapter = bluetoothManager.getAdapter(); - - // Check if Bluetooth is enabled - if (btAdapter == null || !btAdapter.isEnabled()) { - Timber.d("Bluetooth is not enabled"); - Toast.makeText(getContext(), "Bluetooth " + getContext().getResources().getString(R.string.info_is_not_enable), Toast.LENGTH_SHORT).show(); - stepNavigationBack(); - return; - } - - // Check if Bluetooth 4.x is available - if (!context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) { - Timber.d("No Bluetooth 4.x available"); - Toast.makeText(getContext(), "Bluetooth 4.x " + getContext().getResources().getString(R.string.info_is_not_available), Toast.LENGTH_SHORT).show(); - stepNavigationBack(); - return; - } - - // Check if GPS or Network location service is enabled - LocationManager locationManager = (LocationManager) context.getSystemService(LOCATION_SERVICE); - if (!(locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER))) { - Timber.d("No GPS or Network location service is enabled, ask user for permission"); - - AlertDialog.Builder builder = new AlertDialog.Builder(context); - builder.setTitle(R.string.permission_bluetooth_info_title); - builder.setIcon(R.drawable.ic_preferences_about); - builder.setMessage(R.string.permission_location_service_info); - builder.setPositiveButton(R.string.label_ok, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialogInterface, int i) { - // Show location settings when the user acknowledges the alert dialog - Intent intent = new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS); - context.startActivity(intent); - } - }); - builder.setNegativeButton(R.string.label_no, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialogInterface, int i) { - stepNavigationBack(); - } - }); - - Dialog alertDialog = builder.create(); - alertDialog.setCanceledOnTouchOutside(false); - alertDialog.show(); - return; - } - - String[] requiredPermissions; - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && targetSdkVersion >= Build.VERSION_CODES.S) { - Timber.d("SDK >= 31 request for Bluetooth Scan and Bluetooth connect permissions"); - requiredPermissions = new String[]{Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT}; - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && targetSdkVersion >= Build.VERSION_CODES.Q) { - Timber.d("SDK >= 29 request for Access fine location permission"); - requiredPermissions = new String[]{Manifest.permission.ACCESS_FINE_LOCATION}; - } else { - Timber.d("SDK < 29 request for coarse location permission"); - requiredPermissions = new String[]{Manifest.permission.ACCESS_FINE_LOCATION}; - } - - if (hasPermissions(requiredPermissions)) { - startBluetoothDiscovery(); - } else if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)) { - AlertDialog.Builder builder = new AlertDialog.Builder(context); - Timber.d("No access fine location permission granted"); - - builder.setMessage(R.string.permission_bluetooth_info) - .setTitle(R.string.permission_bluetooth_info_title) - .setIcon(R.drawable.ic_preferences_about) - .setPositiveButton(R.string.label_ok, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.dismiss(); - requestPermissionBluetoothLauncher.launch(requiredPermissions); - } - }); - - Dialog alertDialog = builder.create(); - alertDialog.setCanceledOnTouchOutside(false); - alertDialog.show(); - - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && targetSdkVersion >= Build.VERSION_CODES.S && shouldShowRequestPermissionRationale(Manifest.permission.BLUETOOTH_SCAN)) { - AlertDialog.Builder builder = new AlertDialog.Builder(context); - Timber.d("No access Bluetooth scan permission granted"); - - builder.setMessage(R.string.permission_bluetooth_info) - .setTitle(R.string.permission_bluetooth_info_title) - .setIcon(R.drawable.ic_preferences_about) - .setPositiveButton(R.string.label_ok, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.dismiss(); - requestPermissionBluetoothLauncher.launch(requiredPermissions); - } - }); - - Dialog alertDialog = builder.create(); - alertDialog.setCanceledOnTouchOutside(false); - alertDialog.show(); - - } else { - requestPermissionBluetoothLauncher.launch(requiredPermissions); - } - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - menu.clear(); - } - - private static final String formatDeviceName(String name, String address) { - if (TextUtils.isEmpty(name) && !address.isEmpty()) { - return String.format("[%s]", address); - } - if (name.isEmpty() || address.isEmpty()) { - return "-"; - } - return String.format("%s [%s]", name, address); - } - - private static final String formatDeviceName(BluetoothDevice device) { - return formatDeviceName(device.getName(), device.getAddress()); - } - - private final BluetoothCentralManagerCallback bluetoothCentralCallback = new BluetoothCentralManagerCallback() { - @Override - public void onDiscoveredPeripheral(BluetoothPeripheral peripheral, ScanResult scanResult) { - new Handler().post(new Runnable() { - @Override - public void run() { - onDeviceFound(scanResult); - } - }); - } - }; - - private void startBluetoothDiscovery() { - deviceListView.removeAllViews(); - foundDevices.clear(); - - central = new BluetoothCentralManager(requireContext(), bluetoothCentralCallback, new Handler(Looper.getMainLooper())); - central.scanForPeripherals(); - - txtSearching.setVisibility(View.VISIBLE); - txtSearching.setText(R.string.label_bluetooth_searching); - progressBar.setVisibility(View.VISIBLE); - - progressHandler = new Handler(); - - // Don't let the BLE discovery run forever - progressHandler.postDelayed(new Runnable() { - @Override - public void run() { - stopBluetoothDiscovery(); - - txtSearching.setText(R.string.label_bluetooth_searching_finished); - progressBar.setVisibility(View.GONE); - - new Handler().post(new Runnable() { - @Override - public void run() { - try { - BluetoothDeviceView notSupported = new BluetoothDeviceView(requireContext()); - notSupported.setDeviceName(requireContext().getString(R.string.label_scale_not_supported)); - notSupported.setSummaryText(requireContext().getString(R.string.label_click_to_help_add_support)); - notSupported.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - Intent notSupportedIntent = new Intent(Intent.ACTION_VIEW); - notSupportedIntent.setData( - Uri.parse("https://github.com/oliexdev/openScale/wiki/Supported-scales-in-openScale")); - - startActivity(notSupportedIntent); - } - }); - deviceListView.addView(notSupported); - } catch(IllegalStateException ex) { - Timber.e(ex.getMessage()); - } - } - }); - } - }, 20 * 1000); - } - - private void stopBluetoothDiscovery() { - if (progressHandler != null) { - progressHandler.removeCallbacksAndMessages(null); - progressHandler = null; - } - - if (central != null) { - central.stopScan(); - } - } - - private void onDeviceFound(final ScanResult bleScanResult) { - BluetoothDevice device = bleScanResult.getDevice(); - Context context = getContext(); - - if (foundDevices.containsKey(device.getAddress()) || context == null) { - return; - } - - String deviceName = device.getName(); - if (deviceName == null) { - deviceName = BluetoothFactory.convertNoNameToDeviceName(bleScanResult.getScanRecord().getManufacturerSpecificData()); - } - if (deviceName == null) { - return; - } - - BluetoothDeviceView deviceView = new BluetoothDeviceView(context); - deviceView.setDeviceName(formatDeviceName(deviceName, device.getAddress())); - deviceView.setAlias(deviceName); - - BluetoothCommunication btDevice = BluetoothFactory.createDeviceDriver(context, deviceName); - if (btDevice != null) { - Timber.d("Found supported device %s (driver: %s)", - formatDeviceName(device), btDevice.driverName()); - deviceView.setDeviceAddress(device.getAddress()); - deviceView.setIcon(R.drawable.ic_bluetooth_device_supported); - deviceView.setSummaryText(btDevice.driverName()); - } - else { - Timber.d("Found unsupported device %s", - formatDeviceName(device)); - deviceView.setIcon(R.drawable.ic_bluetooth_device_not_supported); - deviceView.setSummaryText(context.getString(R.string.label_bt_device_no_support)); - deviceView.setEnabled(false); - - if (OpenScale.DEBUG_MODE) { - deviceView.setEnabled(true); - deviceView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - getDebugInfo(device); - } - }); - } - } - - foundDevices.put(device.getAddress(), btDevice != null ? device : null); - deviceListView.addView(deviceView); - } - - private void getDebugInfo(final BluetoothDevice device) { - AlertDialog.Builder builder = new AlertDialog.Builder(requireContext()); - builder.setTitle("Fetching info") - .setMessage("Please wait while we fetch extended info from your scale...") - .setNegativeButton("Cancel", new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - OpenScale.getInstance().disconnectFromBluetoothDevice(); - dialog.dismiss(); - } - }); - - final AlertDialog dialog = builder.create(); - - Handler btHandler = new Handler() { - @Override - public void handleMessage(Message msg) { - switch (BluetoothCommunication.BT_STATUS.values()[msg.what]) { - case CONNECTION_LOST: - OpenScale.getInstance().disconnectFromBluetoothDevice(); - dialog.dismiss(); - break; - } - } - }; - - dialog.show(); - - String macAddress = device.getAddress(); - stopBluetoothDiscovery(); - OpenScale.getInstance().connectToBluetoothDeviceDebugMode(macAddress, btHandler); - } - - private class BluetoothDeviceView extends LinearLayout implements View.OnClickListener { - - private TextView deviceName; - private ImageView deviceIcon; - private String deviceAddress; - private String deviceAlias; - - public BluetoothDeviceView(Context context) { - super(context); - - LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); - - layoutParams.setMargins(0, 20, 0, 20); - setLayoutParams(layoutParams); - - deviceName = new TextView(context); - deviceName.setLines(2); - deviceIcon = new ImageView(context);; - - LinearLayout.LayoutParams centerLayoutParams = new LinearLayout.LayoutParams( - LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT); - layoutParams.gravity= Gravity.CENTER; - - deviceIcon.setLayoutParams(centerLayoutParams); - deviceName.setLayoutParams(centerLayoutParams); - - deviceName.setOnClickListener(this); - deviceIcon.setOnClickListener(this); - setOnClickListener(this); - - addView(deviceIcon); - addView(deviceName); - } - - public void setAlias(String alias) { - deviceAlias = alias; - } - - public String getAlias() { - return deviceAlias; - } - - public void setDeviceAddress(String address) { - deviceAddress = address; - } - - public String getDeviceAddress() { - return deviceAddress; - } - - public void setDeviceName(String name) { - deviceName.setText(name); - } - - public void setSummaryText(String text) { - SpannableStringBuilder stringBuilder = new SpannableStringBuilder(new String()); - - stringBuilder.append(deviceName.getText()); - stringBuilder.append("\n"); - - int deviceNameLength = deviceName.getText().length(); - - stringBuilder.append(text); - stringBuilder.setSpan(new ForegroundColorSpan(Color.GRAY), deviceNameLength, deviceNameLength + text.length()+1, - Spanned.SPAN_INCLUSIVE_INCLUSIVE); - stringBuilder.setSpan(new RelativeSizeSpan(0.8f), deviceNameLength, deviceNameLength + text.length()+1, - Spanned.SPAN_INCLUSIVE_INCLUSIVE); - - deviceName.setText(stringBuilder); - } - - public void setIcon(int resId) { - deviceIcon.setImageResource(resId); - - int tintColor = ColorUtil.getTintColor(requireContext()); - deviceIcon.setColorFilter(tintColor, PorterDuff.Mode.SRC_IN); - } - - @Override - public void setOnClickListener(OnClickListener listener) { - super.setOnClickListener(listener); - deviceName.setOnClickListener(listener); - deviceIcon.setOnClickListener(listener); - } - - @Override - public void setEnabled(boolean status) { - super.setEnabled(status); - deviceName.setEnabled(status); - deviceIcon.setEnabled(status); - } - - @Override - public void onClick(View view) { - BluetoothDevice device = foundDevices.get(getDeviceAddress()); - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); - - prefs.edit() - .putString(PREFERENCE_KEY_BLUETOOTH_HW_ADDRESS, device.getAddress()) - .putString(PREFERENCE_KEY_BLUETOOTH_DEVICE_NAME, getAlias()) - .apply(); - - Timber.d("Saved Bluetooth device " + getAlias() + " with address " + device.getAddress()); - - stopBluetoothDiscovery(); - - stepNavigationBack(); - } - } - - private void stepNavigationBack() { - if (getActivity().findViewById(R.id.nav_host_fragment) != null) { - Navigation.findNavController(requireActivity(), R.id.nav_host_fragment).getPreviousBackStackEntry().getSavedStateHandle().set("update", true); - Navigation.findNavController(requireActivity(), R.id.nav_host_fragment).navigateUp(); - } else { - getActivity().finish(); - } - } - - private boolean hasPermissions(String[] permissions) { - if (permissions != null) { - for (String permission : permissions) { - if (ContextCompat.checkSelfPermission(getContext(), permission) != PackageManager.PERMISSION_GRANTED) { - Timber.d("Permission is not granted: " + permission); - return false; - } - Timber.d("Permission already granted: " + permission); - } - return true; - } - return false; - } - - private ActivityResultLauncher requestPermissionBluetoothLauncher = - registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), isGranted -> { - if (isGranted.containsValue(false)) { - Timber.d("At least one Bluetooth permission was not granted"); - Toast.makeText(requireContext(), getString(R.string.label_bluetooth_title) + ": " + getString(R.string.permission_not_granted), Toast.LENGTH_SHORT).show(); - stepNavigationBack(); - } - else { - startBluetoothDiscovery(); - } - }); -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/preferences/GeneralPreferences.java b/android_app/app/src/main/java/com/health/openscale/gui/preferences/GeneralPreferences.java deleted file mode 100644 index 62ed8716..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/preferences/GeneralPreferences.java +++ /dev/null @@ -1,63 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.preferences; - -import android.os.Bundle; -import android.view.Menu; -import android.view.MenuInflater; - -import androidx.appcompat.app.AppCompatDelegate; -import androidx.preference.ListPreference; -import androidx.preference.Preference; -import androidx.preference.PreferenceFragmentCompat; - -import com.health.openscale.R; - -public class GeneralPreferences extends PreferenceFragmentCompat { - @Override - public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { - setPreferencesFromResource(R.xml.general_preferences, rootKey); - - setHasOptionsMenu(true); - - final ListPreference prefTheme = findPreference("app_theme"); - prefTheme.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - if (newValue.equals("Dark")) { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); - } else { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); - } - return true; - } - }); - - final ListPreference prefLanguage = findPreference("language"); - prefLanguage.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - getActivity().recreate(); - return true; - } - }); - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - menu.clear(); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/preferences/GraphPreferences.java b/android_app/app/src/main/java/com/health/openscale/gui/preferences/GraphPreferences.java deleted file mode 100644 index c21ddac0..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/preferences/GraphPreferences.java +++ /dev/null @@ -1,68 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.preferences; - -import android.os.Bundle; -import android.view.Menu; -import android.view.MenuInflater; - -import androidx.preference.DropDownPreference; -import androidx.preference.Preference; -import androidx.preference.PreferenceFragmentCompat; -import androidx.preference.SeekBarPreference; - -import com.health.openscale.R; -import com.health.openscale.gui.measurement.ChartMeasurementView; - -public class GraphPreferences extends PreferenceFragmentCompat { - @Override - public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { - setPreferencesFromResource(R.xml.graph_preferences, rootKey); - - setHasOptionsMenu(true); - - DropDownPreference trendlinePreference = findPreference("trendlineComputationMethod"); - SeekBarPreference simpleMovingAveragePreference = findPreference("simpleMovingAverageNumDays"); - - simpleMovingAveragePreference.setVisible( - trendlinePreference.getValue().equals( - ChartMeasurementView.COMPUTATION_METHOD_SIMPLE_MOVING_AVERAGE - ) - ); - - trendlinePreference.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - String selectedValue = (String) newValue; - boolean simpleMovingAverageEnabled = selectedValue.equals( - ChartMeasurementView.COMPUTATION_METHOD_SIMPLE_MOVING_AVERAGE - ); - - // hide selector of the number of days when simple moving average is not selected - simpleMovingAveragePreference.setVisible(simpleMovingAverageEnabled); - // scroll to the bottom to show the new preference to the user - getListView().scrollToPosition(getListView().getChildCount()); - - return true; - } - }); - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - menu.clear(); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/preferences/MainPreferences.java b/android_app/app/src/main/java/com/health/openscale/gui/preferences/MainPreferences.java deleted file mode 100644 index 633dd371..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/preferences/MainPreferences.java +++ /dev/null @@ -1,147 +0,0 @@ -/* Copyright (C) 2020 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.gui.preferences; - -import android.graphics.drawable.Drawable; -import android.os.Bundle; -import android.util.TypedValue; -import android.view.Menu; -import android.view.MenuInflater; - -import androidx.core.content.ContextCompat; -import androidx.core.graphics.drawable.DrawableCompat; -import androidx.navigation.NavDirections; -import androidx.navigation.Navigation; -import androidx.preference.Preference; -import androidx.preference.PreferenceFragmentCompat; -import androidx.preference.PreferenceGroup; - -import com.health.openscale.R; - -public class MainPreferences extends PreferenceFragmentCompat { - - @Override - public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { - setPreferencesFromResource(R.xml.main_preferences, rootKey); - - setHasOptionsMenu(true); - - TypedValue typedValue = new TypedValue(); - getContext().getTheme().resolveAttribute(R.attr.colorControlNormal, typedValue, true); - int color = ContextCompat.getColor(getContext(), typedValue.resourceId); - - tintIcons(getPreferenceScreen(), color); - - final Preference prefBackup = findPreference("backup"); - prefBackup.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - NavDirections action = MainPreferencesDirections.actionNavMainPreferencesToNavBackupPreferences(); - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigate(action); - return true; - } - }); - - final Preference prefBluetooth = findPreference("bluetooth"); - prefBluetooth.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - NavDirections action = MainPreferencesDirections.actionNavMainPreferencesToNavBluetoothPreferences(); - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigate(action); - return true; - } - }); - - final Preference prefGeneral = findPreference("general"); - prefGeneral.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - NavDirections action = MainPreferencesDirections.actionNavMainPreferencesToNavGeneralPreferences(); - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigate(action); - return true; - } - }); - - final Preference prefGraph = findPreference("graph"); - prefGraph.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - NavDirections action = MainPreferencesDirections.actionNavMainPreferencesToNavGraphPreferences(); - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigate(action); - return true; - } - }); - - final Preference prefMeasurements = findPreference("measurements"); - prefMeasurements.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - NavDirections action = MainPreferencesDirections.actionNavMainPreferencesToNavMeasurementPreferences(); - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigate(action); - return true; - } - }); - - final Preference prefReminder = findPreference("reminder"); - prefReminder.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - NavDirections action = MainPreferencesDirections.actionNavMainPreferencesToNavReminderPreferences(); - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigate(action); - return true; - } - }); - - final Preference prefUsers = findPreference("users"); - prefUsers.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - NavDirections action = MainPreferencesDirections.actionNavMainPreferencesToNavUserPreferences(); - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigate(action); - return true; - } - }); - - final Preference prefAbout = findPreference("about"); - prefAbout.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - NavDirections action = MainPreferencesDirections.actionNavMainPreferencesToNavAboutPreferences(); - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigate(action); - return true; - } - }); - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - menu.clear(); - } - - private static void tintIcons(Preference preference, int color) { - if (preference instanceof PreferenceGroup) { - PreferenceGroup group = ((PreferenceGroup) preference); - for (int i = 0; i < group.getPreferenceCount(); i++) { - tintIcons(group.getPreference(i), color); - } - } else { - Drawable icon = preference.getIcon(); - if (icon != null) { - DrawableCompat.setTint(icon, color); - } - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/preferences/MeasurementDetailPreferences.java b/android_app/app/src/main/java/com/health/openscale/gui/preferences/MeasurementDetailPreferences.java deleted file mode 100644 index e1ce54c2..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/preferences/MeasurementDetailPreferences.java +++ /dev/null @@ -1,52 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* Copyright (C) 2018 Erik Johansson -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.preferences; - -import android.os.Bundle; -import android.view.Menu; -import android.view.MenuInflater; - -import androidx.preference.PreferenceFragmentCompat; -import androidx.preference.PreferenceScreen; - -import com.health.openscale.R; -import com.health.openscale.gui.measurement.MeasurementView; - -public class MeasurementDetailPreferences extends PreferenceFragmentCompat { - - private static MeasurementView measurementView; - - @Override - public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { - setPreferencesFromResource(R.xml.measurement_detail_preferences, rootKey); - - setHasOptionsMenu(true); - - final PreferenceScreen screen = getPreferenceManager().createPreferenceScreen(getActivity()); - measurementView.prepareExtraPreferencesScreen(screen); - setPreferenceScreen(screen); - } - - public static void setMeasurementView(MeasurementView view) { - measurementView = view; - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - menu.clear(); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/preferences/MeasurementPreferences.java b/android_app/app/src/main/java/com/health/openscale/gui/preferences/MeasurementPreferences.java deleted file mode 100644 index 7e6a09c2..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/preferences/MeasurementPreferences.java +++ /dev/null @@ -1,337 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* Copyright (C) 2018 Erik Johansson -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.preferences; - -import android.app.AlertDialog; -import android.content.Context; -import android.content.DialogInterface; -import android.graphics.Point; -import android.graphics.PorterDuff; -import android.graphics.drawable.Drawable; -import android.os.Bundle; -import android.view.DragEvent; -import android.view.GestureDetector; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MotionEvent; -import android.view.View; -import android.widget.CompoundButton; -import android.widget.ImageView; -import android.widget.Switch; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.navigation.NavDirections; -import androidx.navigation.Navigation; -import androidx.preference.Preference; -import androidx.preference.PreferenceCategory; -import androidx.preference.PreferenceFragmentCompat; -import androidx.preference.PreferenceGroup; -import androidx.preference.PreferenceManager; -import androidx.preference.PreferenceViewHolder; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.gui.measurement.MeasurementView; -import com.health.openscale.gui.measurement.WeightMeasurementView; - -import java.util.ArrayList; -import java.util.List; - -public class MeasurementPreferences extends PreferenceFragmentCompat { - private static final String PREFERENCE_KEY_DELETE_ALL = "deleteAll"; - private static final String PREFERENCE_KEY_RESET_ORDER = "resetOrder"; - private static final String PREFERENCE_KEY_MEASUREMENTS = "measurements"; - - private PreferenceCategory measurementCategory; - - @Override - public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { - setPreferencesFromResource(R.xml.measurement_preferences, rootKey); - - setHasOptionsMenu(true); - - Preference deleteAll = findPreference(PREFERENCE_KEY_DELETE_ALL); - deleteAll.setOnPreferenceClickListener(new onClickListenerDeleteAll()); - - measurementCategory = (PreferenceCategory) findPreference(PREFERENCE_KEY_MEASUREMENTS); - - Preference resetOrder = findPreference(PREFERENCE_KEY_RESET_ORDER); - resetOrder.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - PreferenceManager.getDefaultSharedPreferences(getActivity()).edit() - .remove(MeasurementView.PREF_MEASUREMENT_ORDER).apply(); - updateMeasurementPreferences(); - return true; - } - }); - - updateMeasurementPreferences(); - } - - private void updateMeasurementPreferences() { - measurementCategory.removeAll(); - - List measurementViews = MeasurementView.getMeasurementList( - getActivity(), MeasurementView.DateTimeOrder.NONE); - - for (MeasurementView measurement : measurementViews) { - Preference preference = new MeasurementOrderPreference( - getActivity(), measurementCategory, measurement); - - measurementCategory.addPreference(preference); - } - } - - private class onClickListenerDeleteAll implements Preference.OnPreferenceClickListener { - @Override - public boolean onPreferenceClick(Preference preference) { - - AlertDialog.Builder deleteAllDialog = new AlertDialog.Builder(getActivity()); - - deleteAllDialog.setMessage(getResources().getString(R.string.question_really_delete_all)); - - deleteAllDialog.setPositiveButton(getResources().getString(R.string.label_yes), new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - OpenScale openScale = OpenScale.getInstance(); - int selectedUserId = openScale.getSelectedScaleUserId(); - - openScale.clearScaleMeasurements(selectedUserId); - - Toast.makeText(getActivity().getApplicationContext(), getResources().getString(R.string.info_data_all_deleted), Toast.LENGTH_SHORT).show(); - } - }); - - deleteAllDialog.setNegativeButton(getResources().getString(R.string.label_no), new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.dismiss(); - } - }); - - deleteAllDialog.show(); - - return false; - } - } - - private class MeasurementOrderPreference extends Preference - implements GestureDetector.OnGestureListener { - PreferenceGroup parentGroup; - MeasurementView measurement; - - GestureDetector gestureDetector; - - View boundView; - ImageView iconView; - TextView textView; - TextView summaryView; - Switch switchView; - ImageView reorderView; - ImageView settingsView; - - MeasurementOrderPreference(Context context, PreferenceGroup parent, MeasurementView measurementView) { - super(context); - parentGroup = parent; - measurement = measurementView; - - gestureDetector = new GestureDetector(getContext(), this); - gestureDetector.setIsLongpressEnabled(true); - - setLayoutResource(R.layout.preference_measurement_order); - } - - @Override - public PreferenceGroup getParent() { - return parentGroup; - } - - @Override - public void onBindViewHolder(PreferenceViewHolder holder) { - super.onBindViewHolder(holder); - boundView = holder.itemView; - - textView = (TextView)holder.findViewById(R.id.textView); - summaryView = (TextView)holder.findViewById(R.id.summaryView); - iconView = (ImageView)holder.findViewById(R.id.iconView); - switchView = (Switch)holder.findViewById(R.id.switchView); - reorderView = (ImageView)holder.findViewById(R.id.reorderView); - settingsView = (ImageView)holder.findViewById(R.id.settingsView); - - textView.setText(measurement.getName()); - summaryView.setText(measurement.getPreferenceSummary()); - Drawable icon = measurement.getIcon(); - icon.setColorFilter(measurement.getForegroundColor(), PorterDuff.Mode.SRC_IN); - iconView.setImageDrawable(icon); - - switchView.setChecked(measurement.getSettings().isEnabledIgnoringDependencies()); - - setKey(measurement.getSettings().getEnabledKey()); - setDefaultValue(measurement.getSettings().isEnabledIgnoringDependencies()); - setPersistent(true); - - setEnableView(measurement.getSettings().areDependenciesEnabled() && switchView.isChecked()); - - if (measurement instanceof WeightMeasurementView) { - switchView.setVisibility(View.INVISIBLE); - } else { - switchView.setVisibility(View.VISIBLE); - } - - switchView.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { - @Override - public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { - if (buttonView.isPressed()) { - persistBoolean(isChecked); - setEnableView(isChecked); - - for (int i = 0; i < getParent().getPreferenceCount(); ++i) { - MeasurementOrderPreference preference = (MeasurementOrderPreference) getParent().getPreference(i); - preference.setEnabled(preference.measurement.getSettings().areDependenciesEnabled()); - } - } - } - }); - - boundView.setOnTouchListener(new View.OnTouchListener() { - @Override - public boolean onTouch(View v, MotionEvent event) { - return gestureDetector.onTouchEvent(event); - } - }); - - boundView.setOnDragListener(new onDragListener()); - } - - private void setEnableView(boolean status) { - if(status) { - textView.setEnabled(true); - summaryView.setEnabled(true); - reorderView.setEnabled(true); - settingsView.setEnabled(true); - } else { - textView.setEnabled(false); - summaryView.setEnabled(false); - reorderView.setEnabled(false); - settingsView.setEnabled(false); - } - } - - @Override - public boolean onDown(MotionEvent e) { - return isEnabled(); - } - - @Override - public void onShowPress(MotionEvent e) { - boundView.setPressed(true); - } - - @Override - public boolean onSingleTapUp(MotionEvent e) { - boundView.setPressed(false); - - // Must be enabled to show extra preferences screen - if (!measurement.getSettings().isEnabled()) { - return true; - } - - // HACK to pass an object using navigation controller - MeasurementDetailPreferences.setMeasurementView(measurement); - - NavDirections action = MeasurementPreferencesDirections.actionNavMeasurementPreferencesToNavMeasurementDetailPreferences(); - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigate(action); - - return true; - } - - @Override - public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { - return false; - } - - @Override - public void onLongPress(MotionEvent event) { - int x = Math.round(event.getX()); - int y = Math.round(event.getY()); - - boundView.startDrag(null, new dragShadowBuilder(boundView, x, y), this, 0); - } - - private class dragShadowBuilder extends View.DragShadowBuilder { - private int x; - private int y; - public dragShadowBuilder(View view, int x, int y) { - super(view); - this.x = x; - this.y = y; - } - - @Override - public void onProvideShadowMetrics(Point outShadowSize, Point outShadowTouchPoint) { - super.onProvideShadowMetrics(outShadowSize, outShadowTouchPoint); - outShadowTouchPoint.set(x, y); - } - } - - @Override - public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { - return false; - } - - private class onDragListener implements View.OnDragListener { - @Override - public boolean onDrag(View view, DragEvent event) { - switch (event.getAction()) { - case DragEvent.ACTION_DROP: - MeasurementOrderPreference draggedPref = (MeasurementOrderPreference) event.getLocalState(); - - ArrayList measurementViews = new ArrayList<>(); - for (int i = 0; i < measurementCategory.getPreferenceCount(); i++) { - MeasurementOrderPreference pref = (MeasurementOrderPreference) measurementCategory.getPreference(i); - - if (pref != draggedPref) { - measurementViews.add(pref.measurement); - } - - if (pref.boundView == view) { - measurementViews.add(draggedPref.measurement); - } - } - - measurementCategory.removeAll(); - - for (MeasurementView measurement : measurementViews) { - Preference preference = new MeasurementOrderPreference( - getActivity(), measurementCategory, measurement); - - measurementCategory.addPreference(preference); - } - - MeasurementView.saveMeasurementViewsOrder(getContext(), measurementViews); - break; - } - return true; - } - } - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - menu.clear(); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/preferences/ReminderPreferences.java b/android_app/app/src/main/java/com/health/openscale/gui/preferences/ReminderPreferences.java deleted file mode 100644 index 714b22da..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/preferences/ReminderPreferences.java +++ /dev/null @@ -1,204 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.preferences; - -import android.app.AlertDialog; -import android.app.Dialog; -import android.content.ComponentName; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.provider.Settings; -import android.util.Pair; -import android.view.Menu; -import android.view.MenuInflater; - -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.fragment.app.DialogFragment; -import androidx.preference.CheckBoxPreference; -import androidx.preference.MultiSelectListPreference; -import androidx.preference.Preference; -import androidx.preference.PreferenceFragmentCompat; - -import com.health.openscale.R; -import com.health.openscale.core.alarm.AlarmHandler; -import com.health.openscale.core.alarm.ReminderBootReceiver; - -import java.util.stream.Collectors; -import java.util.stream.IntStream; - -public class ReminderPreferences extends PreferenceFragmentCompat - implements SharedPreferences.OnSharedPreferenceChangeListener { - - public static final String PREFERENCE_KEY_REMINDER_NOTIFY_TEXT = "reminderNotifyText"; - public static final String PREFERENCE_KEY_REMINDER_WEEKDAYS = "reminderWeekdays"; - public static final String PREFERENCE_KEY_REMINDER_TIME = "reminderTime"; - private static final String PREFERENCE_KEY_REMINDER_ENABLE = "reminderEnable"; - - private CheckBoxPreference reminderEnable; - - @Override - public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { - setPreferencesFromResource(R.xml.reminder_preferences, rootKey); - - setHasOptionsMenu(true); - - reminderEnable = (CheckBoxPreference) findPreference(PREFERENCE_KEY_REMINDER_ENABLE); - - final MultiSelectListPreference prefDays = findPreference("reminderWeekdays"); - - prefDays.setSummaryProvider(new Preference.SummaryProvider() { - @Override - public CharSequence provideSummary(MultiSelectListPreference preference) { - final String[] values = getResources().getStringArray(R.array.weekdays_values); - final String[] translated = getResources().getStringArray(R.array.weekdays_entries); - - return IntStream.range(0, values.length) - .mapToObj(i -> new Pair<>(values[i], translated[i])) - .filter(p -> preference.getValues().contains(p.first)) - .map(p -> p.second) - .collect(Collectors.joining(", ")); - } - }); - - updateAlarmPreferences(); - } - - @Override - public void onDisplayPreferenceDialog(Preference preference) { - DialogFragment dialogFragment = null; - - if (preference instanceof TimePreference) { - dialogFragment = TimePreferenceDialog.newInstance(preference.getKey()); - } - - if (dialogFragment != null) { - dialogFragment.setTargetFragment(this, 0); - dialogFragment.show(getParentFragmentManager(), "timePreferenceDialog"); - } else { - super.onDisplayPreferenceDialog(preference); - } - } - - @Override - public void onResume() - { - super.onResume(); - getPreferenceManager().getSharedPreferences().registerOnSharedPreferenceChangeListener(this); - } - - @Override - public void onPause() - { - getPreferenceManager().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(this); - super.onPause(); - } - - @Override - public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) - { - updateAlarmPreferences(); - } - - private void updateAlarmPreferences() - { - if (reminderEnable.isChecked()) { - if (Build.VERSION.SDK_INT >= 33) { - requestPermissionNotificationLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS); - } else { - enableAlarmReminder(); - } - } - else { - disableAlarmReminder(); - } - } - - private void enableAlarmReminder() { - ComponentName receiver = new ComponentName(getActivity().getApplicationContext(), ReminderBootReceiver.class); - PackageManager pm = getActivity().getApplicationContext().getPackageManager(); - - AlarmHandler alarmHandler = new AlarmHandler(); - - alarmHandler.scheduleAlarms(getActivity()); - - pm.setComponentEnabledSetting(receiver, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, - PackageManager.DONT_KILL_APP); - } - - private void disableAlarmReminder() { - ComponentName receiver = new ComponentName(getActivity().getApplicationContext(), ReminderBootReceiver.class); - PackageManager pm = getActivity().getApplicationContext().getPackageManager(); - - AlarmHandler alarmHandler = new AlarmHandler(); - - alarmHandler.disableAllAlarms(getActivity()); - - pm.setComponentEnabledSetting(receiver, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, - PackageManager.DONT_KILL_APP); - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - menu.clear(); - } - - private ActivityResultLauncher requestPermissionNotificationLauncher = - registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> { - if (!isGranted) { - if (Build.VERSION.SDK_INT >= 33) { - if (shouldShowRequestPermissionRationale(android.Manifest.permission.POST_NOTIFICATIONS)) { - AlertDialog.Builder builder = new AlertDialog.Builder(getContext()); - builder.setTitle(R.string.permission_bluetooth_info_title); - builder.setIcon(R.drawable.ic_preferences_about); - builder.setMessage(R.string.permission_notification_info); - builder.setPositiveButton(R.string.label_ok, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialogInterface, int i) { - requestPermissionNotificationLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS); - } - }); - - Dialog alertDialog = builder.create(); - alertDialog.setCanceledOnTouchOutside(false); - alertDialog.show(); - } else { - AlertDialog.Builder builder = new AlertDialog.Builder(getContext()); - builder.setTitle(R.string.permission_bluetooth_info_title); - builder.setIcon(R.drawable.ic_preferences_about); - builder.setMessage(R.string.permission_notification_info); - builder.setPositiveButton(R.string.label_ok, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialogInterface, int i) { - Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + getContext().getPackageName())); - getContext().startActivity(intent); - } - }); - - Dialog alertDialog = builder.create(); - alertDialog.setCanceledOnTouchOutside(false); - alertDialog.show(); - } - } - } else { - enableAlarmReminder(); - } - }); -} - diff --git a/android_app/app/src/main/java/com/health/openscale/gui/preferences/TimePreference.java b/android_app/app/src/main/java/com/health/openscale/gui/preferences/TimePreference.java deleted file mode 100644 index e4539043..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/preferences/TimePreference.java +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright (C) 2020 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.gui.preferences; - -import android.content.Context; -import android.content.res.TypedArray; -import android.text.format.DateFormat; -import android.util.AttributeSet; - -import androidx.preference.DialogPreference; - -import com.health.openscale.R; - -import java.util.Calendar; - -public class TimePreference extends DialogPreference { - - private long timeInMillis; - - public TimePreference(Context context) { - this(context, null); - } - - public TimePreference(Context context, AttributeSet attrs) { - this(context, attrs, R.attr.preferenceStyle); - } - - public TimePreference(Context context, AttributeSet attrs, int defStyleAttr) { - this(context, attrs, defStyleAttr, defStyleAttr); - } - - public TimePreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - - } - - public long getTimeInMillis() { - return timeInMillis; - } - - public void setTimeInMillis(long timeInMillis) { - this.timeInMillis = timeInMillis; - - persistLong(this.timeInMillis); - } - - @Override - protected Object onGetDefaultValue(TypedArray a, int index) { - Calendar defaultTime = Calendar.getInstance(); - defaultTime.set(Calendar.HOUR_OF_DAY, 16); - defaultTime.set(Calendar.MINUTE, 0); - - return defaultTime.getTimeInMillis(); - } - - @Override - public int getDialogLayoutResource() { - return R.layout.preference_timepicker; - } - - - @Override - protected void onSetInitialValue(boolean restore, Object defaultValue) { - setTimeInMillis(restore ? getPersistedLong(timeInMillis) : (long) defaultValue); - } - - @Override - public CharSequence getSummary() { - Calendar calendar = Calendar.getInstance(); - calendar.setTimeInMillis(timeInMillis); - - return (DateFormat.getTimeFormat(getContext()).format(calendar.getTime())); - } - -} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/gui/preferences/TimePreferenceDialog.java b/android_app/app/src/main/java/com/health/openscale/gui/preferences/TimePreferenceDialog.java deleted file mode 100644 index 039b35f6..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/preferences/TimePreferenceDialog.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (C) 2020 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - -package com.health.openscale.gui.preferences; - -import android.os.Build; -import android.os.Bundle; -import android.text.format.DateFormat; -import android.view.View; -import android.widget.TimePicker; - -import androidx.preference.DialogPreference; -import androidx.preference.PreferenceDialogFragmentCompat; - -import com.health.openscale.R; - -import java.util.Calendar; - -public class TimePreferenceDialog extends PreferenceDialogFragmentCompat { - private Calendar calendar; - private TimePicker timePicker; - - public static TimePreferenceDialog newInstance(String key) { - final TimePreferenceDialog fragment = new TimePreferenceDialog(); - final Bundle b = new Bundle(1); - b.putString(ARG_KEY, key); - fragment.setArguments(b); - - return fragment; - } - - @Override - protected void onBindDialogView(View view) { - super.onBindDialogView(view); - - timePicker = view.findViewById(R.id.timePicker); - calendar = Calendar.getInstance(); - - Long timeInMillis = null; - DialogPreference preference = getPreference(); - - if (preference instanceof TimePreference) { - TimePreference timePreference = (TimePreference) preference; - timeInMillis = timePreference.getTimeInMillis(); - } - - if (timeInMillis != null) { - calendar.setTimeInMillis(timeInMillis); - boolean is24hour = DateFormat.is24HourFormat(getContext()); - - timePicker.setIs24HourView(is24hour); - timePicker.setCurrentHour(calendar.get(Calendar.HOUR_OF_DAY)); - timePicker.setCurrentMinute(calendar.get(Calendar.MINUTE)); - } - } - - @Override - public void onDialogClosed(boolean positiveResult) { - if (positiveResult) { - int hours; - int minutes; - - if (Build.VERSION.SDK_INT >= 23) { - hours = timePicker.getHour(); - minutes = timePicker.getMinute(); - } else { - hours = timePicker.getCurrentHour(); - minutes = timePicker.getCurrentMinute(); - } - - calendar.set(Calendar.HOUR_OF_DAY, hours); - calendar.set(Calendar.MINUTE, minutes); - - long timeInMillis = calendar.getTimeInMillis(); - - DialogPreference preference = getPreference(); - if (preference instanceof TimePreference) { - TimePreference timePreference = ((TimePreference) preference); - if (timePreference.callChangeListener(timeInMillis)) { - timePreference.setTimeInMillis(timeInMillis); - timePreference.setSummary(DateFormat.getTimeFormat(getContext()).format(calendar.getTime())); - } - } - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/preferences/UserSettingsFragment.java b/android_app/app/src/main/java/com/health/openscale/gui/preferences/UserSettingsFragment.java deleted file mode 100644 index e07e2167..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/preferences/UserSettingsFragment.java +++ /dev/null @@ -1,518 +0,0 @@ -/* Copyright (C) 2014 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.gui.preferences; - -import android.app.AlertDialog; -import android.content.Context; -import android.content.DialogInterface; -import android.graphics.Color; -import android.graphics.drawable.Drawable; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.CheckBox; -import android.widget.CompoundButton; -import android.widget.EditText; -import android.widget.RadioGroup; -import android.widget.Spinner; -import android.widget.TableRow; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.core.graphics.drawable.DrawableCompat; -import androidx.core.view.MenuProvider; -import androidx.fragment.app.Fragment; -import androidx.lifecycle.Lifecycle; -import androidx.navigation.Navigation; - -import com.google.android.material.datepicker.MaterialDatePicker; -import com.google.android.material.datepicker.MaterialPickerOnPositiveButtonClickListener; -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.core.utils.Converters; - -import java.text.DateFormat; -import java.util.Calendar; -import java.util.Date; -import java.util.List; - -public class UserSettingsFragment extends Fragment { - public enum USER_SETTING_MODE {ADD, EDIT}; - - private USER_SETTING_MODE mode = USER_SETTING_MODE.ADD; - - private Date birthday = new Date(); - private Date goal_date = new Date(); - - private EditText txtUserName; - private EditText txtBodyHeight; - private EditText txtBirthday; - private EditText txtInitialWeight; - private CheckBox chkGoalEnabled; - private EditText txtGoalWeight; - private EditText txtGoalDate; - private RadioGroup radioScaleUnit; - private RadioGroup radioGender; - private CheckBox assistedWeighing; - private RadioGroup radioMeasurementUnit; - private Spinner spinnerActivityLevel; - private Spinner spinnerLeftAmputationLevel; - private Spinner spinnerRightAmputationLevel; - private TableRow rowGoalWeight; - private TableRow rowGoalDate; - - private final DateFormat dateFormat = DateFormat.getDateInstance(); - - private Context context; - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View root = inflater.inflate(R.layout.fragment_usersettings, container, false); - context = getContext(); - - requireActivity().addMenuProvider(new MenuProvider() { - @Override - public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) { - menu.clear(); - menuInflater.inflate(R.menu.userentry_menu, menu); - - // Apply a tint to all icons in the toolbar - for (int i = 0; i < menu.size(); ++i) { - MenuItem item = menu.getItem(i); - final Drawable drawable = item.getIcon(); - if (drawable == null) { - continue; - } - - final Drawable wrapped = DrawableCompat.wrap(drawable.mutate()); - - if (item.getItemId() == R.id.saveButton) { - DrawableCompat.setTint(wrapped, Color.parseColor("#FFFFFF")); - } else if (item.getItemId() == R.id.deleteButton) { - DrawableCompat.setTint(wrapped, Color.parseColor("#FF4444")); - } - - item.setIcon(wrapped); - } - - MenuItem deleteButton = menu.findItem(R.id.deleteButton); - - switch (mode) { - case ADD: - deleteButton.setVisible(false); - break; - case EDIT: - deleteButton.setVisible(true); - break; - } - } - - @Override - public boolean onMenuItemSelected(@NonNull MenuItem menuItem) { - switch (menuItem.getItemId()) { - case R.id.saveButton: - if (saveUserData()) { - if (getActivity().findViewById(R.id.nav_host_fragment) != null){ - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).getPreviousBackStackEntry().getSavedStateHandle().set("update", true); - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigateUp(); - } else { - getActivity().finish(); - } - } - return true; - - case R.id.deleteButton: - deleteUser(); - return true; - } - - return false; - } - }, getViewLifecycleOwner(), Lifecycle.State.RESUMED); - - if (getArguments() != null) { - mode = UserSettingsFragmentArgs.fromBundle(getArguments()).getMode(); - } else { - mode = USER_SETTING_MODE.ADD; - } - - txtUserName = root.findViewById(R.id.txtUserName); - txtBodyHeight = root.findViewById(R.id.txtBodyHeight); - radioScaleUnit = root.findViewById(R.id.groupScaleUnit); - radioGender = root.findViewById(R.id.groupGender); - assistedWeighing = root.findViewById(R.id.asisstedWeighing); - radioMeasurementUnit = root.findViewById(R.id.groupMeasureUnit); - spinnerActivityLevel = root.findViewById(R.id.spinnerActivityLevel); - spinnerLeftAmputationLevel = root.findViewById(R.id.spinnerLeftAmputationLevel); - spinnerRightAmputationLevel = root.findViewById(R.id.spinnerRightAmputationLevel); - txtInitialWeight = root.findViewById(R.id.txtInitialWeight); - chkGoalEnabled = root.findViewById(R.id.chkGoalEnabled); - txtGoalWeight = root.findViewById(R.id.txtGoalWeight); - txtGoalDate = root.findViewById(R.id.txtGoalDate); - rowGoalWeight = root.findViewById(R.id.rowGoalWeight); - rowGoalDate = root.findViewById(R.id.rowGoalDate); - - txtBirthday = root.findViewById(R.id.txtBirthday); - - txtBodyHeight.setHint(getResources().getString(R.string.info_enter_value_in) + " " + Converters.MeasureUnit.CM.toString()); - txtInitialWeight.setHint(getResources().getString(R.string.info_enter_value_in) + " " + Converters.WeightUnit.KG.toString()); - txtGoalWeight.setHint(getResources().getString(R.string.info_enter_value_in) + " " + Converters.WeightUnit.KG.toString()); - - Calendar birthdayCal = Calendar.getInstance(); - birthdayCal.setTime(birthday); - birthdayCal.add(Calendar.YEAR, -20); - birthday = birthdayCal.getTime(); - - Calendar goalCal = Calendar.getInstance(); - goalCal.setTime(goal_date); - goalCal.add(Calendar.MONTH, 6); - goal_date = goalCal.getTime(); - - txtBirthday.setText(dateFormat.format(birthday)); - txtGoalDate.setText(dateFormat.format(goal_date)); - - txtBirthday.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - MaterialDatePicker datePicker = MaterialDatePicker.Builder - .datePicker() - .setSelection(birthday.getTime()) - .build(); - - datePicker.addOnPositiveButtonClickListener(birthdayPickerListener); - datePicker.show(requireActivity().getSupportFragmentManager(), "Birthday_DatePicker"); - } - }); - - chkGoalEnabled.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { - @Override - public void onCheckedChanged(CompoundButton compoundButton, boolean b) { - if (b) { - rowGoalDate.setVisibility(View.VISIBLE); - rowGoalWeight.setVisibility(View.VISIBLE); - } else { - rowGoalDate.setVisibility(View.GONE); - rowGoalWeight.setVisibility(View.GONE); - } - } - }); - - txtGoalDate.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - MaterialDatePicker datePicker = MaterialDatePicker.Builder - .datePicker() - .setSelection(goal_date.getTime()) - .build(); - - datePicker.addOnPositiveButtonClickListener(goalDatePickerListener); - datePicker.show(getActivity().getSupportFragmentManager(), "Goal_DatePicker"); - } - }); - - radioScaleUnit.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() { - @Override - public void onCheckedChanged(RadioGroup group, int checkedId) { - Converters.WeightUnit scale_unit = Converters.WeightUnit.KG; - - switch (checkedId) { - case R.id.btnRadioKG: - scale_unit = Converters.WeightUnit.KG; - break; - case R.id.btnRadioLB: - scale_unit = Converters.WeightUnit.LB; - break; - case R.id.btnRadioST: - scale_unit = Converters.WeightUnit.ST; - break; - } - - txtInitialWeight.setHint(getResources().getString(R.string.info_enter_value_in) + " " + scale_unit.toString()); - txtGoalWeight.setHint(getResources().getString(R.string.info_enter_value_in) + " " + scale_unit.toString()); - } - }); - - radioMeasurementUnit.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() { - @Override - public void onCheckedChanged(RadioGroup group, int checkedId) { - Converters.MeasureUnit measure_unit = Converters.MeasureUnit.CM; - - switch (radioMeasurementUnit.getCheckedRadioButtonId()) { - case R.id.btnRadioCM: - measure_unit = Converters.MeasureUnit.CM; - break; - case R.id.btnRadioINCH: - measure_unit = Converters.MeasureUnit.INCH; - break; - } - - txtBodyHeight.setHint(getResources().getString(R.string.info_enter_value_in) + " " + measure_unit.toString()); - } - }); - - if (mode == USER_SETTING_MODE.EDIT) { - editMode(); - } - - return root; - } - - private void editMode() - { - int id = UserSettingsFragmentArgs.fromBundle(getArguments()).getUserId(); - - OpenScale openScale = OpenScale.getInstance(); - - ScaleUser scaleUser = openScale.getScaleUser(id); - - birthday = scaleUser.getBirthday(); - goal_date = scaleUser.getGoalDate(); - - txtUserName.setText(scaleUser.getUserName()); - txtBodyHeight.setText(Float.toString(Math.round(Converters.fromCentimeter(scaleUser.getBodyHeight(), scaleUser.getMeasureUnit()) * 100.0f) / 100.0f)); - txtBodyHeight.setHint(getResources().getString(R.string.info_enter_value_in) + " " + scaleUser.getMeasureUnit().toString()); - txtBirthday.setText(dateFormat.format(birthday)); - txtGoalDate.setText(dateFormat.format(goal_date)); - txtInitialWeight.setText(Float.toString(Math.round(Converters.fromKilogram(scaleUser.getInitialWeight(), scaleUser.getScaleUnit())*100.0f)/100.0f)); - txtGoalWeight.setText(Float.toString(Math.round(Converters.fromKilogram(scaleUser.getGoalWeight(), scaleUser.getScaleUnit())*100.0f)/100.0f)); - txtInitialWeight.setHint(getResources().getString(R.string.info_enter_value_in) + " " + scaleUser.getScaleUnit().toString()); - txtGoalWeight.setHint(getResources().getString(R.string.info_enter_value_in) + " " + scaleUser.getScaleUnit().toString()); - - switch (scaleUser.getMeasureUnit()) { - case CM: - radioMeasurementUnit.check(R.id.btnRadioCM); - break; - case INCH: - radioMeasurementUnit.check(R.id.btnRadioINCH); - break; - } - - switch (scaleUser.getScaleUnit()) - { - case KG: - radioScaleUnit.check(R.id.btnRadioKG); - break; - case LB: - radioScaleUnit.check(R.id.btnRadioLB); - break; - case ST: - radioScaleUnit.check(R.id.btnRadioST); - break; - } - - switch (scaleUser.getGender()) - { - case MALE: - radioGender.check(R.id.btnRadioMale); - break; - case FEMALE: - radioGender.check(R.id.btnRadioWoman); - break; - } - - chkGoalEnabled.setChecked(scaleUser.isGoalEnabled()); - assistedWeighing.setChecked(scaleUser.isAssistedWeighing()); - - if (chkGoalEnabled.isChecked()) { - rowGoalDate.setVisibility(View.VISIBLE); - rowGoalWeight.setVisibility(View.VISIBLE); - } else { - rowGoalDate.setVisibility(View.GONE); - rowGoalWeight.setVisibility(View.GONE); - } - - spinnerActivityLevel.setSelection(scaleUser.getActivityLevel().toInt()); - spinnerLeftAmputationLevel.setSelection(scaleUser.getLeftAmputationLevel().toInt()); - spinnerRightAmputationLevel.setSelection(scaleUser.getRightAmputationLevel().toInt()); - } - - private boolean validateInput() - { - boolean validate = true; - - if (txtUserName.getText().toString().length() == 0) { - txtUserName.setError(getResources().getString(R.string.error_user_name_required)); - validate = false; - } - - if (txtBodyHeight.getText().toString().length() == 0) { - txtBodyHeight.setError(getResources().getString(R.string.error_height_required)); - validate = false; - } - - if (txtInitialWeight.getText().toString().length() == 0) { - txtInitialWeight.setError(getResources().getString(R.string.error_initial_weight_required)); - validate = false; - } - - if (chkGoalEnabled.isChecked()) { - if (txtGoalWeight.getText().toString().length() == 0) { - txtGoalWeight.setError(getResources().getString(R.string.error_goal_weight_required)); - validate = false; - } - } - - return validate; - } - - private final MaterialPickerOnPositiveButtonClickListener birthdayPickerListener = new MaterialPickerOnPositiveButtonClickListener() { - @Override - public void onPositiveButtonClick(Long selection) { - birthday = new Date(selection); - txtBirthday.setText(dateFormat.format(birthday)); - } - }; - - private final MaterialPickerOnPositiveButtonClickListener goalDatePickerListener = new MaterialPickerOnPositiveButtonClickListener() { - @Override - public void onPositiveButtonClick(Long selection) { - goal_date = new Date(selection); - txtGoalDate.setText(dateFormat.format(goal_date)); - } - }; - - private void deleteUser() { - AlertDialog.Builder deleteAllDialog = new AlertDialog.Builder(context); - - deleteAllDialog.setMessage(getResources().getString(R.string.question_really_delete_user)); - - deleteAllDialog.setPositiveButton(getResources().getString(R.string.label_yes), new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - int userId = UserSettingsFragmentArgs.fromBundle(getArguments()).getUserId(); - - OpenScale openScale = OpenScale.getInstance(); - boolean isSelected = openScale.getSelectedScaleUserId() == userId; - - openScale.clearScaleMeasurements(userId); - openScale.deleteScaleUser(userId); - - if (isSelected) { - List scaleUser = openScale.getScaleUserList(); - - int lastUserId = -1; - if (!scaleUser.isEmpty()) { - lastUserId = scaleUser.get(0).getId(); - } - - openScale.selectScaleUser(lastUserId); - } - - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).getPreviousBackStackEntry().getSavedStateHandle().set("update", true); - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigateUp(); - } - }); - - deleteAllDialog.setNegativeButton(getResources().getString(R.string.label_no), new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.dismiss(); - } - }); - - deleteAllDialog.show(); - } - - private boolean saveUserData() { - try { - if (validateInput()) { - OpenScale openScale = OpenScale.getInstance(); - - String name = txtUserName.getText().toString(); - float body_height = Float.valueOf(txtBodyHeight.getText().toString()); - float initial_weight = Float.valueOf(txtInitialWeight.getText().toString()); - - Converters.MeasureUnit measure_unit = Converters.MeasureUnit.CM; - - switch (radioMeasurementUnit.getCheckedRadioButtonId()) { - case R.id.btnRadioCM: - measure_unit = Converters.MeasureUnit.CM; - break; - case R.id.btnRadioINCH: - measure_unit = Converters.MeasureUnit.INCH; - break; - } - - Converters.WeightUnit scale_unit = Converters.WeightUnit.KG; - - switch (radioScaleUnit.getCheckedRadioButtonId()) { - case R.id.btnRadioKG: - scale_unit = Converters.WeightUnit.KG; - break; - case R.id.btnRadioLB: - scale_unit = Converters.WeightUnit.LB; - break; - case R.id.btnRadioST: - scale_unit = Converters.WeightUnit.ST; - break; - } - - Converters.Gender gender = Converters.Gender.MALE; - - switch (radioGender.getCheckedRadioButtonId()) { - case R.id.btnRadioMale: - gender = Converters.Gender.MALE; - break; - case R.id.btnRadioWoman: - gender = Converters.Gender.FEMALE; - break; - } - - final ScaleUser scaleUser = new ScaleUser(); - - scaleUser.setUserName(name); - scaleUser.setBirthday(birthday); - scaleUser.setBodyHeight(Converters.toCentimeter(body_height, measure_unit)); - scaleUser.setScaleUnit(scale_unit); - scaleUser.setMeasureUnit(measure_unit); - scaleUser.setActivityLevel(Converters.fromActivityLevelInt(spinnerActivityLevel.getSelectedItemPosition())); - scaleUser.setLeftAmputationLevel(Converters.fromAmputationLevelInt(spinnerLeftAmputationLevel.getSelectedItemPosition())); - scaleUser.setRightAmputationLevel(Converters.fromAmputationLevelInt(spinnerRightAmputationLevel.getSelectedItemPosition())); - scaleUser.setGender(gender); - scaleUser.setAssistedWeighing(assistedWeighing.isChecked()); - scaleUser.setInitialWeight(Converters.toKilogram(initial_weight, scale_unit)); - scaleUser.setGoalEnabled(chkGoalEnabled.isChecked()); - if (chkGoalEnabled.isChecked()) { - float goal_weight = Float.valueOf(txtGoalWeight.getText().toString()); - scaleUser.setGoalWeight(Converters.toKilogram(goal_weight, scale_unit)); - scaleUser.setGoalDate(goal_date); - } - - switch (mode) { - case ADD: - int id = openScale.addScaleUser(scaleUser); - scaleUser.setId(id); - break; - case EDIT: - scaleUser.setId(UserSettingsFragmentArgs.fromBundle(getArguments()).getUserId()); - openScale.updateScaleUser(scaleUser); - break; - } - - openScale.selectScaleUser(scaleUser.getId()); - - return true; - } - } catch (NumberFormatException ex) { - Toast.makeText(context, getResources().getString(R.string.error_value_range) + "(" + ex.getMessage() + ")", Toast.LENGTH_SHORT).show(); - } - - return false; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/preferences/UsersPreferences.java b/android_app/app/src/main/java/com/health/openscale/gui/preferences/UsersPreferences.java deleted file mode 100644 index 450fd51e..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/preferences/UsersPreferences.java +++ /dev/null @@ -1,156 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* 2018 Erik Johansson -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.preferences; - -import android.content.Context; -import android.os.Bundle; -import android.util.TypedValue; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.RadioButton; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.lifecycle.Observer; -import androidx.navigation.Navigation; -import androidx.preference.Preference; -import androidx.preference.PreferenceCategory; -import androidx.preference.PreferenceFragmentCompat; -import androidx.preference.PreferenceViewHolder; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleUser; - -public class UsersPreferences extends PreferenceFragmentCompat { - private static final String PREFERENCE_KEY_ADD_USER = "addUser"; - private static final String PREFERENCE_KEY_USERS = "users"; - - private PreferenceCategory users; - - @Override - public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { - setPreferencesFromResource(R.xml.users_preferences, rootKey); - - setHasOptionsMenu(true); - - Preference addUser = findPreference(PREFERENCE_KEY_ADD_USER); - addUser.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - UsersPreferencesDirections.ActionNavUserPreferencesToNavUsersettings action = UsersPreferencesDirections.actionNavUserPreferencesToNavUsersettings(); - action.setMode(UserSettingsFragment.USER_SETTING_MODE.ADD); - action.setTitle(getString(R.string.label_add_user)); - - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigate(action); - return true; - } - }); - - users = (PreferenceCategory) findPreference(PREFERENCE_KEY_USERS); - updateUserPreferences(); - } - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - - View view = super.onCreateView(inflater, container, savedInstanceState); - - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).getCurrentBackStackEntry().getSavedStateHandle().getLiveData("update", false).observe(getViewLifecycleOwner(), new Observer() { - @Override - public void onChanged(Boolean aBoolean) { - if (aBoolean) { - updateUserPreferences(); - } - } - }); - - return view; - } - - private void updateUserPreferences() { - users.removeAll(); - for (ScaleUser scaleUser : OpenScale.getInstance().getScaleUserList()) { - users.addPreference(new UserPreference(getActivity(), users, scaleUser)); - } - } - - class UserPreference extends Preference { - PreferenceCategory preferenceCategory; - ScaleUser scaleUser; - RadioButton radioButton; - - UserPreference(Context context, PreferenceCategory category, ScaleUser scaleUser) { - super(context); - - preferenceCategory = category; - this.scaleUser = scaleUser; - - setTitle(scaleUser.getUserName()); - setWidgetLayoutResource(R.layout.user_preference_widget_layout); - } - - @Override - public void onBindViewHolder(PreferenceViewHolder holder) { - super.onBindViewHolder(holder); - - holder.itemView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - UsersPreferencesDirections.ActionNavUserPreferencesToNavUsersettings action = UsersPreferencesDirections.actionNavUserPreferencesToNavUsersettings(); - action.setMode(UserSettingsFragment.USER_SETTING_MODE.EDIT); - action.setTitle(scaleUser.getUserName()); - action.setUserId(scaleUser.getId()); - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigate(action); - } - }); - - TypedValue outValue = new TypedValue(); - getActivity().getTheme().resolveAttribute(R.attr.selectableItemBackground, outValue, true); - holder.itemView.setBackgroundResource(outValue.resourceId); - - radioButton = holder.itemView.findViewById(R.id.user_radio_button); - radioButton.setChecked(scaleUser.getId() == OpenScale.getInstance().getSelectedScaleUserId()); - - radioButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - for (int i = 0; i < preferenceCategory.getPreferenceCount(); ++i) { - UserPreference pref = (UserPreference) preferenceCategory.getPreference(i); - pref.setChecked(false); - } - - radioButton.setChecked(true); - OpenScale.getInstance().selectScaleUser(scaleUser.getId()); - } - }); - } - - public void setChecked(boolean checked) { - radioButton.setChecked(checked); - } - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - menu.clear(); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/slides/AppIntroActivity.java b/android_app/app/src/main/java/com/health/openscale/gui/slides/AppIntroActivity.java deleted file mode 100644 index b10b0f9c..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/slides/AppIntroActivity.java +++ /dev/null @@ -1,68 +0,0 @@ -/* Copyright (C) 2019 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.gui.slides; - -import android.os.Bundle; - -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; - -import com.github.appintro.AppIntro; -import com.health.openscale.R; - -public class AppIntroActivity extends AppIntro { - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - setBarColor(getResources().getColor(R.color.seed)); - - setSkipButtonEnabled(true); - - addSlide(WelcomeIntroSlide.newInstance(R.layout.slide_welcome)); - addSlide(PrivacyIntroSlide.newInstance(R.layout.slide_privacy)); - addSlide(UserIntroSlide.newInstance(R.layout.slide_user)); - addSlide(OpenSourceIntroSlide.newInstance(R.layout.slide_opensource)); - addSlide(BluetoothIntroSlide.newInstance(R.layout.slide_bluetooth)); - addSlide(MetricsIntroSlide.newInstance(R.layout.slide_metrics)); - addSlide(SupportIntroSlide.newInstance(R.layout.slide_support)); - } - - @Override - public void onSkipPressed(Fragment currentFragment) { - super.onSkipPressed(currentFragment); - finish(); - } - - @Override - public void onDonePressed(Fragment currentFragment) { - super.onDonePressed(currentFragment); - finish(); - } - - @Override - public void onSlideChanged(@Nullable Fragment oldFragment, @Nullable Fragment newFragment) { - super.onSlideChanged(oldFragment, newFragment); - - if (newFragment instanceof WelcomeIntroSlide) { - setSkipButtonEnabled(true); - setWizardMode(false); - } else { - setSkipButtonEnabled(false); - setWizardMode(true); - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/slides/BluetoothIntroSlide.java b/android_app/app/src/main/java/com/health/openscale/gui/slides/BluetoothIntroSlide.java deleted file mode 100644 index 63704c40..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/slides/BluetoothIntroSlide.java +++ /dev/null @@ -1,100 +0,0 @@ -/* Copyright (C) 2019 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.gui.slides; - -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TextView; - -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; - -import com.health.openscale.R; -import com.health.openscale.gui.preferences.BluetoothSettingsFragment; - -public class BluetoothIntroSlide extends Fragment { - private static final String ARG_LAYOUT_RES_ID = "layoutResId"; - private int layoutResId; - - private Button btnSearchScale; - private TextView txtFoundDevice; - - public static BluetoothIntroSlide newInstance(int layoutResId) { - BluetoothIntroSlide sampleSlide = new BluetoothIntroSlide(); - - Bundle args = new Bundle(); - args.putInt(ARG_LAYOUT_RES_ID, layoutResId); - sampleSlide.setArguments(args); - - return sampleSlide; - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - if (getArguments() != null && getArguments().containsKey(ARG_LAYOUT_RES_ID)) { - layoutResId = getArguments().getInt(ARG_LAYOUT_RES_ID); - } - } - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - View view = inflater.inflate(layoutResId, container, false); - - txtFoundDevice = view.findViewById(R.id.txtFoundDevice); - txtFoundDevice.setText(getCurrentDeviceName()); - - btnSearchScale = view.findViewById(R.id.btnSearchScale); - btnSearchScale.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - Intent intent = new Intent(getContext(), SlideToNavigationAdapter.class); - intent.putExtra(SlideToNavigationAdapter.EXTRA_MODE, SlideToNavigationAdapter.EXTRA_BLUETOOTH_SETTING_MODE); - startActivityForResult(intent, 100); - } - }); - - return view; - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - txtFoundDevice.setText(getCurrentDeviceName()); - } - - private final String formatDeviceName(String name, String address) { - if (name.isEmpty() || address.isEmpty()) { - return "[" + getContext().getString(R.string.label_empty) + "]"; - } - return String.format("%s [%s]", name, address); - } - - private String getCurrentDeviceName() { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); - return formatDeviceName( - prefs.getString(BluetoothSettingsFragment.PREFERENCE_KEY_BLUETOOTH_DEVICE_NAME, ""), - prefs.getString(BluetoothSettingsFragment.PREFERENCE_KEY_BLUETOOTH_HW_ADDRESS, "")); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/slides/MetricsIntroSlide.java b/android_app/app/src/main/java/com/health/openscale/gui/slides/MetricsIntroSlide.java deleted file mode 100644 index 439f732f..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/slides/MetricsIntroSlide.java +++ /dev/null @@ -1,68 +0,0 @@ -/* Copyright (C) 2019 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.gui.slides; - -import android.os.Bundle; -import android.text.method.LinkMovementMethod; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; - -import com.health.openscale.R; - -public class MetricsIntroSlide extends Fragment { - - private static final String ARG_LAYOUT_RES_ID = "layoutResId"; - private int layoutResId; - - private TextView slideMainText; - - public static MetricsIntroSlide newInstance(int layoutResId) { - MetricsIntroSlide sampleSlide = new MetricsIntroSlide(); - - Bundle args = new Bundle(); - args.putInt(ARG_LAYOUT_RES_ID, layoutResId); - sampleSlide.setArguments(args); - - return sampleSlide; - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - if (getArguments() != null && getArguments().containsKey(ARG_LAYOUT_RES_ID)) { - layoutResId = getArguments().getInt(ARG_LAYOUT_RES_ID); - } - } - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - View view = inflater.inflate(layoutResId, container, false); - - slideMainText = view.findViewById(R.id.slideMainText); - slideMainText.setLinksClickable(true); - slideMainText.setMovementMethod(LinkMovementMethod.getInstance()); - - return view; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/slides/OpenSourceIntroSlide.java b/android_app/app/src/main/java/com/health/openscale/gui/slides/OpenSourceIntroSlide.java deleted file mode 100644 index dff77219..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/slides/OpenSourceIntroSlide.java +++ /dev/null @@ -1,68 +0,0 @@ -/* Copyright (C) 2019 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.gui.slides; - -import android.os.Bundle; -import android.text.method.LinkMovementMethod; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; - -import com.health.openscale.R; - -public class OpenSourceIntroSlide extends Fragment { - - private static final String ARG_LAYOUT_RES_ID = "layoutResId"; - private int layoutResId; - - private TextView slideMainText; - - public static OpenSourceIntroSlide newInstance(int layoutResId) { - OpenSourceIntroSlide sampleSlide = new OpenSourceIntroSlide(); - - Bundle args = new Bundle(); - args.putInt(ARG_LAYOUT_RES_ID, layoutResId); - sampleSlide.setArguments(args); - - return sampleSlide; - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - if (getArguments() != null && getArguments().containsKey(ARG_LAYOUT_RES_ID)) { - layoutResId = getArguments().getInt(ARG_LAYOUT_RES_ID); - } - } - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - View view = inflater.inflate(layoutResId, container, false); - - slideMainText = view.findViewById(R.id.slideMainText); - slideMainText.setLinksClickable(true); - slideMainText.setMovementMethod(LinkMovementMethod.getInstance()); - - return view; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/slides/PrivacyIntroSlide.java b/android_app/app/src/main/java/com/health/openscale/gui/slides/PrivacyIntroSlide.java deleted file mode 100644 index c3d32ad7..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/slides/PrivacyIntroSlide.java +++ /dev/null @@ -1,68 +0,0 @@ -/* Copyright (C) 2019 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.gui.slides; - -import android.os.Bundle; -import android.text.method.LinkMovementMethod; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; - -import com.health.openscale.R; - -public class PrivacyIntroSlide extends Fragment { - - private static final String ARG_LAYOUT_RES_ID = "layoutResId"; - private int layoutResId; - - private TextView slideMainText; - - public static PrivacyIntroSlide newInstance(int layoutResId) { - PrivacyIntroSlide sampleSlide = new PrivacyIntroSlide(); - - Bundle args = new Bundle(); - args.putInt(ARG_LAYOUT_RES_ID, layoutResId); - sampleSlide.setArguments(args); - - return sampleSlide; - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - if (getArguments() != null && getArguments().containsKey(ARG_LAYOUT_RES_ID)) { - layoutResId = getArguments().getInt(ARG_LAYOUT_RES_ID); - } - } - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - View view = inflater.inflate(layoutResId, container, false); - - slideMainText = view.findViewById(R.id.slideMainText); - slideMainText.setLinksClickable(true); - slideMainText.setMovementMethod(LinkMovementMethod.getInstance()); - - return view; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/slides/SlideToNavigationAdapter.java b/android_app/app/src/main/java/com/health/openscale/gui/slides/SlideToNavigationAdapter.java deleted file mode 100644 index 4b7d6d33..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/slides/SlideToNavigationAdapter.java +++ /dev/null @@ -1,76 +0,0 @@ -/* Copyright (C) 2020 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.gui.slides; - -import android.os.Bundle; -import android.view.View; - -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.widget.Toolbar; -import androidx.navigation.NavController; -import androidx.navigation.NavDirections; -import androidx.navigation.fragment.NavHostFragment; - -import com.health.openscale.R; -import com.health.openscale.SlideNavigationDirections; - -// TODO HACK to access from AppIntro activity to MainActivity fragments until AppIntro support native Androidx navigation component -public class SlideToNavigationAdapter extends AppCompatActivity { - public static String EXTRA_MODE = "mode"; - public static final int EXTRA_USER_SETTING_MODE = 100; - public static final int EXTRA_BLUETOOTH_SETTING_MODE = 200; - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - setContentView(R.layout.activity_slidetonavigation); - - // Set a Toolbar to replace the ActionBar. - Toolbar toolbar = findViewById(R.id.toolbar); - setSupportActionBar(toolbar); - - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - - toolbar.setNavigationOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - finish(); - } - }); - - int mode = getIntent().getExtras().getInt(EXTRA_MODE); - - NavDirections action = null; - - switch (mode) { - case EXTRA_USER_SETTING_MODE: - action = SlideNavigationDirections.actionNavSlideNavigationToNavUsersettings(); - setTitle(R.string.label_add_user); - break; - case EXTRA_BLUETOOTH_SETTING_MODE: - action = SlideNavigationDirections.actionNavSlideNavigationToNavBluetoothsettings(); - setTitle(R.string.label_bluetooth_title); - break; - } - - if (action != null) { - NavController navController = ((NavHostFragment)getSupportFragmentManager().findFragmentById(R.id.nav_slide_navigation)).getNavController(); - - navController.navigate(action); - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/slides/SupportIntroSlide.java b/android_app/app/src/main/java/com/health/openscale/gui/slides/SupportIntroSlide.java deleted file mode 100644 index 531b7b8c..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/slides/SupportIntroSlide.java +++ /dev/null @@ -1,68 +0,0 @@ -/* Copyright (C) 2019 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.gui.slides; - -import android.os.Bundle; -import android.text.method.LinkMovementMethod; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; - -import com.health.openscale.R; - -public class SupportIntroSlide extends Fragment { - - private static final String ARG_LAYOUT_RES_ID = "layoutResId"; - private int layoutResId; - - private TextView slideMainText; - - public static SupportIntroSlide newInstance(int layoutResId) { - SupportIntroSlide sampleSlide = new SupportIntroSlide(); - - Bundle args = new Bundle(); - args.putInt(ARG_LAYOUT_RES_ID, layoutResId); - sampleSlide.setArguments(args); - - return sampleSlide; - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - if (getArguments() != null && getArguments().containsKey(ARG_LAYOUT_RES_ID)) { - layoutResId = getArguments().getInt(ARG_LAYOUT_RES_ID); - } - } - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - View view = inflater.inflate(layoutResId, container, false); - - slideMainText = view.findViewById(R.id.slideMainText); - slideMainText.setLinksClickable(true); - slideMainText.setMovementMethod(LinkMovementMethod.getInstance()); - - return view; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/slides/UserIntroSlide.java b/android_app/app/src/main/java/com/health/openscale/gui/slides/UserIntroSlide.java deleted file mode 100644 index 8fef6747..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/slides/UserIntroSlide.java +++ /dev/null @@ -1,165 +0,0 @@ -/* Copyright (C) 2019 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.gui.slides; - -import android.content.Intent; -import android.graphics.Typeface; -import android.os.Bundle; -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TableLayout; -import android.widget.TableRow; -import android.widget.TextView; - -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleUser; - -import java.util.List; - -public class UserIntroSlide extends Fragment{ - - private static final String ARG_LAYOUT_RES_ID = "layoutResId"; - private int layoutResId; - private Button btnAddUser; - private TableLayout tblUsers; - - public static UserIntroSlide newInstance(int layoutResId) { - UserIntroSlide sampleSlide = new UserIntroSlide(); - - Bundle args = new Bundle(); - args.putInt(ARG_LAYOUT_RES_ID, layoutResId); - sampleSlide.setArguments(args); - - return sampleSlide; - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - if (getArguments() != null && getArguments().containsKey(ARG_LAYOUT_RES_ID)) { - layoutResId = getArguments().getInt(ARG_LAYOUT_RES_ID); - } - } - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - View view = inflater.inflate(layoutResId, container, false); - - btnAddUser = view.findViewById(R.id.btnAddUser); - tblUsers = view.findViewById(R.id.tblUsers); - - btnAddUser.setOnClickListener(new onBtnAddUserClickListener()); - - updateTableUsers(); - - return view; - } - - private class onBtnAddUserClickListener implements View.OnClickListener { - - @Override - public void onClick(View view) { - Intent intent = new Intent(getContext(), SlideToNavigationAdapter.class); - intent.putExtra(SlideToNavigationAdapter.EXTRA_MODE, SlideToNavigationAdapter.EXTRA_USER_SETTING_MODE); - startActivityForResult(intent, 100); - } - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - - updateTableUsers(); - } - - - private void updateTableUsers() { - tblUsers.removeAllViews(); - tblUsers.setStretchAllColumns(true); - - List scaleUserList = OpenScale.getInstance().getScaleUserList(); - - TableRow header = new TableRow(getContext()); - - TextView headerUsername = new TextView(getContext()); - headerUsername.setText(R.string.label_user_name); - headerUsername.setGravity(Gravity.CENTER_HORIZONTAL); - headerUsername.setTypeface(null, Typeface.BOLD); - header.addView(headerUsername); - - TextView headAge = new TextView(getContext()); - headAge.setText(R.string.label_age); - headAge.setGravity(Gravity.CENTER_HORIZONTAL); - headAge.setTypeface(null, Typeface.BOLD); - header.addView(headAge); - - TextView headerGender = new TextView(getContext()); - headerGender.setText(R.string.label_gender); - headerGender.setGravity(Gravity.CENTER_HORIZONTAL); - headerGender.setTypeface(null, Typeface.BOLD); - header.addView(headerGender); - - tblUsers.addView(header); - - if (!scaleUserList.isEmpty()) { - TableRow row = new TableRow(getContext()); - - for (ScaleUser scaleUser : scaleUserList) { - row = new TableRow(getContext()); - - TextView txtUsername = new TextView(getContext()); - txtUsername.setText(scaleUser.getUserName()); - txtUsername.setGravity(Gravity.CENTER_HORIZONTAL); - row.addView(txtUsername); - - TextView txtAge = new TextView(getContext()); - txtAge.setText(Integer.toString(scaleUser.getAge())); - txtAge.setGravity(Gravity.CENTER_HORIZONTAL); - row.addView(txtAge); - - TextView txtGender = new TextView(getContext()); - txtGender.setText((scaleUser.getGender().isMale()) ? getString(R.string.label_male) : getString(R.string.label_female)); - txtGender.setGravity(Gravity.CENTER_HORIZONTAL); - row.addView(txtGender); - - row.setGravity(Gravity.CENTER_HORIZONTAL); - - tblUsers.addView(row); - } - } else { - TableRow row = new TableRow(getContext()); - - TextView txtEmpty = new TextView(getContext()); - txtEmpty.setText("[" + getContext().getString(R.string.label_empty) + "]"); - txtEmpty.setGravity(Gravity.CENTER_HORIZONTAL); - row.addView(txtEmpty); - - row.setGravity(Gravity.CENTER_HORIZONTAL); - - tblUsers.addView(row); - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/slides/WelcomeIntroSlide.java b/android_app/app/src/main/java/com/health/openscale/gui/slides/WelcomeIntroSlide.java deleted file mode 100644 index f73e43c2..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/slides/WelcomeIntroSlide.java +++ /dev/null @@ -1,56 +0,0 @@ -/* Copyright (C) 2019 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.gui.slides; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; - -public class WelcomeIntroSlide extends Fragment { - - private static final String ARG_LAYOUT_RES_ID = "layoutResId"; - private int layoutResId; - - public static WelcomeIntroSlide newInstance(int layoutResId) { - WelcomeIntroSlide sampleSlide = new WelcomeIntroSlide(); - - Bundle args = new Bundle(); - args.putInt(ARG_LAYOUT_RES_ID, layoutResId); - sampleSlide.setArguments(args); - - return sampleSlide; - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - if (getArguments() != null && getArguments().containsKey(ARG_LAYOUT_RES_ID)) { - layoutResId = getArguments().getInt(ARG_LAYOUT_RES_ID); - } - } - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - return inflater.inflate(layoutResId, container, false); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/statistic/StatisticAdapter.java b/android_app/app/src/main/java/com/health/openscale/gui/statistic/StatisticAdapter.java deleted file mode 100644 index 2e93c2ae..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/statistic/StatisticAdapter.java +++ /dev/null @@ -1,207 +0,0 @@ -/* Copyright (C) 2023 olie.xdev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ -package com.health.openscale.gui.statistic; - -import android.app.Activity; -import android.content.res.ColorStateList; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.style.RelativeSizeSpan; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import com.github.mikephil.charting.charts.LineChart; -import com.github.mikephil.charting.data.Entry; -import com.github.mikephil.charting.data.LineData; -import com.github.mikephil.charting.data.LineDataSet; -import com.github.mikephil.charting.interfaces.datasets.ILineDataSet; -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.google.android.material.shape.ShapeAppearanceModel; -import com.health.openscale.R; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.gui.measurement.FloatMeasurementView; -import com.health.openscale.gui.measurement.MeasurementView; - -import java.time.LocalDate; -import java.time.ZoneId; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.List; - -class StatisticAdapter extends RecyclerView.Adapter { - private Activity activity; - private List scaleMeasurementList; - private ScaleMeasurement firstMeasurement; - private ScaleMeasurement lastMeasurement; - private List measurementViewList; - - public StatisticAdapter(Activity activity, List scaleMeasurementList) { - this.activity = activity; - this.scaleMeasurementList = scaleMeasurementList; - - if (scaleMeasurementList.isEmpty()) { - this.firstMeasurement = new ScaleMeasurement(); - this.lastMeasurement = new ScaleMeasurement(); - } else if (scaleMeasurementList.size() == 1) { - this.firstMeasurement = scaleMeasurementList.get(0); - this.lastMeasurement = scaleMeasurementList.get(0); - } else { - this.firstMeasurement = scaleMeasurementList.get(scaleMeasurementList.size()-1); - this.lastMeasurement = scaleMeasurementList.get(0); - } - - List fullMeasurementViewList = MeasurementView.getMeasurementList(activity, MeasurementView.DateTimeOrder.LAST); - measurementViewList = new ArrayList<>(); - - for (MeasurementView measurementView : fullMeasurementViewList) { - if (measurementView instanceof FloatMeasurementView && measurementView.isVisible()) { - measurementViewList.add((FloatMeasurementView)measurementView); - } - } - } - - @Override - public StatisticAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_statistic, parent, false); - - ViewHolder viewHolder = new ViewHolder(view); - - return viewHolder; - } - - @Override - public void onBindViewHolder(@NonNull StatisticAdapter.ViewHolder holder, int position) { - FloatMeasurementView measurementView = measurementViewList.get(position); - List lineEntries = new ArrayList<>(); - - Collections.reverse(scaleMeasurementList); - - int i=0; - float sumValue = 0; - float maxValue = Float.MIN_VALUE; - float minValue = Float.MAX_VALUE; - for (ScaleMeasurement scaleMeasurement : scaleMeasurementList) { - measurementView.loadFrom(scaleMeasurement, null); - - float value = measurementView.getValue(); - - sumValue += value; - if (value > maxValue) { - maxValue = value; - } - if (value < minValue) { - minValue = value; - } - - lineEntries.add(new Entry(i, value)); - i++; - } - - Collections.reverse(scaleMeasurementList); - - LineDataSet lineDataSet = new LineDataSet(lineEntries, holder.measurementName.getText().toString()); - lineDataSet.setColor(measurementView.getColor()); - lineDataSet.setDrawCircles(false); - lineDataSet.setFillColor(measurementView.getColor()); - lineDataSet.setDrawFilled(true); - lineDataSet.setDrawValues(false); - lineDataSet.setHighlightEnabled(false); - - List dataSets = new ArrayList<>(); - dataSets.add(lineDataSet); - - LineData data = new LineData(dataSets); - holder.diffChartView.setData(data); - holder.diffChartView.invalidate(); - - measurementView.loadFrom(lastMeasurement, firstMeasurement); - - holder.measurementName.setText(measurementView.getName()); - SpannableStringBuilder statisticValueText = new SpannableStringBuilder(); - statisticValueText.append(activity.getResources().getString(R.string.label_abbr_min) + " " + measurementView.formatValue(minValue != Float.MAX_VALUE ? minValue : 0, true) + "\n"); - statisticValueText.append(activity.getResources().getString(R.string.label_abbr_max) + " " + measurementView.formatValue(maxValue != Float.MIN_VALUE ? maxValue : 0, true) + "\n"); - statisticValueText.append(activity.getResources().getString(R.string.label_abbr_avg) + " " + measurementView.formatValue(sumValue != 0 ? sumValue / scaleMeasurementList.size() : 0, true) + "\n"); - statisticValueText.setSpan(new RelativeSizeSpan(0.8f), 0, statisticValueText.length(), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - - holder.statisticValueView.setText(statisticValueText); - SpannableStringBuilder endValueText = new SpannableStringBuilder(); - measurementView.appendDiffValue(endValueText, true ); - endValueText.append("\n"); - endValueText.append(measurementView.getValueAsString(true)); - holder.endValueView.setText(endValueText); - holder.iconView.setImageDrawable(measurementView.getIcon()); - holder.iconView.setShapeAppearanceModel(ShapeAppearanceModel.builder().setAllCornerSizes(1000).build()); - holder.iconView.setBackgroundTintList(ColorStateList.valueOf(measurementView.getColor())); - - measurementView.loadFrom(firstMeasurement, null); - holder.startValueView.setText(measurementView.getValueAsString(true)); - } - - private int convertDateToInt(Date date) { - LocalDate localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); - return (int)localDate.toEpochDay(); - } - - @Override - public long getItemId(int position) { - return measurementViewList.get(position).getId(); - } - - @Override - public int getItemCount() { - return measurementViewList.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - TextView measurementName; - TextView statisticValueView; - TextView startValueView; - FloatingActionButton iconView; - LineChart diffChartView; - TextView endValueView; - - public ViewHolder(@NonNull View itemView) { - super(itemView); - - measurementName = itemView.findViewById(R.id.measurementName); - statisticValueView = itemView.findViewById(R.id.statisticValueView); - startValueView = itemView.findViewById(R.id.startValueView); - iconView = itemView.findViewById(R.id.iconView); - diffChartView = itemView.findViewById(R.id.diffChartView); - endValueView = itemView.findViewById(R.id.endValueView); - - diffChartView.getLegend().setEnabled(false); - diffChartView.getDescription().setEnabled(false); - diffChartView.getAxisRight().setDrawLabels(false); - diffChartView.getAxisRight().setDrawGridLines(false); - diffChartView.getAxisRight().setDrawAxisLine(false); - diffChartView.getAxisLeft().setDrawGridLines(false); - diffChartView.getAxisLeft().setDrawLabels(false); - diffChartView.getAxisLeft().setDrawAxisLine(false); - diffChartView.getXAxis().setDrawGridLines(false); - diffChartView.getXAxis().setDrawLabels(false); - diffChartView.getXAxis().setDrawAxisLine(false); - diffChartView.setMinOffset(0); - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/statistic/StatisticsFragment.java b/android_app/app/src/main/java/com/health/openscale/gui/statistic/StatisticsFragment.java deleted file mode 100644 index e6fe5c65..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/statistic/StatisticsFragment.java +++ /dev/null @@ -1,290 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ - -package com.health.openscale.gui.statistic; - -import android.content.DialogInterface; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.os.Parcel; -import android.preference.PreferenceManager; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.activity.OnBackPressedCallback; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.core.util.Pair; -import androidx.fragment.app.Fragment; -import androidx.lifecycle.Observer; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.material.datepicker.CalendarConstraints; -import com.google.android.material.datepicker.CompositeDateValidator; -import com.google.android.material.datepicker.DateValidatorPointBackward; -import com.google.android.material.datepicker.DateValidatorPointForward; -import com.google.android.material.datepicker.MaterialDatePicker; -import com.google.android.material.datepicker.MaterialPickerOnPositiveButtonClickListener; -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; - -import java.text.DateFormat; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.Date; -import java.util.List; - -public class StatisticsFragment extends Fragment { - private RecyclerView compareRecyclerView; - private TextView diffDateTextView; - private TextView countMeasurementTextView; - private ImageView datePickerView; - private StatisticAdapter statisticAdapter; - private List scaleMeasurementList; - private SharedPreferences prefs; - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View statisticsView = inflater.inflate(R.layout.fragment_statistics, container, false); - - prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); - compareRecyclerView = statisticsView.findViewById(R.id.compareRecyclerView); - diffDateTextView = statisticsView.findViewById(R.id.diffDateTextView); - countMeasurementTextView = statisticsView.findViewById(R.id.countMeasurementTextView); - datePickerView = statisticsView.findViewById(R.id.datePickerView); - - datePickerView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); - builder.setTitle(R.string.label_time_period) - .setItems(R.array.range_options_entries, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - switch (which) { - case 0: // all days - setAllDaysRange(); - break; - case 1: // last 7 days - setLast7DaysRange(); - break; - case 2: // last 30 days - setLast30DaysRange(); - break; - case 3: // set reference day - MaterialDatePicker materialDatePicker = MaterialDatePicker.Builder.datePicker().setCalendarConstraints(getCalendarConstraints()).build(); - materialDatePicker.show(getActivity().getSupportFragmentManager(), "MATERIAL_DATE_PICKER"); - - materialDatePicker.addOnPositiveButtonClickListener(new MaterialPickerOnPositiveButtonClickListener() { - @Override - public void onPositiveButtonClick(Long selection) { - setReferenceDay(new Date(selection)); - } - }); - break; - case 4: // custom range - MaterialDatePicker> materialDateRangePicker = MaterialDatePicker.Builder.dateRangePicker().setCalendarConstraints(getCalendarConstraints()).build(); - materialDateRangePicker.show(getActivity().getSupportFragmentManager(), "MATERIAL_DATE_RANGE_PICKER"); - - materialDateRangePicker.addOnPositiveButtonClickListener(new MaterialPickerOnPositiveButtonClickListener>() { - @Override public void onPositiveButtonClick(Pair selection) { - setCustomRange(new Date(selection.first), new Date(selection.second)); - } - }); - break; - } - } - }); - builder.create(); - builder.show(); - } - }); - - LinearLayoutManager layoutManager = new LinearLayoutManager(getContext()); - compareRecyclerView.setLayoutManager(layoutManager); - - OpenScale.getInstance().getScaleMeasurementsLiveData().observe(getViewLifecycleOwner(), new Observer>() { - @Override - public void onChanged(List scaleMeasurements) { - updateOnView(scaleMeasurements); - } - }); - - OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) { - @Override - public void handleOnBackPressed() { - requireActivity().finish(); - } - }; - - requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), onBackPressedCallback); - - return statisticsView; - } - - public void updateStatistic(List rangeScaleMeasurementList) { - compareRecyclerView.setVisibility(View.VISIBLE); - statisticAdapter = new StatisticAdapter(getActivity(), rangeScaleMeasurementList); - compareRecyclerView.setAdapter(statisticAdapter); - - countMeasurementTextView.setText(rangeScaleMeasurementList.size() + " " + getResources().getString(R.string.label_measurements)); - } - - public void setDiffDateText(Date firstDate, Date secondDate) { - String diffDateText = DateFormat.getDateInstance(DateFormat.MEDIUM).format(firstDate) + " - " + - DateFormat.getDateInstance(DateFormat.MEDIUM).format(secondDate); - - diffDateTextView.setText(diffDateText); - } - - public void updateOnView(List scaleMeasurementList) { - this.scaleMeasurementList = scaleMeasurementList; - - Long prefStartDate = prefs.getLong("statistic_range_start_date", -1); - Long prefEndDate = prefs.getLong("statistic_range_end_date", -1); - - if (prefStartDate == -1) { - setAllDaysRange(); - } else if (prefStartDate == -7) { - setLast7DaysRange(); - } else if (prefStartDate == -30) { - setLast30DaysRange(); - } else if (prefEndDate == -1 && prefStartDate > 0) { - setReferenceDay(new Date(prefStartDate)); - }else if (prefEndDate > 0 && prefStartDate > 0) { - setCustomRange(new Date(prefStartDate), new Date(prefEndDate)); - } - } - - private void setAllDaysRange() { - diffDateTextView.setText(getResources().getString(R.string.label_time_period_all_days)); - prefs.edit().putLong("statistic_range_start_date", -1).commit(); - updateStatistic(scaleMeasurementList); - } - - private void setLast7DaysRange() { - Calendar startCalendar = Calendar.getInstance(); - startCalendar.setTime(new Date()); - startCalendar.add(Calendar.DAY_OF_YEAR, -7); - - prefs.edit().putLong("statistic_range_start_date", -7).commit(); - diffDateTextView.setText(getResources().getString(R.string.label_time_period_last_7_days)); - - List rangeScaleMeasurementList = OpenScale.getInstance().getScaleMeasurementOfStartDate(startCalendar.get(Calendar.YEAR), startCalendar.get(Calendar.MONTH), startCalendar.get(Calendar.DAY_OF_MONTH)); - - updateStatistic(rangeScaleMeasurementList); - } - - private void setLast30DaysRange() { - Calendar startCalendar = Calendar.getInstance(); - startCalendar.setTime(new Date()); - startCalendar.add(Calendar.DAY_OF_YEAR, -30); - - prefs.edit().putLong("statistic_range_start_date", -30).commit(); - diffDateTextView.setText(getResources().getString(R.string.label_time_period_last_30_days)); - - List rangeScaleMeasurementList = OpenScale.getInstance().getScaleMeasurementOfStartDate(startCalendar.get(Calendar.YEAR), startCalendar.get(Calendar.MONTH), startCalendar.get(Calendar.DAY_OF_MONTH)); - - updateStatistic(rangeScaleMeasurementList); - } - - private void setReferenceDay(Date selectionDate) { - Calendar startCalendar = Calendar.getInstance(); - startCalendar.setTime(selectionDate); - List rangeScaleMeasurementList = OpenScale.getInstance().getScaleMeasurementOfStartDate(startCalendar.get(Calendar.YEAR), startCalendar.get(Calendar.MONTH), startCalendar.get(Calendar.DAY_OF_MONTH)); - - prefs.edit().putLong("statistic_range_start_date", startCalendar.getTime().getTime()).commit(); - prefs.edit().putLong("statistic_range_end_date", -1).commit(); - - diffDateTextView.setText(DateFormat.getDateInstance(DateFormat.MEDIUM).format(startCalendar.getTime())); - - updateStatistic(rangeScaleMeasurementList); - } - - private void setCustomRange(Date begin, Date end) { - Calendar startCalendar = Calendar.getInstance(); - startCalendar.setTime(begin); - Calendar endCalendar = Calendar.getInstance(); - endCalendar.setTime(end); - - setDiffDateText(startCalendar.getTime(), endCalendar.getTime()); - - List rangeScaleMeasurementList = OpenScale.getInstance().getScaleMeasurementOfRangeDates(startCalendar.get(Calendar.YEAR), startCalendar.get(Calendar.MONTH), startCalendar.get(Calendar.DAY_OF_MONTH), - endCalendar.get(Calendar.YEAR), endCalendar.get(Calendar.MONTH), endCalendar.get(Calendar.DAY_OF_MONTH)); - - prefs.edit().putLong("statistic_range_start_date", startCalendar.getTime().getTime()).commit(); - prefs.edit().putLong("statistic_range_end_date", endCalendar.getTime().getTime()).commit(); - - ScaleMeasurement firstMeasurement; - ScaleMeasurement lastMeasurement; - - if (rangeScaleMeasurementList.isEmpty()) { - firstMeasurement = new ScaleMeasurement(); - lastMeasurement = new ScaleMeasurement(); - } else if (rangeScaleMeasurementList.size() == 1) { - firstMeasurement = rangeScaleMeasurementList.get(0); - lastMeasurement = rangeScaleMeasurementList.get(0); - } else { - firstMeasurement = rangeScaleMeasurementList.get(rangeScaleMeasurementList.size() - 1); - lastMeasurement = rangeScaleMeasurementList.get(0); - } - - setDiffDateText(firstMeasurement.getDateTime(), lastMeasurement.getDateTime()); - - updateStatistic(rangeScaleMeasurementList); - } - - private final CalendarConstraints getCalendarConstraints() { - List dateValidatorList = new ArrayList<>(); - - CalendarConstraints.DateValidator selectedDateValidator = new CalendarConstraints.DateValidator() { - @Override - public boolean isValid(long date) { - Calendar dateCalendar = Calendar.getInstance(); - dateCalendar.setTime(new Date(date)); - - List dateScaleMeasurementList = OpenScale.getInstance().getScaleMeasurementOfDay(dateCalendar.get(Calendar.YEAR), dateCalendar.get(Calendar.MONTH), dateCalendar.get(Calendar.DAY_OF_MONTH)); - - if (!dateScaleMeasurementList.isEmpty()) { - return true; - } - - return false; - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(@NonNull Parcel parcel, int i) { - - } - }; - dateValidatorList.add(DateValidatorPointForward.from(scaleMeasurementList.get(scaleMeasurementList.size()-1).getDateTime().getTime())); - dateValidatorList.add(DateValidatorPointBackward.before(scaleMeasurementList.get(0).getDateTime().getTime())); - dateValidatorList.add(selectedDateValidator); - - CalendarConstraints constraintsBuilderRange = new CalendarConstraints.Builder().setValidator(CompositeDateValidator.allOf(dateValidatorList)).build(); - - return constraintsBuilderRange; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/table/StickyHeaderTableView.java b/android_app/app/src/main/java/com/health/openscale/gui/table/StickyHeaderTableView.java deleted file mode 100644 index 55e21aa0..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/table/StickyHeaderTableView.java +++ /dev/null @@ -1,1466 +0,0 @@ -package com.health.openscale.gui.table; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.PorterDuff; -import android.graphics.Rect; -import android.graphics.drawable.Drawable; -import android.os.Build; -import android.text.StaticLayout; -import android.text.TextPaint; -import android.util.AttributeSet; -import android.util.DisplayMetrics; -import android.util.SparseIntArray; -import android.util.TypedValue; -import android.view.GestureDetector; -import android.view.MotionEvent; -import android.view.View; -import android.view.animation.DecelerateInterpolator; - -import androidx.core.view.NestedScrollingChild; -import androidx.core.view.NestedScrollingChildHelper; -import androidx.core.view.ViewCompat; - -import com.health.openscale.R; -import com.health.openscale.gui.utils.ColorUtil; - -/** - * Created by Mitul Varmora on 11/8/2016. - * StickyHeaderTableView, see https://github.com/MitulVarmora/StickyHeaderTableView - * MIT License - * modified 2023 by olie.xdev - */ - -public class StickyHeaderTableView extends View implements NestedScrollingChild { - private final Paint paintStrokeRect = new Paint(); - private final Paint paintHeaderCellFillRect = new Paint(); - private final Paint paintContentCellFillRect = new Paint(); - private final TextPaint paintLabelText = new TextPaint(); - private final Paint paintDrawable = new Paint(); - - private final TextPaint paintHeaderText = new TextPaint(); - private final Rect textRectBounds = new Rect(); - - private int maxMeasure = 0; - - /** - * Visible rect size of view which is displayed on screen - */ - private final Rect visibleContentRect = new Rect(0, 0, 0, 0); - /** - * based on scrolling this rect value will update - */ - private final Rect scrolledRect = new Rect(0, 0, 0, 0); - /** - * Actual rect size of canvas drawn content (Which may be larger or smaller than mobile screen) - */ - private final Rect actualContentRect = new Rect(0, 0, 0, 0); - // below variables are used for fling animation (Not for scrolling) - private final DecelerateInterpolator animateInterpolator = new DecelerateInterpolator(); - private NestedScrollingChildHelper nestedScrollingChildHelper; - private int NESTED_SCROLL_AXIS = ViewCompat.SCROLL_AXIS_NONE; - private OnTableCellClickListener onTableCellClickListener = null; - private boolean isScrollingHorizontally = false; - private boolean isScrollingVertically = false; - /** - * This is used to stop fling animation if user has touch intercepted - */ - private boolean isFlinging = false; - // Below are configurable variables via xml (also can be used via setter methods) - private boolean isDisplayLeftHeadersVertically = false; - private boolean is2DScrollingEnabled; - private boolean isWrapHeightOfEachRow = false; - private boolean isWrapWidthOfEachColumn = false; - private int textLabelColor; - private int textHeaderColor; - private int dividerColor; - private int textLabelSize; - private int textHeaderSize; - private int dividerThickness; - private int headerCellFillColor; - private int contentCellFillColor; - private int cellPadding; - /** - * Used to identify clicked position for #OnTableCellClickListener - */ - private Rect[][] rectEachCellBoundData = new Rect[][]{}; - private Object[][] data = null; - private int maxWidthOfCell = 0; - private int maxHeightOfCell = 0; - private SparseIntArray maxHeightSparseIntArray = new SparseIntArray(); - private SparseIntArray maxWidthSparseIntArray = new SparseIntArray(); - /** - * Used for scroll events - */ - private GestureDetector gestureDetector; - private long startTime; - private long endTime; - private float totalAnimDx; - private float totalAnimDy; - private float lastAnimDx; - private float lastAnimDy; - - public interface OnTableCellClickListener { - public void onTableCellClicked(int rowPosition, int columnPosition); - } - - public StickyHeaderTableView(Context context) { - this(context, null, 0); - } - - public StickyHeaderTableView(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public StickyHeaderTableView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - - final int defaultTextSize = (int) dpToPixels(getContext(), 14); - - TypedArray a = context.getTheme().obtainStyledAttributes( - attrs, R.styleable.StickyHeaderTableView, defStyleAttr, defStyleAttr); - - if (a != null) { - try { - textLabelColor = a.getColor( - R.styleable.StickyHeaderTableView_shtv_textLabelColor, Color.BLACK); - textHeaderColor = a.getColor( - R.styleable.StickyHeaderTableView_shtv_textHeaderColor, Color.BLACK); - dividerColor = a.getColor( - R.styleable.StickyHeaderTableView_shtv_dividerColor, Color.BLACK); - - textLabelSize = a.getDimensionPixelSize( - R.styleable.StickyHeaderTableView_shtv_textLabelSize, defaultTextSize); - textHeaderSize = a.getDimensionPixelSize( - R.styleable.StickyHeaderTableView_shtv_textHeaderSize, defaultTextSize); - dividerThickness = a.getDimensionPixelSize(R.styleable.StickyHeaderTableView_shtv_dividerThickness, 0); - cellPadding = a.getDimensionPixelSize(R.styleable.StickyHeaderTableView_shtv_cellPadding, 0); - - is2DScrollingEnabled = a.getBoolean(R.styleable.StickyHeaderTableView_shtv_is2DScrollEnabled, false); - isDisplayLeftHeadersVertically = a.getBoolean(R.styleable.StickyHeaderTableView_shtv_isDisplayLeftHeadersVertically, false); - isWrapHeightOfEachRow = a.getBoolean(R.styleable.StickyHeaderTableView_shtv_isWrapHeightOfEachRow, false); - isWrapWidthOfEachColumn = a.getBoolean(R.styleable.StickyHeaderTableView_shtv_isWrapWidthOfEachColumn, false); - - headerCellFillColor = a.getColor( - R.styleable.StickyHeaderTableView_shtv_headerCellFillColor, Color.TRANSPARENT); - - contentCellFillColor = a.getColor( - R.styleable.StickyHeaderTableView_shtv_contentCellFillColor, Color.TRANSPARENT); - - } catch (Exception e) { - textLabelColor = Color.BLACK; - textHeaderColor = Color.BLACK; - dividerColor = Color.BLACK; - textLabelSize = defaultTextSize; - textHeaderSize = defaultTextSize; - dividerThickness = 0; - cellPadding = 0; - is2DScrollingEnabled = false; - headerCellFillColor = Color.TRANSPARENT; - contentCellFillColor = Color.TRANSPARENT; - } finally { - a.recycle(); - } - } else { - textLabelColor = Color.BLACK; - textHeaderColor = Color.BLACK; - dividerColor = Color.BLACK; - textLabelSize = defaultTextSize; - textHeaderSize = defaultTextSize; - dividerThickness = 0; - cellPadding = 0; - is2DScrollingEnabled = false; - headerCellFillColor = Color.TRANSPARENT; - contentCellFillColor = Color.TRANSPARENT; - } - - setupPaint(); - setupScrolling(); - } - - private float dpToPixels(Context context, float dpValue) { - DisplayMetrics metrics = context.getResources().getDisplayMetrics(); - return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dpValue, metrics); - } - - private void setupPaint() { - paintStrokeRect.setStyle(Paint.Style.STROKE); - paintStrokeRect.setColor(dividerColor); - paintStrokeRect.setStrokeWidth(dividerThickness); - - paintHeaderCellFillRect.setStyle(Paint.Style.FILL); - paintHeaderCellFillRect.setColor(headerCellFillColor); - - paintContentCellFillRect.setStyle(Paint.Style.FILL); - paintContentCellFillRect.setColor(contentCellFillColor); - - paintLabelText.setStyle(Paint.Style.FILL); - paintLabelText.setColor(textLabelColor); - paintLabelText.setTextSize(textLabelSize); - paintLabelText.setTextAlign(Paint.Align.LEFT); - - paintHeaderText.setStyle(Paint.Style.FILL); - paintHeaderText.setColor(textHeaderColor); - paintHeaderText.setTextSize(textHeaderSize); - paintHeaderText.setTextAlign(Paint.Align.LEFT); - } - - private void setupScrolling() { - - nestedScrollingChildHelper = new NestedScrollingChildHelper(this); - - GestureDetector.SimpleOnGestureListener simpleOnGestureListener = new GestureDetector.SimpleOnGestureListener() { - - public boolean onDown(MotionEvent e) { - if (isNestedScrollingEnabled()) { - startNestedScroll(NESTED_SCROLL_AXIS); - } - if (isFlinging) { - isFlinging = false; - } - return true; - } - - public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { - if (isNestedScrollingEnabled()) { - dispatchNestedPreFling(velocityX, velocityY); - } - - if (!canScrollHorizontally() && !canScrollVertically()) { - return false; - } - - final float distanceTimeFactor = 0.4f; - totalAnimDx = (distanceTimeFactor * velocityX / 2); - totalAnimDy = (distanceTimeFactor * velocityY / 2); - lastAnimDx = 0; - lastAnimDy = 0; - startTime = System.currentTimeMillis(); - endTime = startTime + (long) (1000 * distanceTimeFactor); - - float deltaY = e2.getY() - e1.getY(); - float deltaX = e2.getX() - e1.getX(); - - if (!is2DScrollingEnabled) { - if (Math.abs(deltaX) > Math.abs(deltaY)) { - isScrollingHorizontally = true; - } else { - isScrollingVertically = true; - } - } - isFlinging = true; - - if (onFlingAnimateStep()) { - if (isNestedScrollingEnabled()) { - dispatchNestedFling(-velocityX, -velocityY, true); - } - return true; - } else { - if (isNestedScrollingEnabled()) { - dispatchNestedFling(-velocityX, -velocityY, false); - } - return false; - } - - } - - public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { - - if (isNestedScrollingEnabled()) { - dispatchNestedPreScroll((int) distanceX, (int) distanceY, null, null); - } - - boolean isScrolled; - - if (is2DScrollingEnabled) { - isScrolled = scroll2D(distanceX, distanceY); - } else { - - if (isScrollingHorizontally) { - isScrolled = scrollHorizontal(distanceX); - } else if (isScrollingVertically) { - isScrolled = scrollVertical(distanceY); - } else { - - float deltaY = e2.getY() - e1.getY(); - float deltaX = e2.getX() - e1.getX(); - - if (Math.abs(deltaX) > Math.abs(deltaY)) { - // if deltaX > 0 : the user made a sliding right gesture - // else : the user made a sliding left gesture - isScrollingHorizontally = true; - isScrolled = scrollHorizontal(distanceX); - } else { - // if deltaY > 0 : the user made a sliding down gesture - // else : the user made a sliding up gesture - isScrollingVertically = true; - isScrolled = scrollVertical(distanceY); - } - } - } - - // Fix scrolling (if any parent view is scrollable in layout hierarchy, - // than this will disallow intercepting touch event) - if (getParent() != null && isScrolled) { - getParent().requestDisallowInterceptTouchEvent(true); - } - - if (isScrolled) { - if (isNestedScrollingEnabled()) { - dispatchNestedScroll((int) distanceX, (int) distanceY, 0, 0, null); - } - } else { - if (isNestedScrollingEnabled()) { - dispatchNestedScroll(0, 0, (int) distanceX, (int) distanceY, null); - } - } - - return isScrolled; - } - - public boolean onSingleTapUp(MotionEvent e) { - - if (onTableCellClickListener != null) { - - final float x = e.getX(); - final float y = e.getY(); - - boolean isEndLoop = false; - - for (int i = 0; i < rectEachCellBoundData.length; i++) { - - if (rectEachCellBoundData[i][0].top <= y && rectEachCellBoundData[i][0].bottom >= y) { - - for (int j = 0; j < rectEachCellBoundData[0].length; j++) { - - if (rectEachCellBoundData[i][j].left <= x && rectEachCellBoundData[i][j].right >= x) { - isEndLoop = true; - onTableCellClickListener.onTableCellClicked(i, j); - break; - } - } - } - if (isEndLoop) { - break; - } - } - } - - return super.onSingleTapUp(e); - } - - public void onLongPress(MotionEvent e) { - super.onLongPress(e); - } - - public boolean onDoubleTapEvent(MotionEvent e) { - return super.onDoubleTapEvent(e); - } - - }; - gestureDetector = new GestureDetector(getContext(), simpleOnGestureListener); - } - - /** - * This will start fling animation - * - * @return true if fling animation consumed - */ - private boolean onFlingAnimateStep() { - - boolean isScrolled = false; - - long curTime = System.currentTimeMillis(); - float percentTime = (float) (curTime - startTime) / (float) (endTime - startTime); - float percentDistance = animateInterpolator.getInterpolation(percentTime); - float curDx = percentDistance * totalAnimDx; - float curDy = percentDistance * totalAnimDy; - - float distanceX = curDx - lastAnimDx; - float distanceY = curDy - lastAnimDy; - lastAnimDx = curDx; - lastAnimDy = curDy; - - if (is2DScrollingEnabled) { - isScrolled = scroll2D(-distanceX, -distanceY); - } else if (isScrollingHorizontally) { - isScrolled = scrollHorizontal(-distanceX); - } else if (isScrollingVertically) { - isScrolled = scrollVertical(-distanceY); - } - - // This will stop fling animation if user has touch intercepted - if (!isFlinging) { - return false; - } - - if (percentTime < 1.0f) { - // fling animation running - post(this::onFlingAnimateStep); - } else { - // fling animation ended - isFlinging = false; - isScrollingVertically = false; - isScrollingHorizontally = false; - } - return isScrolled; - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - - int desiredWidth = 0; - int desiredHeight = 0; - - if (data != null) { - updateMaxWidthHeightOfCell(); - if (isWrapHeightOfEachRow) { - - for (int i = 0; i < maxHeightSparseIntArray.size(); i++) { - desiredHeight = desiredHeight + maxHeightSparseIntArray.get(i, 0); - } - desiredHeight = desiredHeight + (dividerThickness / 2); - } else { - desiredHeight = maxHeightOfCell * data.length + (dividerThickness / 2); - } - - if (isWrapWidthOfEachColumn) { - - for (int i = 0; i < maxWidthSparseIntArray.size(); i++) { - desiredWidth = desiredWidth + maxWidthSparseIntArray.get(i, 0); - } - desiredWidth = desiredWidth + (dividerThickness / 2); - - } else { - desiredWidth = maxWidthOfCell * data[0].length + (dividerThickness / 2); - } - - scrolledRect.set(0, 0, desiredWidth, desiredHeight); - actualContentRect.set(0, 0, desiredWidth, desiredHeight); - } - - int widthMode = MeasureSpec.getMode(widthMeasureSpec); - int widthSize = MeasureSpec.getSize(widthMeasureSpec); - int heightMode = MeasureSpec.getMode(heightMeasureSpec); - int heightSize = MeasureSpec.getSize(heightMeasureSpec); - - int width; - int height; - - //Measure Width - if (widthMode == MeasureSpec.EXACTLY) { - //Must be this size - width = widthSize; - } else if (widthMode == MeasureSpec.AT_MOST) { - //Can't be bigger than... - width = Math.min(desiredWidth, widthSize); - } else { - //Be whatever you want - width = desiredWidth; - } - - //Measure Height - if (heightMode == MeasureSpec.EXACTLY) { - //Must be this size - height = heightSize; - } else if (heightMode == MeasureSpec.AT_MOST) { - //Can't be bigger than... - height = Math.min(desiredHeight, heightSize); - } else { - //Be whatever you want - height = desiredHeight; - } - - //MUST CALL THIS - setMeasuredDimension(width, height); - } - - /** - * Calculate and update max width height of cell
- * Required for onMeasure() method - */ - private void updateMaxWidthHeightOfCell() { - // call only once otherwise it is very cpu time consuming - if (maxMeasure > 0) { - return; - } - maxMeasure++; - - maxWidthOfCell = 0; - maxHeightOfCell = 0; - maxHeightSparseIntArray.clear(); - maxWidthSparseIntArray.clear(); - - final int doubleCellPadding = cellPadding + cellPadding; - - for (int i = 0; i < data.length; i++) { - - for (int j = 0; j < data[0].length; j++) { - - if (i == 0 && j == 0) { -// data[0][0] = "xx"; - - if (data[i][j] instanceof String) { - String str = (String)data[i][j]; - paintHeaderText.getTextBounds(str, 0, str.length(), textRectBounds); - } else if (data[i][j] instanceof Drawable) { - Drawable icon = (Drawable) data[i][j]; - textRectBounds.set(0,0, icon.getIntrinsicWidth() + 30, icon.getIntrinsicHeight()); - } - - if (maxWidthOfCell < textRectBounds.width()) { - maxWidthOfCell = textRectBounds.width(); - } - if (maxHeightOfCell < textRectBounds.height()) { - maxHeightOfCell = textRectBounds.height(); - } - - if (maxWidthSparseIntArray.get(j, 0) < textRectBounds.width()) { - maxWidthSparseIntArray.put(j, textRectBounds.width()); - } - if (maxHeightSparseIntArray.get(i, 0) < textRectBounds.height()) { - maxHeightSparseIntArray.put(i, textRectBounds.height()); - } - } else if (i == 0) { - // Top headers cells - - if (data[i][j] instanceof String) { - String str = (String)data[i][j]; - paintHeaderText.getTextBounds(str, 0, str.length(), textRectBounds); - } else if (data[i][j] instanceof Drawable) { - Drawable icon = (Drawable) data[i][j]; - textRectBounds.set(0,0,icon.getIntrinsicWidth() + 30, icon.getIntrinsicHeight()); - } - if (maxWidthOfCell < textRectBounds.width()) { - maxWidthOfCell = textRectBounds.width(); - } - if (maxHeightOfCell < textRectBounds.height()) { - maxHeightOfCell = textRectBounds.height(); - } - - if (maxWidthSparseIntArray.get(j, 0) < textRectBounds.width()) { - maxWidthSparseIntArray.put(j, textRectBounds.width()); - } - if (maxHeightSparseIntArray.get(i, 0) < textRectBounds.height()) { - maxHeightSparseIntArray.put(i, textRectBounds.height()); - } - } else if (j == 0) { - // Left headers cells - if (data[i][j] instanceof String) { - String str = (String)data[i][j]; - if (str.indexOf("\n") != -1) { - String[] split = str.split("\n"); - - if (split[0].length() >= split[1].length()) { - str = split[0]; - } else { - str = split[1]; - } - } - paintHeaderText.getTextBounds(str, 0, str.length(), textRectBounds); - } else if (data[i][j] instanceof Drawable) { - Drawable icon = (Drawable) data[i][j]; - textRectBounds.set(0,0,icon.getIntrinsicWidth(), icon.getIntrinsicHeight() / 2); - } - - if (isDisplayLeftHeadersVertically) { - - if (maxWidthOfCell < textRectBounds.height()) { - maxWidthOfCell = textRectBounds.height(); - } - if (maxHeightOfCell < textRectBounds.width()) { - maxHeightOfCell = textRectBounds.width(); - } - - if (maxWidthSparseIntArray.get(j, 0) < textRectBounds.height()) { - maxWidthSparseIntArray.put(j, textRectBounds.height()); - } - if (maxHeightSparseIntArray.get(i, 0) < textRectBounds.width()) { - maxHeightSparseIntArray.put(i, textRectBounds.width()); - } - - } else { - - if (maxWidthOfCell < textRectBounds.width()) { - maxWidthOfCell = textRectBounds.width(); - } - if (maxHeightOfCell < textRectBounds.height()) { - maxHeightOfCell = textRectBounds.height(); - } - - if (maxWidthSparseIntArray.get(j, 0) < textRectBounds.width()) { - maxWidthSparseIntArray.put(j, textRectBounds.width()); - } - if (maxHeightSparseIntArray.get(i, 0) < textRectBounds.height()) { - maxHeightSparseIntArray.put(i, textRectBounds.height()); - } - } - } else { - // Other content cells - if (data[i][j] instanceof String) { - String str = (String)data[i][j]; - - if (str.indexOf("\n") != -1) { - String[] split = str.split("\n"); - - // split.length == 1 handle the case when str has only 1 \n and it is at the end. - if (split.length == 1 || split[0].length() >= split[1].length()) { - str = split[0]; - } else { - str = split[1]; - } - } - paintLabelText.getTextBounds(str, 0, str.length(), textRectBounds); - } else if (data[i][j] instanceof Drawable) { - Drawable icon = (Drawable) data[i][j]; - textRectBounds.set(0,0,icon.getIntrinsicWidth(), icon.getIntrinsicHeight() / 2); - } - - if (maxWidthOfCell < textRectBounds.width()) { - maxWidthOfCell = textRectBounds.width(); - } - if (maxHeightOfCell < textRectBounds.height()) { - maxHeightOfCell = textRectBounds.height(); - } - - if (maxWidthSparseIntArray.get(j, 0) < textRectBounds.width()) { - maxWidthSparseIntArray.put(j, textRectBounds.width()); - } - if (maxHeightSparseIntArray.get(i, 0) < textRectBounds.height()) { - maxHeightSparseIntArray.put(i, textRectBounds.height()); - } - } - } - } - maxWidthOfCell = maxWidthOfCell + doubleCellPadding; - maxHeightOfCell = maxHeightOfCell + doubleCellPadding; - - for (int i = 0; i < maxHeightSparseIntArray.size(); i++) { - maxHeightSparseIntArray.put(i, maxHeightSparseIntArray.get(i, 0) + doubleCellPadding); - } - - for (int i = 0; i < maxWidthSparseIntArray.size(); i++) { - maxWidthSparseIntArray.put(i, maxWidthSparseIntArray.get(i, 0) + doubleCellPadding); - } - } - - @Override - protected void onSizeChanged(int w, int h, int oldW, int oldH) { - super.onSizeChanged(w, h, oldW, oldH); - - visibleContentRect.set(0, 0, w, h); - } - - @Override - protected void onDraw(Canvas canvas) { - super.onDraw(canvas); - - if (data == null) { - return; - } - - - int cellLeftX; - int cellTopY = scrolledRect.top; - int cellRightX; - int cellBottomY = scrolledRect.top + getHeightOfRow(0); - int halfDividerThickness = dividerThickness / 2; - - float drawTextX; - float drawTextY; - String textToDraw; - Drawable iconToDraw; - - // *************************** Calculate each cells to draw ************************** - // This is top-left most cell (0,0) - updateRectPointData(0, 0, halfDividerThickness, halfDividerThickness, getWidthOfColumn(0), getHeightOfRow(0)); - - for (int i = 0; i < data.length; i++) { - cellRightX = scrolledRect.left; - int heightOfRowI = getHeightOfRow(i); - if (i == 0) { - cellTopY = halfDividerThickness; - for (int j = 0; j < data[i].length; j++) { - cellLeftX = cellRightX - halfDividerThickness; - cellRightX += getWidthOfColumn(j); - if (j != 0) { - // This are top header cells (0,*) - updateRectPointData(i, j, cellLeftX, cellTopY, cellRightX, heightOfRowI); - } - } - cellBottomY = scrolledRect.top + getHeightOfRow(i); - } else { - // These are content cells - for (int j = 0; j < data[0].length; j++) { - cellLeftX = cellRightX - halfDividerThickness; - cellRightX += getWidthOfColumn(j); - if (j != 0) { - updateRectPointData(i, j, cellLeftX, cellTopY, cellRightX, cellBottomY); - } - } - - // This are left header cells (*,0) - cellRightX = 0; - cellLeftX = cellRightX + halfDividerThickness; - cellRightX += getWidthOfColumn(0); - updateRectPointData(i, 0, cellLeftX, cellTopY, cellRightX, cellBottomY); - } - cellTopY = cellBottomY - halfDividerThickness; - cellBottomY = cellBottomY + getHeightOfRow(i + 1); - } - - // ******************** Draw contents & left headers ******************** - boolean isLeftVisible; - boolean isTopVisible; - boolean isRightVisible; - boolean isBottomVisible; - - for (int i = 1; i < data.length; i++) { - isTopVisible = rectEachCellBoundData[i][0].top >= rectEachCellBoundData[0][0].bottom - && rectEachCellBoundData[i][0].top <= visibleContentRect.bottom; - isBottomVisible = rectEachCellBoundData[i][0].bottom >= rectEachCellBoundData[0][0].bottom - && rectEachCellBoundData[i][0].bottom <= visibleContentRect.bottom; - - if (isTopVisible || isBottomVisible) { - - // ******************** Draw contents ******************** - for (int j = 1; j < data[i].length; j++) { - isLeftVisible = rectEachCellBoundData[i][j].left >= rectEachCellBoundData[i][0].right - && rectEachCellBoundData[i][j].left <= visibleContentRect.right; - isRightVisible = rectEachCellBoundData[i][j].right >= rectEachCellBoundData[i][0].right - && rectEachCellBoundData[i][j].right <= visibleContentRect.right; - - if (isLeftVisible || isRightVisible) { - canvas.drawRect(rectEachCellBoundData[i][j].left, rectEachCellBoundData[i][j].top, rectEachCellBoundData[i][j].right, rectEachCellBoundData[i][j].bottom, paintContentCellFillRect); - if (dividerThickness != 0) { - canvas.drawRect(rectEachCellBoundData[i][j].left, rectEachCellBoundData[i][j].top, rectEachCellBoundData[i][j].right, rectEachCellBoundData[i][j].bottom, paintStrokeRect); - } - - textToDraw = (String)data[i][j]; - // paintLabelText.getTextBounds(textToDraw, 0, textToDraw.length(), textRectBounds); - - drawTextX = rectEachCellBoundData[i][j].right - getWidthOfColumn(j) + getCellPadding(); - drawTextY = rectEachCellBoundData[i][j].bottom - (getHeightOfRow(i)) + (textRectBounds.height() / 2f); - - StaticLayout staticLayout = StaticLayout.Builder.obtain(textToDraw, 0, textToDraw.length(), paintLabelText, getWidthOfColumn(j)).build(); - - canvas.save(); - canvas.translate(drawTextX, drawTextY); - staticLayout.draw(canvas); - canvas.restore(); - - //canvas.drawText(textToDraw, 0, textToDraw.length(), drawTextX, drawTextY, paintLabelText); - } - } - - // ******************** Draw left header (*,0) ******************** - canvas.drawRect(rectEachCellBoundData[i][0].left, rectEachCellBoundData[i][0].top, rectEachCellBoundData[i][0].right, rectEachCellBoundData[i][0].bottom, paintHeaderCellFillRect); - if (dividerThickness != 0) { - canvas.drawRect(rectEachCellBoundData[i][0].left, rectEachCellBoundData[i][0].top, rectEachCellBoundData[i][0].right, rectEachCellBoundData[i][0].bottom, paintStrokeRect); - } - - textToDraw = (String)data[i][0]; - // paintHeaderText.getTextBounds(textToDraw, 0, textToDraw.length(), textRectBounds); - - if (isDisplayLeftHeadersVertically) { - drawTextX = rectEachCellBoundData[i][0].right - (getWidthOfColumn(0)) + (textRectBounds.height()); - drawTextY = rectEachCellBoundData[i][0].bottom - getCellPadding(); - - StaticLayout staticLayout = StaticLayout.Builder.obtain(textToDraw, 0, textToDraw.length(), paintHeaderText, getHeightOfRow(i)).build(); - - canvas.save(); - canvas.translate(drawTextX, drawTextY); - canvas.rotate(-90); - staticLayout.draw(canvas); - //canvas.drawText(textToDraw, 0, textToDraw.length(), drawTextX, drawTextY, paintHeaderText); - canvas.restore(); - } else { - drawTextX = rectEachCellBoundData[i][0].right - getWidthOfColumn(0) + getCellPadding(); - drawTextY = rectEachCellBoundData[i][0].bottom - (getHeightOfRow(i)) + (textRectBounds.height() / 2f); - - StaticLayout staticLayout = StaticLayout.Builder.obtain(textToDraw, 0, textToDraw.length(), paintLabelText, getWidthOfColumn(0)).build(); - - canvas.save(); - canvas.translate(drawTextX, drawTextY); - staticLayout.draw(canvas); - canvas.restore(); - - //canvas.drawText(textToDraw, 0, textToDraw.length(), drawTextX, drawTextY, paintHeaderText); - } - } - } - - // ******************** Draw top headers (0,*) ******************** - for (int j = 1; j < data[0].length; j++) { - isLeftVisible = rectEachCellBoundData[0][j].left >= rectEachCellBoundData[0][0].right - && rectEachCellBoundData[0][j].left <= visibleContentRect.right; - isRightVisible = rectEachCellBoundData[0][j].right >= rectEachCellBoundData[0][0].right - && rectEachCellBoundData[0][j].right <= visibleContentRect.right; - - if (isLeftVisible || isRightVisible) { - canvas.drawRect(rectEachCellBoundData[0][j].left, rectEachCellBoundData[0][j].top, rectEachCellBoundData[0][j].right, rectEachCellBoundData[0][j].bottom, paintHeaderCellFillRect); - if (dividerThickness != 0) { - canvas.drawRect(rectEachCellBoundData[0][j].left, rectEachCellBoundData[0][j].top, rectEachCellBoundData[0][j].right, rectEachCellBoundData[0][j].bottom, paintStrokeRect); - } - - if (data[0][j] instanceof String) { - textToDraw = (String)data[0][j]; - // paintHeaderText.getTextBounds(textToDraw, 0, textToDraw.length(), textRectBounds); - - drawTextX = rectEachCellBoundData[0][j].right - (getWidthOfColumn(j) / 2f) - (textRectBounds.width() / 2f); - drawTextY = rectEachCellBoundData[0][j].bottom - (getHeightOfRow(0) / 2f) + (textRectBounds.height() / 2f); - - canvas.drawText(textToDraw, 0, textToDraw.length(), drawTextX, drawTextY, paintHeaderText); - } else if (data[0][j] instanceof Drawable) { - iconToDraw = (Drawable) data[0][j]; - - drawTextX = rectEachCellBoundData[0][j].right - (getWidthOfColumn(j) / 2f) - (iconToDraw.getIntrinsicWidth() / 2f); - //drawTextY = rectEachCellBoundData[0][j].bottom - (getHeightOfRow(0) / 2f) + (iconToDraw.getIntrinsicHeight() / 2f); - - iconToDraw.setBounds((int)drawTextX, 25, (int)drawTextX + iconToDraw.getIntrinsicWidth(), 25 + iconToDraw.getIntrinsicHeight()); - - // draw circle with the tinted icon color and tint the icon with black - paintDrawable.setColorFilter(iconToDraw.getColorFilter()); - iconToDraw.setColorFilter(ColorUtil.COLOR_BLACK, PorterDuff.Mode.SRC_ATOP); - canvas.drawOval((int)drawTextX-25, 10, drawTextX+ iconToDraw.getIntrinsicWidth()+25, 45 + iconToDraw.getIntrinsicHeight(), paintDrawable); - - iconToDraw.draw(canvas); - - // save the tinted icon color back to the icon - iconToDraw.setColorFilter(paintDrawable.getColorFilter()); - } - } - } - - // ******************** Draw top-left most cell (0,0) ******************** - canvas.drawRect(rectEachCellBoundData[0][0].left, rectEachCellBoundData[0][0].top, rectEachCellBoundData[0][0].right, rectEachCellBoundData[0][0].bottom, paintHeaderCellFillRect); - - if (dividerThickness != 0) { - canvas.drawRect(rectEachCellBoundData[0][0].left, rectEachCellBoundData[0][0].top, rectEachCellBoundData[0][0].right, rectEachCellBoundData[0][0].bottom, paintStrokeRect); - } - - if (data[0][0] instanceof String) { - textToDraw = (String)data[0][0]; - - // paintHeaderText.getTextBounds(textToDraw, 0, textToDraw.length(), textRectBounds); - - drawTextX = getWidthOfColumn(0) - (getWidthOfColumn(0) / 2f) - (textRectBounds.width()/ 2f); - drawTextY = getHeightOfRow(0) - (getHeightOfRow(0) / 2f) + (textRectBounds.height() / 2f); - - canvas.drawText(textToDraw, 0, textToDraw.length(), drawTextX, drawTextY, paintHeaderText); - } else if (data[0][0] instanceof Drawable) { - iconToDraw = (Drawable) data[0][0]; - - drawTextX = getWidthOfColumn(0) - (getWidthOfColumn(0) / 2f) - (iconToDraw.getIntrinsicWidth()/ 2f); - //drawTextY = getHeightOfRow(0) - (getHeightOfRow(0) / 2f) + (iconToDraw.getIntrinsicHeight() / 2f); - iconToDraw.setBounds((int)drawTextX, 25, (int)drawTextX + iconToDraw.getIntrinsicWidth(), 25 + iconToDraw.getIntrinsicHeight()); - - // draw circle with the tinted icon color and tint the icon with black - paintDrawable.setColorFilter(iconToDraw.getColorFilter()); - iconToDraw.setColorFilter(ColorUtil.COLOR_BLACK, PorterDuff.Mode.SRC_ATOP); - canvas.drawOval((int)drawTextX-25, 10, drawTextX+ iconToDraw.getIntrinsicWidth()+25, 45 + iconToDraw.getIntrinsicHeight(), paintDrawable); - - iconToDraw.draw(canvas); - - // save the tinted icon color back to the icon - iconToDraw.setColorFilter(paintDrawable.getColorFilter()); - } - - - // ******************** Draw whole view border same as cell border ******************** - if (dividerThickness != 0) { - canvas.drawRect(visibleContentRect.left, visibleContentRect.top, visibleContentRect.right - halfDividerThickness, visibleContentRect.bottom - halfDividerThickness, paintStrokeRect); - } - } - - private int getWidthOfColumn(int key) { - if (isWrapWidthOfEachColumn) { - return maxWidthSparseIntArray.get(key, 0); - } else { - return maxWidthOfCell; - } - } - - private int getHeightOfRow(int key) { - if (isWrapHeightOfEachRow) { - return maxHeightSparseIntArray.get(key, 0); - } else { - return maxHeightOfCell; - } - } - - /** - * This will update cell bound rect data, which is used for handling cell click event - * - * @param i row position - * @param j column position - * @param cellLeftX leftX - * @param cellTopY topY - * @param cellRightX rightX - * @param cellBottomY bottomY - */ - private void updateRectPointData(int i, int j, int cellLeftX, int cellTopY, int cellRightX, int cellBottomY) { - if (rectEachCellBoundData[i][j] == null) { - rectEachCellBoundData[i][j] = new Rect(cellLeftX, cellTopY, cellRightX, cellBottomY); - } else { - rectEachCellBoundData[i][j].left = cellLeftX; - rectEachCellBoundData[i][j].top = cellTopY; - rectEachCellBoundData[i][j].right = cellRightX; - rectEachCellBoundData[i][j].bottom = cellBottomY; - } - } - - @SuppressLint("ClickableViewAccessibility") - @Override - public boolean onTouchEvent(MotionEvent event) { - super.onTouchEvent(event); - - switch (event.getActionMasked()) { - - case MotionEvent.ACTION_DOWN: - case MotionEvent.ACTION_MOVE: - break; - - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: - isScrollingHorizontally = false; - isScrollingVertically = false; - break; - } - - return gestureDetector.onTouchEvent(event); - //return true; - } - - private void updateLayoutChanges() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { - if (!isInLayout()) { - requestLayout(); - } else { - invalidate(); - } - } else { - requestLayout(); - } - } - - /** - * Check if content width is bigger than view width - * - * @return true if content width is bigger than view width - */ - public boolean canScrollHorizontally() { - return actualContentRect.right > visibleContentRect.right; - } - - /** - * Check if content height is bigger than view height - * - * @return true if content height is bigger than view height - */ - public boolean canScrollVertically() { - return actualContentRect.bottom > visibleContentRect.bottom; - } - - /** - * Scroll horizontally - * - * @param distanceX distance to scroll - * @return true if horizontally scrolled, false otherwise - */ - public boolean scrollHorizontal(float distanceX) { - - if (!canScrollHorizontally() || distanceX == 0) { - return false; - } - - int newScrolledLeft = scrolledRect.left - (int) distanceX; - int newScrolledRight = scrolledRect.right - (int) distanceX; - - if (newScrolledLeft > 0) { - newScrolledLeft = 0; - newScrolledRight = actualContentRect.right; - } else if (newScrolledLeft < -(actualContentRect.right - visibleContentRect.right)) { - newScrolledLeft = -(actualContentRect.right - visibleContentRect.right); - newScrolledRight = visibleContentRect.right; - } - - if (scrolledRect.left == newScrolledLeft) { - return false; - } - scrolledRect.set(newScrolledLeft, scrolledRect.top, newScrolledRight, scrolledRect.bottom); - invalidate(); - return true; - } - - /** - * Scroll vertically - * - * @param distanceY distance to scroll - * @return true if vertically scrolled, false otherwise - */ - public boolean scrollVertical(float distanceY) { - - if (!canScrollVertically() || distanceY == 0) { - return false; - } - - int newScrolledTop = scrolledRect.top - (int) distanceY; - int newScrolledBottom = scrolledRect.bottom - (int) distanceY; - - if (newScrolledTop > 0) { - newScrolledTop = 0; - newScrolledBottom = actualContentRect.bottom; - } else if (newScrolledTop < -(actualContentRect.bottom - visibleContentRect.bottom)) { - newScrolledTop = -(actualContentRect.bottom - visibleContentRect.bottom); - newScrolledBottom = visibleContentRect.bottom; - } - - if (scrolledRect.top == newScrolledTop) { - return false; - } - scrolledRect.set(scrolledRect.left, newScrolledTop, scrolledRect.right, newScrolledBottom); - invalidate(); - return true; - } - - /** - * Scroll vertically & horizontal both side - * - * @param distanceX distance to scroll - * @param distanceY distance to scroll - * @return true if scrolled, false otherwise - */ - public boolean scroll2D(float distanceX, float distanceY) { - - boolean isScrollHappened = false; - int newScrolledLeft; - int newScrolledTop; - int newScrolledRight; - int newScrolledBottom; - - if (canScrollHorizontally()) { - newScrolledLeft = scrolledRect.left - (int) distanceX; - newScrolledRight = scrolledRect.right - (int) distanceX; - - if (newScrolledLeft > 0) { - newScrolledLeft = 0; - } - if (newScrolledLeft < -(actualContentRect.right - visibleContentRect.right)) { - newScrolledLeft = -(actualContentRect.right - visibleContentRect.right); - } - isScrollHappened = true; - } else { - newScrolledLeft = scrolledRect.left; - newScrolledRight = scrolledRect.right; - } - - if (canScrollVertically()) { - newScrolledTop = scrolledRect.top - (int) distanceY; - newScrolledBottom = scrolledRect.bottom - (int) distanceY; - - if (newScrolledTop > 0) { - newScrolledTop = 0; - } - if (newScrolledTop < -(actualContentRect.bottom - visibleContentRect.bottom)) { - newScrolledTop = -(actualContentRect.bottom - visibleContentRect.bottom); - } - isScrollHappened = true; - } else { - newScrolledTop = scrolledRect.top; - newScrolledBottom = scrolledRect.bottom; - } - - if (!isScrollHappened) { - return false; - } - - scrolledRect.set(newScrolledLeft, newScrolledTop, newScrolledRight, newScrolledBottom); - invalidate(); - return true; - } - - /** - * @return true if content are scrollable from top to bottom side - */ - public boolean canScrollTop() { - return scrolledRect.top < visibleContentRect.top; - } - - /** - * @return true if content are scrollable from bottom to top side - */ - public boolean canScrollBottom() { - return scrolledRect.bottom > visibleContentRect.bottom; - } - - /** - * @return true if content are scrollable from left to right side - */ - public boolean canScrollRight() { - return scrolledRect.right > visibleContentRect.right; - } - - /** - * @return true if content are scrollable from right to left side - */ - public boolean canScrollLeft() { - return scrolledRect.left < visibleContentRect.left; - } - - - // *************************** implemented NestedScrollChild methods ******************************************* - - @Override - public boolean isNestedScrollingEnabled() { - return nestedScrollingChildHelper.isNestedScrollingEnabled(); - } - - @Override - public void setNestedScrollingEnabled(boolean enabled) { - nestedScrollingChildHelper.setNestedScrollingEnabled(enabled); - } - - @Override - public boolean hasNestedScrollingParent() { - return nestedScrollingChildHelper.hasNestedScrollingParent(); - } - - /** - * default Nested scroll axis is ViewCompat.SCROLL_AXIS_NONE
- * Nested scroll axis must be one of the
ViewCompat.SCROLL_AXIS_NONE
or ViewCompat.SCROLL_AXIS_HORIZONTAL
or ViewCompat.SCROLL_AXIS_VERTICAL - * - * @param nestedScrollAxis value of nested scroll direction - */ - public void setNestedScrollAxis(int nestedScrollAxis) { - switch (nestedScrollAxis) { - - case ViewCompat.SCROLL_AXIS_HORIZONTAL: - NESTED_SCROLL_AXIS = ViewCompat.SCROLL_AXIS_HORIZONTAL; - break; - case ViewCompat.SCROLL_AXIS_VERTICAL: - NESTED_SCROLL_AXIS = ViewCompat.SCROLL_AXIS_VERTICAL; - break; - default: - NESTED_SCROLL_AXIS = ViewCompat.SCROLL_AXIS_NONE; - break; - } - } - - @Override - public boolean startNestedScroll(int axes) { - return nestedScrollingChildHelper.startNestedScroll(axes); - } - - @Override - public void stopNestedScroll() { - nestedScrollingChildHelper.stopNestedScroll(); - } - - @Override - public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow) { - return nestedScrollingChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow); - } - - @Override - public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) { - return nestedScrollingChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow); - } - - @Override - public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) { - return nestedScrollingChildHelper.dispatchNestedFling(velocityX, velocityY, consumed); - } - - @Override - public boolean dispatchNestedPreFling(float velocityX, float velocityY) { - return nestedScrollingChildHelper.dispatchNestedPreFling(velocityX, velocityY); - } - - @Override - public void onDetachedFromWindow() { - super.onDetachedFromWindow(); - nestedScrollingChildHelper.onDetachedFromWindow(); - } - - // *************************** Getter/Setter methods ******************************************* - - /** - * @return data which is previously set by setData(data) method. otherwise null. - */ - public Object[][] getData() { - return data; - } - - /** - * Set you table content data - * - * @param data table content data - */ - public void setData(Object[][] data) { - this.data = data; - rectEachCellBoundData = new Rect[data.length][data[0].length]; - updateLayoutChanges(); - } - - /** - * set the cell click event - * - * @param onTableCellClickListener tableCellClickListener - */ - public void setOnTableCellClickListener(OnTableCellClickListener onTableCellClickListener) { - this.onTableCellClickListener = onTableCellClickListener; - } - - /** - * enable or disable 2 directional scroll - * - * @param is2DScrollingEnabled true if you wants to enable 2 directional scroll - */ - public void setIs2DScrollingEnabled(boolean is2DScrollingEnabled) { - this.is2DScrollingEnabled = is2DScrollingEnabled; - } - - /** - * Check whether is 2 directional scroll is enabled or not - * - * @return true if 2 directional scroll is enabled - */ - public boolean is2DScrollingEnabled() { - return is2DScrollingEnabled; - } - - /** - * @return text color of the content cells - */ - public int getTextLabelColor() { - return textLabelColor; - } - - /** - * Set text color for content cells - * - * @param textLabelColor color - */ - public void setTextLabelColor(int textLabelColor) { - this.textLabelColor = textLabelColor; - invalidate(); - } - - /** - * @return text color of the header cells - */ - public int getTextHeaderColor() { - return textHeaderColor; - } - - /** - * Set text color for header cells - * - * @param textHeaderColor color - */ - public void setTextHeaderColor(int textHeaderColor) { - this.textHeaderColor = textHeaderColor; - invalidate(); - } - - /** - * @return color of the cell divider or cell border - */ - public int getDividerColor() { - return dividerColor; - } - - /** - * Set divider or border color for cell - * - * @param dividerColor color - */ - public void setDividerColor(int dividerColor) { - this.dividerColor = dividerColor; - invalidate(); - } - - /** - * @return text size in pixels of content cells - */ - public int getTextLabelSize() { - return textLabelSize; - } - - /** - * Set text size in pixels for content cells
- * You can use {@link DisplayMatrixHelper#dpToPixels(Context, float)} method to convert dp to pixel - * - * @param textLabelSize text size in pixels - */ - public void setTextLabelSize(int textLabelSize) { - this.textLabelSize = textLabelSize; - updateLayoutChanges(); - } - - /** - * @return text header size in pixels of header cells - */ - public int getTextHeaderSize() { - return textHeaderSize; - } - - /** - * Set text header size in pixels for header cells
- * You can use {@link DisplayMatrixHelper#dpToPixels(Context, float)} method to convert dp to pixel - * - * @param textHeaderSize text header size in pixels - */ - public void setTextHeaderSize(int textHeaderSize) { - this.textHeaderSize = textHeaderSize; - updateLayoutChanges(); - } - - /** - * @return divider thickness in pixels - */ - public int getDividerThickness() { - return dividerThickness; - } - - /** - * Set divider thickness size in pixels for all cells
- * You can use {@link DisplayMatrixHelper#dpToPixels(Context, float)} method to convert dp to pixel - * - * @param dividerThickness divider thickness size in pixels - */ - public void setDividerThickness(int dividerThickness) { - this.dividerThickness = dividerThickness; - invalidate(); - } - - /** - * @return header cell's fill color - */ - public int getHeaderCellFillColor() { - return headerCellFillColor; - } - - /** - * Set header cell fill color - * - * @param headerCellFillColor color to fill in header cell - */ - public void setHeaderCellFillColor(int headerCellFillColor) { - this.headerCellFillColor = headerCellFillColor; - invalidate(); - } - - /** - * @return content cell's fill color - */ - public int getContentCellFillColor() { - return contentCellFillColor; - } - - /** - * Set content cell fill color - * - * @param contentCellFillColor color to fill in content cell - */ - public void setContentCellFillColor(int contentCellFillColor) { - this.contentCellFillColor = contentCellFillColor; - invalidate(); - } - - /** - * @return cell padding in pixels - */ - public int getCellPadding() { - return cellPadding; - } - - /** - * Set padding for all cell of table
- * You can use {@link DisplayMatrixHelper#dpToPixels(Context, float)} method to convert dp to pixel - * - * @param cellPadding cell padding in pixels - */ - public void setCellPadding(int cellPadding) { - this.cellPadding = cellPadding; - updateLayoutChanges(); - } - - /** - * @return true if left header cell text are displayed vertically enabled - */ - public boolean isDisplayLeftHeadersVertically() { - return isDisplayLeftHeadersVertically; - } - - /** - * Set left header text display vertically or horizontal - * - * @param displayLeftHeadersVertically true if you wants to set left header text display vertically - */ - public void setDisplayLeftHeadersVertically(boolean displayLeftHeadersVertically) { - isDisplayLeftHeadersVertically = displayLeftHeadersVertically; - updateLayoutChanges(); - } - - /** - * @return true if you settled true for wrap height of each row - */ - public boolean isWrapHeightOfEachRow() { - return isWrapHeightOfEachRow; - } - - /** - * Set whether height of each row should wrap or not - * - * @param wrapHeightOfEachRow pass true if you wants to set each row should wrap the height - */ - public void setWrapHeightOfEachRow(boolean wrapHeightOfEachRow) { - isWrapHeightOfEachRow = wrapHeightOfEachRow; - updateLayoutChanges(); - } - - /** - * @return true if you settled true for wrap width of each column - */ - public boolean isWrapWidthOfEachColumn() { - return isWrapWidthOfEachColumn; - } - - /** - * Set whether width of each column should wrap or not - * - * @param wrapWidthOfEachColumn pass true if you wants to set each column should wrap the width - */ - public void setWrapWidthOfEachColumn(boolean wrapWidthOfEachColumn) { - isWrapWidthOfEachColumn = wrapWidthOfEachColumn; - updateLayoutChanges(); - } - - /** - * @return the Rect object which is visible area on screen - */ - public Rect getVisibleContentRect() { - return visibleContentRect; - } - - /** - * @return the Rect object which is last scrolled area from actual content rectangle - */ - public Rect getScrolledRect() { - return scrolledRect; - } - - /** - * @return the Rect object which is actual content area - */ - public Rect getActualContentRect() { - return actualContentRect; - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/table/TableFragment.java b/android_app/app/src/main/java/com/health/openscale/gui/table/TableFragment.java deleted file mode 100644 index 7a8ac115..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/table/TableFragment.java +++ /dev/null @@ -1,169 +0,0 @@ -/* Copyright (C) 2014 olie.xdev -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see -*/ -package com.health.openscale.gui.table; - -import android.graphics.PorterDuff; -import android.graphics.drawable.Drawable; -import android.os.Bundle; -import android.text.SpannableStringBuilder; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ProgressBar; - -import androidx.activity.OnBackPressedCallback; -import androidx.fragment.app.Fragment; -import androidx.lifecycle.Observer; -import androidx.navigation.Navigation; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.gui.measurement.DateMeasurementView; -import com.health.openscale.gui.measurement.MeasurementEntryFragment; -import com.health.openscale.gui.measurement.MeasurementView; -import com.health.openscale.gui.measurement.TimeMeasurementView; -import com.health.openscale.gui.measurement.UserMeasurementView; - -import java.text.DateFormat; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.List; - -public class TableFragment extends Fragment { - private View tableView; - - private ProgressBar progressBar; - private StickyHeaderTableView tableDataView; - - private List measurementViews; - private List scaleMeasurementList; - private ArrayList iconList; - private final DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.SHORT); - private final DateFormat timeFormat = DateFormat.getTimeInstance(DateFormat.SHORT); - private final DateFormat dayFormat = new SimpleDateFormat("EE"); - private final SpannableStringBuilder contentFormat = new SpannableStringBuilder(); - - public TableFragment() { - - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - tableView = inflater.inflate(R.layout.fragment_table, container, false); - - progressBar = tableView.findViewById(R.id.progressBarTable); - tableDataView = tableView.findViewById(R.id.tableDataView); - progressBar.setVisibility(View.VISIBLE); - - tableDataView.setOnTableCellClickListener(new StickyHeaderTableView.OnTableCellClickListener() { - @Override - public void onTableCellClicked(int rowPosition, int columnPosition) { - if (rowPosition > 0) { - TableFragmentDirections.ActionNavTableToNavDataentry action = TableFragmentDirections.actionNavTableToNavDataentry(); - action.setMeasurementId(scaleMeasurementList.get(rowPosition-1).getId()); - action.setMode(MeasurementEntryFragment.DATA_ENTRY_MODE.VIEW); - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigate(action); - } - } - }); - - measurementViews = MeasurementView.getMeasurementList( - getContext(), MeasurementView.DateTimeOrder.FIRST); - - iconList = new ArrayList<>(); - - for (MeasurementView measurementView : measurementViews) { - if (!measurementView.isVisible() || measurementView instanceof UserMeasurementView || measurementView instanceof TimeMeasurementView) { - continue; - } - - measurementView.setUpdateViews(false); - - measurementView.getIcon().setColorFilter(measurementView.getColor(), PorterDuff.Mode.SRC_ATOP); - iconList.add(measurementView.getIcon()); - } - - OpenScale.getInstance().getScaleMeasurementsLiveData().observe(getViewLifecycleOwner(), new Observer>() { - @Override - public void onChanged(List scaleMeasurements) { - updateOnView(scaleMeasurements); - } - }); - - OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) { - @Override - public void handleOnBackPressed() { - requireActivity().finish(); - } - }; - - requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), onBackPressedCallback); - - return tableView; - } - - public void updateOnView(List scaleMeasurementList) - { - this.scaleMeasurementList = scaleMeasurementList; - - Object[][] tableData = new Object[scaleMeasurementList.size()+1][iconList.size()]; - - // add header icons to the first table data row - for (int j=0; j - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - -package com.health.openscale.gui.utils; - -import android.content.Context; -import android.content.SharedPreferences; -import android.content.res.Configuration; -import android.graphics.Color; -import android.preference.PreferenceManager; - -public class ColorUtil { - public static final int COLOR_BLUE = Color.parseColor("#33B5E5"); - public static final int COLOR_VIOLET = Color.parseColor("#AA66CC"); - public static final int COLOR_GREEN = Color.parseColor("#99CC00"); - public static final int COLOR_ORANGE = Color.parseColor("#FFBB33"); - public static final int COLOR_RED = Color.parseColor("#FF4444"); - public static final int COLOR_GRAY = Color.parseColor("#d3d3d3"); - public static final int COLOR_WHITE = Color.parseColor("#ffffff"); - public static final int COLOR_BLACK = Color.parseColor("#000000"); - public static final int[] COLORS = new int[]{COLOR_BLUE, COLOR_VIOLET, COLOR_GREEN, COLOR_ORANGE, COLOR_RED}; - - public static int getPrimaryColor(Context context) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - int nightModeFlags = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; - - if (prefs.getString("app_theme", "Light").equals("Dark") || nightModeFlags == Configuration.UI_MODE_NIGHT_YES) { - return context.getResources().getColor(android.R.color.primary_text_dark); - } - - return context.getResources().getColor(android.R.color.primary_text_light); - } - - public static int getTintColor(Context context) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - int nightModeFlags = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; - - if (prefs.getString("app_theme", "Light").equals("Dark") || nightModeFlags == Configuration.UI_MODE_NIGHT_YES) { - return Color.parseColor("#b3ffffff"); - } - - return Color.parseColor("#de000000"); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/widget/WidgetConfigure.java b/android_app/app/src/main/java/com/health/openscale/gui/widget/WidgetConfigure.java deleted file mode 100644 index 80cfd502..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/widget/WidgetConfigure.java +++ /dev/null @@ -1,127 +0,0 @@ -/* Copyright (C) 2018 Erik Johansson - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - -package com.health.openscale.gui.widget; - -import android.appwidget.AppWidgetManager; -import android.content.Intent; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.view.View; -import android.widget.ArrayAdapter; -import android.widget.Spinner; -import android.widget.TableRow; - -import androidx.appcompat.app.AppCompatActivity; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleUser; -import com.health.openscale.gui.measurement.MeasurementView; - -import java.util.ArrayList; -import java.util.List; - -public class WidgetConfigure extends AppCompatActivity { - private int appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - setResult(RESULT_CANCELED); - - Intent intent = getIntent(); - Bundle extras = intent.getExtras(); - if (extras != null) { - appWidgetId = extras.getInt( - AppWidgetManager.EXTRA_APPWIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID); - } - - if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { - finish(); - } - - setContentView(R.layout.widget_configuration); - - OpenScale openScale = OpenScale.getInstance(); - - // Set up user spinner - final Spinner userSpinner = findViewById(R.id.widget_user_spinner); - List users = new ArrayList<>(); - final List userIds = new ArrayList<>(); - for (ScaleUser scaleUser : openScale.getScaleUserList()) { - users.add(scaleUser.getUserName()); - userIds.add(scaleUser.getId()); - } - - // Hide user selector when there's only one user - if (users.size() == 1) { - TableRow row = (TableRow) userSpinner.getParent(); - row.setVisibility(View.GONE); - } - else if (users.isEmpty()) { - users.add(getResources().getString(R.string.info_no_selected_user)); - userIds.add(-1); - findViewById(R.id.widget_save).setEnabled(false); - } - - ArrayAdapter userAdapter = new ArrayAdapter<>( - this, android.R.layout.simple_spinner_item, users); - userAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - userSpinner.setAdapter(userAdapter); - - // Set up measurement spinner - final Spinner measurementSpinner = findViewById(R.id.widget_measurement_spinner); - List measurements = new ArrayList<>(); - final List measurementKeys = new ArrayList<>(); - for (MeasurementView measurementView : MeasurementView.getMeasurementList( - this, MeasurementView.DateTimeOrder.NONE)) { - if (measurementView.isVisible()) { - measurements.add(measurementView.getName().toString()); - measurementKeys.add(measurementView.getKey()); - } - } - ArrayAdapter measurementAdapter = new ArrayAdapter<>( - this, android.R.layout.simple_spinner_item, measurements); - measurementAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - measurementSpinner.setAdapter(measurementAdapter); - - findViewById(R.id.widget_save).setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - int userId = userIds.get(userSpinner.getSelectedItemPosition()); - String measurementKey = measurementKeys.get(measurementSpinner.getSelectedItemPosition()); - - PreferenceManager.getDefaultSharedPreferences(getApplicationContext()).edit() - .putInt(WidgetProvider.getUserIdPreferenceName(appWidgetId), userId) - .putString(WidgetProvider.getMeasurementPreferenceName(appWidgetId), measurementKey) - .apply(); - - Intent intent = new Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE, null); - intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, new int[] {appWidgetId}); - sendBroadcast(intent); - - Intent resultValue = new Intent(); - resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); - setResult(RESULT_OK, resultValue); - - finish(); - } - }); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/gui/widget/WidgetProvider.java b/android_app/app/src/main/java/com/health/openscale/gui/widget/WidgetProvider.java deleted file mode 100644 index 51ac866b..00000000 --- a/android_app/app/src/main/java/com/health/openscale/gui/widget/WidgetProvider.java +++ /dev/null @@ -1,206 +0,0 @@ -/* Copyright (C) 2018 Erik Johansson - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - -package com.health.openscale.gui.widget; - -import android.app.PendingIntent; -import android.appwidget.AppWidgetManager; -import android.appwidget.AppWidgetProvider; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.text.SpannableStringBuilder; -import android.util.TypedValue; -import android.view.View; -import android.widget.RemoteViews; - -import com.health.openscale.R; -import com.health.openscale.core.OpenScale; -import com.health.openscale.core.datatypes.ScaleMeasurement; -import com.health.openscale.gui.MainActivity; -import com.health.openscale.gui.measurement.MeasurementView; - -import java.text.DateFormat; -import java.util.List; - -import timber.log.Timber; - -public class WidgetProvider extends AppWidgetProvider { - List measurementViews; - - public static final String getUserIdPreferenceName(int appWidgetId) { - return String.format("widget_%d_userid", appWidgetId); - } - - public static final String getMeasurementPreferenceName(int appWidgetId) { - return String.format("widget_%d_measurement", appWidgetId); - } - - private void updateWidget(Context context, AppWidgetManager appWidgetManager, - int appWidgetId, Bundle newOptions) { - // Make sure we use the correct language - context = MainActivity.createBaseContext(context); - - final int minWidth = newOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH); - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - int userId = prefs.getInt(getUserIdPreferenceName(appWidgetId), -1); - String key = prefs.getString(getMeasurementPreferenceName(appWidgetId), ""); - - Timber.d("Update widget %d (%s) for user %d, min width %ddp", - appWidgetId, key, userId, minWidth); - - if (measurementViews == null) { - measurementViews = MeasurementView.getMeasurementList( - context, MeasurementView.DateTimeOrder.NONE); - } - - MeasurementView measurementView = measurementViews.get(0); - for (MeasurementView view : measurementViews) { - if (view.getKey().equals(key)) { - measurementView = view; - break; - } - } - - OpenScale openScale = OpenScale.getInstance(); - ScaleMeasurement latest = openScale.getLastScaleMeasurement(userId); - if (latest != null) { - ScaleMeasurement previous = openScale.getTupleOfScaleMeasurement(latest.getId())[0]; - measurementView.loadFrom(latest, previous); - } - - // From https://developer.android.com/guide/practices/ui_guidelines/widget_design - final int twoCellsMinWidth = 110; - final int thirdCellsMinWidth = 180; - final int fourCellsMinWidth = 250; - - RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget); - - // Add some transparency to make the corners appear rounded - int indicatorColor = measurementView.getIndicatorColor(); - indicatorColor = (180 << 24) | (indicatorColor & 0xffffff); - views.setInt(R.id.indicator_view, "setBackgroundColor", indicatorColor); - - // Show icon in >= two cell mode - if (minWidth >= twoCellsMinWidth) { - views.setImageViewResource(R.id.widget_icon, measurementView.getIconResource()); - views.setViewVisibility(R.id.widget_icon, View.VISIBLE); - views.setViewVisibility(R.id.widget_icon_vertical, View.GONE); - } - else { - views.setImageViewResource(R.id.widget_icon_vertical, measurementView.getIconResource()); - views.setViewVisibility(R.id.widget_icon_vertical, View.VISIBLE); - views.setViewVisibility(R.id.widget_icon, View.GONE); - } - - // Show measurement name in >= four cell mode - if (minWidth >= fourCellsMinWidth) { - views.setTextViewText(R.id.widget_name, measurementView.getName()); - views.setTextViewText(R.id.widget_date, - latest != null - ? DateFormat.getDateTimeInstance( - DateFormat.LONG, DateFormat.SHORT).format(latest.getDateTime()) - : ""); - views.setViewVisibility(R.id.widget_name_date_layout, View.VISIBLE); - } - else { - views.setViewVisibility(R.id.widget_name_date_layout, View.GONE); - } - - // Always show value and delta, but adjust font size based on widget width - views.setTextViewText(R.id.widget_value, measurementView.getValueAsString(true)); - SpannableStringBuilder delta = new SpannableStringBuilder(); - measurementView.appendDiffValue(delta, false); - views.setTextViewText(R.id.widget_delta, delta); - - int textSize; - if (minWidth >= thirdCellsMinWidth) { - textSize = 18; - } - else if (minWidth >= twoCellsMinWidth) { - textSize = 17; - } - else { - textSize = 12; - } - views.setTextViewTextSize(R.id.widget_value, TypedValue.COMPLEX_UNIT_DIP, textSize); - views.setTextViewTextSize(R.id.widget_delta, TypedValue.COMPLEX_UNIT_DIP, textSize); - - // Start main activity when widget is clicked - Intent intent = new Intent(context, MainActivity.class); - PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE); - views.setOnClickPendingIntent(R.id.widget_layout, pendingIntent); - - appWidgetManager.updateAppWidget(appWidgetId, views); - } - - @Override - public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { - for (int appWidgetId : appWidgetIds) { - Bundle newOptions = appWidgetManager.getAppWidgetOptions(appWidgetId); - updateWidget(context, appWidgetManager, appWidgetId, newOptions); - } - } - - @Override - public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, - int appWidgetId, Bundle newOptions) { - updateWidget(context, appWidgetManager, appWidgetId, newOptions); - } - - @Override - public void onDeleted(Context context, int[] appWidgetIds) { - SharedPreferences.Editor editor = - PreferenceManager.getDefaultSharedPreferences(context).edit(); - for (int appWidgetId : appWidgetIds) { - editor.remove(getUserIdPreferenceName(appWidgetId)); - editor.remove(getMeasurementPreferenceName(appWidgetId)); - } - editor.apply(); - } - - @Override - public void onDisabled(Context context) { - measurementViews = null; - } - - @Override - public void onRestored(Context context, int[] oldWidgetIds, int[] newWidgetIds) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - SharedPreferences.Editor editor = prefs.edit(); - - for (int i = 0; i < oldWidgetIds.length; ++i) { - String oldKey = getUserIdPreferenceName(oldWidgetIds[i]); - if (prefs.contains(oldKey)) { - editor.putInt(getUserIdPreferenceName(newWidgetIds[i]), - prefs.getInt(oldKey, -1)); - editor.remove(oldKey); - } - - oldKey = getMeasurementPreferenceName(oldWidgetIds[i]); - if (prefs.contains(oldKey)) { - editor.putString(getMeasurementPreferenceName(newWidgetIds[i]), - prefs.getString(oldKey, "")); - editor.remove(oldKey); - } - } - - editor.apply(); - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt b/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt new file mode 100644 index 00000000..ab08ae56 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt @@ -0,0 +1,614 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.navigation + +import android.app.Application +import android.content.res.Resources +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.health.openscale.R +import com.health.openscale.core.data.User +import com.health.openscale.ui.navigation.Routes.getIconForRoute +import com.health.openscale.ui.screen.SharedViewModel +import com.health.openscale.ui.screen.bluetooth.BluetoothViewModel +import com.health.openscale.ui.screen.components.TableScreen +import com.health.openscale.ui.screen.createViewModelFactory +import com.health.openscale.ui.screen.graph.GraphScreen +import com.health.openscale.ui.screen.overview.MeasurementDetailScreen +import com.health.openscale.ui.screen.overview.OverviewScreen +import com.health.openscale.ui.screen.settings.AboutScreen +import com.health.openscale.ui.screen.settings.BluetoothScreen +import com.health.openscale.ui.screen.settings.DataManagementSettingsScreen +import com.health.openscale.ui.screen.settings.GeneralSettingsScreen +import com.health.openscale.ui.screen.settings.MeasurementTypeDetailScreen +import com.health.openscale.ui.screen.settings.MeasurementTypeSettingsScreen +import com.health.openscale.ui.screen.settings.SettingsScreen +import com.health.openscale.ui.screen.settings.SettingsViewModel +import com.health.openscale.ui.screen.settings.UserDetailScreen +import com.health.openscale.ui.screen.settings.UserSettingsScreen +import com.health.openscale.ui.screen.statistics.StatisticsScreen +import com.health.openscale.ui.theme.Black +import com.health.openscale.ui.theme.Blue +import com.health.openscale.ui.theme.White +import kotlinx.coroutines.launch + +/** + * Main composable function that sets up the application's navigation structure. + * This includes a modal navigation drawer, a top app bar, a snackbar host for displaying + * messages, and a [NavHost] for handling screen transitions based on defined routes. + * + * It observes [SharedViewModel] for shared UI state like the top bar title, actions, + * user information, and snackbar messages. + * + * @param sharedViewModel The [SharedViewModel] instance shared across multiple screens, + * providing access to shared data and UI event channels. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppNavigation(sharedViewModel: SharedViewModel) { + val context = LocalContext.current + val resources = context.resources // Get resources for non-composable string access + val application = context.applicationContext as Application + val navController = rememberNavController() + val drawerState = rememberDrawerState(DrawerValue.Closed) + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + // Initialize ViewModels that might be needed by screens within this navigation structure + val settingsViewModel: SettingsViewModel = viewModel( + factory = createViewModelFactory { SettingsViewModel(sharedViewModel) } + ) + + val bluetoothViewModel: BluetoothViewModel = viewModel( + factory = createViewModelFactory { BluetoothViewModel(application, sharedViewModel) } + ) + + // Observe the current navigation route + val currentBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = currentBackStackEntry?.destination?.route + + // Define the main navigation routes that appear in the navigation drawer + val mainRoutes = listOf( + Routes.OVERVIEW, + Routes.GRAPH, + Routes.TABLE, + Routes.STATISTICS, + Routes.SETTINGS + ) + + // Collect UI states from SharedViewModel + val topBarTitleFromVM by sharedViewModel.topBarTitle.collectAsState() + val topBarActions by sharedViewModel.topBarActions.collectAsState() + val allUsers by sharedViewModel.allUsers.collectAsState() + val selectedUser by sharedViewModel.selectedUser.collectAsState() + + // Resolve the title for the TopAppBar. + // The title can be provided as a direct String or as a @StringRes Int. + val topBarTitle = when (val titleData = topBarTitleFromVM) { + is String -> titleData + is Int -> if (titleData != Routes.NO_TITLE_RESOURCE_ID) stringResource(id = titleData) else "" + else -> "" // Default to empty string if title data is null or unexpected type + } + + // Listen for snackbar events emitted by the SharedViewModel. + // This LaunchedEffect runs once when AppNavigation is composed. + LaunchedEffect(sharedViewModel.snackbarChannel) { + sharedViewModel.snackbarChannel.collect { event -> + // Launch a new coroutine for each snackbar event to handle its display. + // This allows multiple snackbars to be queued and shown sequentially. + scope.launch { + val messageText: String = if (event.messageResId != Routes.NO_TITLE_RESOURCE_ID) { + try { + resources.getString(event.messageResId, *(event.messageFormatArgs ?: emptyArray())) + } catch (e: Resources.NotFoundException) { + // Log this error or handle it, then fallback + event.message // Fallback to raw message if resource ID is invalid + } + } else { + event.message + } + + val actionLabelText: String? = if (event.actionLabelResId != null && event.actionLabelResId != Routes.NO_TITLE_RESOURCE_ID) { + try { + resources.getString(event.actionLabelResId) + } catch (e: Resources.NotFoundException) { + // Log this error or handle it, then fallback + event.actionLabel // Fallback to raw label if resource ID is invalid + } + } else { + event.actionLabel + } + + val result = snackbarHostState.showSnackbar( + message = messageText, + duration = event.duration, + actionLabel = actionLabelText + ) + if (result == SnackbarResult.ActionPerformed) { + event.onAction?.invoke() + } + } + } + } + + // Reset top bar actions when the current route changes. + // This prevents actions from a previous screen from lingering on the new screen. + LaunchedEffect(currentRoute) { + sharedViewModel.setTopBarAction(null) + } + + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + ModalDrawerSheet( + drawerContainerColor = Black, // Custom drawer background color + drawerContentColor = White // Custom drawer content color for icons and text + ) { + // Drawer Header: Displays the app logo and name. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(RoundedCornerShape(topEnd = 24.dp)) // Specific rounding for visual style + .background(Blue) // Themed background for the header + .padding(8.dp) + .fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.drawable.ic_launcher_foreground), + contentDescription = stringResource(R.string.app_logo_content_description), + modifier = Modifier.size(64.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(id = R.string.app_name), + style = MaterialTheme.typography.titleMedium + ) + } + Spacer(modifier = Modifier.height(8.dp)) // Spacing after the header + + // Drawer Items: Dynamically created for each main route. + mainRoutes.forEach { route -> + // Add a divider before the "Settings" item for visual separation. + if (route == Routes.SETTINGS) { + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + + val titleResId = Routes.getTitleResourceId(route) + val titleText = if (titleResId != Routes.NO_TITLE_RESOURCE_ID) { + stringResource(id = titleResId) + } else { + route // Fallback to the raw route string if no title resource ID is defined. + } + + NavigationDrawerItem( + icon = { + Icon( + imageVector = getIconForRoute(route), + contentDescription = titleText // Provides accessibility for the icon. + ) + }, + label = { Text(titleText) }, + selected = currentRoute == route, // Highlights the item if it's the current route. + onClick = { + navController.navigate(route) { + // Pop up to the start destination of the graph to avoid building up a large back stack. + popUpTo(navController.graph.startDestinationId) { + saveState = true // Save the state of popped destinations. + } + // Avoid multiple copies of the same destination when reselecting the same item. + launchSingleTop = true + // Restore state when reselecting a previously visited item. + restoreState = true + } + scope.launch { drawerState.close() } // Close the drawer after selection. + }, + colors = NavigationDrawerItemDefaults.colors( + // Custom colors for selected and unselected drawer items. + selectedIconColor = Blue, + selectedTextColor = Blue, + selectedContainerColor = Color.Transparent, // No background for the selected item itself. + + unselectedIconColor = White, + unselectedTextColor = White, + unselectedContainerColor = Color.Transparent + ) + ) + } + } + } + ) { + Scaffold( + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) { snackbarData -> + // Custom Snackbar appearance defined here. + Snackbar( + modifier = Modifier.padding(8.dp), // Padding around the snackbar. + shape = RoundedCornerShape(8.dp), // Rounded corners for the snackbar. + containerColor = Blue, // Custom background color. + contentColor = White, // Custom text and icon color. + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = stringResource(R.string.app_logo_content_description), // Accessibility. + tint = LocalContentColor.current // Uses the contentColor from Snackbar. + ) + Spacer(Modifier.width(8.dp)) + Text(snackbarData.visuals.message) + } + } + } + }, + topBar = { + TopAppBar( + title = { Text(topBarTitle) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Black, + titleContentColor = White, + navigationIconContentColor = White, + actionIconContentColor = White + ), + navigationIcon = { + if (currentRoute in mainRoutes) { + // Show menu icon for main routes to open the drawer. + IconButton(onClick = { + scope.launch { drawerState.open() } + }) { + Icon( + Icons.Default.Menu, + contentDescription = stringResource(R.string.content_desc_open_menu) + ) + } + } else { + // Show back arrow for non-main (detail or sub-page) routes. + IconButton(onClick = { + navController.popBackStack() + }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_desc_back) + ) + } + } + }, + actions = { + // Display actions defined in SharedViewModel. + topBarActions.forEach { action -> + val contentDesc = action.contentDescriptionResId?.let { stringResource(id = it) } + ?: action.contentDescription + IconButton(onClick = action.onClick) { + Icon(imageVector = action.icon, contentDescription = contentDesc) + } + // If the action has associated dropdown content, invoke it here. + // This allows TopAppBar actions to also host DropdownMenus. + action.dropdownContent?.invoke() + } + + // Show user switcher dropdown if on a main route and users exist. + if (currentRoute in mainRoutes && allUsers.isNotEmpty() && currentRoute != Routes.SETTINGS) { + UserDropdownAsAction( + users = allUsers, + selectedUser = selectedUser, + onUserSelected = { userId -> + sharedViewModel.selectUser(userId) + // Consider closing the drawer if open, or other UI updates. + }, + onManageUsersClicked = { + navController.navigate(Routes.USER_SETTINGS) + // Consider closing the drawer if open. + } + ) + } + } + ) + } + ) { innerPadding -> + Column(modifier = Modifier.fillMaxSize()) { + NavHost( + navController = navController, + startDestination = Routes.OVERVIEW, + modifier = Modifier + .padding(innerPadding) // Apply padding from Scaffold. + .weight(1f) // NavHost takes the remaining space in the Column. + ) { + // Define all composable screens for navigation routes. + composable(Routes.OVERVIEW) { + OverviewScreen( + navController = navController, + sharedViewModel = sharedViewModel, + bluetoothViewModel = bluetoothViewModel + ) + } + composable(Routes.GRAPH) { + GraphScreen(sharedViewModel) + } + composable(Routes.TABLE) { + TableScreen( + navController = navController, + sharedViewModel = sharedViewModel + ) + } + composable(Routes.STATISTICS) { + StatisticsScreen(sharedViewModel) + } + composable(Routes.SETTINGS) { + SettingsScreen( + navController = navController, + sharedViewModel = sharedViewModel, + settingsViewModel = settingsViewModel + ) + } + composable(Routes.GENERAL_SETTINGS) { + GeneralSettingsScreen( + navController = navController, + sharedViewModel = sharedViewModel, + settingsViewModel = settingsViewModel + ) + } + composable(Routes.USER_SETTINGS) { + UserSettingsScreen( + sharedViewModel = sharedViewModel, + settingsViewModel = settingsViewModel, + onEditUser = { userId -> + navController.navigate(Routes.userDetail(userId)) + } + ) + } + composable( + route = "${Routes.USER_DETAIL}?id={id}", // Argument in route pattern + arguments = listOf(navArgument("id") { + type = NavType.IntType + defaultValue = -1 // Indicates a new user if ID is -1 (or not passed) + }) + ) { backStackEntry -> + val userId = backStackEntry.arguments?.getInt("id") ?: -1 + UserDetailScreen( + navController = navController, + userId = userId, + sharedViewModel = sharedViewModel, + settingsViewModel = settingsViewModel + ) + } + composable(Routes.MEASUREMENT_TYPES) { + MeasurementTypeSettingsScreen( + sharedViewModel = sharedViewModel, + settingsViewModel = settingsViewModel, + onEditType = { typeId -> + navController.navigate(Routes.measurementTypeDetail(typeId)) + } + ) + } + composable( + route = "${Routes.MEASUREMENT_DETAIL}?measurementId={measurementId}&userId={userId}", + arguments = listOf( + navArgument("measurementId") { + type = NavType.IntType + defaultValue = -1 // Default if not provided + }, + navArgument("userId") { + type = NavType.IntType + defaultValue = -1 // Default if not provided, might also fetch from selectedUser if appropriate + } + ) + ) { backStackEntry -> + val measurementId = backStackEntry.arguments?.getInt("measurementId") ?: -1 + val userId = backStackEntry.arguments?.getInt("userId") ?: -1 + MeasurementDetailScreen( + navController = navController, + measurementId = measurementId, + userId = userId, + sharedViewModel = sharedViewModel + ) + } + composable( + route = "${Routes.MEASUREMENT_TYPE_DETAIL}?id={id}", + arguments = listOf(navArgument("id") { + type = NavType.IntType + defaultValue = -1 // Indicates a new type if ID is -1 + }) + ) { backStackEntry -> + val typeId = backStackEntry.arguments?.getInt("id") ?: -1 + MeasurementTypeDetailScreen( + navController = navController, + typeId = typeId, + sharedViewModel = sharedViewModel, + settingsViewModel = settingsViewModel + ) + } + composable(Routes.BLUETOOTH_SETTINGS) { + BluetoothScreen( + sharedViewModel = sharedViewModel, + bluetoothViewModel = bluetoothViewModel + ) + } + composable(Routes.DATA_MANAGEMENT_SETTINGS) { + DataManagementSettingsScreen( + navController = navController, + settingsViewModel = settingsViewModel + ) + } + composable(Routes.ABOUT_SETTINGS) { + AboutScreen( + navController = navController, + sharedViewModel = sharedViewModel + ) + } + } + // Box to fill the space behind the system navigation bar, if visible. + // This prevents UI elements from being drawn under a translucent navigation bar, + // ensuring consistent background color. + Box( + modifier = Modifier + .fillMaxWidth() + .height( + WindowInsets.navigationBars // Get insets for the system navigation bar. + .asPaddingValues() + .calculateBottomPadding() // Calculate its height. + ) + .background(Black) // Match TopAppBar color or general theme background. + ) + } + } + } +} + +/** + * Composable function for a dropdown menu in the TopAppBar to switch users or navigate to user management. + * This provides a dedicated UI element for user selection and management access. + * + * @param users List of available [User]s to display in the dropdown. + * @param selectedUser The currently selected [User], or null if no user is selected. + * This is used to highlight the current user in the list. + * @param onUserSelected Callback invoked with the user's ID when a user is selected from the dropdown. + * @param onManageUsersClicked Callback invoked when the "Manage Users" option is clicked, + * typically to navigate to a user management screen. + * @param modifier Optional [Modifier] for this composable, allowing for custom styling or layout. + */ +@Composable +fun UserDropdownAsAction( + users: List, + selectedUser: User?, + onUserSelected: (Int) -> Unit, + onManageUsersClicked: () -> Unit, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } // State to control dropdown visibility. + + if (users.isEmpty()) { + return // Do not show the dropdown if there are no users. + } + + Box(modifier = modifier) { // Box is used to anchor the DropdownMenu. + IconButton(onClick = { expanded = true }) { // Icon to trigger the dropdown. + Icon( + imageVector = Icons.Filled.AccountCircle, + contentDescription = stringResource( + R.string.content_desc_switch_user, // Dynamic content description. + selectedUser?.name ?: stringResource(R.string.text_none) // Display selected user's name or "None". + ), + modifier = Modifier.size(28.dp) // Specific size for the icon. + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } // Close dropdown when clicked outside. + ) { + users.forEach { user -> + DropdownMenuItem( + text = { Text(user.name) }, + onClick = { + onUserSelected(user.id) + expanded = false // Close dropdown after selection. + }, + leadingIcon = { // Show a checkmark next to the currently selected user. + if (user.id == selectedUser?.id) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = stringResource(R.string.content_desc_selected_user_indicator) + ) + } + } + ) + } + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) // Visual separator. + DropdownMenuItem( + text = { Text(stringResource(R.string.manage_users)) }, + onClick = { + onManageUsersClicked() + expanded = false // Close dropdown after selection. + }, + leadingIcon = { // Icon for the "Manage Users" option. + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = null // Decorative icon, as text already describes the action. + ) + } + ) + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/navigation/Routes.kt b/android_app/app/src/main/java/com/health/openscale/ui/navigation/Routes.kt new file mode 100644 index 00000000..20b26880 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/navigation/Routes.kt @@ -0,0 +1,90 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.navigation + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ShowChart +import androidx.compose.material.icons.filled.Analytics +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.QuestionMark +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.TableRows +import androidx.compose.ui.graphics.vector.ImageVector +import com.health.openscale.R +import com.health.openscale.ui.navigation.Routes.NO_TITLE_RESOURCE_ID + +object Routes { + // Main screens + const val OVERVIEW = "overview" + const val GRAPH = "graph" + const val TABLE = "table" + const val STATISTICS = "statistics" + const val SETTINGS = "settings" + + const val MEASUREMENT_DETAIL = "measurementDetail" // Not a main navigation item, but a route + + // Sub-pages (Settings Subgraph) + const val GENERAL_SETTINGS = "settings/general" + const val USER_SETTINGS = "settings/users" + const val USER_DETAIL = "settings/userDetail" + const val MEASUREMENT_TYPES = "settings/types" + const val MEASUREMENT_TYPE_DETAIL = "settings/typeDetail" + const val BLUETOOTH_SETTINGS = "settings/bluetooth" + const val DATA_MANAGEMENT_SETTINGS = "settings/dataManagement" + const val ABOUT_SETTINGS = "settings/about" + + // Special constant for no title + const val NO_TITLE_RESOURCE_ID = 0 + + // Routes with parameters + fun userDetail(userId: Int?) = "$USER_DETAIL?id=${userId ?: -1}" + fun measurementTypeDetail(typeId: Int?) = "$MEASUREMENT_TYPE_DETAIL?id=${typeId ?: -1}" + + fun measurementDetail(measurementId: Int?, userId: Int?): String = + "$MEASUREMENT_DETAIL?measurementId=${measurementId ?: -1}&userId=$userId" + + /** + * Gets the string resource ID for the title of a given route. + * Intended for main navigation items displayed in the TopAppBar or NavigationDrawer. + * + * @param route The route string. + * @return The string resource ID for the title, or [NO_TITLE_RESOURCE_ID] if no title is defined. + */ + @StringRes + fun getTitleResourceId(route: String?): Int = when { + route == null -> NO_TITLE_RESOURCE_ID + route.startsWith(OVERVIEW) -> R.string.route_title_overview + route.startsWith(GRAPH) -> R.string.route_title_graph + route.startsWith(TABLE) -> R.string.route_title_table + route.startsWith(STATISTICS) -> R.string.route_title_statistics + route.startsWith(SETTINGS) -> R.string.route_title_settings + else -> NO_TITLE_RESOURCE_ID // No specific title for other routes via this function + } + + fun getIconForRoute(route: String): ImageVector { + return when (route) { + OVERVIEW -> Icons.Filled.Home + GRAPH -> Icons.AutoMirrored.Filled.ShowChart + TABLE -> Icons.Filled.TableRows + STATISTICS -> Icons.Filled.Analytics + SETTINGS -> Icons.Filled.Settings + else -> Icons.Filled.QuestionMark // Default icon for routes not explicitly handled + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/SharedViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/SharedViewModel.kt new file mode 100644 index 00000000..30b8e599 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/SharedViewModel.kt @@ -0,0 +1,614 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen + +import androidx.annotation.StringRes +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.health.openscale.R +import com.health.openscale.core.data.InputFieldType +import com.health.openscale.core.data.Measurement +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementValue +import com.health.openscale.core.data.TimeRangeFilter +import com.health.openscale.core.data.Trend +import com.health.openscale.core.data.User +import com.health.openscale.core.database.DatabaseRepository +import com.health.openscale.core.model.MeasurementValueWithType +import com.health.openscale.core.model.MeasurementWithValues +import com.health.openscale.core.utils.LogManager +import com.health.openscale.core.database.UserSettingsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.util.Calendar + +private const val TAG = "SharedViewModel" + +/** + * Represents an event to display a Snackbar. + * It supports internationalization through string resource IDs and formatted strings, + * as well as direct string content as a fallback. + * + * @property messageResId The resource ID for the Snackbar message. Defaults to 0 if not used. + * @property message A direct string for the Snackbar message. Used if [messageResId] is 0. + * @property messageFormatArgs Optional arguments for formatting the [messageResId] string. + * @property duration The [SnackbarDuration] for which the Snackbar is shown. + * @property actionLabelResId Optional resource ID for the Snackbar's action button label. + * @property actionLabel Optional direct string for the action button label. Used if [actionLabelResId] is null. + * @property onAction Optional lambda to be executed when the action button is pressed. + */ +data class SnackbarEvent( + @StringRes val messageResId: Int = 0, + val message: String = "", + val messageFormatArgs: Array? = null, + val duration: SnackbarDuration = SnackbarDuration.Short, + @StringRes val actionLabelResId: Int? = null, + val actionLabel: String? = null, + val onAction: (() -> Unit)? = null +) + +/** + * Represents a single measurement value ([MeasurementValueWithType]) enhanced with its + * calculated difference from a previous value and a [Trend] indicator. + * + * @property currentValue The [MeasurementValueWithType] this data is about. + * @property difference The calculated difference (e.g., current - previous). Null if not applicable or no previous value. + * @property trend The [Trend] indicating if the value went up, down, stayed the same, or is not applicable. + */ +data class ValueWithDifference( + val currentValue: MeasurementValueWithType, + val difference: Float? = null, + val trend: Trend = Trend.NOT_APPLICABLE +) + +/** + * Represents a complete measurement ([MeasurementWithValues]) where each of its individual values + * has been enriched with trend information, resulting in a list of [ValueWithDifference]. + * This is typically used for display purposes where trend indicators are shown next to values. + * + * @property measurementWithValues The original [MeasurementWithValues] data. + * @property valuesWithTrend A list of [ValueWithDifference], corresponding to each value in [measurementWithValues], + * but enriched with trend and difference information. + */ +data class EnrichedMeasurement( + val measurementWithValues: MeasurementWithValues, + val valuesWithTrend: List +) + +/** + * Shared ViewModel for managing UI state and business logic accessible across multiple screens. + * It handles user selection, measurement data (CRUD operations, display, enrichment), + * and UI elements like top bar titles/actions and Snackbars. + * + * @param databaseRepository Repository for accessing measurement and user data from the database. + * @param userSettingRepository Repository for managing user-specific settings, like the last selected user. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SharedViewModel( + val databaseRepository: DatabaseRepository, + val userSettingRepository: UserSettingsRepository +) : ViewModel() { + + // --- Top Bar UI State --- + + private val _topBarTitle = MutableStateFlow(R.string.app_name) + val topBarTitle: StateFlow = _topBarTitle.asStateFlow() + + fun setTopBarTitle(title: String) { + _topBarTitle.value = title + } + + fun setTopBarTitle(@StringRes titleResId: Int) { + _topBarTitle.value = titleResId + } + + data class TopBarAction( + val icon: ImageVector, + val onClick: () -> Unit, + @StringRes val contentDescriptionResId: Int? = null, + val contentDescription: String? = null, + val dropdownContent: (@Composable () -> Unit)? = null + ) + + private val _topBarActions = MutableStateFlow>(emptyList()) + val topBarActions: StateFlow> = _topBarActions.asStateFlow() + + fun setTopBarAction(action: TopBarAction?) { + _topBarActions.value = if (action != null) listOf(action) else emptyList() + } + + fun setTopBarActions(actions: List) { + _topBarActions.value = actions + } + + // --- Snackbar UI Event Channel --- + + private val _snackbarChannel = MutableSharedFlow() // Consider extraBufferCapacity = 1 + val snackbarChannel: Flow = _snackbarChannel.asSharedFlow() + + fun showSnackbar( + message: String, + duration: SnackbarDuration = SnackbarDuration.Short, + actionLabel: String? = null, + onAction: (() -> Unit)? = null + ) { + viewModelScope.launch { + LogManager.v(TAG, "Snackbar requested (String): \"$message\" (UI Event)") + _snackbarChannel.emit( + SnackbarEvent( + message = message, + duration = duration, + actionLabel = actionLabel, + onAction = onAction + ) + ) + } + } + + fun showSnackbar( + @StringRes messageResId: Int, + formatArgs: Array? = null, + duration: SnackbarDuration = SnackbarDuration.Short, + @StringRes actionLabelResId: Int? = null, + actionLabel: String? = null, + onAction: (() -> Unit)? = null + ) { + viewModelScope.launch { + LogManager.v(TAG, "Snackbar requested (Res ID): $messageResId, HasFormatArgs: ${formatArgs != null} (UI Event)") + val finalActionLabel = if (actionLabelResId != null) null else actionLabel + _snackbarChannel.emit( + SnackbarEvent( + messageResId = messageResId, + messageFormatArgs = formatArgs, + duration = duration, + actionLabelResId = actionLabelResId, + actionLabel = finalActionLabel, + onAction = onAction + ) + ) + } + } + + // --- User Management --- + + private val _selectedUserId = MutableStateFlow(null) + val selectedUserId: StateFlow = _selectedUserId.asStateFlow() + + val allUsers: StateFlow> = databaseRepository.getAllUsers() + .flowOn(Dispatchers.IO) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000L), + initialValue = emptyList() + ).also { + LogManager.v(TAG, "allUsers flow initialized. (Data Flow)") + } + + val selectedUser: StateFlow = selectedUserId.flatMapLatest { userId -> + if (userId == null) { + LogManager.d(TAG, "No user ID selected, selectedUser Flow emits null. (User Data Flow)") + MutableStateFlow(null) + } else { + LogManager.d(TAG, "Fetching user by ID: $userId for selectedUser Flow. (User Data Flow)") + databaseRepository.getUserById(userId).flowOn(Dispatchers.IO) + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000L), + initialValue = null + ).also { + LogManager.v(TAG, "selectedUser flow initialized. (User Data Flow)") + } + + fun selectUser(userId: Int?) { + viewModelScope.launch { + _selectedUserId.value = userId + userSettingRepository.setCurrentUserId(userId) + LogManager.i(TAG, "User selection changed to ID: $userId. Persisted to settings. (User Action)") + } + } + + // --- Measurement Type Data --- + + val measurementTypes: StateFlow> = databaseRepository.getAllMeasurementTypes() + .flowOn(Dispatchers.IO) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000L), + initialValue = emptyList() + ).also { + LogManager.v(TAG, "measurementTypes flow initialized. (Data Flow)") + } + + // --- Current Measurement Management (for editing/detail view) --- + + private val _currentMeasurementId = MutableStateFlow(null) + + val currentMeasurementWithValues: StateFlow = _currentMeasurementId + .flatMapLatest { id -> + if (id == null || id == -1) { + LogManager.d(TAG, "Current measurement ID is $id, emitting null for currentMeasurementWithValues. (Measurement Detail Flow)") + MutableStateFlow(null) + } else { + LogManager.d(TAG, "Fetching measurement with values for ID: $id for currentMeasurementWithValues flow. (Measurement Detail Flow)") + databaseRepository.getMeasurementWithValuesById(id).flowOn(Dispatchers.IO) + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000L), + initialValue = null + ).also { + LogManager.v(TAG, "currentMeasurementWithValues flow initialized. (Measurement Detail Flow)") + } + + fun setCurrentMeasurementId(measurementId: Int?) { + _currentMeasurementId.value = measurementId + LogManager.d(TAG, "Current measurement ID set to: $measurementId (UI/Navigation Action)") + } + + // --- Measurement CRUD Operations --- + + fun saveMeasurement(measurementToSave: Measurement, valuesToSave: List) { + viewModelScope.launch(Dispatchers.IO) { + val isNewMeasurement = measurementToSave.id == 0 + val operationType = if (isNewMeasurement) "insert" else "update" + LogManager.i(TAG, "User initiated $operationType for measurement. (User Action to Data Operation)") + + try { + if (!isNewMeasurement) { + LogManager.d(TAG, "Preparing to update existing measurement ID: ${measurementToSave.id}. (ViewModel Logic)") + databaseRepository.updateMeasurement(measurementToSave) + + val existingDbValues = databaseRepository.getValuesForMeasurement(measurementToSave.id).first() + val valueIdsInNewSet = valuesToSave.mapNotNull { if (it.id != 0) it.id else null }.toSet() + val valueIdsInDbSet = existingDbValues.map { it.id }.toSet() + + val valueIdsToDelete = valueIdsInDbSet - valueIdsInNewSet + valueIdsToDelete.forEach { valueId -> + databaseRepository.deleteMeasurementValueById(valueId) + } + + valuesToSave.forEach { value -> + val existingValue = existingDbValues.find { dbVal -> dbVal.id == value.id && value.id != 0 } + if (existingValue != null) { + databaseRepository.updateMeasurementValue(value.copy(measurementId = measurementToSave.id)) + } else { + databaseRepository.insertMeasurementValue(value.copy(measurementId = measurementToSave.id)) + } + } + LogManager.i(TAG, "Measurement ID ${measurementToSave.id} and its values update process completed by ViewModel. (ViewModel Result)") + showSnackbar(messageResId = R.string.success_measurement_updated) + } else { + LogManager.d(TAG, "Preparing to insert new measurement. (ViewModel Logic)") + val newMeasurementId = databaseRepository.insertMeasurement(measurementToSave).toInt() + valuesToSave.forEach { value -> + databaseRepository.insertMeasurementValue(value.copy(measurementId = newMeasurementId)) + } + LogManager.i(TAG, "New measurement insertion process completed by ViewModel with ID: $newMeasurementId. (ViewModel Result)") + showSnackbar(messageResId = R.string.success_measurement_saved) + } + } catch (e: Exception) { + LogManager.e(TAG, "Error during $operationType orchestration for measurement (ID if existing: ${measurementToSave.id}): ${e.message}", e) + showSnackbar(messageResId = R.string.error_saving_measurement) + } + } + } + + fun deleteMeasurement(measurement: Measurement) { + viewModelScope.launch(Dispatchers.IO) { + LogManager.i(TAG, "User initiated deletion for measurement ID: ${measurement.id}. (User Action to Data Operation)") + try { + LogManager.d(TAG, "Preparing to delete measurement ID: ${measurement.id}. (ViewModel Logic)") + databaseRepository.deleteMeasurement(measurement) + LogManager.i(TAG, "Measurement ID ${measurement.id} deletion process completed by ViewModel. (ViewModel Result)") + showSnackbar(messageResId = R.string.success_measurement_deleted) + if (_currentMeasurementId.value == measurement.id) { + _currentMeasurementId.value = null + LogManager.d(TAG, "Cleared currentMeasurementId as deleted measurement was active. (ViewModel State Update)") + } + } catch (e: Exception) { + LogManager.e(TAG, "Error during delete orchestration for measurement ID ${measurement.id}: ${e.message}", e) + showSnackbar(messageResId = R.string.error_deleting_measurement) + } + } + } + + // --- Displaying Measurement Lists & Enriched Data --- + + val allMeasurementsForSelectedUser: StateFlow> = + selectedUserId + .flatMapLatest { userId -> + if (userId == null) { + LogManager.d(TAG, "No user selected, allMeasurementsForSelectedUser emitting empty list. (Measurement List Flow)") + MutableStateFlow(emptyList()) + } else { + LogManager.d(TAG, "Fetching all measurements for user ID: $userId for allMeasurementsForSelectedUser flow. (Measurement List Flow)") + databaseRepository.getMeasurementsWithValuesForUser(userId).flowOn(Dispatchers.IO) + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000L), + initialValue = emptyList() + ).also { + LogManager.v(TAG, "allMeasurementsForSelectedUser flow initialized. (Measurement List Flow)") + } + + val lastMeasurementOfSelectedUser: StateFlow = + allMeasurementsForSelectedUser.map { measurements -> + measurements.firstOrNull().also { + LogManager.d(TAG, "Last measurement for selected user updated. Has value: ${it != null}. (Derived Data Flow)") + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000L), + initialValue = null + ).also { + LogManager.v(TAG, "lastMeasurementOfSelectedUser flow initialized. (Derived Data Flow)") + } + + private val _isBaseDataLoading = MutableStateFlow(false) + val isBaseDataLoading: StateFlow = _isBaseDataLoading.asStateFlow() + + @OptIn(ExperimentalCoroutinesApi::class) + val enrichedMeasurementsFlow: StateFlow> = + allMeasurementsForSelectedUser.combine(measurementTypes) { measurements, globalTypes -> + LogManager.v(TAG, "Recalculating enrichedMeasurementsFlow. Measurements: ${measurements.size}, GlobalTypes: ${globalTypes.size}. (Data Enrichment Logic)") + if (measurements.isEmpty()) { + return@combine emptyList() + } + + if (globalTypes.isEmpty() && measurements.isNotEmpty()) { + LogManager.w(TAG, "Global measurement types are empty during enrichment. Trend calculation will be limited or inaccurate. (Data Enrichment Warning)") + return@combine measurements.map { currentMeasurement -> + val trendValuesUnsorted = currentMeasurement.values.map { currentValueWithType -> + val (difference, trendResult) = calculateSingleValueTrendLogic( + currentValueWithType, + null, + currentValueWithType.type + ) + ValueWithDifference(currentValueWithType, difference, trendResult) + } + EnrichedMeasurement(currentMeasurement, trendValuesUnsorted) + } + } + + measurements.mapIndexed { index, currentMeasurement -> + val previousMeasurement: MeasurementWithValues? = measurements.getOrNull(index + 1) + val processedAndSortedTrendValues = currentMeasurement.values + .mapNotNull { valueWithType -> + val fullType = globalTypes.find { it.id == valueWithType.type.id } + if (fullType == null || !fullType.isEnabled) { + if (fullType == null) { + LogManager.w(TAG, "Measurement value type ID ${valueWithType.type.id} not found in global types during enrichment. Skipping value. (Data Enrichment Warning)") + } else { + LogManager.d(TAG, "Measurement value type (ID: ${fullType.id}) is disabled globally. Skipping value in enrichment. (Data Enrichment Logic)") + } + null + } else { + val previousValueForType = previousMeasurement?.values?.find { it.type.id == fullType.id } + val (difference, trendResult) = calculateSingleValueTrendLogic( + valueWithType, + previousValueForType, + fullType + ) + ValueWithDifference(valueWithType.copy(type = fullType), difference, trendResult) + } + } + .sortedBy { valueWithDiff -> + valueWithDiff.currentValue.type.displayOrder + } + EnrichedMeasurement(currentMeasurement, processedAndSortedTrendValues) + } + } + .onStart { + LogManager.d(TAG, "enrichedMeasurementsFlow collection started, setting base data loading to true. (Flow Lifecycle)") + _isBaseDataLoading.value = true + } + .mapLatest { enrichedMeasurements -> + _isBaseDataLoading.value = false + LogManager.d(TAG, "enrichedMeasurementsFlow processing complete. Count: ${enrichedMeasurements.size}. Base data loading set to false. (Flow Update)") + enrichedMeasurements + } + .flowOn(Dispatchers.Default) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000L), + initialValue = emptyList() + ).also { + LogManager.v(TAG, "enrichedMeasurementsFlow initialized. (Data Enrichment Flow)") + } + + fun getTimeFilteredEnrichedMeasurements( + selectedTimeRange: TimeRangeFilter + ): Flow> { + LogManager.v(TAG, "Request to get time-filtered enriched measurements for range: $selectedTimeRange. (Filtering Request)") + return enrichedMeasurementsFlow.map { allEnrichedMeasurements -> + LogManager.v(TAG, "Applying time filter '$selectedTimeRange' to ${allEnrichedMeasurements.size} enriched measurements. (Filtering Logic)") + if (selectedTimeRange == TimeRangeFilter.ALL_DAYS) { + allEnrichedMeasurements + } else { + val calendar = Calendar.getInstance() + val endTime = calendar.timeInMillis + + when (selectedTimeRange) { + TimeRangeFilter.LAST_7_DAYS -> calendar.add(Calendar.DAY_OF_YEAR, -7) + TimeRangeFilter.LAST_30_DAYS -> calendar.add(Calendar.DAY_OF_YEAR, -30) + TimeRangeFilter.LAST_365_DAYS -> calendar.add(Calendar.DAY_OF_YEAR, -365) + TimeRangeFilter.ALL_DAYS -> { /* Handled */ } + } + calendar.set(Calendar.HOUR_OF_DAY, 0) + calendar.set(Calendar.MINUTE, 0) + calendar.set(Calendar.SECOND, 0) + calendar.set(Calendar.MILLISECOND, 0) + val startTime = calendar.timeInMillis + + allEnrichedMeasurements.filter { + it.measurementWithValues.measurement.timestamp in startTime..endTime + } + } + } + .distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + /** + * Filters a list of already (e.g., time-filtered) enriched measurements by selected measurement type IDs. + * This is a synchronous operation on a given list, intended for further refinement of an already processed list. + * + * @param measurementsToFilter The list of [EnrichedMeasurement]s to filter. + * @param selectedTypeIds A set of type IDs to filter by. If empty, returns the original list unmodified. + * @return A new list containing only measurements that include at least one of the selected types. + * The values within each measurement are not filtered, only the top-level measurements. + */ + fun filterEnrichedMeasurementsByTypes( + measurementsToFilter: List, + selectedTypeIds: Set + ): List { + LogManager.d(TAG, "Filtering ${measurementsToFilter.size} enriched measurements by type IDs. Selected count: ${selectedTypeIds.size}. (Synchronous Filter)") + if (selectedTypeIds.isEmpty()) { + return measurementsToFilter + } + return measurementsToFilter.filter { enrichedMeasurement -> + enrichedMeasurement.valuesWithTrend.any { valueWithDifference -> + valueWithDifference.currentValue.type.id in selectedTypeIds + } + } + } + + /** + * Internal helper to calculate the difference and trend for a single measurement value + * compared to its previous value, considering the measurement type. + * This logic is central to the enrichment process. + * + * @param currentMeasurementValue The current value with its type. + * @param previousMeasurementValue The corresponding previous value with its type. Can be null. + * @param type The definitive [com.health.openscale.core.data.MeasurementType] of the value, + * assumed to be globally enabled and correct for trend calculation. + * @return A Pair containing the calculated difference (Float?) and the determined [com.health.openscale.core.data.Trend]. + */ + private fun calculateSingleValueTrendLogic( + currentMeasurementValue: MeasurementValueWithType, // Current value is non-null here as per usage + previousMeasurementValue: MeasurementValueWithType?, + type: MeasurementType + ): Pair { + var differenceValue: Float? = null + var trend = Trend.NOT_APPLICABLE + + if (previousMeasurementValue != null) { + if (currentMeasurementValue.type.id == previousMeasurementValue.type.id && currentMeasurementValue.type.id == type.id) { + when (type.inputType) { + InputFieldType.FLOAT -> { + val currentVal = currentMeasurementValue.value.floatValue + val previousVal = previousMeasurementValue.value.floatValue + if (currentVal != null && previousVal != null) { + differenceValue = currentVal - previousVal + trend = when { + differenceValue > 0.001f -> Trend.UP + differenceValue < -0.001f -> Trend.DOWN + else -> Trend.NONE + } + } + } + InputFieldType.INT -> { + val currentVal = currentMeasurementValue.value.intValue + val previousVal = previousMeasurementValue.value.intValue + if (currentVal != null && previousVal != null) { + differenceValue = (currentVal - previousVal).toFloat() + trend = when { + differenceValue > 0f -> Trend.UP + differenceValue < 0f -> Trend.DOWN + else -> Trend.NONE + } + } + } + else -> { + trend = Trend.NOT_APPLICABLE + } + } + } else { + LogManager.w(TAG, "Trend calculation skipped: type ID mismatch. Current: ${currentMeasurementValue.type.id}, Previous: ${previousMeasurementValue.type.id}, Authoritative Type: ${type.id}. (Trend Logic Warning)") + } + } else { + trend = Trend.NOT_APPLICABLE + } + return differenceValue to trend + } + + init { + LogManager.i(TAG, "ViewModel initializing... (Lifecycle Event)") + setTopBarTitle(R.string.app_name) + + viewModelScope.launch { + LogManager.d(TAG, "Init: Attempting to load last selected user ID from UserSettingsRepository. (Initialization Logic)") + val lastSelectedId = userSettingRepository.currentUserId.first() + + if (lastSelectedId != null) { + LogManager.d(TAG, "Init: User ID $lastSelectedId found in settings. Verifying existence in database. (Initialization Logic)") + val userExists = databaseRepository.getUserById(lastSelectedId) + .flowOn(Dispatchers.IO) + .first() != null + + if (userExists) { + _selectedUserId.value = lastSelectedId + LogManager.i(TAG, "Init: User $lastSelectedId loaded from settings and verified in DB. Set as selected. (Initialization Result)") + } else { + LogManager.w(TAG, "Init: User $lastSelectedId from settings not found in DB. Clearing selection. (Initialization Warning)") + _selectedUserId.value = null + userSettingRepository.setCurrentUserId(null) + } + } else { + LogManager.i(TAG, "Init: No user ID found in settings. No user auto-selected. (Initialization Logic)") + } + } + LogManager.i(TAG, "ViewModel initialization complete. (Lifecycle Event)") + } +} + +/** + * Utility function to create a [ViewModelProvider.Factory] for ViewModels that have constructor dependencies. + */ +inline fun createViewModelFactory(crossinline creator: () -> VM): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return creator() as T + } + } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothConnectionManager.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothConnectionManager.kt new file mode 100644 index 00000000..a434a0f2 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothConnectionManager.kt @@ -0,0 +1,507 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.bluetooth + +import android.annotation.SuppressLint +import android.content.Context +import androidx.compose.material3.SnackbarDuration +import com.health.openscale.core.bluetooth.BluetoothEvent +import com.health.openscale.core.bluetooth.ScaleCommunicator +import com.health.openscale.core.bluetooth.ScaleFactory +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.data.Measurement +import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.MeasurementValue +import com.health.openscale.core.database.DatabaseRepository +import com.health.openscale.core.utils.LogManager +import com.health.openscale.ui.screen.SharedViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch + +/** + * Manages Bluetooth connections to scale devices, handling the connection lifecycle, + * data reception, and error reporting. It interacts with [ScaleCommunicator] instances + * created by [ScaleFactory] and updates UI state via [SharedViewModel] and observable Flows. + * + * This class is designed to be used within a [CoroutineScope], typically from a ViewModel. + * It implements [AutoCloseable] to ensure resources are released when it's no longer needed. + * + * @param context The application context, used for creating [ScaleCommunicator] instances. + * It's preferred to use `ApplicationContext` to avoid memory leaks. + * @param scope The [CoroutineScope] in which background operations like connection and + * event observation will be launched (e.g., `viewModelScope` from BluetoothViewModel). + * @param scaleFactory A factory for creating [ScaleCommunicator] instances based on device information. + * @param databaseRepository Repository for saving received measurements. + * @param sharedViewModel ViewModel for showing snackbars and potentially other UI interactions. + * @param getCurrentScaleUser Callback function to retrieve the current Bluetooth scale user. + * @param getCurrentAppUserId Callback function to retrieve the ID of the current application user. + * @param onUserSelectionRequired Callback to notify the UI when user interaction on the device is needed. + * @param onSavePreferredDevice Callback to save the successfully connected device as preferred. + */ +class BluetoothConnectionManager( + private val context: Context, + private val scope: CoroutineScope, + private val scaleFactory: ScaleFactory, + private val databaseRepository: DatabaseRepository, + private val sharedViewModel: SharedViewModel, + private val getCurrentScaleUser: () -> ScaleUser?, + private val getCurrentAppUserId: () -> Int, + private val onUserSelectionRequired: (BluetoothEvent.UserSelectionRequired) -> Unit, + private val onSavePreferredDevice: suspend (address: String, name: String) -> Unit +) : AutoCloseable { + + private companion object { + const val TAG = "BluetoothConnManager" + const val DISCONNECT_TIMEOUT_MS = 3000L // Timeout for forceful disconnect if no event received. + } + + private val _connectedDeviceName = MutableStateFlow(null) + /** Emits the name of the currently connected device, or null if not connected. */ + val connectedDeviceName: StateFlow = _connectedDeviceName.asStateFlow() + + private val _connectedDeviceAddress = MutableStateFlow(null) + /** Emits the MAC address of the currently connected device, or null if not connected. */ + val connectedDeviceAddress: StateFlow = _connectedDeviceAddress.asStateFlow() + + private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED) + /** Emits the current [ConnectionStatus] of the Bluetooth device. */ + val connectionStatus: StateFlow = _connectionStatus.asStateFlow() + + private val _connectionError = MutableStateFlow(null) + /** Emits an error message if a connection or operational error occurs, null otherwise. */ + val connectionError: StateFlow = _connectionError.asStateFlow() + + private val _showUserSelectionDialog = MutableStateFlow(null) + /** + * Emits a [BluetoothEvent.UserSelectionRequired] event when the connected scale requires + * user interaction (e.g., selecting a user profile on the scale). + * The UI should observe this and display an appropriate dialog. + */ + val showUserSelectionDialog: StateFlow = _showUserSelectionDialog.asStateFlow() + + private var activeCommunicator: ScaleCommunicator? = null + private var communicatorJob: Job? = null // Job for observing events from the activeCommunicator. + private var disconnectTimeoutJob: Job? = null // Job for handling disconnect timeouts. + + /** + * Attempts to connect to the specified Bluetooth device. + * This function is suspendable and performs operations in the [scope] provided during construction. + * + * Note: Bluetooth permissions and enabled status should be checked by the caller (ViewModel) + * before invoking this method, as this manager cannot display UI prompts for them. + * + * @param deviceInfo Information about the scanned device to connect to. + */ + @SuppressLint("MissingPermission") // Permissions are expected to be checked by the caller. + fun connectToDevice(deviceInfo: ScannedDeviceInfo) { + scope.launch { + val deviceDisplayName = deviceInfo.name ?: deviceInfo.address + LogManager.i(TAG, "Attempting to connect to $deviceDisplayName") + + // Basic validation logic (adapted from ViewModel). + // Permissions and Bluetooth status should be checked BEFORE calling this method + // in the ViewModel, as the manager cannot display UI for it. + // Here only a fundamental check. + val currentAppUserId = getCurrentAppUserId() + // Some legacy or specific openScale handlers might require a valid user. + val needsUserCheck = deviceInfo.determinedHandlerDisplayName?.contains("legacy", ignoreCase = true) == true || + deviceInfo.determinedHandlerDisplayName?.startsWith("com.health.openscale") == true + + if (needsUserCheck && currentAppUserId == 0) { + LogManager.e(TAG, "User ID is 0, which might be problematic for handler '${deviceInfo.determinedHandlerDisplayName}'. Connection ABORTED.") + _connectionError.value = "No user selected. Connection to $deviceDisplayName not possible." + _connectionStatus.value = ConnectionStatus.FAILED + return@launch + } + + if (!deviceInfo.isSupported) { + LogManager.e(TAG, "Device $deviceDisplayName is NOT supported according to ScannedInfo. Connection ABORTED.") + _connectionError.value = "$deviceDisplayName is not supported." + _connectionStatus.value = ConnectionStatus.FAILED + return@launch + } + + // Release any existing communicator and its observation job if present. + releaseActiveCommunicator(logPrefix = "Switching to new device: ") + + _connectionStatus.value = ConnectionStatus.CONNECTING + _connectedDeviceAddress.value = deviceInfo.address + _connectedDeviceName.value = deviceInfo.name + _connectionError.value = null // Clear previous errors. + + activeCommunicator = scaleFactory.createCommunicator(deviceInfo) + + if (activeCommunicator == null) { + LogManager.e(TAG, "ScaleFactory could NOT create a communicator for $deviceDisplayName. Connection ABORTED.") + _connectionError.value = "Driver for $deviceDisplayName not found or internal error." + _connectionStatus.value = ConnectionStatus.FAILED + _connectedDeviceAddress.value = null + _connectedDeviceName.value = null + return@launch + } + + LogManager.i(TAG, "ActiveCommunicator successfully created: ${activeCommunicator!!.javaClass.simpleName}. Starting observation job...") + observeActiveCommunicatorEvents(deviceInfo) + activeCommunicator?.connect(deviceInfo.address, getCurrentScaleUser(), currentAppUserId) + } + } + + /** + * Observes connection status and events from the [activeCommunicator]. + * This involves collecting from `isConnected` and `getEventsFlow()`. + * This job is cancelled and restarted if a new device connection is initiated. + * + * @param connectedDeviceInfo Information about the device for which events are being observed. + * Used for display names in logs and UI messages. + */ + private fun observeActiveCommunicatorEvents(connectedDeviceInfo: ScannedDeviceInfo) { + val deviceDisplayName = connectedDeviceInfo.name ?: connectedDeviceInfo.address + communicatorJob?.cancel() // Ensure any previous observation job is stopped. + communicatorJob = scope.launch { + activeCommunicator?.let { comm -> + // Observe the isConnected Flow from the Communicator. + launch { + comm.isConnected.collect { isConnected -> + LogManager.d(TAG, "Adapter isConnected: $isConnected for $deviceDisplayName (Status: ${_connectionStatus.value})") + if (isConnected) { + // Only transition to CONNECTED if we were in the process of CONNECTING. + if (_connectionStatus.value == ConnectionStatus.CONNECTING) { + _connectionStatus.value = ConnectionStatus.CONNECTED + // Address and name should already be set when starting the connection, + // but confirm here for safety. + _connectedDeviceAddress.value = connectedDeviceInfo.address + _connectedDeviceName.value = connectedDeviceInfo.name + onSavePreferredDevice(connectedDeviceInfo.address, connectedDeviceInfo.name ?: "Unknown Scale") + sharedViewModel.showSnackbar("Connected to $deviceDisplayName", SnackbarDuration.Short) + _connectionError.value = null // Clear any errors on successful connection. + LogManager.i(TAG, "Successfully connected to $deviceDisplayName via adapter's isConnected flow.") + disconnectTimeoutJob?.cancel() // Successfully connected, timeout no longer needed. + } + } else { + // If isConnected goes false and we were connected or connecting/disconnecting + // to this specific device. + if ((_connectionStatus.value == ConnectionStatus.CONNECTED || + _connectionStatus.value == ConnectionStatus.CONNECTING || + _connectionStatus.value == ConnectionStatus.DISCONNECTING) && + _connectedDeviceAddress.value == connectedDeviceInfo.address + ) { + LogManager.i(TAG, "Adapter no longer reports connected for $deviceDisplayName. Current status: ${_connectionStatus.value}. Expecting Disconnected Event.") + // Do not immediately set to DISCONNECTED here. Wait for the Disconnected event, + // as it often provides more information (e.g., reason). + // If no event arrives, the timeout in disconnect() or another mechanism will handle it. + } + } + } + } + + // Observe the Event Flow from the Communicator. + launch { + comm.getEventsFlow().collect { event -> + handleBluetoothEvent(event, connectedDeviceInfo) + } + } + } ?: LogManager.w(TAG, "observeActiveCommunicatorEvents called with null activeCommunicator for $deviceDisplayName") + } + } + + /** + * Handles [BluetoothEvent]s received from the [activeCommunicator]. + * Updates connection status, handles measurements, errors, and other device interactions. + * + * @param event The [BluetoothEvent] to handle. + * @param deviceInfo Information about the device that emitted the event. + */ + private suspend fun handleBluetoothEvent(event: BluetoothEvent, deviceInfo: ScannedDeviceInfo) { + val deviceDisplayName = deviceInfo.name ?: deviceInfo.address // Fallback to address for display. + LogManager.d(TAG, "BluetoothEvent received: $event for $deviceDisplayName") + + when (event) { + is BluetoothEvent.Connected -> { + LogManager.i(TAG, "Event: Connected to ${event.deviceName ?: deviceDisplayName} (${event.deviceAddress})") + disconnectTimeoutJob?.cancel() // Successfully connected, timeout no longer needed. + if (_connectionStatus.value != ConnectionStatus.CONNECTED) { + _connectionStatus.value = ConnectionStatus.CONNECTED + _connectedDeviceAddress.value = event.deviceAddress + _connectedDeviceName.value = event.deviceName ?: deviceInfo.name // Prefer event name. + onSavePreferredDevice(event.deviceAddress, event.deviceName ?: deviceInfo.name ?: "Unknown Scale") + sharedViewModel.showSnackbar("Connected to ${event.deviceName ?: deviceDisplayName}", SnackbarDuration.Short) + _connectionError.value = null + } + } + is BluetoothEvent.Disconnected -> { + LogManager.i(TAG, "Event: Disconnected from ${event.deviceAddress}. Reason: ${event.reason}") + disconnectTimeoutJob?.cancel() // Disconnect event received, timeout no longer needed. + // Only act if this disconnect event is for the currently tracked device or if we are in the process of disconnecting. + if (_connectedDeviceAddress.value == event.deviceAddress || _connectionStatus.value == ConnectionStatus.DISCONNECTING) { + _connectionStatus.value = ConnectionStatus.DISCONNECTED + _connectedDeviceAddress.value = null + _connectedDeviceName.value = null + // Optionally: _connectionError.value = "Disconnected: ${event.reason}" + releaseActiveCommunicator(logPrefix = "Disconnected event: ") + } else { + LogManager.w(TAG, "Disconnected event for unexpected address ${event.deviceAddress} or status ${_connectionStatus.value}") + } + } + is BluetoothEvent.ConnectionFailed -> { + LogManager.w(TAG, "Event: Connection failed for ${event.deviceAddress}. Reason: ${event.error}") + disconnectTimeoutJob?.cancel() // Error, timeout no longer needed. + // Check if this error is relevant to the current connection attempt. + if (_connectedDeviceAddress.value == event.deviceAddress || _connectionStatus.value == ConnectionStatus.CONNECTING) { + _connectionStatus.value = ConnectionStatus.FAILED + _connectionError.value = "Connection to $deviceDisplayName failed: ${event.error}" + _connectedDeviceAddress.value = null + _connectedDeviceName.value = null + releaseActiveCommunicator(logPrefix = "ConnectionFailed event: ") + } else { + LogManager.w(TAG, "ConnectionFailed event for unexpected address ${event.deviceAddress} or status ${_connectionStatus.value}") + } + } + is BluetoothEvent.MeasurementReceived -> { + LogManager.i(TAG, "Event: Measurement received from $deviceDisplayName: Weight ${event.measurement.weight}") + saveMeasurementFromEvent(event.measurement, event.deviceAddress, deviceDisplayName) + } + is BluetoothEvent.DeviceMessage -> { + LogManager.d(TAG, "Event: Message from $deviceDisplayName: ${event.message}") + sharedViewModel.showSnackbar("$deviceDisplayName: ${event.message}", duration = SnackbarDuration.Long) + } + is BluetoothEvent.Error -> { + LogManager.e(TAG, "Event: Error from $deviceDisplayName: ${event.error}") + _connectionError.value = "Error with $deviceDisplayName: ${event.error}" + // Consider setting status to FAILED if it's a critical error + // that impacts/loses the connection. + } + is BluetoothEvent.UserSelectionRequired -> { + LogManager.i(TAG, "Event: UserSelectionRequired for ${event.deviceIdentifier}. Description: ${event.description}.") + _showUserSelectionDialog.value = event // For the ViewModel to observe and show a dialog. + onUserSelectionRequired(event) // Direct callback to ViewModel if it needs to react immediately. + sharedViewModel.showSnackbar( + "Action required on $deviceDisplayName: ${event.description.take(50)}...", + SnackbarDuration.Long + ) + } + } + } + + /** + * Saves a [ScaleMeasurement] received from a device to the database. + * This involves creating a [Measurement] entity and associated [MeasurementValue]s. + * + * @param measurementData The raw measurement data from the scale. + * @param deviceAddress The address of the device that sent the measurement. + * @param deviceName The name of the device. + */ + private suspend fun saveMeasurementFromEvent(measurementData: ScaleMeasurement, deviceAddress: String, deviceName: String) { + val currentAppUserId = getCurrentAppUserId() + if (currentAppUserId == 0) { + LogManager.e(TAG, "($deviceName): No App User ID to save measurement.") + sharedViewModel.showSnackbar("Measurement from $deviceName cannot be assigned to a user.", SnackbarDuration.Long) + return + } + LogManager.i(TAG, "($deviceName): Saving measurement for App User ID $currentAppUserId.") + + // This logic is largely identical to what might be in a ViewModel and could + // potentially be moved entirely into a dedicated MeasurementRepository or similar service. + scope.launch(Dispatchers.IO) { // Perform database operations on IO dispatcher. + val newDbMeasurement = Measurement( + userId = currentAppUserId, + timestamp = measurementData.dateTime?.time ?: System.currentTimeMillis() + ) + + // Fetch measurement type IDs from the database to map keys to foreign keys. + val typeKeyToIdMap: Map = + databaseRepository.getAllMeasurementTypes().firstOrNull() + ?.associate { it.key to it.id } ?: run { + LogManager.e(TAG, "Could not load MeasurementTypes from DB for $deviceName.") + sharedViewModel.showSnackbar("Error: Measurement types not loaded.", SnackbarDuration.Long) + return@launch + } + fun getTypeIdFromMap(key: MeasurementTypeKey): Int? = typeKeyToIdMap[key] + + val values = mutableListOf() + measurementData.weight.takeIf { it.isFinite() && it > 0.0f }?.let { + getTypeIdFromMap(MeasurementTypeKey.WEIGHT)?.let { typeId -> + values.add(MeasurementValue(measurementId = 0, typeId = typeId, floatValue = it)) + } + } + measurementData.fat.takeIf { it.isFinite() && it > 0.0f }?.let { + getTypeIdFromMap(MeasurementTypeKey.BODY_FAT)?.let { typeId -> + values.add(MeasurementValue(measurementId = 0, typeId = typeId, floatValue = it)) + } + } + measurementData.water.takeIf { it.isFinite() && it > 0.0f }?.let { + getTypeIdFromMap(MeasurementTypeKey.WATER)?.let { typeId -> + values.add(MeasurementValue(measurementId = 0, typeId = typeId, floatValue = it)) + } + } + measurementData.muscle.takeIf { it.isFinite() && it > 0.0f }?.let { + getTypeIdFromMap(MeasurementTypeKey.MUSCLE)?.let { typeId -> + values.add(MeasurementValue(measurementId = 0, typeId = typeId, floatValue = it)) + } + } + measurementData.visceralFat.takeIf { it.isFinite() && it >= 0.0f }?.let { + getTypeIdFromMap(MeasurementTypeKey.VISCERAL_FAT)?.let { typeId -> + values.add(MeasurementValue(measurementId = 0, typeId = typeId, floatValue = it)) + } + } + measurementData.bone.takeIf { it.isFinite() && it > 0.0f }?.let { + getTypeIdFromMap(MeasurementTypeKey.BONE)?.let { typeId -> + values.add(MeasurementValue(measurementId = 0, typeId = typeId, floatValue = it)) + } + } + // Add other values here (BMI, BMR etc. if available from ScaleMeasurement) + + if (values.isEmpty()) { + LogManager.w(TAG, "No valid values from measurement of $deviceName to save.") + sharedViewModel.showSnackbar("No valid measurement values received from $deviceName.", SnackbarDuration.Long) + return@launch + } + + try { + val measurementId = databaseRepository.insertMeasurement(newDbMeasurement) + val finalValues = values.map { it.copy(measurementId = measurementId.toInt()) } + finalValues.forEach { databaseRepository.insertMeasurementValue(it) } + + LogManager.i(TAG, "Measurement from $deviceName for User $currentAppUserId saved (ID: $measurementId). Values: ${finalValues.size}") + sharedViewModel.showSnackbar("Measurement (${measurementData.weight} kg) from $deviceName saved.", SnackbarDuration.Short) + } catch (e: Exception) { + LogManager.e(TAG, "Error saving measurement from $deviceName.", e) + sharedViewModel.showSnackbar("Error saving measurement from $deviceName.", SnackbarDuration.Long) + } + } + } + + /** + * Disconnects from the currently connected device, if any. + * This method initiates the disconnection process and starts a timeout + * to forcefully update the status if the communicator doesn't report disconnection promptly. + */ + fun disconnect() { + val deviceDisplayName = _connectedDeviceName.value ?: _connectedDeviceAddress.value ?: "current device" + LogManager.i(TAG, "disconnect() called for $deviceDisplayName. Active communicator: ${activeCommunicator != null}, Status: ${_connectionStatus.value}") + + if (activeCommunicator == null && _connectionStatus.value != ConnectionStatus.CONNECTED && _connectionStatus.value != ConnectionStatus.CONNECTING) { + LogManager.w(TAG, "No active communicator or active connection to disconnect.") + // Ensure status consistency if no active connection exists. + if (_connectionStatus.value != ConnectionStatus.DISCONNECTED && _connectionStatus.value != ConnectionStatus.FAILED) { + _connectionStatus.value = ConnectionStatus.DISCONNECTED + _connectedDeviceAddress.value = null + _connectedDeviceName.value = null + } + return + } + + if (_connectionStatus.value != ConnectionStatus.DISCONNECTING && _connectionStatus.value != ConnectionStatus.DISCONNECTED) { + _connectionStatus.value = ConnectionStatus.DISCONNECTING + } + + activeCommunicator?.disconnect() // Request the communicator to disconnect. + + // Fallback timeout in case no Disconnected event is received from the communicator. + disconnectTimeoutJob?.cancel() + disconnectTimeoutJob = scope.launch { + delay(DISCONNECT_TIMEOUT_MS) + if (_connectionStatus.value == ConnectionStatus.DISCONNECTING) { + LogManager.w(TAG, "Disconnect timeout for $deviceDisplayName. Forcing status to DISCONNECTED.") + _connectionStatus.value = ConnectionStatus.DISCONNECTED + _connectedDeviceAddress.value = null + _connectedDeviceName.value = null + releaseActiveCommunicator(logPrefix = "Disconnect timeout: ") + } + } + } + + /** + * Releases the [activeCommunicator] and cancels associated jobs. + * This includes cancelling the communicator's event observation job and any disconnect timeout. + * If the communicator implements [AutoCloseable], its `close()` method is called. + * + * @param logPrefix A prefix string for log messages, useful for context. + */ + private fun releaseActiveCommunicator(logPrefix: String = "") { + LogManager.d(TAG, "${logPrefix}Releasing active communicator: ${activeCommunicator?.javaClass?.simpleName}") + communicatorJob?.cancel() // Important: Stop the job observing events. + communicatorJob = null + disconnectTimeoutJob?.cancel() // Also stop the timeout job for disconnects. + disconnectTimeoutJob = null + + try { + (activeCommunicator as? AutoCloseable)?.close() // If the communicator is AutoCloseable. + } catch (e: Exception) { + LogManager.e(TAG, "${logPrefix}Error closing activeCommunicator: ${e.message}", e) + } + activeCommunicator = null + LogManager.d(TAG, "${logPrefix}Active communicator released and set to null.") + } + + /** + * Sets an external connection error message. This can be used by the hosting ViewModel + * to report errors that occur outside the manager's direct connection logic (e.g., permission issues). + * + * @param errorMessage The error message to display. If null, the error is cleared (see [clearConnectionError]). + */ + fun setExternalConnectionError(errorMessage: String?) { + LogManager.w(TAG, "External connection error set: $errorMessage") + _connectionError.value = errorMessage + if (errorMessage != null) { + // When an error is set, typically the connection status should reflect failure. + // However, be mindful if this is called before any connection attempt has even started. + // If no connection attempt was active, setting to FAILED might be immediate. + // If a connection was in progress and this is an additional error, it might already be FAILED. + if (_connectionStatus.value != ConnectionStatus.CONNECTING && + _connectionStatus.value != ConnectionStatus.CONNECTED && + _connectionStatus.value != ConnectionStatus.DISCONNECTING + ) { + _connectionStatus.value = ConnectionStatus.FAILED + } + } + } + + /** + * Clears any existing connection error message. + */ + fun clearConnectionError() { + if (_connectionError.value != null) { + _connectionError.value = null + } + } + + /** + * Cleans up resources when the BluetoothConnectionManager is no longer needed. + * This typically involves disconnecting any active connection and releasing the communicator. + * It's important to call this (e.g., from ViewModel's `onCleared()`) to prevent resource leaks. + */ + override fun close() { + LogManager.i(TAG, "Closing BluetoothConnectionManager.") + // Ensure to disconnect if there's an active connection or attempt. + if (_connectionStatus.value == ConnectionStatus.CONNECTED || _connectionStatus.value == ConnectionStatus.CONNECTING) { + disconnect() // Calls the disconnect logic, which also releases the communicator. + } else { + // If not connected/connecting, still ensure everything is clean. + releaseActiveCommunicator(logPrefix = "Closing manager: ") + } + LogManager.i(TAG, "BluetoothConnectionManager closed.") + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothScannerManager.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothScannerManager.kt new file mode 100644 index 00000000..fa79a704 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothScannerManager.kt @@ -0,0 +1,345 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.bluetooth + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothManager +import android.bluetooth.le.ScanResult +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.SparseArray +import com.health.openscale.core.bluetooth.ScaleFactory +import com.health.openscale.core.utils.LogManager +import com.welie.blessed.BluetoothCentralManager +import com.welie.blessed.BluetoothCentralManagerCallback +import com.welie.blessed.BluetoothPeripheral +import com.welie.blessed.ScanFailure +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.UUID + +/** + * Data class to hold information about a scanned Bluetooth LE device. + * + * @property name The advertised name of the device. Can be null. + * @property address The MAC address of the device. + * @property rssi The received signal strength indicator (RSSI) in dBm. + * @property serviceUuids A list of service UUIDs advertised by the device. + * @property manufacturerData Manufacturer-specific data advertised by the device. + * @property isSupported Flag indicating whether openScale has a handler for this device. + * @property determinedHandlerDisplayName The display name of the handler determined for this device, if any. + */ +data class ScannedDeviceInfo( + val name: String?, + val address: String, + val rssi: Int, + val serviceUuids: List, + val manufacturerData: SparseArray?, + var isSupported: Boolean = false, + var determinedHandlerDisplayName: String? = null +) + +/** + * Manages Bluetooth LE device scanning operations using the Blessed library. + * + * This class handles starting, stopping, and processing scan results. It exposes + * [StateFlow]s for discovered devices, scanning status, and scan errors, allowing + * UI components or ViewModels to observe scanning activity. + * + * @param context The application context. + * @param externalScope A [CoroutineScope] (typically from a ViewModel) for launching tasks like scan timeouts. + * @param scaleFactory An instance of [ScaleFactory] used to determine device support and handler information. + */ +class BluetoothScannerManager( + private val context: Context, + private val externalScope: CoroutineScope, + private val scaleFactory: ScaleFactory +) { + private companion object { + const val TAG = "BluetoothScannerMgr" + } + + // Ensures Blessed library callbacks are executed on the main thread. + private val blessedBluetoothHandler = Handler(Looper.getMainLooper()) + private val centralManager: BluetoothCentralManager by lazy { + BluetoothCentralManager(context, centralManagerCallback, blessedBluetoothHandler) + } + + private val _scannedDevices = MutableStateFlow>(emptyList()) + /** + * Emits the current list of discovered and processed [ScannedDeviceInfo] objects. + * The list is sorted by support status (supported first), then by RSSI (strongest signal first), + * and finally by device name. + */ + val scannedDevices: StateFlow> = _scannedDevices.asStateFlow() + + private val _isScanning = MutableStateFlow(false) + /** + * Emits `true` if a Bluetooth LE scan is currently active, `false` otherwise. + */ + val isScanning: StateFlow = _isScanning.asStateFlow() + + private val _scanError = MutableStateFlow(null) + /** + * Emits error messages related to the scanning process. + * Emits `null` if there is no current error or an error has been cleared. + */ + val scanError: StateFlow = _scanError.asStateFlow() + + private var scanTimeoutJob: Job? = null + // Stores unique devices found during a scan, keyed by MAC address, for efficient updates. + private val deviceMap = mutableMapOf() + + /** + * Starts a Bluetooth LE scan for a specified duration. + * + * Prerequisites (e.g., Bluetooth enabled, permissions granted) are checked. + * If a scan is already in progress, this method returns without action. + * + * @param scanDurationMs The duration in milliseconds for the scan. + * The scan automatically stops after this period if not manually stopped earlier. + */ + @SuppressLint("MissingPermission") // Permissions are expected to be checked by the calling ViewModel. + fun startScan(scanDurationMs: Long) { + if (!validateScanPrerequisites()) { + return + } + + if (_isScanning.value || centralManager.isScanning) { + LogManager.d(TAG, "Scan is already in progress.") + return + } + LogManager.i(TAG, "Starting device scan for $scanDurationMs ms.") + + deviceMap.clear() + _scannedDevices.value = emptyList() + _scanError.value = null // Clear previous errors. + _isScanning.value = true + + try { + centralManager.scanForPeripherals() + } catch (e: Exception) { + LogManager.e(TAG, "Exception while starting scan: ${e.message}", e) + _scanError.value = "Error starting scan: ${e.localizedMessage ?: "Unknown error"}" + _isScanning.value = false + return + } + + scanTimeoutJob?.cancel() + scanTimeoutJob = externalScope.launch { + delay(scanDurationMs) + if (_isScanning.value) { + LogManager.i(TAG, "Scan timeout reached after $scanDurationMs ms.") + stopScanInternal(isTimeout = true) + } + } + } + + /** + * Stops the currently active Bluetooth LE scan. + */ + fun stopScan() { + stopScanInternal(isTimeout = false) + } + + /** + * Internal implementation for stopping the scan. + * @param isTimeout Indicates if the stop was triggered by a timeout. + */ + private fun stopScanInternal(isTimeout: Boolean) { + if (!_isScanning.value && !centralManager.isScanning) { + return // Scan not active. + } + LogManager.i(TAG, "Stopping device scan. Triggered by timeout: $isTimeout") + scanTimeoutJob?.cancel() + scanTimeoutJob = null + + try { + if (centralManager.isScanning) { + centralManager.stopScan() + } + } catch (e: Exception) { + LogManager.e(TAG, "Exception while stopping scan: ${e.message}", e) + // Optionally, an error could be set here, but it's often not critical for a stop action. + } + _isScanning.value = false + + if (isTimeout && deviceMap.isEmpty()) { + _scanError.value = "No devices found." + } + LogManager.d(TAG, "Scan stopped. Found devices: ${deviceMap.size}") + } + + /** + * Validates if conditions are met to start a scan (e.g., Bluetooth enabled). + * Note: Permission checks are the responsibility of the calling ViewModel. + * + * @return `true` if prerequisites are met, `false` otherwise. + * If `false`, `_scanError` is updated with the reason. + */ + private fun validateScanPrerequisites(): Boolean { + val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager? + if (bluetoothManager?.adapter?.isEnabled != true) { + LogManager.w(TAG, "Scan prerequisites not met: Bluetooth is disabled.") + _scanError.value = "Bluetooth is disabled. Please enable it to scan." + return false + } + + if (_isScanning.value) { + LogManager.d(TAG, "Scan is already in progress (checked in validate).") + return false + } + _scanError.value = null // Clear errors if prerequisites are met. + return true + } + + /** + * Clears any active scan error message from `scanError` StateFlow. + */ + fun clearScanError() { + if (_scanError.value != null) { + _scanError.value = null + } + } + + /** + * Releases resources used by the scanner, including the Blessed [BluetoothCentralManager]. + * Call this when the scanner is no longer needed (e.g., in ViewModel's `onCleared`). + */ + fun close() { + LogManager.i(TAG, "Closing BluetoothScannerManager.") + stopScanInternal(isTimeout = false) // Ensure scan is stopped. + try { + // Crucial to close BluetoothCentralManager to release system resources + // and unregister internal broadcast receivers used by the Blessed library. + centralManager.close() + LogManager.d(TAG, "Blessed BluetoothCentralManager closed successfully.") + } catch (e: Exception) { + LogManager.e(TAG, "Error closing Blessed BluetoothCentralManager: ${e.message}", e) + } + } + + private val centralManagerCallback = object : BluetoothCentralManagerCallback() { + @SuppressLint("MissingPermission") // Permissions are handled before scan initiation. + override fun onDiscoveredPeripheral(peripheral: BluetoothPeripheral, scanResult: ScanResult) { + val deviceName = peripheral.name + val deviceAddress = peripheral.address + val rssi = scanResult.rssi + val serviceUuids: List = scanResult.scanRecord?.serviceUuids?.mapNotNull { it?.uuid } ?: emptyList() + val manufacturerData: SparseArray? = scanResult.scanRecord?.manufacturerSpecificData + + val (isSupported, handlerName) = scaleFactory.getSupportingHandlerInfo( + deviceName = deviceName, + deviceAddress = deviceAddress, + serviceUuids = serviceUuids, + manufacturerData = manufacturerData + ) + + val newDevice = ScannedDeviceInfo( + name = deviceName, + address = deviceAddress, + rssi = rssi, + serviceUuids = serviceUuids, + manufacturerData = manufacturerData, + isSupported = isSupported, + determinedHandlerDisplayName = handlerName + ) + + val existingDevice = deviceMap[newDevice.address] + var listShouldBeUpdated = false + + if (existingDevice != null) { + // Update criteria: if RSSI changed, or if key device info (name, support, handler, services, manufacturer data) has improved or changed. + val nameChangedToKnown = newDevice.name != null && existingDevice.name == null + val supportStatusImproved = !existingDevice.isSupported && newDevice.isSupported + val handlerChanged = newDevice.determinedHandlerDisplayName != existingDevice.determinedHandlerDisplayName + val serviceUuidsUpdated = newDevice.serviceUuids.isNotEmpty() && newDevice.serviceUuids != existingDevice.serviceUuids + val manuDataUpdated = newDevice.manufacturerData != null && !newDevice.manufacturerData.contentEquals(existingDevice.manufacturerData) + + if (newDevice.rssi != existingDevice.rssi || nameChangedToKnown || supportStatusImproved || handlerChanged || serviceUuidsUpdated || manuDataUpdated) { + deviceMap[newDevice.address] = existingDevice.copy( + name = newDevice.name ?: existingDevice.name, // Prefer new name if available. + rssi = newDevice.rssi, + isSupported = existingDevice.isSupported || newDevice.isSupported, // Retain 'supported' status if ever true. + determinedHandlerDisplayName = newDevice.determinedHandlerDisplayName ?: existingDevice.determinedHandlerDisplayName, + serviceUuids = if (newDevice.serviceUuids.isNotEmpty()) newDevice.serviceUuids else existingDevice.serviceUuids, + manufacturerData = newDevice.manufacturerData ?: existingDevice.manufacturerData + ) + listShouldBeUpdated = true + } + } else { + // Add new device if it's supported, or has a meaningful name, or provides service/manufacturer data. + // This avoids populating the list with devices that have no identifying information and are not supported. + if (newDevice.isSupported || + !newDevice.name.isNullOrEmpty() || + newDevice.serviceUuids.isNotEmpty() || + (newDevice.manufacturerData != null && newDevice.manufacturerData.size() > 0) + ) { + deviceMap[newDevice.address] = newDevice + listShouldBeUpdated = true + } + } + + if (listShouldBeUpdated) { + // Filter ensures only devices that are supported or have a meaningful name (not generic "Unknown Device") are emitted. + // Sorting provides a consistent and user-friendly order. + _scannedDevices.value = deviceMap.values + .filter { it.isSupported || (!it.name.isNullOrEmpty() && it.name != "Unbekanntes Gerät" && it.name != "Unknown Device") } + .sortedWith(compareByDescending { it.isSupported } + .thenByDescending { it.rssi } + .thenBy { it.name?.lowercase() ?: "zzzz" }) // "zzzz" ensures null names sort last. + .toList() + } + } + + override fun onScanFailed(scanFailure: ScanFailure) { + LogManager.e(TAG, "Bluetooth scan failed: $scanFailure") + externalScope.launch { + _scanError.value = "Bluetooth Scan Failed: $scanFailure" + _isScanning.value = false + scanTimeoutJob?.cancel() // Stop scan timeout if scan fails. + } + } + } + + /** + * Extension function for content-based comparison of two `SparseArray?` instances. + * The standard `equals` on `SparseArray` only checks for reference equality. + */ + private fun SparseArray?.contentEquals(other: SparseArray?): Boolean { + if (this === other) return true + if (this == null || other == null) return false + if (this.size() != other.size()) return false + + for (i in 0 until this.size()) { + val key = this.keyAt(i) + val valueThis = this.valueAt(i) + val valueOther = other.get(key) + if (valueOther == null || !valueThis.contentEquals(valueOther)) { + return false + } + } + return true + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothViewModel.kt new file mode 100644 index 00000000..9bb32a32 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothViewModel.kt @@ -0,0 +1,567 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.bluetooth + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Application +import android.bluetooth.BluetoothManager +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.compose.material3.SnackbarDuration // Keep if used directly, otherwise remove +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.health.openscale.core.bluetooth.BluetoothEvent +// ScaleCommunicator no longer needed directly here +import com.health.openscale.core.bluetooth.ScaleFactory +// ScaleMeasurement no longer needed directly here for saveMeasurementFromEvent +import com.health.openscale.core.bluetooth.data.ScaleUser +// Measurement, MeasurementTypeKey, MeasurementValue no longer needed directly here +import com.health.openscale.core.data.User +import com.health.openscale.core.utils.LogManager +import com.health.openscale.ui.screen.SharedViewModel +// kotlinx.coroutines.Dispatchers no longer needed directly here for saveMeasurement +// kotlinx.coroutines.Job no longer needed directly here for communicatorJob +// kotlinx.coroutines.delay no longer needed directly here for disconnect-timeout +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.util.Date + +/** + * Represents the various states of a Bluetooth connection. + */ +enum class ConnectionStatus { + /** No connection activity. */ + NONE, + /** Bluetooth adapter is present and enabled, but not actively scanning or connected. */ + IDLE, + /** Actively scanning for Bluetooth devices. */ + SCANNING, + /** No active connection to a device. */ + DISCONNECTED, + /** Attempting to establish a connection to a device. */ + CONNECTING, + /** Successfully connected to a device. */ + CONNECTED, + /** In the process of disconnecting from a device. */ + DISCONNECTING, + /** A connection attempt or an established connection has failed. */ + FAILED +} + +/** + * ViewModel responsible for managing Bluetooth interactions, including device scanning, + * connection, and data handling. It coordinates with [BluetoothScannerManager] for scanning + * and [BluetoothConnectionManager] for connection lifecycle and data events. + * + * This ViewModel also manages user context relevant to Bluetooth operations and exposes + * StateFlows for UI observation. + * + * @param context The application context. + * @param sharedViewModel A [SharedViewModel] instance for accessing shared resources like + * repositories and for displaying global UI messages (e.g., Snackbars). + */ +class BluetoothViewModel( + private val context: Application, + val sharedViewModel: SharedViewModel +) : ViewModel() { + + private companion object { + const val TAG = "BluetoothViewModel" + const val SCAN_DURATION_MS = 20000L // Default scan duration: 20 seconds + } + + // Access to repositories is passed to the managers. + private val databaseRepository = sharedViewModel.databaseRepository + val userSettingsRepository = sharedViewModel.userSettingRepository + + // --- User Context (managed by ViewModel, used by ConnectionManager) --- + private var currentAppUser: User? = null + private var currentBtScaleUser: ScaleUser? = null // Derived from currentAppUser for Bluetooth operations + private var currentAppUserId: Int = 0 + + // --- Dependencies (ScaleFactory is passed to managers) --- + private val scaleFactory = ScaleFactory(context.applicationContext, databaseRepository) + + // --- BluetoothScannerManager (manages device scanning) --- + private val bluetoothScannerManager = BluetoothScannerManager(context, viewModelScope, scaleFactory) + + // --- BluetoothConnectionManager (manages device connection and data events) --- + private val bluetoothConnectionManager = BluetoothConnectionManager( + context = context.applicationContext, + scope = viewModelScope, + scaleFactory = scaleFactory, + databaseRepository = databaseRepository, + sharedViewModel = sharedViewModel, + getCurrentScaleUser = { currentBtScaleUser }, + getCurrentAppUserId = { currentAppUserId }, + onUserSelectionRequired = { event -> + // Update internal state when ConnectionManager requires user selection. + _showUserSelectionDialogFromManager.value = event + }, + onSavePreferredDevice = { address, name -> + // Save preferred device when ConnectionManager successfully connects and indicates to do so. + // Snackbar for user feedback can be shown here or in ConnectionManager; here is fine. + viewModelScope.launch { + userSettingsRepository.saveBluetoothScale(address, name) + sharedViewModel.showSnackbar("$name saved as preferred scale.", SnackbarDuration.Short) + } + } + ) + + // --- Scan State Flows (from BluetoothScannerManager) --- + /** Emits the list of discovered Bluetooth devices. */ + val scannedDevices: StateFlow> = bluetoothScannerManager.scannedDevices + /** Emits `true` if a Bluetooth scan is currently active, `false` otherwise. */ + val isScanning: StateFlow = bluetoothScannerManager.isScanning + /** Emits error messages related to the scanning process, or null if no error. */ + val scanError: StateFlow = bluetoothScannerManager.scanError + + // --- Connection State Flows (from BluetoothConnectionManager) --- + /** Emits the name of the currently connected device, or null if not connected. */ + val connectedDeviceName: StateFlow = bluetoothConnectionManager.connectedDeviceName + /** Emits the MAC address of the currently connected device, or null if not connected. */ + val connectedDeviceAddress: StateFlow = bluetoothConnectionManager.connectedDeviceAddress + /** Emits the current [ConnectionStatus] of the Bluetooth device. */ + val connectionStatus: StateFlow = bluetoothConnectionManager.connectionStatus + /** Emits connection-related error messages, or null if no error. */ + val connectionError: StateFlow = bluetoothConnectionManager.connectionError + + + // --- Permissions and System State (managed by ViewModel) --- + private val _permissionsGranted = MutableStateFlow(checkInitialPermissions()) + /** Emits `true` if all necessary Bluetooth permissions are granted, `false` otherwise. */ + val permissionsGranted: StateFlow = _permissionsGranted.asStateFlow() + + // --- Saved Device Info (for UI display and auto-connect logic) --- + /** Emits the MAC address of the saved preferred Bluetooth scale, or null if none is saved. */ + val savedScaleAddress: StateFlow = userSettingsRepository.savedBluetoothScaleAddress + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), null) + /** Emits the name of the saved preferred Bluetooth scale, or null if none is saved. */ + val savedScaleName: StateFlow = userSettingsRepository.savedBluetoothScaleName + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), null) + + // --- UI Interaction for User Selection (triggered by ConnectionManager callback) --- + private val _showUserSelectionDialogFromManager = MutableStateFlow(null) + /** + * Emits a [BluetoothEvent.UserSelectionRequired] when the connected scale needs user interaction + * (e.g., selecting a user profile on the scale). The UI should observe this to show a dialog. + * Emits null when the dialog should be dismissed or is not needed. + */ + val showUserSelectionDialog: StateFlow = _showUserSelectionDialogFromManager.asStateFlow() + + init { + LogManager.i(TAG, "ViewModel initialized. Setting up user observation.") + observeUserChanges() + // attemptAutoConnectToSavedScale() // Can be enabled if auto-connect on ViewModel init is desired. + } + + /** + * Observes changes to the selected application user and updates the Bluetooth user context accordingly. + * This ensures that operations like saving measurements or providing user data to the scale + * use the correct user profile. + */ + private fun observeUserChanges() { + viewModelScope.launch { + // Observe user selected via SharedViewModel (e.g., user picker in UI) + sharedViewModel.selectedUser.filterNotNull().collectLatest { appUser -> + LogManager.d(TAG, "User selected via SharedViewModel: ${appUser.name}. Updating context.") + updateCurrentUserContext(appUser) + } + } + viewModelScope.launch { + // Fallback: Observe current user ID from settings if no user is selected via SharedViewModel. + // This handles scenarios where the app starts and a default user is already set. + if (sharedViewModel.selectedUser.value == null) { + userSettingsRepository.currentUserId.filterNotNull().collectLatest { userId -> + if (userId != 0) { + databaseRepository.getUserById(userId).filterNotNull().firstOrNull()?.let { userDetails -> + if (currentAppUserId != userDetails.id) { // Only update if the user actually changed. + LogManager.d(TAG, "User changed via UserSettingsRepository: ${userDetails.name}. Updating context.") + updateCurrentUserContext(userDetails) + } + } ?: run { + LogManager.w(TAG, "User with ID $userId from settings not found in database. Clearing context.") + clearUserContext() + } + } else { + LogManager.d(TAG, "No current user ID set in settings. Clearing context.") + clearUserContext() + } + } + } + } + } + + /** + * Updates the internal state for the current application user and the corresponding Bluetooth scale user. + * @param appUser The [User] object representing the current application user. + */ + private fun updateCurrentUserContext(appUser: User) { + currentAppUser = appUser + currentAppUserId = appUser.id + currentBtScaleUser = convertAppUserToBtScaleUser(appUser) + LogManager.i(TAG, "User context updated for Bluetooth operations: User '${currentBtScaleUser?.userName}' (App ID: ${currentAppUserId})") + } + + /** + * Clears the current user context. Called when no user is selected or found. + */ + private fun clearUserContext() { + currentAppUser = null + currentAppUserId = 0 + currentBtScaleUser = null + LogManager.i(TAG, "User context cleared for Bluetooth operations.") + } + + /** + * Converts an application [User] object to a [ScaleUser] object, + * which is the format expected by some Bluetooth scale drivers. + * @param appUser The application [User] to convert. + * @return A [ScaleUser] representation. + */ + private fun convertAppUserToBtScaleUser(appUser: User): ScaleUser { + return ScaleUser().apply { + // Note: ScaleUser.id often corresponds to the on-scale user slot (1-N), + // while appUser.id is the database ID. Some drivers might use appUser.id directly + // if the scale supports arbitrary user identifiers or if we manage mapping externally. + // For now, using appUser.id as a general identifier for the ScaleUser. + id = appUser.id + userName = appUser.name + birthday = Date(appUser.birthDate) // Ensure birthDate is in millis + bodyHeight = appUser.heightCm?.toFloat() ?: 0f // Default to 0f if height is null + gender = appUser.gender + } + } + + // --- Scan Control --- + + /** + * Requests the [BluetoothScannerManager] to start scanning for devices. + * Checks for necessary permissions and Bluetooth enabled status before initiating the scan. + */ + @SuppressLint("MissingPermission") // Permissions are checked before calling the manager. + fun requestStartDeviceScan() { + LogManager.i(TAG, "User requested to start device scan.") + refreshPermissionsStatus() // Ensure permission state is up-to-date. + + if (!permissionsGranted.value) { + LogManager.w(TAG, "Scan request denied: Bluetooth permissions missing.") + sharedViewModel.showSnackbar("Bluetooth permissions are required to scan for devices.", SnackbarDuration.Long) + return + } + if (!isBluetoothEnabled()) { + LogManager.w(TAG, "Scan request denied: Bluetooth is disabled.") + sharedViewModel.showSnackbar("Bluetooth is disabled. Please enable it to scan for devices.", SnackbarDuration.Long) + return + } + clearAllErrors() // Clear previous scan/connection errors. + LogManager.d(TAG, "Prerequisites met. Delegating scan start to BluetoothScannerManager.") + bluetoothScannerManager.startScan(SCAN_DURATION_MS) + } + + /** + * Requests the [BluetoothScannerManager] to stop an ongoing device scan. + */ + fun requestStopDeviceScan() { + LogManager.i(TAG, "User requested to stop device scan. Delegating to BluetoothScannerManager.") + // The `isTimeout` parameter is an internal detail for the scanner manager; + // from ViewModel's perspective, it's a manual stop request. + bluetoothScannerManager.stopScan() + } + + // --- Connection Control --- + + /** + * Initiates a connection attempt to the specified Bluetooth device. + * If a scan is active, it will be stopped first. + * Prerequisites like permissions and Bluetooth status are validated. + * + * @param deviceInfo The [ScannedDeviceInfo] of the device to connect to. + */ + @SuppressLint("MissingPermission") // Permissions are checked by validateConnectionPrerequisites. + fun connectToDevice(deviceInfo: ScannedDeviceInfo) { + val deviceDisplayName = deviceInfo.name ?: deviceInfo.address + LogManager.i(TAG, "User requested to connect to device: $deviceDisplayName") + + if (isScanning.value) { + LogManager.d(TAG, "Scan is active, stopping it before initiating connection to $deviceDisplayName.") + requestStopDeviceScan() + // Optional: A small delay could be added here if needed to ensure scan stop completes, + // but usually the managers handle sequential operations gracefully. + // viewModelScope.launch { delay(200) } + } + + if (!validateConnectionPrerequisites(deviceDisplayName, isManualConnect = true)) { + // validateConnectionPrerequisites logs and shows Snackbar for errors. + return + } + + LogManager.d(TAG, "Prerequisites for connecting to $deviceDisplayName met. Delegating to BluetoothConnectionManager.") + bluetoothConnectionManager.connectToDevice(deviceInfo) + } + + + /** + * Attempts to connect to the saved preferred Bluetooth scale. + * Retrieves device info from [userSettingsRepository] and then delegates + * to [BluetoothConnectionManager]. + */ + @SuppressLint("MissingPermission") // Permissions are checked by validateConnectionPrerequisites. + fun connectToSavedDevice() { + viewModelScope.launch { + val address = savedScaleAddress.value + val name = savedScaleName.value + LogManager.i(TAG, "User or system requested to connect to saved device: Name='$name', Address='$address'") + + if (isScanning.value) { + LogManager.d(TAG, "Scan is active, stopping it before connecting to saved device '$name'.") + requestStopDeviceScan() + // delay(200) // Optional delay + } + + if (!validateConnectionPrerequisites(name, isManualConnect = false)) { + // If isManualConnect is false, validateConnectionPrerequisites shows a Snackbar + // but doesn't set an error in ConnectionManager, which is fine for auto-attempts. + return@launch + } + + if (address != null && name != null) { + // For a saved device, we need to re-evaluate its support status using ScaleFactory, + // as supported handlers might change with app updates. + LogManager.d(TAG, "Re-evaluating support for saved device '$name' ($address) using ScaleFactory.") + val (isPotentiallySupported, handlerNameFromFactory) = scaleFactory.getSupportingHandlerInfo( + deviceName = name, + deviceAddress = address, + serviceUuids = emptyList(), // Service UUIDs are unknown without a fresh scan. + manufacturerData = null // Manufacturer data is unknown without a fresh scan. + ) + + val deviceInfoForConnect = ScannedDeviceInfo( + name = name, + address = address, + rssi = 0, // RSSI is not relevant for a direct connection attempt to a saved device. + serviceUuids = emptyList(), + manufacturerData = null, + isSupported = isPotentiallySupported, // Use current support assessment. + determinedHandlerDisplayName = handlerNameFromFactory + ) + + if (!deviceInfoForConnect.isSupported) { + LogManager.w(TAG, "Saved device '$name' ($address) is currently not supported by ScaleFactory. Connection aborted.") + // This error is specific to connecting to a *saved* device that's no longer supported. + // The ConnectionManager might not have a dedicated error state for this nuance if it only expects + // ScannedDeviceInfo for connection attempts. Showing a Snackbar is a direct user feedback. + sharedViewModel.showSnackbar("Saved scale '$name' is no longer supported.", SnackbarDuration.Long) + // We don't want to set a generic connectionError in BluetoothConnectionManager here, + // as no connection attempt was made *through* it yet. + return@launch + } + LogManager.d(TAG, "Saved device '$name' is supported. Delegating connection to BluetoothConnectionManager.") + bluetoothConnectionManager.connectToDevice(deviceInfoForConnect) + } else { + LogManager.w(TAG, "Attempted to connect to saved device, but no device is saved.") + sharedViewModel.showSnackbar("No Bluetooth scale saved in settings.", SnackbarDuration.Short) + } + } + } + + /** + * Validates common prerequisites for initiating a Bluetooth connection. + * Checks for permissions and Bluetooth enabled status. + * + * @param deviceName The name/identifier of the device for logging/messages. + * @param isManualConnect `true` if this is a direct user action to connect, `false` for automated attempts. + * This influences how errors are reported (e.g., setting an error in ConnectionManager vs. just a Snackbar). + * @return `true` if all prerequisites are met, `false` otherwise. + */ + private fun validateConnectionPrerequisites(deviceName: String?, isManualConnect: Boolean): Boolean { + refreshPermissionsStatus() // Always get the latest permission status. + + if (!permissionsGranted.value) { + val errorMsg = "Bluetooth permissions are required to connect to ${deviceName ?: "the device"}." + LogManager.w(TAG, "Connection prerequisite failed for '${deviceName ?: "device"}': $errorMsg") + if (isManualConnect) { + // For manual attempts, set an error in the ConnectionManager to reflect in UI state. + bluetoothConnectionManager.setExternalConnectionError(errorMsg) + } else { + // For automatic attempts (e.g., auto-connect), a Snackbar might be sufficient without altering permanent error state. + sharedViewModel.showSnackbar(errorMsg, SnackbarDuration.Long) + } + return false + } + if (!isBluetoothEnabled()) { + val errorMsg = "Bluetooth is disabled. Please enable it to connect to ${deviceName ?: "the device"}." + LogManager.w(TAG, "Connection prerequisite failed for '${deviceName ?: "device"}': $errorMsg") + if (isManualConnect) { + bluetoothConnectionManager.setExternalConnectionError(errorMsg) + } else { + sharedViewModel.showSnackbar(errorMsg, SnackbarDuration.Long) + } + return false + } + // User ID check is now more nuanced and handled within BluetoothConnectionManager, + // as its necessity can be handler-specific. + // LogManager.d(TAG, "Connection prerequisites met for ${deviceName ?: "device"}.") + return true + } + + + /** + * Requests the [BluetoothConnectionManager] to disconnect from the currently connected device. + */ + fun disconnectDevice() { + LogManager.i(TAG, "User requested to disconnect device. Delegating to BluetoothConnectionManager.") + bluetoothConnectionManager.disconnect() + } + + // --- Error Handling --- + + /** + * Clears all error states managed by both the scanner and connection managers. + */ + fun clearAllErrors() { + LogManager.d(TAG, "Clearing all scan and connection errors.") + bluetoothScannerManager.clearScanError() + bluetoothConnectionManager.clearConnectionError() + } + + /** + * Clears the user selection dialog state. This should be called by the UI + * after the user has made a selection or dismissed the dialog. + */ + fun clearUserSelectionDialog() { + LogManager.d(TAG, "Clearing user selection dialog.") + _showUserSelectionDialogFromManager.value = null + // If BluetoothConnectionManager held its own state for this event (beyond the callback), + // a method like `bluetoothConnectionManager.userSelectionActionCompleted()` might be called here. + } + + + // --- Device Preferences --- + + /** + * Saves the given scanned device as the preferred Bluetooth scale in user settings. + * @param device The [ScannedDeviceInfo] of the device to save. + */ + fun saveDeviceAsPreferred(device: ScannedDeviceInfo) { + viewModelScope.launch { + val nameToSave = device.name ?: "Unknown Scale" // Provide a default name if null. + LogManager.i(TAG, "User requested to save device as preferred: Name='${device.name}', Address='${device.address}'. Saving as '$nameToSave'.") + userSettingsRepository.saveBluetoothScale(device.address, nameToSave) + sharedViewModel.showSnackbar("'$nameToSave' saved as preferred scale.", SnackbarDuration.Short) + // The savedScaleAddress/Name flows will update automatically, triggering any observers. + } + } + + // --- Permissions and System State Methods --- + + /** + * Checks if the necessary Bluetooth permissions are currently granted. + * Handles different permission sets for Android S (API 31) and above vs. older versions. + * @return `true` if permissions are granted, `false` otherwise. + */ + private fun checkInitialPermissions(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED + } else { + // For older Android versions (below S) + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADMIN) == PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + } + } + + /** + * Refreshes the `permissionsGranted` StateFlow by re-checking the current permission status. + * Should be called when the app regains focus or when permissions might have changed. + */ + fun refreshPermissionsStatus() { + val currentStatus = checkInitialPermissions() + if (_permissionsGranted.value != currentStatus) { + _permissionsGranted.value = currentStatus + LogManager.i(TAG, "Bluetooth permission status refreshed: ${if (currentStatus) "Granted" else "Denied"}.") + } + } + + /** + * Checks if the Bluetooth adapter is currently enabled on the device. + * @return `true` if Bluetooth is enabled, `false` otherwise. + */ + fun isBluetoothEnabled(): Boolean { + val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager? + val isEnabled = bluetoothManager?.adapter?.isEnabled ?: false + // LogManager.v(TAG, "Bluetooth enabled status check: $isEnabled") // Potentially too verbose for frequent checks + return isEnabled + } + + // Logic for handling Bluetooth events directly, saving measurements, observing communicator, + // and releasing communicator has been moved to BluetoothConnectionManager. + + /** + * Attempts to automatically connect to the saved preferred Bluetooth scale, if one exists + * and the app is not already connected or connecting to it. + * This might be called on ViewModel initialization or when the app comes to the foreground. + */ + @SuppressLint("MissingPermission") // connectToSavedDevice handles permission checks. + fun attemptAutoConnectToSavedScale() { + viewModelScope.launch { + val address = savedScaleAddress.value + val name = savedScaleName.value + + if (address != null && name != null) { + LogManager.i(TAG, "Attempting auto-connect to saved scale: '$name' ($address).") + // Check if already connected or connecting to the target device. + if ((connectionStatus.value == ConnectionStatus.CONNECTED || connectionStatus.value == ConnectionStatus.CONNECTING) && + connectedDeviceAddress.value == address + ) { + LogManager.d(TAG, "Auto-connect: Already connected or connecting to '$name' ($address). No action needed.") + return@launch + } + // Delegate to the standard method for connecting to a saved device. + connectToSavedDevice() + } else { + LogManager.d(TAG, "Auto-connect attempt: No saved scale found.") + } + } + } + + + /** + * Called when the ViewModel is about to be destroyed. + * Ensures that resources used by Bluetooth managers are released (e.g., stopping scans, + * disconnecting devices, closing underlying Bluetooth resources). + */ + override fun onCleared() { + super.onCleared() + LogManager.i(TAG, "BluetoothViewModel onCleared. Releasing resources from managers.") + bluetoothScannerManager.close() + bluetoothConnectionManager.close() + LogManager.i(TAG, "BluetoothViewModel onCleared completed.") + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/LineChart.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/LineChart.kt new file mode 100644 index 00000000..aa01dbd4 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/LineChart.kt @@ -0,0 +1,779 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.components + +import android.text.Layout +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarToday +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckBoxOutlineBlank +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.health.openscale.R +import com.health.openscale.core.data.InputFieldType +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.TimeRangeFilter +import com.health.openscale.core.database.UserPreferenceKeys +import com.health.openscale.core.database.UserSettingsRepository +import com.health.openscale.ui.screen.SharedViewModel +import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost +import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisGuidelineComponent +import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom +import com.patrykandpatrick.vico.compose.cartesian.axis.rememberStart +import com.patrykandpatrick.vico.compose.cartesian.layer.point +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.marker.rememberDefaultCartesianMarker +import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart +import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState +import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState +import com.patrykandpatrick.vico.compose.common.component.fixed +import com.patrykandpatrick.vico.compose.common.component.rememberShapeComponent +import com.patrykandpatrick.vico.compose.common.component.rememberTextComponent +import com.patrykandpatrick.vico.compose.common.fill +import com.patrykandpatrick.vico.compose.common.insets +import com.patrykandpatrick.vico.compose.common.shape.markerCorneredShape +import com.patrykandpatrick.vico.core.cartesian.Zoom +import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis +import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis +import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter +import com.patrykandpatrick.vico.core.cartesian.data.lineSeries +import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer +import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker +import com.patrykandpatrick.vico.core.cartesian.marker.DefaultCartesianMarker +import com.patrykandpatrick.vico.core.common.Fill +import com.patrykandpatrick.vico.core.common.LayeredComponent +import com.patrykandpatrick.vico.core.common.component.ShapeComponent +import com.patrykandpatrick.vico.core.common.component.TextComponent +import com.patrykandpatrick.vico.core.common.data.ExtraStore +import com.patrykandpatrick.vico.core.common.shape.CorneredShape +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +internal val DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM") +internal val X_TO_DATE_MAP_KEY = ExtraStore.Key>() // Key for storing date mapping in chart model +private const val TIME_RANGE_SUFFIX = "_time_range" +private const val SELECTED_TYPES_SUFFIX = "_selected_types" +private const val SHOW_TYPE_FILTER_ROW_SUFFIX = "_show_type_filter_row" + +/** + * A Composable function that displays a line chart for visualizing measurement data over time. + * It allows filtering by time range and measurement types. + * + * @param modifier Modifier for this composable. + * @param sharedViewModel The [SharedViewModel] providing access to data and settings. + * @param screenContextName A unique name for the screen or context where this chart is used. + * This is used to persist filter settings uniquely for this context. + * @param showFilterControls If true, filter controls (like time range and type selection) + * might be displayed directly or through a top bar action. + * @param showFilterTitle If true, a title indicating the current time range filter and data count is shown. + * @param showYAxis If true, the Y-axis (vertical axis showing values) is displayed. + * @param targetMeasurementTypeId If non-null, the chart will only display data for this specific + * measurement type, and type selection filters will be hidden. + * This is useful for focused views, like a detail screen for one measurement type. + */ +@Composable +fun LineChart( + modifier: Modifier = Modifier, + sharedViewModel: SharedViewModel, + screenContextName: String, + showFilterControls: Boolean, + showFilterTitle: Boolean = false, + showYAxis: Boolean = true, + targetMeasurementTypeId: Int? = null +) { + val scope = rememberCoroutineScope() + val userSettingsRepository = sharedViewModel.userSettingRepository + + val uiSelectedTimeRange by rememberContextualTimeRangeFilter( + screenContextName = screenContextName, + userSettingsRepository = userSettingsRepository + ) + val showTypeFilterRowSetting by rememberContextualBooleanSetting( + screenContextName = screenContextName, + settingSuffix = SHOW_TYPE_FILTER_ROW_SUFFIX, + userSettingsRepository = userSettingsRepository, + defaultValue = showFilterControls // Initial default based on what the caller suggests + ) + // The measurement type filter row is only shown if not targeting a specific type. + val effectiveShowTypeFilterRow = if (targetMeasurementTypeId != null) false else showTypeFilterRowSetting + + val allAvailableMeasurementTypes by sharedViewModel.measurementTypes.collectAsState() + val defaultSelectedTypesValue = remember(targetMeasurementTypeId, allAvailableMeasurementTypes) { + if (targetMeasurementTypeId != null) { + setOf(targetMeasurementTypeId.toString()) // If a specific type is targeted, that's the default. + } else { + // Default selection for the general line chart (uses String IDs for settings). + setOf(MeasurementTypeKey.WEIGHT.id.toString(), MeasurementTypeKey.BODY_FAT.id.toString()) + } + } + val currentSelectedTypeIdsStrings by rememberContextualSelectedTypeIds( + screenContextName = screenContextName, + userSettingsRepository = userSettingsRepository, + defaultSelectedTypeIds = defaultSelectedTypesValue + ) + val currentSelectedTypeIntIds: Set = remember(currentSelectedTypeIdsStrings) { + currentSelectedTypeIdsStrings.mapNotNull { stringId: String -> stringId.toIntOrNull() }.toSet() + } + + val timeFilteredData by sharedViewModel.getTimeFilteredEnrichedMeasurements(uiSelectedTimeRange) + .collectAsState(initial = emptyList()) + + val fullyFilteredEnrichedMeasurements = remember(timeFilteredData, currentSelectedTypeIntIds) { + sharedViewModel.filterEnrichedMeasurementsByTypes(timeFilteredData, currentSelectedTypeIntIds) + } + + // Extracting measurements with their values for plotting. + val measurementsWithValues = remember(fullyFilteredEnrichedMeasurements) { + fullyFilteredEnrichedMeasurements.map { it.measurementWithValues } + } + + // Determine which measurement types to actually plot based on current selections, + // target ID, and whether they are enabled and have a plottable input type. + val lineTypesToActuallyPlot = remember(allAvailableMeasurementTypes, currentSelectedTypeIntIds, targetMeasurementTypeId) { + allAvailableMeasurementTypes.filter { type -> + val typeIsSelected = type.id in currentSelectedTypeIntIds + val typeIsTarget = targetMeasurementTypeId != null && type.id == targetMeasurementTypeId + val typeIsPlotable = type.isEnabled && (type.inputType == InputFieldType.FLOAT || type.inputType == InputFieldType.INT) + // If a target ID is provided, only that type is considered (if plotable). + // Otherwise, selected types are considered. + (if (targetMeasurementTypeId != null) typeIsTarget else typeIsSelected) && typeIsPlotable + } + } + + Column(modifier = modifier) { + AnimatedVisibility(visible = effectiveShowTypeFilterRow) { + MeasurementTypeFilterRow( + allMeasurementTypesProvider = { allAvailableMeasurementTypes }, + selectedTypeIdsFlowProvider = { + userSettingsRepository.observeSetting( + "${screenContextName}${SELECTED_TYPES_SUFFIX}", + defaultSelectedTypesValue // This is the Set for the FilterRow state + ) + }, + onPersistSelectedTypeIds = { newIdsSetToPersist -> + scope.launch { + userSettingsRepository.saveSetting( + "${screenContextName}${SELECTED_TYPES_SUFFIX}", + newIdsSetToPersist + ) + } + }, + filterLogic = { allTypes -> // Logic to determine which types are selectable in the filter row + allTypes.filter { + it.isEnabled && (it.inputType == InputFieldType.FLOAT || it.inputType == InputFieldType.INT) + } + }, + onSelectionChanged = { /* selectedIntIds -> Currently no direct action needed here on selection change */ }, + defaultSelectionLogic = { selectableFilteredTypes -> + // Logic to determine which types should be selected by default *within the filter row itself* + // when it's first displayed or reset. + if (targetMeasurementTypeId != null) { + selectableFilteredTypes.find { it.id == targetMeasurementTypeId } + ?.let { listOf(it.id) } ?: emptyList() + } else { + val defaultIdsToTry = listOf( + MeasurementTypeKey.WEIGHT.id, + MeasurementTypeKey.BODY_FAT.id + ) + + val selectedByDefault = defaultIdsToTry.filter { defaultIntId -> + selectableFilteredTypes.any { selectableType -> selectableType.id == defaultIntId } + } + + selectedByDefault.ifEmpty { // If default primary types aren't available, pick the first available + selectableFilteredTypes.firstOrNull()?.let { listOf(it.id) } + ?: emptyList() + } + } + } + ) + } + + + if (showFilterTitle) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.CalendarToday, + contentDescription = stringResource(R.string.content_description_time_range_icon), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(end = 8.dp) + ) + Text( + text = stringResource( + R.string.line_chart_filter_title_template, + uiSelectedTimeRange.displayName, + measurementsWithValues.size + ), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + } + } + + // Early exit if there's absolutely nothing to do (no plotable types AND no data AND filter not visible) + // This is a general "empty state" for the chart area. + if (lineTypesToActuallyPlot.isEmpty() && measurementsWithValues.isEmpty() && !effectiveShowTypeFilterRow && targetMeasurementTypeId == null) { + Box( + modifier = Modifier + .weight(1f) // Takes up available vertical space in the Column + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + // Provide a more specific message if no types are plottable at all. + if (allAvailableMeasurementTypes.none { it.isEnabled && (it.inputType == InputFieldType.FLOAT || it.inputType == InputFieldType.INT) }) + stringResource(R.string.line_chart_no_plottable_types) + else stringResource(R.string.line_chart_no_data_to_display) + ) + } + return@Column // Exits the Column Composable early + }else if (lineTypesToActuallyPlot.isEmpty() && measurementsWithValues.isEmpty() && targetMeasurementTypeId != null) { + // Specific empty state when a target type is specified, but no data exists for it. + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + stringResource( + R.string.line_chart_no_data_for_type_in_range, + allAvailableMeasurementTypes.find { it.id == targetMeasurementTypeId }?.getDisplayName(LocalContext.current) + ?: stringResource(R.string.line_chart_this_type_placeholder) + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + return@Column + } + + // State to hold the processed series data for the chart. + var seriesEntries by remember { mutableStateOf>>>>(emptyList()) } + // State to hold the mapping from X-axis float values (epoch days) back to LocalDate objects. + var xToDatesMapForStore by remember { mutableStateOf>(emptyMap()) } + + // Process measurement data into series for the chart when relevant inputs change. + LaunchedEffect(measurementsWithValues, lineTypesToActuallyPlot) { + val calculatedSeriesEntries = lineTypesToActuallyPlot.mapNotNull { type -> + val dateValuePairs = mutableMapOf() + measurementsWithValues.forEach { mwv -> // MeasurementWithValues + mwv.values.find { it.type.id == type.id }?.let { valueWithType -> + val yValue = when (type.inputType) { + InputFieldType.FLOAT -> valueWithType.value.floatValue + InputFieldType.INT -> valueWithType.value.intValue?.toFloat() + else -> null // Should not happen due to lineTypesToActuallyPlot filter + } + yValue?.let { + val date = Instant.ofEpochMilli(mwv.measurement.timestamp) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + // If multiple values exist for the same type on the same day, + // the last one processed will overwrite previous ones. + // Consider averaging or other aggregation if needed. + dateValuePairs[date] = it + } + } + } + if (dateValuePairs.isNotEmpty()) { + type to dateValuePairs.toList().sortedBy { it.first } // Sort by date for correct line plotting + } else { + null // No data for this type + } + } + seriesEntries = calculatedSeriesEntries + + // Create the X-axis value to LocalDate map for formatting axis labels. + if (calculatedSeriesEntries.isNotEmpty()) { + val allDates = calculatedSeriesEntries.flatMap { (_, pairs) -> pairs.map { it.first } }.distinct() + xToDatesMapForStore = allDates.associateBy { it.toEpochDay().toFloat() } + } else { + xToDatesMapForStore = emptyMap() + } + } + + // Second check: if after processing, no series are available to plot (e.g., data existed but not for selected types). + if (seriesEntries.isEmpty()) { + Box( + modifier = Modifier + .weight(1f) // Takes up available vertical space + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + val message = if (lineTypesToActuallyPlot.isEmpty() && effectiveShowTypeFilterRow) { + // Filter row is visible, but either nothing is selected or no data for selection. + if (measurementsWithValues.isEmpty() && currentSelectedTypeIntIds.isNotEmpty()) stringResource(R.string.line_chart_no_data_for_selected_types) + else if (measurementsWithValues.isEmpty()) stringResource(R.string.line_chart_no_data_to_display) + else stringResource(R.string.line_chart_please_select_types) + } else if (lineTypesToActuallyPlot.isEmpty()) { + // Filter not visible and no types to plot (likely because default is empty or no plottable types overall). + if (allAvailableMeasurementTypes.none { it.isEnabled && (it.inputType == InputFieldType.FLOAT || it.inputType == InputFieldType.INT) }) + stringResource(R.string.line_chart_no_plottable_types) + else stringResource(R.string.line_chart_no_data_or_types_to_select) + } else if (measurementsWithValues.isEmpty()){ // Types selected, but no data entries at all. + stringResource(R.string.line_chart_no_data_to_display) + } + else { // Types selected, data exists, but not for these specific types. + stringResource(R.string.line_chart_no_data_for_selected_types) + } + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + return@Column // Exits the Column Composable early + } + + // Determine colors for each series, using type's predefined color or gray as fallback. + val typeColors = remember(seriesEntries) { + seriesEntries.map { (type, _) -> // Index not used here + if (type.color != 0) Color(type.color) else Color.Gray + } + } + + val modelProducer = remember { CartesianChartModelProducer() } + + // Update the chart model when series data or the date map changes. + LaunchedEffect(seriesEntries, xToDatesMapForStore) { + if (seriesEntries.isNotEmpty()) { + modelProducer.runTransaction { + lineSeries { // Vico's DSL for defining line series + seriesEntries.forEach { (_, sortedDateValuePairs) -> + val xValues = sortedDateValuePairs.map { it.first.toEpochDay().toFloat() } + val yValues = sortedDateValuePairs.map { it.second } + if (xValues.isNotEmpty()) { + series(x = xValues, y = yValues) + } + } + } + extras { it[X_TO_DATE_MAP_KEY] = xToDatesMapForStore } // Store date map in model extras + } + } else { + // Clear the model if there are no series + modelProducer.runTransaction { + lineSeries {} // Empty series + extras { it.remove(X_TO_DATE_MAP_KEY) } + } + } + } + + val scrollState = rememberVicoScrollState() + val zoomState = rememberVicoZoomState( + zoomEnabled = true, + initialZoom = Zoom.Content, // Zoom to fit content initially + ) + + val xAxisValueFormatter = rememberXAxisValueFormatter(X_TO_DATE_MAP_KEY, DATE_FORMATTER) + val yAxisValueFormatter = CartesianValueFormatter.decimal() // Standard decimal formatting for Y-axis + + // Conditionally create X-axis; hide if a specific targetMeasurementTypeId is set (for cleaner detail view). + val xAxis = if (targetMeasurementTypeId == null) { + HorizontalAxis.rememberBottom( + valueFormatter = xAxisValueFormatter, + guideline = null, // No guideline for X-axis for cleaner look + ) + } else { + null // Hide X-axis when showing a single, targeted measurement type + } + + // Conditionally create Y-axis. + val yAxis = if (showYAxis) { + VerticalAxis.rememberStart( + valueFormatter = yAxisValueFormatter, + // guideline = rememberAxisGuidelineComponent(), // Optionally add Y-axis guidelines + ) + } else { + null + } + + // Define how lines are drawn (color, thickness, etc.) + val lineProvider = remember(seriesEntries, typeColors) { + LineCartesianLayer.LineProvider.series( + seriesEntries.mapIndexedNotNull { index, _ -> + if (index < typeColors.size) createLineSpec(typeColors[index], statisticsMode = targetMeasurementTypeId != null) else null + } + ) + } + + val lineLayer = rememberLineCartesianLayer(lineProvider = lineProvider) + + val chart = rememberCartesianChart( + lineLayer, + startAxis = yAxis, // Y-axis + bottomAxis = xAxis, // X-axis + marker = rememberMarker() // Interactive marker for data points + ) + + CartesianChartHost( + chart = chart, + modelProducer = modelProducer, + modifier = Modifier + .fillMaxWidth() + .weight(1f), // Occupy available vertical space + scrollState = scrollState, + zoomState = zoomState + ) + } +} + +/** + * Provides a [SharedViewModel.TopBarAction] for filtering the line chart. + * This includes options for selecting the time range and toggling the visibility + * of the measurement type filter row. + * + * @param sharedViewModel The [SharedViewModel] to access settings. + * @param screenContextName The context name to scope the filter settings. If null, no action is provided. + * @return A [SharedViewModel.TopBarAction] configuration for the filter menu, or null if context is not provided. + */ +@Composable +fun provideFilterTopBarAction( + sharedViewModel: SharedViewModel, + screenContextName: String? +): SharedViewModel.TopBarAction? { + + if (screenContextName == null) return null // Context name is essential for settings persistence + + val userSettingsRepository = sharedViewModel.userSettingRepository + val scope = rememberCoroutineScope() + + // --- Time Range Setting --- + val targetTimeRangeKeyName = "${screenContextName}${TIME_RANGE_SUFFIX}" + val defaultTimeRangeValue = TimeRangeFilter.ALL_DAYS.name // Default if no setting found + val currentPersistedTimeRangeName by userSettingsRepository.observeSetting(targetTimeRangeKeyName, defaultTimeRangeValue) + .collectAsState(initial = defaultTimeRangeValue) + val activeTimeRange = remember(currentPersistedTimeRangeName) { + TimeRangeFilter.entries.find { it.name == currentPersistedTimeRangeName } ?: TimeRangeFilter.ALL_DAYS + } + + // --- Show MeasurementTypeFilterRow Setting --- + val targetShowFilterRowKeyName = "${screenContextName}${SHOW_TYPE_FILTER_ROW_SUFFIX}" + // The default value here is for the TopBarAction's initial state if no setting exists. + // LineChart itself uses `showFilterControls` passed to it as its initial display default. + val defaultShowFilterRowForTopBar = true + val currentShowFilterRowSetting by userSettingsRepository.observeSetting(targetShowFilterRowKeyName, defaultShowFilterRowForTopBar) + .collectAsState(initial = defaultShowFilterRowForTopBar) + + var showMenuState by remember { mutableStateOf(false) } // Controls dropdown menu visibility + + return SharedViewModel.TopBarAction( + icon = Icons.Default.FilterList, + contentDescription = stringResource(R.string.content_description_filter_chart_data), // Accessibility + onClick = { showMenuState = !showMenuState } + ) { // Content of the DropdownMenu + DropdownMenu( + expanded = showMenuState, + onDismissRequest = { showMenuState = false } + ) { + // Time Range Options + TimeRangeFilter.entries.forEach { timeRange -> + DropdownMenuItem( + text = { Text(timeRange.displayName) }, + leadingIcon = { + if (activeTimeRange == timeRange) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource( + R.string.content_description_time_range_selected, + timeRange.displayName // Same i18n consideration as above + ) + ) + } else { + // Optional: Maintain alignment by adding a spacer if no icon + Spacer(Modifier.width(24.dp)) // Width of the Check icon + } + }, + onClick = { + scope.launch { + userSettingsRepository.saveSetting(targetTimeRangeKeyName, timeRange.name) + } + showMenuState = false // Close menu after selection + } + ) + } + + // The option to toggle the measurement type filter row is not shown for the Statistics screen, + // as it has its own dedicated type selection mechanism. + if (screenContextName != UserPreferenceKeys.STATISTICS_SCREEN_CONTEXT) { + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + // Toggle MeasurementTypeFilterRow Option + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_item_measurement_filter)) }, + leadingIcon = { + if (currentShowFilterRowSetting) { + Icon( + imageVector = Icons.Default.Check, // Indicates filter row is currently SHOWN + contentDescription = stringResource(R.string.content_description_measurement_filter_visible) + ) + } else { + Icon( + imageVector = Icons.Filled.CheckBoxOutlineBlank, // Indicates filter row is HIDDEN + contentDescription = stringResource(R.string.content_description_measurement_filter_hidden) + ) + } + }, + onClick = { + scope.launch { + userSettingsRepository.saveSetting( + targetShowFilterRowKeyName, + !currentShowFilterRowSetting // Toggle the setting + ) + } + showMenuState = false // Close menu after selection + } + ) + } + } + } +} + + +/** + * Remembers a [CartesianValueFormatter] for the X-axis that converts epoch day float values + * back to formatted date strings using a provided map. + * + * @param xToDateMapKey The [ExtraStore.Key] used to retrieve the date mapping from the chart model. + * @param dateFormatter The [DateTimeFormatter] to format the [LocalDate]. + * @return A memoized [CartesianValueFormatter]. + */ +@Composable +private fun rememberXAxisValueFormatter( + xToDateMapKey: ExtraStore.Key>, + dateFormatter: DateTimeFormatter +): CartesianValueFormatter = remember(xToDateMapKey, dateFormatter) { + CartesianValueFormatter { context, value, _ -> // `value` is the x-axis value (epochDay as float) + val chartModel = context.model + val xToDatesMap = chartModel.extraStore[xToDateMapKey] // Retrieve map from chart model + val xKey = value.toFloat() + + (xToDatesMap[xKey] ?: LocalDate.ofEpochDay(value.toLong())) + .format(dateFormatter) + } +} + +/** + * Creates a [LineCartesianLayer.Line] specification for a single series in the chart. + * + * @param color The color of the line and points. + * @param statisticsMode If true, an area fill is added below the line, and points are hidden. + * This is typically used when `targetMeasurementTypeId` is set. + * @return A configured [LineCartesianLayer.Line]. + */ +private fun createLineSpec(color: Color, statisticsMode : Boolean): LineCartesianLayer.Line { + val lineStroke = LineCartesianLayer.LineStroke.Continuous( + thicknessDp = 2f, + ) + + val lineFill = LineCartesianLayer.LineFill.single( // Defines the color of the line itself + fill = Fill(color.toArgb()) + ) + + return LineCartesianLayer.Line( + fill = lineFill, + stroke = lineStroke, + // Area fill is shown in statistics mode (e.g., when a single type is focused) + areaFill = if (statisticsMode) LineCartesianLayer.AreaFill.single(Fill(color.copy(alpha = 0.2f).toArgb())) else null, + // Points on the line are shown unless in statistics mode + pointProvider = if (!statisticsMode) { + LineCartesianLayer.PointProvider.single( + LineCartesianLayer.point(ShapeComponent(fill(color), CorneredShape.Pill)) + ) } else null + // dataLabel = null, // Keine Datenbeschriftungen an den Punkten + //pointConnector = LineCartesianLayer.PointConnector.cubic() // Standard, kann explizit sein + ) +} + +/** + * Remembers and configures a [CartesianMarker] for displaying details when a data point is interacted with. + * + * @param valueFormatter The formatter for the value displayed in the marker. + * @param showIndicator If true, an indicator (like a dot) is shown on the line at the marker's position. + * @return A memoized [CartesianMarker]. + */ +@Composable +fun rememberMarker( + // Keeping this public as it's a significant, potentially reusable component configuration + valueFormatter: DefaultCartesianMarker.ValueFormatter = + DefaultCartesianMarker.ValueFormatter.default(), // Uses default formatting for the value + showIndicator: Boolean = true, +): CartesianMarker { + val labelBackgroundShape = markerCorneredShape(CorneredShape.Corner.Rounded) + val labelBackground = + rememberShapeComponent( + fill = fill(MaterialTheme.colorScheme.background), + shape = labelBackgroundShape, + strokeThickness = 1.dp, + strokeFill = fill(MaterialTheme.colorScheme.outline), // Outline for the label + ) + val label = + rememberTextComponent( + // Text component for the marker + color = MaterialTheme.colorScheme.onSurface, // Text color + textAlignment = Layout.Alignment.ALIGN_CENTER, + padding = insets(horizontal = 8.dp, vertical = 4.dp), // Padding within the label + background = labelBackground, + minWidth = TextComponent.MinWidth.fixed(40.dp), // Minimum width for the label + ) + val indicatorFrontComponent = + rememberShapeComponent(fill(MaterialTheme.colorScheme.surface), CorneredShape.Pill) + val guideline = rememberAxisGuidelineComponent() + + return rememberDefaultCartesianMarker( + label = label, + valueFormatter = valueFormatter, + indicator = // Custom indicator drawing logic + if (showIndicator) { + { color -> // `color` is the color of the series line + LayeredComponent( + back = ShapeComponent(fill(color.copy(alpha = 0.15f)), CorneredShape.Pill), + front = + LayeredComponent( + back = ShapeComponent( + fill = fill(color), + shape = CorneredShape.Pill + ), + front = indicatorFrontComponent, + padding = insets(5.dp), + ), + padding = insets(10.dp), + ) + } + } else { + null // No indicator if showIndicator is false + }, + indicatorSize = 36.dp, // Overall size of the indicator area + guideline = guideline, // Vertical guideline that follows the marker + ) +} + +/** + * Remembers a [TimeRangeFilter] value that is persisted in [UserSettingsRepository] + * based on the provided [screenContextName]. + * + * @param screenContextName The unique context name for this setting. + * @param userSettingsRepository The repository to observe and save the setting. + * @param defaultFilter The default [TimeRangeFilter] to use if no setting is found. + * @return A [State] holding the current [TimeRangeFilter]. + */ +@Composable +fun rememberContextualTimeRangeFilter( + screenContextName: String, + userSettingsRepository: UserSettingsRepository, + defaultFilter: TimeRangeFilter = TimeRangeFilter.ALL_DAYS +): State { + val timeRangeKeyName = remember(screenContextName) { "${screenContextName}${TIME_RANGE_SUFFIX}" } + val persistedTimeRangeName by userSettingsRepository.observeSetting(timeRangeKeyName, defaultFilter.name) + .collectAsState(initial = defaultFilter.name) + + // Using `derivedStateOf` might be slightly more optimal if TimeRangeFilter.entries could change, + // but for enums, `remember` with `persistedTimeRangeName` as key is fine. + return remember(persistedTimeRangeName) { + mutableStateOf( + TimeRangeFilter.entries.find { it.name == persistedTimeRangeName } ?: defaultFilter + ) + } +} + +/** + * Remembers a set of selected measurement type IDs (as strings) that is persisted + * in [UserSettingsRepository] based on the provided [screenContextName]. + * + * @param screenContextName The unique context name for this setting. + * @param userSettingsRepository The repository to observe and save the setting. + * @param defaultSelectedTypeIds The default set of type IDs to use if no setting is found. + * @return A [State] holding the current [Set] of selected type IDs (strings). + */ +@Composable +fun rememberContextualSelectedTypeIds( + screenContextName: String, + userSettingsRepository: UserSettingsRepository, + defaultSelectedTypeIds: Set = emptySet() +): State> { + val selectedTypesKeyName = remember(screenContextName) { "${screenContextName}${SELECTED_TYPES_SUFFIX}" } + // Directly collect the flow as state. + return userSettingsRepository.observeSetting(selectedTypesKeyName, defaultSelectedTypeIds) + .collectAsState(initial = defaultSelectedTypeIds) +} + +/** + * Remembers a boolean setting value that is persisted in [UserSettingsRepository] + * based on the provided [screenContextName] and [settingSuffix]. + * + * @param screenContextName The unique context name for this setting. + * @param settingSuffix The specific suffix for this boolean setting (e.g., "_show_filter"). + * @param userSettingsRepository The repository to observe and save the setting. + * @param defaultValue The default boolean value to use if no setting is found. + * @return A [State] holding the current boolean value. + */ +@Composable +fun rememberContextualBooleanSetting( + screenContextName: String, + settingSuffix: String, + userSettingsRepository: UserSettingsRepository, + defaultValue: Boolean +): State { + val keyName = remember(screenContextName, settingSuffix) { "${screenContextName}${settingSuffix}" } + // Directly collect the flow as state. + return userSettingsRepository.observeSetting(keyName, defaultValue) + .collectAsState(initial = defaultValue) +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementTypeFilterRow.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementTypeFilterRow.kt new file mode 100644 index 00000000..f68bab3d --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementTypeFilterRow.kt @@ -0,0 +1,268 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch + +/** + * A Composable that displays a horizontal row of circular icons representing measurement types, + * allowing the user to select one or more types. The selection is persisted and can be + * observed via a Flow. + * + * This component handles: + * - Displaying available measurement types based on a filter logic. + * - Loading initial selection from a persisted Flow or applying default selection logic. + * - Updating the UI and persisting changes when the user selects/deselects types. + * - Reacting to external changes in the persisted selection Flow. + * + * @param allMeasurementTypesProvider A lambda function that returns the complete list of available [MeasurementType]s. + * @param selectedTypeIdsFlowProvider A lambda function that returns a [Flow] emitting the set of currently persisted selected measurement type IDs (as Strings). + * @param onPersistSelectedTypeIds A lambda function called when the selection changes and needs to be persisted. It receives the set of selected type IDs (as Strings). + * @param onSelectionChanged A lambda function called when the displayed selection changes. It receives a list of selected type IDs (as Ints). + * @param filterLogic A lambda function that filters the `allMeasurementTypesProvider` list to determine which types are actually selectable and displayed in this row. + * @param defaultSelectionLogic A lambda function that determines the default selection of type IDs (as Ints) if no persisted selection is found or if the persisted selection is invalid for the currently available types. + * @param modifier The [Modifier] to be applied to this Composable. + * @param allowEmptySelection If true, the user can deselect all types. If false, at least one type must remain selected (if there's more than one option). + * @param iconBoxSize The size of the circular background for each measurement type icon. + * @param iconSize The size of the measurement type icon itself. + * @param spaceBetweenItems The horizontal spacing between each measurement type item in the row. + */ +@Composable +fun MeasurementTypeFilterRow( + allMeasurementTypesProvider: () -> List, + selectedTypeIdsFlowProvider: () -> Flow>, + onPersistSelectedTypeIds: (Set) -> Unit, + onSelectionChanged: (List) -> Unit, + filterLogic: (List) -> List, + defaultSelectionLogic: (List) -> List, + modifier: Modifier = Modifier, + allowEmptySelection: Boolean = true, + iconBoxSize: Dp = 40.dp, + iconSize: Dp = 24.dp, + spaceBetweenItems: Dp = 8.dp +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val selectableTypes = remember(allMeasurementTypesProvider, filterLogic) { + filterLogic(allMeasurementTypesProvider()) + } + + val selectedTypeIdsFlow = remember(selectedTypeIdsFlowProvider) { selectedTypeIdsFlowProvider() } + + var displayedSelectedIds by remember { mutableStateOf>(emptyList()) } + var isInitialized by remember { mutableStateOf(false) } + + // Effect 1: Initial loading from the Flow or applying default logic. + LaunchedEffect(selectableTypes, selectedTypeIdsFlow, defaultSelectionLogic) { + val savedTypeIdsSet = selectedTypeIdsFlow.firstOrNull() ?: emptySet() + val initialIdsToDisplay: List + + if (selectableTypes.isNotEmpty()) { + if (savedTypeIdsSet.isNotEmpty()) { + // Filter saved IDs to include only those present in the current selectableTypes + var validPersistedIds = savedTypeIdsSet + .mapNotNull { it.toIntOrNull() } + .filter { id -> selectableTypes.any { type -> type.id == id } } + + if (validPersistedIds.isEmpty()) { + // If persisted IDs are all invalid for current selectable types, or if none were persisted + // that are currently selectable, apply default logic. + val defaultIds = defaultSelectionLogic(selectableTypes) + initialIdsToDisplay = defaultIds + // Persist these defaults only if the original saved set was non-empty but resulted in no valid IDs, + // or if the default set is different from what was (emptily) loaded. + if (savedTypeIdsSet.isNotEmpty() || defaultIds.map { it.toString() }.toSet() != savedTypeIdsSet) { + onPersistSelectedTypeIds(defaultIds.map { it.toString() }.toSet()) + } + } else { + initialIdsToDisplay = validPersistedIds + } + } else { + // No saved selection, apply default logic and persist it. + val defaultIds = defaultSelectionLogic(selectableTypes) + initialIdsToDisplay = defaultIds + onPersistSelectedTypeIds(defaultIds.map { it.toString() }.toSet()) + } + + // Update displayed state and notify callback if different or not yet initialized + if (displayedSelectedIds.toSet() != initialIdsToDisplay.toSet()) { + displayedSelectedIds = initialIdsToDisplay + onSelectionChanged(initialIdsToDisplay) + } else if (!isInitialized) { + // Ensure onSelectionChanged is called at least once with the initial state + onSelectionChanged(initialIdsToDisplay) + } + } else { + // No selectable types are available + initialIdsToDisplay = emptyList() + if (displayedSelectedIds.isNotEmpty() || savedTypeIdsSet.isNotEmpty()) { + // Clear any previous selection if types become unavailable + displayedSelectedIds = emptyList() + onPersistSelectedTypeIds(emptySet()) // Persist empty set + onSelectionChanged(emptyList()) + } else if (!isInitialized) { + onSelectionChanged(emptyList()) + } + } + isInitialized = true + } + + // Effect 2: React to changes from the Flow AFTER initialization. + LaunchedEffect(isInitialized, selectedTypeIdsFlow, allMeasurementTypesProvider, filterLogic) { + if (isInitialized) { + selectedTypeIdsFlow + .distinctUntilChanged() + .collect { newPersistedSet -> + // Recalculate selectable types in case they changed externally + val currentAllTypes = allMeasurementTypesProvider() + val currentAvailableTypesForFilter = filterLogic(currentAllTypes) + + val newIdsFromFlow = newPersistedSet + .mapNotNull { it.toIntOrNull() } + .filter { id -> currentAvailableTypesForFilter.any { type -> type.id == id } } + + if (newIdsFromFlow.toSet() != displayedSelectedIds.toSet()) { + displayedSelectedIds = newIdsFromFlow + onSelectionChanged(newIdsFromFlow) + } + } + } + } + + // Do not render the row if there are no selectable types and initialization is complete. + if (selectableTypes.isEmpty() && isInitialized) { + return + } + + Row( + modifier = modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 8.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(spaceBetweenItems), + verticalAlignment = Alignment.CenterVertically + ) { + selectableTypes.forEach { type -> + val isSelected = type.id in displayedSelectedIds + val backgroundColor = if (isSelected) Color(type.color) else MaterialTheme.colorScheme.surfaceVariant + val contentColor = if (isSelected) Color.Black else MaterialTheme.colorScheme.onSurfaceVariant // Consider MaterialTheme.colorScheme.onPrimary for selected state if type.color is primary-like + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(vertical = 4.dp) // Padding for touch target + .clickable { + if (!isInitialized) { // Prevent clicks during initial setup + return@clickable + } + + val currentSelectionMutable = displayedSelectedIds.toMutableList() + val currentlySelectedInList = type.id in currentSelectionMutable + + if (currentlySelectedInList) { + // Only allow deselection if empty selection is allowed or if more than one item is selected + if (allowEmptySelection || currentSelectionMutable.size > 1) { + currentSelectionMutable.remove(type.id) + } else { + // Prevent deselecting the last item if allowEmptySelection is false + return@clickable + } + } else { + currentSelectionMutable.add(type.id) + } + + val newSelectedIdsList = currentSelectionMutable.toList() + displayedSelectedIds = newSelectedIdsList + onSelectionChanged(newSelectedIdsList) + + scope.launch { + val setToPersist = newSelectedIdsList.map { it.toString() }.toSet() + onPersistSelectedTypeIds(setToPersist) + } + } + ) { + Box( + modifier = Modifier + .size(iconBoxSize) + .clip(CircleShape) + .background(backgroundColor) + .padding((iconBoxSize - iconSize) / 2), // Center icon within the box + contentAlignment = Alignment.Center + ) { + val iconResId = remember(type.icon, context) { + if (type.icon.isNotBlank()) { + // It's generally safer to handle potential ResourceNotFoundException if icon names might be invalid + try { + context.resources.getIdentifier(type.icon, "drawable", context.packageName) + } catch (e: Exception) { + // Log error or handle missing icon gracefully + 0 // Return 0 if icon not found + } + } else 0 + } + if (iconResId != 0) { + Icon( + painter = painterResource(id = iconResId), + contentDescription = stringResource(R.string.content_desc_measurement_type_icon, type.getDisplayName(LocalContext.current)), + tint = contentColor, + modifier = Modifier.size(iconSize) + ) + } + } + // Optionally, add a Text Composable here to display type.name below the icon + // Text(text = type.name, style = MaterialTheme.typography.labelSmall, color = contentColor) + } + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/ColorPickerDialog.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/ColorPickerDialog.kt new file mode 100644 index 00000000..6c43ee50 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/ColorPickerDialog.kt @@ -0,0 +1,118 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog + +val tangoColors = listOf( + Color(0xFFEF2929), Color(0xFFF57900), Color(0xFFFFCE44), Color(0xFF8AE234), + Color(0xFF729FCF), Color(0xFFAD7FA8), Color(0xFFE9B96E), Color(0xFF888A85), + Color(0xFF204A87), Color(0xFF3465A4), Color(0xFF4E9A06), Color(0xFF5C3566), + Color(0xFFC17D11), Color(0xFFA40000), Color(0xFFCE5C00), Color(0xFFEDD400), + Color(0xFF73D216), Color(0xFF11A879), Color(0xFF555753), Color(0xFFBABDB6), + Color(0xFFD3D7CF), Color(0xFFEEEEEC), Color(0xFF2E3436), Color(0xFF000000), + Color(0xFFFFC0CB), Color(0xFFFFA07A), Color(0xFF87CEEB), Color(0xFF20B2AA), + Color(0xFF9370DB), Color(0xFFFFD700), Color(0xFFFF8C00), Color(0xFFB22222) +) + +@Composable +fun ColorPickerDialog( + currentColor: Color, + onColorSelected: (Color) -> Unit, + onDismiss: () -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = MaterialTheme.shapes.medium, + tonalElevation = 8.dp, + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Farbe auswählen", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(16.dp)) + + LazyVerticalGrid( + columns = GridCells.Fixed(4), + modifier = Modifier + .fillMaxWidth() + .height(240.dp), + horizontalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(tangoColors) { color -> + Box( + modifier = Modifier + .aspectRatio(1f) // stellt sicher, dass Höhe = Breite = Kreis + .padding(4.dp) + .clip(CircleShape) + .background(color) + .border( + width = if (color == currentColor) 3.dp else 1.dp, + color = if (color == currentColor) Color.Black else Color.Gray, + shape = CircleShape + ) + .clickable { + onColorSelected(color) + onDismiss() + } + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text("Abbrechen") + } + } + } + } + } +} + diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/DateInputDialog.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/DateInputDialog.kt new file mode 100644 index 00000000..0a60890d --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/DateInputDialog.kt @@ -0,0 +1,104 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DatePicker +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DateInputDialog( + title: String, + initialTimestamp: Long, + iconRes: Int, + color: Color, + onDismiss: () -> Unit, + onConfirm: (Long) -> Unit +) { + val context = LocalContext.current + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = initialTimestamp + ) + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + val selectedDate = datePickerState.selectedDateMillis + if (selectedDate != null) { + onConfirm(selectedDate) + onDismiss() + } + } + ) { + Text("OK") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Abbrechen") + } + }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(color), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = Color.Black, + modifier = Modifier.size(20.dp) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Text(title, style = MaterialTheme.typography.titleMedium) + } + }, + text = { + DatePicker(state = datePickerState) + } + ) +} + diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/IconPickerDialog.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/IconPickerDialog.kt new file mode 100644 index 00000000..c340f462 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/IconPickerDialog.kt @@ -0,0 +1,116 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.dialog + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.health.openscale.R + +fun getIconResIdByName(name: String): Int { + return when (name) { + "ic_weight" -> R.drawable.ic_weight + "ic_bmi" -> R.drawable.ic_bmi + "ic_body_fat" -> R.drawable.ic_fat + "ic_water" -> R.drawable.ic_water + "ic_muscle" -> R.drawable.ic_muscle + "ic_lbm" -> R.drawable.ic_lbm + "ic_bone" -> R.drawable.ic_bone + "ic_waist" -> R.drawable.ic_waist + "ic_whr" -> R.drawable.ic_whr + "ic_hips" -> R.drawable.ic_hip + "ic_visceral_fat" -> R.drawable.ic_visceral_fat + "ic_chest" -> R.drawable.ic_chest + "ic_thigh" -> R.drawable.ic_thigh + "ic_biceps" -> R.drawable.ic_biceps + "ic_neck" -> R.drawable.ic_neck + "ic_caliper1" -> R.drawable.ic_caliper1 + "ic_caliper2" -> R.drawable.ic_caliper2 + "ic_caliper3" -> R.drawable.ic_caliper3 + "ic_fat_caliper" -> R.drawable.ic_fat_caliper + "ic_bmr" -> R.drawable.ic_bmr + "ic_tdee" -> R.drawable.ic_tdee + "ic_calories" -> R.drawable.ic_calories + "ic_comment" -> R.drawable.ic_comment + "ic_time" -> R.drawable.ic_time + "ic_date" -> R.drawable.ic_date + else -> R.drawable.ic_weight // Fallback + } +} + + +@Composable +fun IconPickerDialog( + currentIcon: String, + onIconSelected: (String) -> Unit, + onDismiss: () -> Unit +) { + val icons = listOf( + "ic_weight", "ic_bmi", "ic_body_fat", "ic_water", "ic_muscle", "ic_lbm", "ic_bone", + "ic_waist", "ic_whr", "ic_hips", "ic_visceral_fat", "ic_chest", "ic_thigh", "ic_biceps", + "ic_neck", "ic_caliper", "ic_bmr", "ic_tdee", "ic_calories", "ic_comment", "ic_time", "ic_date" + ) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Icon auswählen") }, + text = { + LazyVerticalGrid( + columns = GridCells.Fixed(4), + modifier = Modifier.height(200.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(icons) { iconName -> + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .clickable { onIconSelected(iconName) }, + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = getIconResIdByName(iconName)), + contentDescription = iconName, + modifier = Modifier.size(28.dp) + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Abbrechen") } + } + ) +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/NumberInputDialog.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/NumberInputDialog.kt new file mode 100644 index 00000000..a173d377 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/NumberInputDialog.kt @@ -0,0 +1,156 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.health.openscale.core.data.InputFieldType + +@Composable +fun NumberInputDialog( + title: String, + initialValue: String, + inputType: InputFieldType, + iconRes: Int, + color: Color, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit +) { + var value by remember { mutableStateOf(initialValue) } + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = { + onConfirm(value) + onDismiss() + }) { + Text("OK") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Abbrechen") + } + }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(color), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = Color.Black, + modifier = Modifier.size(20.dp) + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Text(title) + } + }, + text = { + OutlinedTextField( + value = value, + onValueChange = { value = it }, + label = { Text("Wert eingeben") }, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = when (inputType) { + InputFieldType.FLOAT -> KeyboardType.Decimal + InputFieldType.INT -> KeyboardType.Number + else -> KeyboardType.Text + } + ), + trailingIcon = { + if (inputType == InputFieldType.INT || inputType == InputFieldType.FLOAT) { + Column { + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + contentDescription = "Erhöhen", + modifier = Modifier + .size(24.dp) + .clickable { + value = incrementValue(value, inputType) + } + ) + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "Verringern", + modifier = Modifier + .size(24.dp) + .clickable { + value = decrementValue(value, inputType) + } + ) + } + } + }, + modifier = Modifier.fillMaxWidth() + ) + } + ) +} + +fun incrementValue(value: String, type: InputFieldType): String { + return when (type) { + InputFieldType.INT -> (value.toIntOrNull()?.plus(1) ?: 1).toString() + InputFieldType.FLOAT -> (value.toFloatOrNull()?.plus(1f) ?: 1f).toString() + else -> value + } +} + +fun decrementValue(value: String, type: InputFieldType): String { + return when (type) { + InputFieldType.INT -> (value.toIntOrNull()?.minus(1) ?: 0).toString() + InputFieldType.FLOAT -> (value.toFloatOrNull()?.minus(1f) ?: 0f).toString() + else -> value + } +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/TextInputDialog.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/TextInputDialog.kt new file mode 100644 index 00000000..3f87f44c --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/TextInputDialog.kt @@ -0,0 +1,98 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp + +@Composable +fun TextInputDialog( + title: String, + initialValue: String, + iconRes: Int, + color: Color, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit +) { + var value by remember { mutableStateOf(initialValue) } + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = { + onConfirm(value) + onDismiss() + }) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Abbrechen") } + }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(color), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = Color.Black, + modifier = Modifier.size(20.dp) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Text(text = title, style = MaterialTheme.typography.titleMedium) + } + }, + text = { + OutlinedTextField( + value = value, + onValueChange = { value = it }, + label = { Text("Text eingeben") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + ) +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/TimeInputDialog.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/TimeInputDialog.kt new file mode 100644 index 00000000..337904ae --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/TimeInputDialog.kt @@ -0,0 +1,164 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import java.util.Calendar + +@Composable +fun TimeInputDialog( + title: String, + initialTimestamp: Long, + iconRes: Int, + color: Color, + onDismiss: () -> Unit, + onConfirm: (Long) -> Unit +) { + val calendar = remember { Calendar.getInstance().apply { timeInMillis = initialTimestamp } } + + var hour by remember { mutableStateOf(calendar.get(Calendar.HOUR_OF_DAY)) } + var minute by remember { mutableStateOf(calendar.get(Calendar.MINUTE)) } + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = { + val updatedCal = Calendar.getInstance().apply { + timeInMillis = initialTimestamp + set(Calendar.HOUR_OF_DAY, hour) + set(Calendar.MINUTE, minute) + } + onConfirm(updatedCal.timeInMillis) + onDismiss() + }) { + Text("OK") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Abbrechen") + } + }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(color), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = Color.Black, + modifier = Modifier.size(20.dp) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Text(title, style = MaterialTheme.typography.titleMedium) + } + }, + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + TimeField( + label = "Stunde", + value = hour, + onValueChange = { hour = it.coerceIn(0, 23) }, + onIncrement = { hour = (hour + 1) % 24 }, + onDecrement = { hour = (hour + 23) % 24 } + ) + TimeField( + label = "Minute", + value = minute, + onValueChange = { minute = it.coerceIn(0, 59) }, + onIncrement = { minute = (minute + 1) % 60 }, + onDecrement = { minute = (minute + 59) % 60 } + ) + } + } + ) +} + +@Composable +private fun TimeField( + label: String, + value: Int, + onValueChange: (Int) -> Unit, + onIncrement: () -> Unit, + onDecrement: () -> Unit +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(label, style = MaterialTheme.typography.labelMedium) + + OutlinedTextField( + value = value.toString().padStart(2, '0'), + onValueChange = { + it.toIntOrNull()?.let { newVal -> onValueChange(newVal) } + }, + modifier = Modifier.width(80.dp), + singleLine = true, + textStyle = MaterialTheme.typography.titleLarge.copy(textAlign = TextAlign.Center), + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number) + ) + + Row { + IconButton(onClick = onIncrement) { + Icon(Icons.Default.KeyboardArrowUp, contentDescription = "Stunde erhöhen") + } + IconButton(onClick = onDecrement) { + Icon(Icons.Default.KeyboardArrowDown, contentDescription = "Stunde verringern") + } + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/graph/GraphScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/graph/GraphScreen.kt new file mode 100644 index 00000000..04a8beb0 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/graph/GraphScreen.kt @@ -0,0 +1,72 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.graph + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.health.openscale.core.database.UserPreferenceKeys +import com.health.openscale.ui.screen.SharedViewModel +import com.health.openscale.ui.screen.components.LineChart +import com.health.openscale.ui.screen.components.provideFilterTopBarAction + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GraphScreen(sharedViewModel: SharedViewModel) { + val isLoading by sharedViewModel.isBaseDataLoading.collectAsState() + val allMeasurementsWithValuesRaw by sharedViewModel.allMeasurementsForSelectedUser.collectAsState() + + val timeFilterAction = provideFilterTopBarAction( + sharedViewModel = sharedViewModel, + screenContextName = UserPreferenceKeys.GRAPH_SCREEN_CONTEXT + ) + + LaunchedEffect(timeFilterAction) { + sharedViewModel.setTopBarTitle("Graph") + + val actions = mutableListOf() + timeFilterAction?.let { actions.add(it) } + + sharedViewModel.setTopBarActions(actions) + } + + Column(modifier = Modifier.fillMaxSize()) { + if (isLoading && allMeasurementsWithValuesRaw.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + LineChart( + modifier = Modifier.fillMaxSize(), + sharedViewModel = sharedViewModel, + screenContextName = UserPreferenceKeys.GRAPH_SCREEN_CONTEXT, + showFilterControls = true + ) + } + } +} + diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt new file mode 100644 index 00000000..7b6a2002 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt @@ -0,0 +1,557 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.overview + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.health.openscale.R +import com.health.openscale.core.data.InputFieldType +import com.health.openscale.core.data.Measurement +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementValue +import com.health.openscale.core.data.UnitType +import com.health.openscale.ui.screen.SharedViewModel +import com.health.openscale.ui.screen.dialog.DateInputDialog +import com.health.openscale.ui.screen.dialog.NumberInputDialog +import com.health.openscale.ui.screen.dialog.TextInputDialog +import com.health.openscale.ui.screen.dialog.TimeInputDialog +import com.health.openscale.ui.screen.dialog.decrementValue +import com.health.openscale.ui.screen.dialog.getIconResIdByName +import com.health.openscale.ui.screen.dialog.incrementValue +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale + +/** + * A screen for creating a new measurement or editing an existing one. + * It displays a list of available measurement types and allows users to input values for them. + * + * @param navController The NavController for navigation. + * @param measurementId The ID of the measurement to edit. If -1, a new measurement is created. + * @param userId The ID of the user for whom the measurement is being recorded/edited. + * @param sharedViewModel The SharedViewModel providing access to data and actions. + */ +@Composable +fun MeasurementDetailScreen( + navController: NavController, + measurementId: Int, + userId: Int, + sharedViewModel: SharedViewModel +) { + val context = LocalContext.current + + // Holds the string representation of measurement values, keyed by MeasurementType ID. + val valuesState = remember { mutableStateMapOf() } + var isPendingNavigation by rememberSaveable { mutableStateOf(false) } + var measurementTimestampState by remember { mutableStateOf(System.currentTimeMillis()) } + var currentMeasurementDbId by remember { mutableStateOf(0) } // DB ID of the current measurement being edited (0 for new) + var currentUserIdState by remember { mutableStateOf(userId) } // User ID for the measurement + + // Controls which generic input dialog (Number or Text) is shown, based on MeasurementType. + var dialogTargetType by remember { mutableStateOf(null) } + + // Flags for date and time dialogs that edit the main measurement timestamp. + var showDatePickerForMainTimestamp by remember { mutableStateOf(false) } + var showTimePickerForMainTimestamp by remember { mutableStateOf(false) } + + val allMeasurementTypes by sharedViewModel.measurementTypes.collectAsState() + val lastMeasurementToPreloadFrom by sharedViewModel.lastMeasurementOfSelectedUser.collectAsState() + val loadedData by sharedViewModel.currentMeasurementWithValues.collectAsState() + + val dateFormat = remember { SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()) } + val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } + + // Show a loading indicator if navigation is pending (e.g., after saving). + if (isPendingNavigation) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return + } + + // Set the current measurement ID in ViewModel and update the top bar title. + LaunchedEffect(measurementId) { + sharedViewModel.setCurrentMeasurementId(measurementId) + sharedViewModel.setTopBarTitle( + if (measurementId == -1) context.getString(R.string.title_new_measurement) + else context.getString(R.string.title_edit_measurement) + ) + } + + // Load data for an existing measurement or preload values for a new measurement. + LaunchedEffect(loadedData, measurementId, userId, allMeasurementTypes, lastMeasurementToPreloadFrom) { + if (measurementId != -1 && measurementId != 0) { // Editing an existing measurement + loadedData?.let { data -> + currentMeasurementDbId = data.measurement.id + currentUserIdState = data.measurement.userId // Use UserID from the loaded measurement + measurementTimestampState = data.measurement.timestamp + valuesState.clear() + data.values.forEach { mvWithType -> + // Populate valuesState for non-date/time, enabled types. + if (mvWithType.type.isEnabled && mvWithType.type.inputType != InputFieldType.DATE && mvWithType.type.inputType != InputFieldType.TIME) { + val valueString = when (mvWithType.type.inputType) { + InputFieldType.FLOAT -> mvWithType.value.floatValue?.let { String.format(Locale.US, "%.2f", it) } ?: "" + InputFieldType.INT -> mvWithType.value.intValue?.toString() ?: "" + InputFieldType.TEXT -> mvWithType.value.textValue ?: "" + else -> "" // Should not happen for these types + } + if (valueString.isNotEmpty()) { + valuesState[mvWithType.type.id] = valueString + } + } + } + } + } else { // Creating a new measurement (measurementId is -1 or 0) + currentMeasurementDbId = 0 + currentUserIdState = userId // Use the passed userId for a new measurement + measurementTimestampState = System.currentTimeMillis() // Always use current timestamp for new + valuesState.clear() + + // Preload values from the user's last measurement, if available and types are loaded. + if (allMeasurementTypes.isNotEmpty() && lastMeasurementToPreloadFrom != null) { + // Ensure the last measurement belongs to the current user. + if (lastMeasurementToPreloadFrom!!.measurement.userId == userId) { + lastMeasurementToPreloadFrom!!.values.forEach { mvFromLast -> + val correspondingType = allMeasurementTypes.find { it.id == mvFromLast.type.id } + if (correspondingType != null && + correspondingType.isEnabled && + correspondingType.inputType != InputFieldType.DATE && + correspondingType.inputType != InputFieldType.TIME + ) { + val valueString = when (correspondingType.inputType) { + InputFieldType.FLOAT -> mvFromLast.value.floatValue?.let { String.format(Locale.US, "%.2f", it) } ?: "" + InputFieldType.INT -> mvFromLast.value.intValue?.toString() ?: "" + InputFieldType.TEXT -> mvFromLast.value.textValue ?: "" + else -> "" + } + if (valueString.isNotEmpty()) { + valuesState[correspondingType.id] = valueString + } + } + } + } else { + // Log if preloading is skipped due to user mismatch (for debugging). + // Consider using a formal logger if this becomes a common scenario to debug. + println("DEBUG: lastMeasurementToPreloadFrom.userId (${lastMeasurementToPreloadFrom!!.measurement.userId}) != currentScreenUserId ($userId). Not preloading values.") + } + } + } + } + + // Configure the top bar save action. + LaunchedEffect(currentUserIdState, measurementTimestampState, valuesState.toMap()) { + sharedViewModel.setTopBarAction( + SharedViewModel.TopBarAction( + icon = Icons.Default.Save, + contentDescription = context.getString(R.string.action_save_measurement), + onClick = { + if (currentUserIdState == -1) { // Ensure a user is selected. + Toast.makeText(context, R.string.toast_no_user_selected, Toast.LENGTH_SHORT).show() + return@TopBarAction + } + + // Prevent saving if it's a new measurement with the exact same timestamp as the user's last one. + if (currentMeasurementDbId == 0 && + lastMeasurementToPreloadFrom != null && + lastMeasurementToPreloadFrom!!.measurement.userId == currentUserIdState && + measurementTimestampState == lastMeasurementToPreloadFrom!!.measurement.timestamp + ) { + Toast.makeText(context, R.string.toast_duplicate_timestamp, Toast.LENGTH_LONG).show() + return@TopBarAction + } + + val measurementToSave = Measurement( + id = currentMeasurementDbId, + userId = currentUserIdState, + timestamp = measurementTimestampState + ) + + val valueList = mutableListOf() + var allConversionsOk = true + + allMeasurementTypes + .filterNot { it.inputType == InputFieldType.DATE || it.inputType == InputFieldType.TIME } // Date/Time handled by main timestamp + .filterNot { it.isDerived } // Derived values are calculated, not input + .forEach { type -> + val inputString = valuesState[type.id]?.trim() + + if (inputString.isNullOrBlank()) return@forEach // Skip empty values + + val existingValueId = if (measurementId != -1 && measurementId != 0) { + loadedData?.values?.find { v -> v.type.id == type.id }?.value?.id ?: 0 + } else 0 + + var floatVal: Float? = null + var intVal: Int? = null + var textVal: String? = null + + when (type.inputType) { + InputFieldType.FLOAT -> { + floatVal = inputString.toFloatOrNull() + if (floatVal == null) { + Toast.makeText(context, context.getString(R.string.toast_invalid_number_format, type.getDisplayName(context), inputString), Toast.LENGTH_LONG).show() + allConversionsOk = false + } + } + InputFieldType.INT -> { + intVal = inputString.toIntOrNull() + if (intVal == null) { + Toast.makeText(context, context.getString(R.string.toast_invalid_integer_format, type.getDisplayName(context), inputString), Toast.LENGTH_LONG).show() + allConversionsOk = false + } + } + InputFieldType.TEXT -> { + textVal = inputString + } + else -> { /* Should not happen due to filters */ } + } + + if (!allConversionsOk) return@TopBarAction // Stop processing if a conversion error occurred. + + valueList.add( + MeasurementValue( + id = existingValueId, + measurementId = 0, // This will be set by the ViewModel/Repository upon insertion. + typeId = type.id, + floatValue = floatVal, + intValue = intVal, + textValue = textVal, + dateValue = null // Date/Time values are not stored this way. + ) + ) + } + + if (allConversionsOk) { + sharedViewModel.saveMeasurement(measurementToSave, valueList) + isPendingNavigation = true // Trigger loading indicator and navigate back. + navController.popBackStack() + } + }) + ) + } + + // Show loading indicator while data for an existing measurement is being fetched. + if (measurementId != -1 && measurementId != 0 && loadedData == null) { + Box( + modifier = Modifier + .fillMaxSize() // Changed from fillMaxWidth().padding() to fillMaxSize() for consistency + .padding(16.dp), // Padding can remain if desired for the indicator's position + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + // Main content: List of measurement value edit rows. + Column(modifier = Modifier.padding(16.dp)) { + LazyColumn { + val activeMeasurementTypes = allMeasurementTypes.filter { it.isEnabled } + + items(activeMeasurementTypes, key = { it.id }) { type -> + val displayValue: String + val currentValueForIncrementDecrement: String? // Used for increment/decrement operations + + when (type.inputType) { + InputFieldType.DATE -> { + displayValue = dateFormat.format(Date(measurementTimestampState)) + currentValueForIncrementDecrement = null // Not applicable + } + InputFieldType.TIME -> { + displayValue = timeFormat.format(Date(measurementTimestampState)) + currentValueForIncrementDecrement = null // Not applicable + } + else -> { // For FLOAT, INT, TEXT + displayValue = valuesState[type.id] ?: "" + currentValueForIncrementDecrement = valuesState[type.id] + } + } + + MeasurementValueEditRow( + type = type, + value = if (displayValue.isBlank() && type.inputType != InputFieldType.DATE && type.inputType != InputFieldType.TIME) { + stringResource(R.string.placeholder_empty_value) // Default placeholder for empty non-date/time values + } else displayValue, + onEditClick = { + if (!type.isDerived) { + when (type.inputType) { + InputFieldType.DATE -> showDatePickerForMainTimestamp = true + InputFieldType.TIME -> showTimePickerForMainTimestamp = true + else -> dialogTargetType = type // Show generic dialog + } + } + }, + showIncrementDecrement = (type.inputType == InputFieldType.FLOAT || type.inputType == InputFieldType.INT) && !type.isDerived, + onIncrement = if ((type.inputType == InputFieldType.FLOAT || type.inputType == InputFieldType.INT) && !type.isDerived) { + { + val currentStr = currentValueForIncrementDecrement ?: if (type.inputType == InputFieldType.FLOAT) "0.0" else "0" + valuesState[type.id] = incrementValue(currentStr, type.inputType) + } + } else null, + onDecrement = if ((type.inputType == InputFieldType.FLOAT || type.inputType == InputFieldType.INT) && !type.isDerived) { + { + val currentStr = currentValueForIncrementDecrement ?: if (type.inputType == InputFieldType.FLOAT) "0.0" else "0" + valuesState[type.id] = decrementValue(currentStr, type.inputType) + } + } else null + ) + } + } + } + } + + // --- Dialogs for FLOAT, INT, TEXT based on dialogTargetType --- + dialogTargetType?.let { currentType -> + val typeIconRes = remember(currentType.icon) { getIconResIdByName(currentType.icon) } + val typeColor = remember(currentType.color) { Color(currentType.color) } + val initialDialogValue = valuesState[currentType.id] ?: when (currentType.inputType) { + InputFieldType.FLOAT -> "0.0" // Default for empty float + InputFieldType.INT -> "0" // Default for empty int + else -> "" + } + val dialogTitle = stringResource(R.string.dialog_title_edit_value, currentType.getDisplayName(context)) + + when (currentType.inputType) { + InputFieldType.FLOAT, InputFieldType.INT -> { + NumberInputDialog( + title = dialogTitle, + initialValue = initialDialogValue, + inputType = currentType.inputType, + iconRes = typeIconRes, + color = typeColor, + onDismiss = { dialogTargetType = null }, + onConfirm = { confirmedValue -> + val trimmedValue = confirmedValue.trim() + if (trimmedValue.isEmpty()) { + valuesState.remove(currentType.id) // Clear value if input is empty + dialogTargetType = null + } else { + var isValid = false + if (currentType.inputType == InputFieldType.FLOAT) { + val floatOrNull = trimmedValue.toFloatOrNull() + if (floatOrNull != null) { + valuesState[currentType.id] = String.format(Locale.US, "%.2f", floatOrNull) + isValid = true + } else { + Toast.makeText(context, context.getString(R.string.toast_invalid_number_format_short, currentType.getDisplayName(context)), Toast.LENGTH_SHORT).show() + } + } else { // INT + val intOrNull = trimmedValue.toIntOrNull() + if (intOrNull != null) { + valuesState[currentType.id] = intOrNull.toString() + isValid = true + } else { + Toast.makeText(context, context.getString(R.string.toast_invalid_integer_format_short, currentType.getDisplayName(context)), Toast.LENGTH_SHORT).show() + } + } + if (isValid) { + dialogTargetType = null // Dismiss dialog only on valid input + } + } + } + ) + } + InputFieldType.TEXT -> { + TextInputDialog( + title = dialogTitle, + initialValue = initialDialogValue, + iconRes = typeIconRes, + color = typeColor, + onDismiss = { dialogTargetType = null }, + onConfirm = { confirmedValue -> + val finalValue = confirmedValue.trim() + if (finalValue.isEmpty()) { + valuesState.remove(currentType.id) + } else { + valuesState[currentType.id] = finalValue + } + dialogTargetType = null + } + // Consider `singleLine = true` if appropriate for your TextInputDialog. + // If multiline input is needed for specific text types, adjust TextInputDialog and pass the parameter here. + ) + } + else -> { /* Should not be reached as DATE/TIME have their own flags and derived are not editable here. */ } + } + } + + // --- Dialogs for the main measurement timestamp (measurementTimestampState) --- + if (showDatePickerForMainTimestamp) { + val triggeringType = allMeasurementTypes.find { it.inputType == InputFieldType.DATE } + val dateDialogTitle = stringResource(R.string.dialog_title_change_date, triggeringType?.getDisplayName(context) ?: stringResource(R.string.label_date)) + DateInputDialog( + title = dateDialogTitle, + initialTimestamp = measurementTimestampState, + iconRes = getIconResIdByName(triggeringType?.icon ?: "ic_calendar"), + color = triggeringType?.let { Color(it.color) } ?: MaterialTheme.colorScheme.primary, + onDismiss = { showDatePickerForMainTimestamp = false }, + onConfirm = { newDateMillis -> + val newCal = Calendar.getInstance().apply { timeInMillis = newDateMillis } + val currentCal = Calendar.getInstance().apply { timeInMillis = measurementTimestampState } + currentCal.set(newCal.get(Calendar.YEAR), newCal.get(Calendar.MONTH), newCal.get(Calendar.DAY_OF_MONTH)) + measurementTimestampState = currentCal.timeInMillis + showDatePickerForMainTimestamp = false + } + ) + } + + if (showTimePickerForMainTimestamp) { + val triggeringType = allMeasurementTypes.find { it.inputType == InputFieldType.TIME } + val timeDialogTitle = stringResource(R.string.dialog_title_change_time, triggeringType?.getDisplayName(context) ?: stringResource(R.string.label_time)) + TimeInputDialog( + title = timeDialogTitle, + initialTimestamp = measurementTimestampState, + iconRes = getIconResIdByName(triggeringType?.icon ?: "ic_time"), + color = triggeringType?.let { Color(it.color) } ?: MaterialTheme.colorScheme.primary, + onDismiss = { showTimePickerForMainTimestamp = false }, + onConfirm = { newTimeMillis -> + val newCal = Calendar.getInstance().apply { timeInMillis = newTimeMillis } + val currentCal = Calendar.getInstance().apply { timeInMillis = measurementTimestampState } + currentCal.set(Calendar.HOUR_OF_DAY, newCal.get(Calendar.HOUR_OF_DAY)) + currentCal.set(Calendar.MINUTE, newCal.get(Calendar.MINUTE)) + measurementTimestampState = currentCal.timeInMillis + showTimePickerForMainTimestamp = false + } + ) + } +} + +/** + * A Composable that displays a row for editing a single measurement value. + * It shows the measurement type's icon, name, current value, and an edit button. + * For numeric types, it can also show increment/decrement buttons. + * + * @param type The [MeasurementType] this row represents. + * @param value The current string value to display for the measurement type. + * @param onEditClick Lambda triggered when the user clicks the row or edit button. + * @param showIncrementDecrement Whether to show increment and decrement buttons (for numeric types). + * @param onIncrement Lambda triggered when the increment button is clicked. + * @param onDecrement Lambda triggered when the decrement button is clicked. + */ +@Composable +fun MeasurementValueEditRow( + type: MeasurementType, + value: String, + onEditClick: () -> Unit, + showIncrementDecrement: Boolean, + onIncrement: (() -> Unit)? = null, + onDecrement: (() -> Unit)? = null +) { + val context = LocalContext.current + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .clickable(onClick = onEditClick, enabled = !type.isDerived) // Clicking row triggers edit, disabled for derived + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(Color(type.color)), // Uses the type's specific color + contentAlignment = Alignment.Center + ) { + val iconId = remember(type.icon) { getIconResIdByName( type.icon) } + if (iconId != 0) { + Icon( + painter = painterResource(id = iconId), + contentDescription = type.getDisplayName(context), // Type name serves as base content description + tint = Color.Black, // Consider a more adaptive tint based on background color for accessibility + modifier = Modifier.size(24.dp) + ) + } + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(text = type.getDisplayName(context), style = MaterialTheme.typography.bodyLarge) + val unitDisplayString = if (type.inputType == InputFieldType.FLOAT || type.inputType == InputFieldType.INT) { + if (type.unit != UnitType.NONE) { + " ${type.unit.displayName}" // Assumes UnitType.displayName is user-friendly + } else { + "" // No unit if UnitType.NONE + } + } else { + "" // No unit for non-numeric types + } + Text( + text = value + unitDisplayString, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (showIncrementDecrement && onIncrement != null && onDecrement != null && !type.isDerived) { + Column { // Layout for increment/decrement buttons + IconButton(onClick = onIncrement, modifier = Modifier.size(24.dp)) { + Icon(Icons.Default.ArrowUpward, contentDescription = stringResource(R.string.content_desc_increase_value, type.getDisplayName(context))) + } + IconButton(onClick = onDecrement, modifier = Modifier.size(24.dp)) { + Icon(Icons.Default.ArrowDownward, contentDescription = stringResource(R.string.content_desc_decrease_value, type.getDisplayName(context))) + } + } + } + Spacer(modifier = Modifier.width(8.dp)) + + // Show edit button only if the type is not derived. + if (!type.isDerived) { + IconButton(onClick = onEditClick, modifier = Modifier.size(36.dp)) { + Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_edit_value, type.getDisplayName(context))) + } + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt new file mode 100644 index 00000000..9cf88334 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt @@ -0,0 +1,871 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.overview + +import android.content.Context +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.BluetoothSearching +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Assessment +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.BluetoothConnected +import androidx.compose.material.icons.filled.BluetoothDisabled +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.PersonSearch +import androidx.compose.material.icons.filled.QuestionMark +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.health.openscale.R +import com.health.openscale.core.data.InputFieldType +import com.health.openscale.core.data.Trend +import com.health.openscale.core.model.MeasurementWithValues +import com.health.openscale.core.database.UserPreferenceKeys +import com.health.openscale.ui.navigation.Routes +import com.health.openscale.ui.screen.SharedViewModel +import com.health.openscale.ui.screen.ValueWithDifference +import com.health.openscale.ui.screen.bluetooth.BluetoothViewModel +import com.health.openscale.ui.screen.bluetooth.ConnectionStatus +import com.health.openscale.ui.screen.components.LineChart +import com.health.openscale.ui.screen.components.provideFilterTopBarAction +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Determines the appropriate top bar action based on the Bluetooth connection status. + * Uses the [SharedViewModel] to display Snackbars for user feedback. + * + * @param context The application context. + * @param savedAddr The address of the currently saved Bluetooth scale, if any. + * @param connStatusEnum The current connection status to the scale. + * @param connectedDevice The address of the currently connected device, if any. + * @param currentNavController The NavController for navigation actions. + * @param bluetoothViewModel The ViewModel for controlling Bluetooth actions. + * @param sharedViewModel The SharedViewModel for triggering global Snackbars. + * @param currentDeviceName The name of the saved scale for more user-friendly messages. + * @return A [SharedViewModel.TopBarAction] instance or null if no specific action is required. + */ +fun determineBluetoothTopBarAction( + context : Context, + savedAddr: String?, + connStatusEnum: ConnectionStatus, + connectedDevice: String?, + currentNavController: NavController, + bluetoothViewModel: BluetoothViewModel, + sharedViewModel: SharedViewModel, + currentDeviceName: String? +): SharedViewModel.TopBarAction? { + // Logic to determine if a connection or disconnection process is currently active + val btConnectingOrDisconnecting = savedAddr != null && + (connStatusEnum == ConnectionStatus.CONNECTING || connStatusEnum == ConnectionStatus.DISCONNECTING) && + // When connecting, connectedDevice might be null or the address being connected to. + // When disconnecting, connectedDevice should be the address of the device being disconnected. + (connectedDevice == savedAddr || connStatusEnum == ConnectionStatus.CONNECTING || (connStatusEnum == ConnectionStatus.DISCONNECTING && connectedDevice == savedAddr)) + + val deviceNameForMessage = currentDeviceName ?: context.getString(R.string.fallback_device_name_saved_scale) + + return when { + // Case 1: Connection or disconnection process is actively running + btConnectingOrDisconnecting -> SharedViewModel.TopBarAction( + icon = Icons.AutoMirrored.Filled.BluetoothSearching, // Icon for "searching" or "working" + contentDescription = context.getString(R.string.bluetooth_action_connecting_disconnecting_desc), + onClick = { + // Typically, the button is not interactive during this time, + // but a Snackbar can confirm the ongoing process. + sharedViewModel.showSnackbar( + message = context.getString( + when (connStatusEnum) { + ConnectionStatus.CONNECTING -> R.string.snackbar_bluetooth_connecting_to + ConnectionStatus.DISCONNECTING -> R.string.snackbar_bluetooth_disconnecting_from + else -> R.string.snackbar_bluetooth_processing_with // Fallback + }, + deviceNameForMessage + ), + duration = SnackbarDuration.Short + ) + } + ) + + // Case 2: No Bluetooth scale is saved + savedAddr == null -> SharedViewModel.TopBarAction( + icon = Icons.Default.Bluetooth, // Default Bluetooth icon + contentDescription = context.getString(R.string.bluetooth_action_no_scale_saved_desc), + onClick = { + sharedViewModel.showSnackbar( + message = context.getString(R.string.snackbar_bluetooth_no_scale_saved), + duration = SnackbarDuration.Short + ) + currentNavController.navigate(Routes.BLUETOOTH_SETTINGS) + } + ) + + // Case 3: Successfully connected to the saved scale + savedAddr == connectedDevice && connStatusEnum == ConnectionStatus.CONNECTED -> SharedViewModel.TopBarAction( + icon = Icons.Filled.BluetoothConnected, // Icon for "connected" + contentDescription = context.getString(R.string.bluetooth_action_disconnect_desc, deviceNameForMessage), + onClick = { + // Trigger the action first, then show the Snackbar + bluetoothViewModel.disconnectDevice() // IMPORTANT: Trigger disconnection here! + sharedViewModel.showSnackbar( + message = context.getString(R.string.snackbar_bluetooth_disconnecting_from, deviceNameForMessage), // Adjusted message + duration = SnackbarDuration.Short + ) + } + ) + + // Case 4: Connection error, and an address is saved + connStatusEnum == ConnectionStatus.FAILED && savedAddr != null -> SharedViewModel.TopBarAction( + icon = Icons.Filled.Error, // Error icon + contentDescription = context.getString(R.string.bluetooth_action_retry_connection_desc, deviceNameForMessage), + onClick = { + sharedViewModel.showSnackbar( + message = context.getString(R.string.snackbar_bluetooth_retry_connection, deviceNameForMessage), + duration = SnackbarDuration.Short + ) + bluetoothViewModel.connectToSavedDevice() + } + ) + + // Case 5: Connection error, and NO address is saved + connStatusEnum == ConnectionStatus.FAILED && savedAddr == null -> SharedViewModel.TopBarAction( + icon = Icons.Filled.Error, // Error icon + contentDescription = context.getString(R.string.bluetooth_action_error_check_settings_desc), + onClick = { + sharedViewModel.showSnackbar( + message = context.getString(R.string.snackbar_bluetooth_error_check_settings), + duration = SnackbarDuration.Short + ) + currentNavController.navigate(Routes.BLUETOOTH_SETTINGS) + } + ) + + // Case 6: Saved device exists but is not connected (disconnected, idle, etc.) + // This case also covers if connStatusEnum = DISCONNECTED, IDLE, or NONE. + savedAddr != null && (connStatusEnum == ConnectionStatus.DISCONNECTED || connStatusEnum == ConnectionStatus.IDLE || connStatusEnum == ConnectionStatus.NONE) -> SharedViewModel.TopBarAction( + icon = Icons.Filled.BluetoothDisabled, // Icon for "disconnected" or "ready to connect" + contentDescription = context.getString(R.string.bluetooth_action_connect_to_desc, deviceNameForMessage), + onClick = { + sharedViewModel.showSnackbar( + message = context.getString(R.string.snackbar_bluetooth_attempting_connection, deviceNameForMessage), + duration = SnackbarDuration.Short + ) + bluetoothViewModel.connectToSavedDevice() + } + ) + + // Fallback: If an address is saved, but the state was not specifically covered above, + // offer to connect. Ideally, this shouldn't be hit often if the logic above is complete. + // If no device is saved and there's no error/connection attempt, + // this was already covered by 'savedAddr == null' (leads to settings). + else -> { + if (savedAddr != null) { + // This serves as a generic "Connect" button if a rare state occurs + SharedViewModel.TopBarAction( + icon = Icons.Filled.BluetoothDisabled, + contentDescription = context.getString(R.string.bluetooth_action_connect_to_desc, deviceNameForMessage), + onClick = { + sharedViewModel.showSnackbar( + message = context.getString(R.string.snackbar_bluetooth_attempting_connection, deviceNameForMessage), + duration = SnackbarDuration.Short + ) + bluetoothViewModel.connectToSavedDevice() + } + ) + } else { + // If really no other condition applies and no device is saved, + // and the above cases haven't been met, "Go to settings" is a safe default. + // This will likely only be hit if connStatusEnum has an unexpected value + // and savedAddr is null, but that should already be covered by "Case 2". + // For safety, nonetheless: + SharedViewModel.TopBarAction( + icon = Icons.Default.Bluetooth, + contentDescription = context.getString(R.string.bluetooth_action_check_settings_desc), + onClick = { + sharedViewModel.showSnackbar( + message = context.getString(R.string.snackbar_bluetooth_check_settings), + duration = SnackbarDuration.Short + ) + currentNavController.navigate(Routes.BLUETOOTH_SETTINGS) + } + ) + } + } + } +} + +/** + * The main screen for displaying an overview of measurements, user status, and Bluetooth controls. + * It allows users to view their measurement history, add new measurements, and manage Bluetooth scale connections. + * + * @param navController The [NavController] used for navigating between screens. + * @param sharedViewModel The [SharedViewModel] providing access to shared data like user selection, + * measurements, and top bar configuration. + * @param bluetoothViewModel The [BluetoothViewModel] for managing Bluetooth state and actions. + */ +@Composable +fun OverviewScreen( + navController: NavController, + sharedViewModel: SharedViewModel, + bluetoothViewModel: BluetoothViewModel +) { + val selectedUserId by sharedViewModel.selectedUserId.collectAsState() + val context = LocalContext.current // Used for Toasts and string resources + + // Time filter action for the top bar, specific to this screen's context + val timeFilterAction = provideFilterTopBarAction( + sharedViewModel = sharedViewModel, + screenContextName = UserPreferenceKeys.OVERVIEW_SCREEN_CONTEXT + ) + val enrichedMeasurements by sharedViewModel.enrichedMeasurementsFlow.collectAsState() + val isLoading by sharedViewModel.isBaseDataLoading.collectAsState() + + // --- Chart selection logic reverted to local state management --- + val allMeasurementTypes by sharedViewModel.measurementTypes.collectAsState() + + val localSelectedOverviewGraphTypeIntIds = remember { mutableStateListOf() } + + // Derived list of MeasurementType objects that are selected for the chart. + val selectedLineTypesForOverviewChart = remember(allMeasurementTypes, localSelectedOverviewGraphTypeIntIds.toList()) { + allMeasurementTypes.filter { type -> + type.id in localSelectedOverviewGraphTypeIntIds && + type.isEnabled && // Ensure the type is globally enabled + (type.inputType == InputFieldType.FLOAT || type.inputType == InputFieldType.INT) // Ensure it's a plottable type + } + } + // --- End of reverted chart selection logic --- + + val savedDeviceAddress by bluetoothViewModel.savedScaleAddress.collectAsState() + val connectionStatus by bluetoothViewModel.connectionStatus.collectAsState() + val connectedDeviceAddr by bluetoothViewModel.connectedDeviceAddress.collectAsState() + val savedDeviceNameString by bluetoothViewModel.savedScaleName.collectAsState() + + // Determine the Bluetooth action for the top bar + val bluetoothTopBarAction = determineBluetoothTopBarAction( + context = context, + savedAddr = savedDeviceAddress, + connStatusEnum = connectionStatus, + connectedDevice = connectedDeviceAddr, + currentNavController = navController, + bluetoothViewModel = bluetoothViewModel, + sharedViewModel = sharedViewModel, + currentDeviceName = savedDeviceNameString + ) + + // LaunchedEffect to configure the top bar based on the current state + LaunchedEffect( + selectedUserId, + isLoading, + enrichedMeasurements.isNotEmpty(), + bluetoothTopBarAction, + selectedLineTypesForOverviewChart.isNotEmpty(), + timeFilterAction, + savedDeviceAddress, + connectionStatus, + connectedDeviceAddr + ) { + sharedViewModel.setTopBarTitle(context.getString(R.string.route_title_overview)) + val actions = mutableListOf() + + // 0. Add Bluetooth action (if determined) at the beginning + bluetoothTopBarAction?.let { btAction -> + actions.add(btAction) + } + + // 1. Add "Add Measurement" icon + actions.add( + SharedViewModel.TopBarAction( + icon = Icons.Default.Add, + contentDescription = context.getString(R.string.action_add_measurement_desc), + onClick = { + if (selectedUserId != null) { + navController.navigate(Routes.measurementDetail(measurementId = null, userId = selectedUserId!!)) + } else { + Toast.makeText(context, context.getString(R.string.toast_select_user_first), Toast.LENGTH_SHORT).show() + } + } + ) + ) + + // Condition for showing filter icons + if (selectedUserId != null && (!isLoading || enrichedMeasurements.isNotEmpty())) { + // Show time filter if the chart is visible (i.e., types are selected locally) or if not loading + if (selectedLineTypesForOverviewChart.isNotEmpty() || !isLoading) { + timeFilterAction?.let { actions.add(it) } + } + } + sharedViewModel.setTopBarActions(actions) + } + + Column(modifier = Modifier.fillMaxSize()) { + if (selectedUserId == null) { + // Display a card prompting user selection if no user is active + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + NoUserSelectedCard(navController = navController) + } + } else { + // Display content for the selected user + + // Loading state for the chart (if data is loading and measurements are empty) + if (isLoading && enrichedMeasurements.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), // Height of the chart area + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else if (!isLoading) { // Show chart if not loading + Box(modifier = Modifier.fillMaxWidth()) { + LineChart( + sharedViewModel = sharedViewModel, // Still useful for other chart data if needed + screenContextName = UserPreferenceKeys.OVERVIEW_SCREEN_CONTEXT, // Still useful for context + showFilterControls = true, // Allow user to select types to display on chart + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .padding(bottom = 8.dp), + showYAxis = false + ) + } + } + + // Divider: shown if measurements exist OR if no chart types are selected (list shown directly) + if (enrichedMeasurements.isNotEmpty() || selectedLineTypesForOverviewChart.isEmpty()) { // << USE LOCAL STATE + HorizontalDivider() + } + + Box(modifier = Modifier.weight(1f)) { + if (isLoading && enrichedMeasurements.isEmpty()) { + // Loading is handled by the CircularProgressIndicator above + } else if (!isLoading && enrichedMeasurements.isEmpty() && selectedUserId != null) { + // If not loading, and there are no measurements for the selected user + NoMeasurementsCard( + navController = navController, + selectedUserId = selectedUserId + ) + } else if (enrichedMeasurements.isNotEmpty()) { + // Display the list of measurements + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + itemsIndexed( + items = enrichedMeasurements, + key = { _, item -> item.measurementWithValues.measurement.id } + ) { _, enrichedItem -> + MeasurementCard( + measurementWithValues = enrichedItem.measurementWithValues, + processedValuesForDisplay = enrichedItem.valuesWithTrend, + onEdit = { + navController.navigate( + Routes.measurementDetail( + enrichedItem.measurementWithValues.measurement.id, + selectedUserId!! + ) + ) + }, + onDelete = { + sharedViewModel.deleteMeasurement(enrichedItem.measurementWithValues.measurement) + } + ) + } + } + } + } + } + } +} + +/** + * A Composable card displayed when no user is currently selected/active. + * It prompts the user to add or select a user. + * + * @param navController The [NavController] for navigating to the user creation/selection screen. + */ +@Composable +fun NoUserSelectedCard(navController: NavController) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Card( + modifier = Modifier.fillMaxWidth(0.9f), // Take 90% of width + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .padding(horizontal = 24.dp, vertical = 32.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = Icons.Filled.PersonSearch, + contentDescription = null, // Decorative icon + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(R.string.no_user_selected_title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + Text( + text = stringResource(R.string.no_user_selected_message), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Button( + onClick = { + // Navigate to user detail screen with -1 to indicate new user creation + navController.navigate(Routes.userDetail(-1)) + }, + modifier = Modifier.fillMaxWidth(0.8f) // Take 80% of card width + ) { + Icon( + Icons.Filled.PersonAdd, + contentDescription = null, // Decorative icon within button + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.action_add_user)) + } + } + } + } +} + +/** + * A Composable card displayed when a user is selected but has no measurements recorded yet. + * It prompts the user to add their first measurement. + * + * @param navController The [NavController] for navigating to the measurement creation screen. + * @param selectedUserId The ID of the currently selected user, to pass to the measurement creation screen. + */ +@Composable +fun NoMeasurementsCard(navController: NavController, selectedUserId: Int?) { + Box( + modifier = Modifier + .fillMaxSize() // Important: To occupy the space assigned by Box(weight(1f)) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Card( + modifier = Modifier.fillMaxWidth(0.9f), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .padding(horizontal = 24.dp, vertical = 32.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = Icons.Filled.Assessment, // Icon suggesting measurement/stats + contentDescription = null, // Decorative icon + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.secondary + ) + Text( + text = stringResource(R.string.no_measurements_title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + Text( + text = stringResource(R.string.no_measurements_message), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + FilledTonalButton( // A less prominent button style + onClick = { + if (selectedUserId != null) { + // Navigate to measurement detail screen for new measurement + navController.navigate(Routes.measurementDetail(measurementId = null, userId = selectedUserId)) + } + }, + modifier = Modifier.fillMaxWidth(0.8f), + enabled = selectedUserId != null // Button is enabled only if a user is selected + ) { + Icon( + Icons.Filled.Add, + contentDescription = null, // Decorative icon within button + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.action_add_measurement)) + } + } + } + } +} + + +/** + * A Composable card that displays a single measurement entry, including its date, + * pinned values, and an expandable section for non-pinned values. + * Provides actions to edit or delete the measurement. + * + * @param measurementWithValues The [MeasurementWithValues] object containing the measurement data and its associated values. + * @param processedValuesForDisplay A list of [ValueWithDifference] objects, derived from the measurement, + * including trend information and formatted for display. + * @param onEdit Callback function triggered when the edit action is selected. + * @param onDelete Callback function triggered when the delete action is selected. + */ +@Composable +fun MeasurementCard( + measurementWithValues: MeasurementWithValues, + processedValuesForDisplay: List, + onEdit: () -> Unit, + onDelete: () -> Unit +) { + val dateFormatted = remember(measurementWithValues.measurement.timestamp) { + SimpleDateFormat("E, dd.MM.yyyy HH:mm", Locale.getDefault()) + .format(Date(measurementWithValues.measurement.timestamp)) + } + + var isExpanded by rememberSaveable { mutableStateOf(false) } + + // Separate values into pinned and non-pinned lists for distinct display logic + val pinnedValues = remember(processedValuesForDisplay) { + processedValuesForDisplay.filter { it.currentValue.type.isPinned && it.currentValue.type.isEnabled } + } + val nonPinnedValues = remember(processedValuesForDisplay) { + processedValuesForDisplay.filter { !it.currentValue.type.isPinned && it.currentValue.type.isEnabled } + } + // All active (enabled) values to check if any data should be displayed + val allActiveProcessedValues = remember(processedValuesForDisplay) { + processedValuesForDisplay.filter { it.currentValue.type.isEnabled } + } + + + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column { + // Header row: Date and action buttons (Edit, Delete, Expand/Collapse) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 8.dp) + ) { + Text( + text = dateFormatted, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f) // Date takes available space + ) + val iconButtonSize = 36.dp // Standard size for action icons + val actionIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + + IconButton(onClick = onEdit, modifier = Modifier.size(iconButtonSize)) { + Icon( + Icons.Default.Edit, + contentDescription = stringResource(R.string.action_edit_measurement_desc, dateFormatted), + tint = actionIconColor + ) + } + IconButton(onClick = onDelete, modifier = Modifier.size(iconButtonSize)) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.action_delete_measurement_desc, dateFormatted), + tint = actionIconColor + ) + } + + // Conditional expand/collapse icon button for non-pinned values, + // only shown if there are non-pinned values and no pinned values (to avoid duplicate expand button logic) + if (nonPinnedValues.isNotEmpty() && pinnedValues.isEmpty()) { + IconButton(onClick = { isExpanded = !isExpanded }, modifier = Modifier.size(iconButtonSize)) { + Icon( + imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = stringResource(if (isExpanded) R.string.action_show_less_desc else R.string.action_show_more_desc) + ) + } + } + } + + // Section for pinned measurement values (always visible if present) + Column( + modifier = Modifier.padding( + start = 16.dp, end = 16.dp, + top = if (pinnedValues.isNotEmpty()) 8.dp else 0.dp, // Add top padding only if there are pinned values + bottom = 0.dp // Bottom padding handled by AnimatedVisibility or Spacer later + ) + ) { + if (pinnedValues.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + pinnedValues.forEach { valueWithTrend -> + MeasurementValueRow(valueWithTrend) + } + } + } + } + + // Animated section for non-pinned measurement values (collapsible) + if (nonPinnedValues.isNotEmpty()) { + AnimatedVisibility(visible = isExpanded || pinnedValues.isEmpty()) { // Also visible if no pinned values and not expanded (default state) + Column( + modifier = Modifier.padding( + start = 16.dp, end = 16.dp, + top = if (pinnedValues.isNotEmpty()) 12.dp else 8.dp, // Smaller top padding if no pinned values + bottom = 8.dp + ), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + nonPinnedValues.forEach { valueWithTrend -> + MeasurementValueRow(valueWithTrend) + } + } + } + } + + // Footer: Expand/Collapse TextButton (only if there are non-pinned values and also pinned values, + // or if there are non-pinned values and it's not the default expanded state for only non-pinned). + if (nonPinnedValues.isNotEmpty() && (pinnedValues.isNotEmpty() || !isExpanded) ) { + // Show divider if the expandable section is visible or if pinned items are present (button will always be there) + if (isExpanded || pinnedValues.isNotEmpty()) { + HorizontalDivider(modifier = Modifier.padding( + top = if (isExpanded && nonPinnedValues.isNotEmpty()) 4.dp else if (pinnedValues.isNotEmpty()) 8.dp else 0.dp, + bottom = 0.dp + )) + } + + TextButton( + onClick = { isExpanded = !isExpanded }, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), // Consistent height for the button + shape = MaterialTheme.shapes.extraSmall // Less rounded corners for a subtle look + ) { + Icon( + imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + tint = MaterialTheme.colorScheme.secondary, // Use secondary color for emphasis + contentDescription = stringResource(if (isExpanded) R.string.action_show_less_desc else R.string.action_show_more_desc) + ) + } + } + + // Message if no active measurement values are present for this entry + if (allActiveProcessedValues.isEmpty()) { + Text( + stringResource(R.string.no_active_values_for_measurement), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + // Add padding at the end of the card if only pinned values are shown and no footer (expand/collapse button) is present + if (pinnedValues.isNotEmpty() && nonPinnedValues.isEmpty() && allActiveProcessedValues.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} + +/** + * A row Composable that displays a single measurement value, including its type icon, + * name, value, unit, and trend indicator if applicable. + * + * @param valueWithTrend The [ValueWithDifference] object containing the current value, + * type information, difference from a previous value, and trend. + */ +@Composable +fun MeasurementValueRow(valueWithTrend: ValueWithDifference) { + val type = valueWithTrend.currentValue.type + val originalValue = valueWithTrend.currentValue.value // This is Measurement.Value object + val difference = valueWithTrend.difference + val trend = valueWithTrend.trend + + val displayValue = when (type.inputType) { + InputFieldType.FLOAT -> originalValue.floatValue?.let { "%.1f".format(Locale.getDefault(), it) } + InputFieldType.INT -> originalValue.intValue?.toString() + InputFieldType.TEXT -> originalValue.textValue + InputFieldType.DATE -> originalValue.dateValue?.let { + SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()).format(Date(it)) + } + InputFieldType.TIME -> originalValue.dateValue?.let { + SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) + } + } ?: "-" // Default to dash if value is null + + val context = LocalContext.current + val iconId = remember(type.icon) { + // Attempt to get the drawable resource ID for the type's icon name + // This relies on the icon name string matching a drawable resource name + context.resources.getIdentifier(type.icon, "drawable", context.packageName) + } + // Dynamic content description for the icon based on type name + val iconContentDescription = stringResource(R.string.measurement_type_icon_desc, type.getDisplayName(context)) + // Fallback content description if the icon is not found (e.g. shows question mark) + val unknownTypeContentDescription = stringResource(R.string.measurement_type_icon_unknown_desc, type.getDisplayName(context)) + + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // Left part: Icon and Type Name + Row( + modifier = Modifier.weight(1f), // Takes available space, pushing value & trend to the right + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(36.dp) // Standardized size for the icon container + .clip(CircleShape) + .background(Color(type.color)), + contentAlignment = Alignment.Center + ) { + if (iconId != 0) { // Check if the resource ID is valid + Icon( + painter = painterResource(id = iconId), + contentDescription = type.getDisplayName(context), + tint = Color.Black, + modifier = Modifier.size(20.dp) + ) + } else { + // Fallback icon if the specified icon resource is not found + Icon( + imageVector = Icons.Filled.QuestionMark, + contentDescription = unknownTypeContentDescription, + modifier = Modifier.size(20.dp), + tint = Color.Black + ) + } + } + Spacer(modifier = Modifier.width(12.dp)) + Column(verticalArrangement = Arrangement.Center) { + Text( + text = type.getDisplayName(context), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + ) + if (difference != null && trend != Trend.NOT_APPLICABLE) { + Spacer(modifier = Modifier.height(1.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + val trendIconVector = when (trend) { + Trend.UP -> Icons.Filled.ArrowUpward + Trend.DOWN -> Icons.Filled.ArrowDownward + Trend.NONE -> null + else -> null + } + val subtleGrayColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + if (trendIconVector != null) { + Icon( + imageVector = trendIconVector, + contentDescription = trend.name, + tint = subtleGrayColor, + modifier = Modifier.size(12.dp) + ) + Spacer(modifier = Modifier.width(3.dp)) + } + Text( + text = (if (difference > 0 && trend != Trend.NONE) "+" else "") + + when (type.inputType) { + InputFieldType.FLOAT -> "%.1f".format(Locale.getDefault(), difference) + InputFieldType.INT -> difference.toInt().toString() + else -> "" + } + " ${type.unit.displayName}", + style = MaterialTheme.typography.bodySmall, + color = subtleGrayColor + ) + } + } else if (type.inputType == InputFieldType.FLOAT || type.inputType == InputFieldType.INT) { + Spacer(modifier = Modifier.height((MaterialTheme.typography.bodySmall.fontSize.value + 2).dp)) + } + } + } + Text( + text = "$displayValue ${type.unit.displayName}", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.End, + modifier = Modifier.padding(start = 8.dp) + ) + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/AboutScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/AboutScreen.kt new file mode 100644 index 00000000..ca0e35a8 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/AboutScreen.kt @@ -0,0 +1,206 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.settings + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Launch +import androidx.compose.material.icons.filled.Business +import androidx.compose.material.icons.filled.Copyright +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.health.openscale.BuildConfig +import com.health.openscale.R +import com.health.openscale.core.utils.LogManager +import com.health.openscale.ui.screen.SharedViewModel + +/** + * Composable function for the "About" screen. + * Displays information about the application, project, license, and provides diagnostic tools. + * + * @param navController The NavController for navigation. + * @param sharedViewModel The SharedViewModel for accessing shared data and actions. + */ +@Composable +fun AboutScreen( + navController: NavController, + sharedViewModel: SharedViewModel, +) { + val context = LocalContext.current + val uriHandler = LocalUriHandler.current + + val projectHomepageUrl = "https://github.com/oliexdev/openScale" + val licenseUrl = "https://www.gnu.org/licenses/gpl-3.0.html" + + LaunchedEffect(Unit) { + sharedViewModel.setTopBarTitle(context.getString(R.string.about_screen_title)) + } + + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(0.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = R.drawable.ic_launcher_foreground), + contentDescription = stringResource(R.string.app_logo_content_description), + modifier = Modifier + .size(128.dp) + .padding(bottom = 8.dp) + ) + Text( + text = stringResource(R.string.app_name), // Using a string resource for app name display + style = MaterialTheme.typography.headlineLarge, + ) + Text( + text = stringResource(R.string.version_info, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 32.dp) + ) + } + + // --- Project Information --- + Text( + text = stringResource(R.string.project_information_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), + fontWeight = FontWeight.Bold + ) + InfoListItem( + headlineText = "olie.xdev", + supportingText = stringResource(R.string.maintainer_label), + leadingIconVector = Icons.Filled.Business, + leadingIconContentDescription = stringResource(R.string.maintainer_icon_content_description) + ) + InfoListItem( + headlineText = stringResource(R.string.project_homepage_display), + supportingText = stringResource(R.string.official_project_page_label), + leadingIconVector = Icons.Filled.Home, + leadingIconContentDescription = stringResource(R.string.homepage_icon_content_description), + url = projectHomepageUrl, + uriHandler = uriHandler + ) + InfoListItem( + headlineText = "GNU GPL v3.0 or newer", + supportingText = stringResource(R.string.software_license_details_label), + leadingIconVector = Icons.Filled.Copyright, + leadingIconContentDescription = stringResource(R.string.license_icon_content_description), + url = licenseUrl, + uriHandler = uriHandler + ) + } +} + + +/** + * A private composable function to display an information list item. + * It can show a headline, supporting text, a leading icon, and can be clickable if a URL is provided. + * + * @param headlineText The main text of the list item. + * @param supportingText Optional supporting text displayed below the headline. + * @param leadingIconVector Optional vector graphic for the leading icon. + * @param leadingIconContentDescription Content description for the leading icon, for accessibility. + * @param url Optional URL to open when the item is clicked. + * @param uriHandler Optional UriHandler to handle opening the URL. + */ +@Composable +private fun InfoListItem( + headlineText: String, + supportingText: String? = null, + leadingIconVector: ImageVector? = null, + leadingIconContentDescription: String?, + url: String? = null, + uriHandler: UriHandler? = null +) { + val itemModifier = if (url != null && uriHandler != null) { + Modifier.clickable { + try { + uriHandler.openUri(url) + } catch (e: Exception) { + // Log the error, a Snackbar could also be shown to the user if desired. + LogManager.e("AboutScreen", "Failed to open URL: $url", e) + // Consider showing a Snackbar to the user: + // scope.launch { sharedViewModel.showSnackbar(context.getString(R.string.error_opening_link)) } + } + } + } else { + Modifier + } + + ListItem( + headlineContent = { Text(headlineText, style = MaterialTheme.typography.bodyMedium) }, + modifier = itemModifier, + supportingContent = if (supportingText != null) { { Text(supportingText, style = MaterialTheme.typography.bodySmall) } } else null, + leadingContent = if (leadingIconVector != null) { + { + Icon( + imageVector = leadingIconVector, + contentDescription = leadingIconContentDescription + ) + } + } else null, + trailingContent = if (url != null) { + { + Icon( + Icons.AutoMirrored.Filled.Launch, + contentDescription = stringResource(R.string.open_link_content_description), // Specific description + tint = MaterialTheme.colorScheme.primary + ) + } + } else null, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent // Makes the list item background transparent + ) + ) +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt new file mode 100644 index 00000000..295e6a50 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt @@ -0,0 +1,505 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.settings + +import android.Manifest +import android.app.Activity +import android.bluetooth.BluetoothAdapter +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.BluetoothSearching +import androidx.compose.material.icons.filled.BluetoothDisabled +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.HighlightOff +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.health.openscale.R +import com.health.openscale.ui.screen.SharedViewModel +import com.health.openscale.ui.screen.bluetooth.BluetoothViewModel +import com.health.openscale.ui.screen.bluetooth.ScannedDeviceInfo +import kotlinx.coroutines.launch + +/** + * Composable function for the Bluetooth screen. + * It handles Bluetooth permissions, enabling Bluetooth, scanning for devices, + * displaying scanned devices, and saving a preferred scale. + * + * @param sharedViewModel The [SharedViewModel] for showing snackbars and accessing shared app functionalities. + * @param bluetoothViewModel The [BluetoothViewModel] for managing Bluetooth state and operations. + */ +@Composable +fun BluetoothScreen( + sharedViewModel: SharedViewModel, + bluetoothViewModel: BluetoothViewModel +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + + val scannedDevices by bluetoothViewModel.scannedDevices.collectAsState() + val isScanning by bluetoothViewModel.isScanning.collectAsState() + val scanError by bluetoothViewModel.scanError.collectAsState() + val connectionError by bluetoothViewModel.connectionError.collectAsState() + val hasPermissions by bluetoothViewModel.permissionsGranted.collectAsState() + + val savedDeviceAddress by bluetoothViewModel.savedScaleAddress.collectAsState() + val savedDeviceName by bluetoothViewModel.savedScaleName.collectAsState() + + val permissionsLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions() + ) { permissionsMap -> + bluetoothViewModel.refreshPermissionsStatus() + val allGranted = permissionsMap.values.all { it } + if (allGranted) { + if (!bluetoothViewModel.isBluetoothEnabled()) { + scope.launch { + sharedViewModel.showSnackbar( + message = context.getString(R.string.bluetooth_enable_for_scan), + duration = SnackbarDuration.Short + ) + } + } + } else { + scope.launch { + sharedViewModel.showSnackbar( + message = context.getString(R.string.bluetooth_permissions_required_for_scan), + duration = SnackbarDuration.Long + ) + } + } + } + + val enableBluetoothLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + bluetoothViewModel.refreshPermissionsStatus() + if (result.resultCode == Activity.RESULT_OK) { + if (!bluetoothViewModel.permissionsGranted.value) { + scope.launch { + sharedViewModel.showSnackbar( + message = context.getString(R.string.bluetooth_enabled_permissions_missing), + duration = SnackbarDuration.Short + ) + } + } + } else { + scope.launch { + sharedViewModel.showSnackbar( + message = context.getString(R.string.bluetooth_must_be_enabled_for_scan), + duration = SnackbarDuration.Short + ) + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val currentBluetoothEnabledStatus = bluetoothViewModel.isBluetoothEnabled() + + // Status and action area (Scan button or info cards) + if (!hasPermissions) { + PermissionRequestCard(onGrantPermissions = { + permissionsLauncher.launch( + arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) + ) + }) + } else if (!currentBluetoothEnabledStatus) { + EnableBluetoothCard(onEnableBluetooth = { + val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) + enableBluetoothLauncher.launch(enableBtIntent) + }) + } else { + // DISPLAY SAVED SCALE (always visible if one is saved) + savedDeviceAddress?.let { address -> + Spacer(modifier = Modifier.height(16.dp)) + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.saved_scale_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = savedDeviceName ?: stringResource(R.string.unknown_device), + style = MaterialTheme.typography.titleSmall + ) + Text( + text = address, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + + // Scan button + Button( + onClick = { + if (isScanning) { + bluetoothViewModel.requestStopDeviceScan() + } else { + bluetoothViewModel.clearAllErrors() // Clear previous errors before starting a new scan + bluetoothViewModel.requestStartDeviceScan() + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + if (isScanning) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.stop_scan_button)) + } else { + Icon(Icons.Default.Search, contentDescription = stringResource(R.string.search_for_scales_button_desc)) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.search_for_scales_button)) + } + } + } + + // Error display + if (hasPermissions && currentBluetoothEnabledStatus) { + val errorToShow = connectionError ?: scanError + errorToShow?.let { errorMsg -> + ErrorCard(errorMsg = errorMsg) + } + } + + // Device list + if (hasPermissions && currentBluetoothEnabledStatus && scanError == null) { + if (scannedDevices.isNotEmpty()) { + Text( + text = stringResource(R.string.found_devices_label), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .padding(top = 16.dp, bottom = 8.dp) + .align(Alignment.Start) + ) + LazyColumn( + modifier = Modifier.weight(1f), // Takes up the remaining space + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(scannedDevices, key = { it.address }) { device -> + DeviceCardItem( + deviceInfo = device, + isCurrentlySaved = device.address == savedDeviceAddress, + onClick = { + bluetoothViewModel.requestStopDeviceScan() // Stop scan before any action + if (device.isSupported) { + // Save device as preferred scale + // (implicitly overwrites any previously saved scale) + bluetoothViewModel.saveDeviceAsPreferred(device) + scope.launch { + sharedViewModel.showSnackbar( + context.getString(R.string.device_saved_as_preferred, device.name ?: context.getString(R.string.unknown_device)), + duration = SnackbarDuration.Short + ) + } + // NO automatic connection attempt anymore + } else { // Device is not supported + scope.launch { + sharedViewModel.showSnackbar( + context.getString(R.string.device_not_supported, device.name ?: context.getString(R.string.unknown_device)), + duration = SnackbarDuration.Short + ) + } + } + } + ) + } + } + } else if (!isScanning) { // Only show empty state if not currently scanning and no devices found + EmptyState( + icon = Icons.AutoMirrored.Filled.BluetoothSearching, + message = stringResource(R.string.no_devices_found_start_scan) + ) + } + } + } +} + +/** + * Composable that displays a card requesting Bluetooth permissions. + * + * @param onGrantPermissions Callback invoked when the user clicks the button to grant permissions. + */ +@Composable +fun PermissionRequestCard(onGrantPermissions: () -> Unit) { + OutlinedCard( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.ErrorOutline, + contentDescription = stringResource(R.string.permissions_required_icon_desc), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(40.dp) + ) + Text(stringResource(R.string.permissions_required_title), style = MaterialTheme.typography.titleMedium) + Text( + stringResource(R.string.permissions_required_message_bluetooth), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + Button(onClick = onGrantPermissions) { + Text(stringResource(R.string.grant_permissions_button)) + } + } + } +} + +/** + * Composable that displays a card prompting the user to enable Bluetooth. + * + * @param onEnableBluetooth Callback invoked when the user clicks the button to enable Bluetooth. + */ +@Composable +fun EnableBluetoothCard(onEnableBluetooth: () -> Unit) { + OutlinedCard( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.BluetoothDisabled, + contentDescription = stringResource(R.string.bluetooth_disabled_icon_desc), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(40.dp) + ) + Text(stringResource(R.string.bluetooth_disabled_title), style = MaterialTheme.typography.titleMedium) + Text( + stringResource(R.string.bluetooth_disabled_message_enable_for_scan), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + Button(onClick = onEnableBluetooth) { + Text(stringResource(R.string.enable_bluetooth_button)) + } + } + } +} + +/** + * Composable that displays an error message in a card. + * + * @param errorMsg The error message to display. + */ +@Composable +fun ErrorCard(errorMsg: String) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) // Consistent padding + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.Warning, + contentDescription = stringResource(R.string.error_icon_desc), // Generic error description + tint = MaterialTheme.colorScheme.error + ) + Spacer(Modifier.width(8.dp)) + Text( + errorMsg, // Error messages from ViewModel are usually already localized or technical + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyMedium + ) + } + } +} + +/** + * Composable that displays an empty state message with an icon. + * Typically used when a list is empty. + * + * @param icon The [ImageVector] to display. + * @param message The message to display below the icon. + */ +@Composable +fun EmptyState(icon: ImageVector, message: String) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, // Decorative icon + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(16.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** + * Composable that displays a card for a scanned Bluetooth device. + * + * @param deviceInfo The [ScannedDeviceInfo] containing details about the device. + * @param isCurrentlySaved Boolean indicating if this device is the currently saved preferred scale. + * @param onClick Callback invoked when the card is clicked. + */ +@Composable +fun DeviceCardItem( + deviceInfo: ScannedDeviceInfo, + isCurrentlySaved: Boolean, + onClick: () -> Unit +) { + val context = LocalContext.current + val supportColor = if (deviceInfo.isSupported) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + val unknownDeviceName = stringResource(R.string.unknown_device_placeholder) + + ElevatedCard( + onClick = onClick, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = deviceInfo.name ?: unknownDeviceName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + if (isCurrentlySaved) { + Spacer(Modifier.width(8.dp)) + Icon( + imageVector = Icons.Filled.Star, + contentDescription = stringResource(R.string.saved_scale_icon_desc), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + } + Text( + text = deviceInfo.address, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 2.dp)) { + Icon( + imageVector = if (deviceInfo.isSupported) Icons.Filled.CheckCircle else Icons.Filled.HighlightOff, + contentDescription = if (deviceInfo.isSupported) stringResource(R.string.supported_icon_desc) else stringResource(R.string.not_supported_icon_desc), + tint = supportColor, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + text = if (deviceInfo.isSupported) { + deviceInfo.determinedHandlerDisplayName ?: stringResource(R.string.supported_label) + } else { + stringResource(R.string.not_supported_label) + }, + style = MaterialTheme.typography.labelMedium, + color = supportColor, + fontWeight = FontWeight.Normal + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + Text( // RSSI value is technical, typically not translated directly but its unit could be. + text = stringResource(R.string.rssi_format, deviceInfo.rssi), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/DataManagementSettingsScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/DataManagementSettingsScreen.kt new file mode 100644 index 00000000..501f338a --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/DataManagementSettingsScreen.kt @@ -0,0 +1,659 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.settings + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.FileUpload +import androidx.compose.material.icons.filled.WarningAmber +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.health.openscale.R +import com.health.openscale.core.data.User + + +/** + * Represents items in the data management settings list. + * Can be an action item or a header. + */ +sealed class DataManagementSettingListItem { + /** + * Represents an actionable item in the settings list. + * @param label The text label for the item. + * @param icon The icon for the item. + * @param onClick The lambda to execute when the item is clicked. + * @param enabled Whether the item is clickable and interactive. + * @param isDestructive If true, indicates a potentially dangerous action, often styled differently (e.g., with error colors). + * @param isLoading If true, shows a loading indicator instead of the icon, and the item might be disabled. + */ + data class ActionItem( + val label: String, + val icon: ImageVector, + val onClick: () -> Unit, + val enabled: Boolean = true, + val isDestructive: Boolean = false, + val isLoading: Boolean = false + ) : DataManagementSettingListItem() +} + +/** + * Represents a header item in a list, used for section titles. + * @param title The text of the header. + */ +data class HeaderItem(val title: String) : DataManagementSettingListItem() // While not used in the provided snippet, it's good practice to document all parts of a sealed class if they exist. + +/** + * Composable screen for managing application data, including import/export of measurements, + * database backup/restore, and deletion of user data or the entire database. + * + * @param navController The NavController for navigation purposes (currently not used in this specific screen's internal logic but good for context). + * @param settingsViewModel The ViewModel that handles the business logic for data management operations. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DataManagementSettingsScreen( + navController: NavController, // Not directly used in this composable's logic but passed for potential future use or consistency + settingsViewModel: SettingsViewModel +) { + val users by settingsViewModel.allUsers.collectAsState() + val showUserSelectionDialogForExport by settingsViewModel.showUserSelectionDialogForExport.collectAsState() + val showUserSelectionDialogForImport by settingsViewModel.showUserSelectionDialogForImport.collectAsState() + + val isLoadingExport by settingsViewModel.isLoadingExport.collectAsState() + val isLoadingImport by settingsViewModel.isLoadingImport.collectAsState() + val isLoadingDeletion by settingsViewModel.isLoadingDeletion.collectAsState() + val isLoadingBackup by settingsViewModel.isLoadingBackup.collectAsState() + val isLoadingRestore by settingsViewModel.isLoadingRestore.collectAsState() + val isLoadingEntireDatabaseDeletion by settingsViewModel.isLoadingEntireDatabaseDeletion.collectAsState() + val showDeleteEntireDatabaseConfirmationDialog by settingsViewModel.showDeleteEntireDatabaseConfirmationDialog.collectAsState() + + val isAnyOperationLoading = isLoadingExport || isLoadingImport || isLoadingDeletion || + isLoadingBackup || isLoadingRestore || isLoadingEntireDatabaseDeletion + + // States for the deletion process + val showUserSelectionDialogForDelete by settingsViewModel.showUserSelectionDialogForDelete.collectAsState() + val userPendingDeletion by settingsViewModel.userPendingDeletion.collectAsState() + val showDeleteConfirmationDialog by settingsViewModel.showDeleteConfirmationDialog.collectAsState() + var showRestoreConfirmationDialog by rememberSaveable { mutableStateOf(false) } + + val context = LocalContext.current + var activeSafActionUserId by remember { mutableStateOf(null) } // Stores user ID for SAF actions like CSV export/import + var activeSafActionId by remember { mutableStateOf(null) } // Stores action ID for distinguishing SAF operations + + // --- ActivityResultLauncher for CSV Export --- + val exportCsvLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("text/csv"), + onResult = { uri: Uri? -> + uri?.let { fileUri -> + activeSafActionUserId?.let { userId -> + settingsViewModel.performCsvExport(userId, fileUri, context.contentResolver) + activeSafActionUserId = null // Reset after use + activeSafActionId = null + } + } + } + ) + + // --- ActivityResultLauncher for CSV Import --- + val importCsvLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + onResult = { uri: Uri? -> + uri?.let { fileUri -> + activeSafActionUserId?.let { userId -> + settingsViewModel.performCsvImport(userId, fileUri, context.contentResolver) + activeSafActionUserId = null // Reset after use + activeSafActionId = null + } + } + } + ) + + // --- ActivityResultLauncher for DB Backup --- + val backupDbLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("*/*"), // Allow any file type as we suggest the name + onResult = { uri: Uri? -> + uri?.let { fileUri -> + // activeSafActionUserId is not relevant here as it's a global backup. + settingsViewModel.performDatabaseBackup(fileUri, context.applicationContext, context.contentResolver) + activeSafActionId = null // Reset + } + } + ) + + // --- ActivityResultLauncher for DB Restore --- + val restoreDbLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + onResult = { uri: Uri? -> + uri?.let { fileUri -> + // activeSafActionUserId is not relevant here. + settingsViewModel.performDatabaseRestore(fileUri, context.applicationContext, context.contentResolver) + activeSafActionId = null // Reset + } + } + ) + + // Collect SAF events from ViewModel to trigger file pickers + LaunchedEffect(key1 = settingsViewModel) { + settingsViewModel.safEvent.collect { event -> + when (event) { + is SafEvent.RequestCreateFile -> { + activeSafActionUserId = event.userId // Retain for CSV export if applicable + activeSafActionId = event.actionId + if (event.actionId == SettingsViewModel.ACTION_ID_BACKUP_DB) { + backupDbLauncher.launch(event.suggestedName) + } else { // Assumption: other CreateFile is CSV export + exportCsvLauncher.launch(event.suggestedName) + } + } + is SafEvent.RequestOpenFile -> { + activeSafActionUserId = event.userId // Retain for CSV import if applicable + activeSafActionId = event.actionId + if (event.actionId == SettingsViewModel.ACTION_ID_RESTORE_DB) { + // For DB Restore, we might expect specific MIME types, + // e.g., "application/octet-stream" or "application/x-sqlite3" for .db, + // or "application/zip" if using ZIPs. + // Using a general type for now: + restoreDbLauncher.launch(arrayOf("*/*")) + } else { // Assumption: other OpenFile is CSV import + val mimeTypes = arrayOf( + "text/csv", + "text/comma-separated-values", + "application/csv", + "text/plain" + ) + importCsvLauncher.launch(mimeTypes) + } + } + } + } + } + + val regularDataManagementItems = buildList { + add( + DataManagementSettingListItem.ActionItem( + label = stringResource(R.string.settings_export_measurements_csv), + icon = Icons.Default.FileDownload, + onClick = { + if (!isAnyOperationLoading) settingsViewModel.startExportProcess() + }, + enabled = users.isNotEmpty() && !isAnyOperationLoading, + isLoading = isLoadingExport + ) + ) + add( + DataManagementSettingListItem.ActionItem( + label = stringResource(R.string.settings_import_measurements_csv), + icon = Icons.Default.FileUpload, + onClick = { + if (!isAnyOperationLoading) settingsViewModel.startImportProcess() + }, + enabled = users.isNotEmpty() && !isAnyOperationLoading, + isLoading = isLoadingImport + ) + ) + add( + DataManagementSettingListItem.ActionItem( + label = stringResource(R.string.settings_backup_database), + icon = Icons.Default.CloudDownload, + onClick = { + if (!isAnyOperationLoading) settingsViewModel.startDatabaseBackup() + }, + enabled = !isAnyOperationLoading, // Always enabled if no other operation is loading + isLoading = isLoadingBackup + ) + ) + add( + DataManagementSettingListItem.ActionItem( + label = stringResource(R.string.settings_restore_database), + icon = Icons.Filled.CloudUpload, + onClick = { + if (!isAnyOperationLoading) showRestoreConfirmationDialog = true + }, + enabled = !isAnyOperationLoading, // Always enabled if no other operation is loading + isLoading = isLoadingRestore + ) + ) + } + + val destructiveDataManagementItems = buildList { + add( + DataManagementSettingListItem.ActionItem( + label = stringResource(R.string.settings_delete_all_measurement_data), + icon = Icons.Default.DeleteForever, + onClick = { + if (!isAnyOperationLoading) settingsViewModel.initiateDeleteAllUserDataProcess() + }, + enabled = users.isNotEmpty() && !isAnyOperationLoading, // Disable if no users or other operation loading + isDestructive = true, + isLoading = isLoadingDeletion + ) + ) + + add( + DataManagementSettingListItem.ActionItem( + label = stringResource(R.string.settings_delete_entire_database), + icon = Icons.Default.WarningAmber, // Or another appropriate icon + onClick = { + if (!isAnyOperationLoading) settingsViewModel.initiateDeleteEntireDatabaseProcess() + }, + enabled = !isAnyOperationLoading, // Always enable if no other operation is loading + isDestructive = true, + isLoading = isLoadingEntireDatabaseDeletion + ) + ) + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + // Regular Actions + items(regularDataManagementItems.size) { index -> + val item = regularDataManagementItems[index] as DataManagementSettingListItem.ActionItem // Safe cast + SettingsCardItem( + label = item.label, + icon = item.icon, + onClick = item.onClick, + enabled = item.enabled, + isDestructive = item.isDestructive, // Will be false here + isLoading = item.isLoading + ) + } + + if (destructiveDataManagementItems.isNotEmpty()) { + item { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.settings_danger_zone), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(bottom = 8.dp) + ) + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + items(destructiveDataManagementItems.size) { index -> + val item = destructiveDataManagementItems[index] as DataManagementSettingListItem.ActionItem // Safe cast + SettingsCardItem( + label = item.label, + icon = item.icon, + onClick = item.onClick, + enabled = item.enabled, + isDestructive = item.isDestructive, + isLoading = item.isLoading // Pass isLoading to the item + ) + } + } + } + + // UserSelectionDialog for Export + if (showUserSelectionDialogForExport) { + UserSelectionDialog( + users = users, + onUserSelected = { userId -> settingsViewModel.proceedWithExportForUser(userId) }, + onDismiss = { if (!isLoadingExport) settingsViewModel.cancelUserSelectionForExport() }, + title = stringResource(R.string.dialog_title_export_select_user), + confirmButtonEnabled = !isLoadingExport, + itemClickEnabled = !isLoadingExport + ) + } + + // UserSelectionDialog for Import + if (showUserSelectionDialogForImport) { + UserSelectionDialog( + users = users, + onUserSelected = { userId -> settingsViewModel.proceedWithImportForUser(userId) }, + onDismiss = { if (!isLoadingImport) settingsViewModel.cancelUserSelectionForImport() }, + title = stringResource(R.string.dialog_title_import_select_user), + confirmButtonEnabled = !isLoadingImport, + itemClickEnabled = !isLoadingImport + ) + } + + // UserSelectionDialog for Delete User Data + if (showUserSelectionDialogForDelete) { + UserSelectionDialog( + users = users, + onUserSelected = { userId -> settingsViewModel.proceedWithDeleteForUser(userId) }, + onDismiss = { if (!isLoadingDeletion) settingsViewModel.cancelUserSelectionForDelete() }, + title = stringResource(R.string.dialog_title_delete_select_user), + confirmButtonEnabled = !isLoadingDeletion, + itemClickEnabled = !isLoadingDeletion + ) + } + + // Confirmation dialog for deleting a specific user's data (shown AFTER a user is selected) + if (showDeleteConfirmationDialog) { + userPendingDeletion?.let { userToDelete -> // Use the user stored in the ViewModel + AlertDialog( + onDismissRequest = { if (!isLoadingDeletion) settingsViewModel.cancelDeleteConfirmation() }, + title = { Text(stringResource(R.string.dialog_title_delete_user_data_confirmation), fontWeight = FontWeight.Bold) }, + text = { + Text( + stringResource(R.string.dialog_message_delete_user_data_confirmation, userToDelete.name), + color = MaterialTheme.colorScheme.error + ) + }, + confirmButton = { + TextButton( + onClick = { + settingsViewModel.confirmActualDeletion() + }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + enabled = !isLoadingDeletion + ) { + if (isLoadingDeletion) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.error) + } else { + Text(stringResource(R.string.button_yes_delete_all)) + } + } + }, + dismissButton = { + TextButton( + onClick = { settingsViewModel.cancelDeleteConfirmation() }, + enabled = !isLoadingDeletion + ) { + Text(stringResource(R.string.cancel_button)) + } + } + ) + } + } + + // Confirmation dialog for deleting the entire database + if (showDeleteEntireDatabaseConfirmationDialog) { + AlertDialog( + onDismissRequest = { + if (!isLoadingEntireDatabaseDeletion) { // Only allow closing if not currently deleting + settingsViewModel.cancelDeleteEntireDatabaseConfirmation() + } + }, + icon = { Icon(Icons.Filled.WarningAmber, contentDescription = stringResource(R.string.content_desc_warning_icon), tint = MaterialTheme.colorScheme.error) }, + title = { + Text(stringResource(R.string.dialog_title_delete_entire_database_confirmation), fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error) + }, + text = { + Text(stringResource(R.string.dialog_message_delete_entire_database_confirmation)) + }, + confirmButton = { + TextButton( + onClick = { + settingsViewModel.confirmDeleteEntireDatabase(context.applicationContext) + }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + enabled = !isLoadingEntireDatabaseDeletion + ) { + if (isLoadingEntireDatabaseDeletion) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.error) + } else { + Text(stringResource(R.string.button_yes_delete_all)) + } + } + }, + dismissButton = { + TextButton( + onClick = { settingsViewModel.cancelDeleteEntireDatabaseConfirmation() }, + enabled = !isLoadingEntireDatabaseDeletion + ) { + Text(stringResource(R.string.cancel_button)) + } + } + ) + } + + // Confirmation dialog for restoring the database + if (showRestoreConfirmationDialog) { + AlertDialog( + onDismissRequest = { + if (!isLoadingRestore) showRestoreConfirmationDialog = false // Only dismiss if not loading + }, + icon = { Icon(Icons.Filled.CloudUpload, contentDescription = stringResource(R.string.content_desc_restore_icon)) }, + title = { + Text(stringResource(R.string.dialog_title_restore_database_confirmation), fontWeight = FontWeight.Bold) + }, + text = { + Text(stringResource(R.string.dialog_message_restore_database_confirmation)) + }, + confirmButton = { + TextButton( + onClick = { + showRestoreConfirmationDialog = false + settingsViewModel.startDatabaseRestore() // This will trigger the SAF event + }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), // Destructive action + enabled = !isLoadingRestore + ) { + if (isLoadingRestore) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.error) + } else { + Text(stringResource(R.string.button_yes_restore)) + } + } + }, + dismissButton = { + TextButton( + onClick = { + showRestoreConfirmationDialog = false + }, + enabled = !isLoadingRestore + ) { + Text(stringResource(R.string.cancel_button)) + } + } + ) + } +} + +/** + * Composable item for displaying a setting in a card layout. + * It includes a label, an icon (or a loading indicator), and handles click actions. + * + * @param label The text label for the setting. + * @param icon The icon to display for the setting. + * @param onClick The lambda to execute when the item is clicked. + * @param enabled Whether the item is clickable and interactive. Defaults to true. + * @param isDestructive If true, indicates a potentially dangerous action, styled with error colors. Defaults to false. + * @param isLoading If true, shows a loading indicator instead of the icon and disables clicks. Defaults to false. + */ +@Composable +fun SettingsCardItem( + label: String, + icon: ImageVector, + onClick: () -> Unit, + enabled: Boolean = true, + isDestructive: Boolean = false, + isLoading: Boolean = false +) { + // Clickability is determined by both 'enabled' and not 'isLoading' + val currentClickable = enabled && !isLoading + + val baseTextColor = if (isDestructive) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface // Or onBackground / onSurfaceVariant as per your theme + } + + val baseIconColor = if (isDestructive) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary // Or onSurfaceVariant etc. depending on design + } + + // Text color adjusted for enabled state (ignoring isLoading for visual disabled state) + val textColor = if (!enabled) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + } else { + baseTextColor + } + + // Icon color adjusted for enabled state + val iconColor = if (!enabled) { + baseIconColor.copy(alpha = 0.38f) // Use the base color (primary or error) and reduce alpha + } else { + baseIconColor + } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .clickable(enabled = currentClickable, onClick = onClick) // Clickability controlled here + ) { + ListItem( + headlineContent = { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, // Consider titleSmall or bodyLarge based on importance + color = textColor + ) + }, + leadingContent = { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(24.dp)) { // Box for consistent icon/loader size + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), // Slightly smaller than the box for padding + strokeWidth = 2.dp, + color = if (isDestructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + ) + } else { + Icon( + imageVector = icon, + contentDescription = label, // Basic content description + tint = iconColor + ) + } + } + } + // No trailing content in this design, but can be added if needed. + ) + } +} + +/** + * Composable dialog for selecting a user from a list. + * + * @param users The list of [User] objects to display for selection. + * @param onUserSelected Lambda called with the selected user's ID. + * @param onDismiss Lambda called when the dialog is dismissed (e.g., by clicking the cancel button or outside the dialog). + * @param title The title of the dialog. + * @param confirmButtonEnabled Controls the enabled state of the dismiss ("Cancel") button. Defaults to true. + * @param itemClickEnabled Controls whether the user list items are clickable. Defaults to true. + */ +@Composable +fun UserSelectionDialog( + users: List, + onUserSelected: (userId: Int) -> Unit, + onDismiss: () -> Unit, + title: String, + confirmButtonEnabled: Boolean = true, + itemClickEnabled: Boolean = true +) { + if (users.isEmpty()) { + // If the dialog is shown with no users, dismiss it immediately. + // It's better to prevent opening the dialog if users list is empty (logic in ViewModel). + LaunchedEffect(Unit) { // Ensure onDismiss is called within a composition + onDismiss() + } + return + } + + AlertDialog( + onDismissRequest = { if (confirmButtonEnabled) onDismiss() }, // Allow dismiss only if not blocked + title = { Text(text = title, style = MaterialTheme.typography.titleLarge) }, // Or headlineSmall + text = { + LazyColumn { + items(users.size) { index -> + val user = users[index] + val textColor = if (itemClickEnabled) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + Text( + text = user.name, + style = MaterialTheme.typography.bodyLarge, // Or subtitle1 + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = itemClickEnabled) { // Control item clickability + onUserSelected(user.id) + } + .padding(vertical = 12.dp), + color = textColor + ) + if (index < users.size - 1) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) // Added vertical padding + } + } + } + }, + confirmButton = { // In this dialog, the AlertDialog's "confirmButton" acts as our "Cancel" button. + TextButton( + onClick = onDismiss, + enabled = confirmButtonEnabled // Control enabled state of the "Cancel" button + ) { + Text(stringResource(R.string.cancel_button)) + } + } + // No dismissButton is explicitly defined here as the confirmButton serves as "Cancel". + // Tapping outside or back press is handled by onDismissRequest. + ) +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/GeneralSettingsScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/GeneralSettingsScreen.kt new file mode 100644 index 00000000..6160af4a --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/GeneralSettingsScreen.kt @@ -0,0 +1,304 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.settings + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Language +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.health.openscale.R +import com.health.openscale.core.data.SupportedLanguage +import com.health.openscale.core.utils.LogManager +import com.health.openscale.ui.screen.SharedViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GeneralSettingsScreen( + navController: NavController, + sharedViewModel: SharedViewModel, + settingsViewModel: SettingsViewModel +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val currentAppDisplayLocale = Locale.getDefault() + + // Get supported languages (enum instances) + val supportedLanguagesEnumEntries = remember { + SupportedLanguage.entries + } + + val currentLanguageCode by settingsViewModel.appLanguageCode.collectAsState() + var expandedLanguageMenu by remember { mutableStateOf(false) } + + val selectedLanguage: SupportedLanguage = remember(currentLanguageCode, supportedLanguagesEnumEntries) { + val defaultSystemLangCode = SupportedLanguage.getDefault().code + supportedLanguagesEnumEntries.find { it.code == currentLanguageCode } + ?: supportedLanguagesEnumEntries.firstOrNull { it.code == settingsViewModel.getDefaultAppLanguage() } + ?: supportedLanguagesEnumEntries.firstOrNull { it.code == defaultSystemLangCode } + ?: SupportedLanguage.getDefault() + } + + val isFileLoggingEnabled by sharedViewModel.userSettingRepository.isFileLoggingEnabled.collectAsState( + initial = false + ) + var showLoggingActivationDialog by remember { mutableStateOf(false) } + + val createFileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + result.data?.data?.let { uri -> + val logFileToCopy = LogManager.getLogFile() + if (logFileToCopy != null && logFileToCopy.exists()) { + scope.launch(Dispatchers.IO) { + try { + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + logFileToCopy.inputStream().use { inputStream -> + inputStream.copyTo(outputStream) + } + } + scope.launch { + sharedViewModel.showSnackbar(context.getString(R.string.log_export_success)) + } + } catch (e: Exception) { + LogManager.e("GeneralSettingsScreen", "Error exporting log file", e) + scope.launch { + sharedViewModel.showSnackbar(context.getString(R.string.log_export_error)) + } + } + } + } else { + scope.launch { + sharedViewModel.showSnackbar(context.getString(R.string.log_export_no_file)) + } + } + } + } else { + scope.launch { + sharedViewModel.showSnackbar(context.getString(R.string.log_export_cancelled)) + } + } + } + + if (showLoggingActivationDialog) { + AlertDialog( + onDismissRequest = { showLoggingActivationDialog = false }, + title = { Text(text = stringResource(R.string.enable_file_logging_dialog_title)) }, + text = { Text(stringResource(R.string.enable_file_logging_dialog_message)) }, + confirmButton = { + TextButton( + onClick = { + scope.launch { + sharedViewModel.userSettingRepository.setFileLoggingEnabled(true) + LogManager.updateLoggingPreference(true) + sharedViewModel.showSnackbar( + context.getString(R.string.file_logging_enabled_snackbar) + ) + } + showLoggingActivationDialog = false + } + ) { Text(stringResource(R.string.enable_button)) } + }, + dismissButton = { + TextButton( + onClick = { showLoggingActivationDialog = false } + ) { Text(stringResource(R.string.cancel_button)) } + } + ) + } + + LaunchedEffect(Unit) { + sharedViewModel.setTopBarTitle(context.getString(R.string.settings_item_general)) + } + + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) { + // --- Language Settings Section --- + ExposedDropdownMenuBox( + expanded = expandedLanguageMenu, + onExpandedChange = { expandedLanguageMenu = !expandedLanguageMenu }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = selectedLanguage.nativeDisplayName, + onValueChange = {}, // Read-only + readOnly = true, + label = { Text(stringResource(id = R.string.settings_language_label)) }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Language, + contentDescription = stringResource(id = R.string.settings_language_label) + ) + }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedLanguageMenu) + }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + + ExposedDropdownMenu( + expanded = expandedLanguageMenu, + onDismissRequest = { expandedLanguageMenu = false }, + modifier = Modifier.fillMaxWidth() + ) { + SupportedLanguage.entries.forEach { langEnumEntry -> + DropdownMenuItem( + text = { + Text(langEnumEntry.nativeDisplayName) + }, + onClick = { + if (settingsViewModel.appLanguageCode.value != langEnumEntry.code) { + settingsViewModel.setAppLanguage(langEnumEntry.code) + } + expandedLanguageMenu = false + }, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + + // --- Diagnostics Sub-Section --- + Text( + text = stringResource(R.string.diagnostics_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier + .padding(top = 24.dp, bottom = 8.dp) + ) + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Icon( + imageVector = Icons.Filled.Description, + contentDescription = stringResource(R.string.file_logging_icon_content_description), + modifier = Modifier.padding(end = 16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.file_logging_label), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + Switch( + checked = isFileLoggingEnabled, + onCheckedChange = { wantsToEnable -> + if (wantsToEnable) { + showLoggingActivationDialog = true + } else { + scope.launch { + sharedViewModel.userSettingRepository.setFileLoggingEnabled(false) + LogManager.updateLoggingPreference(false) + sharedViewModel.showSnackbar( + context.getString(R.string.file_logging_disabled_snackbar) + ) + } + } + } + ) + } + + if (isFileLoggingEnabled) { + OutlinedButton( + onClick = { + val logFile = LogManager.getLogFile() + if (logFile != null && logFile.exists()) { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "text/plain" + putExtra(Intent.EXTRA_TITLE, logFile.name) + } + try { + createFileLauncher.launch(intent) + } catch (e: ActivityNotFoundException) { + scope.launch { + sharedViewModel.showSnackbar(context.getString(R.string.log_export_no_app_error)) + } + LogManager.e( + "GeneralSettingsScreen", + "Error launching create document intent for export", + e + ) + } + } else { + scope.launch { + sharedViewModel.showSnackbar(context.getString(R.string.log_export_no_file_to_export)) + } + } + }, + modifier = Modifier + .fillMaxWidth() + ) { + Text(stringResource(R.string.export_log_file_button)) + } + } + } +} + diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeDetailScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeDetailScreen.kt new file mode 100644 index 00000000..9d5bab97 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeDetailScreen.kt @@ -0,0 +1,386 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.settings + +import android.widget.Toast +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.QuestionMark +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.health.openscale.R +import com.health.openscale.core.data.InputFieldType +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.UnitType +import com.health.openscale.ui.screen.SharedViewModel +import com.health.openscale.ui.screen.dialog.ColorPickerDialog +import com.health.openscale.ui.screen.dialog.IconPickerDialog +import com.health.openscale.ui.screen.dialog.getIconResIdByName + +/** + * Composable screen for creating or editing a [MeasurementType]. + * It allows users to define the name, unit, input type, color, icon, + * and enabled/pinned status for a measurement type. + * + * @param navController NavController for navigating back after saving or cancelling. + * @param typeId The ID of the [MeasurementType] to edit. If -1, a new type is being created. + * @param sharedViewModel The [SharedViewModel] for accessing shared app state like existing measurement types and setting top bar properties. + * @param settingsViewModel The [SettingsViewModel] for performing add or update operations on measurement types. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MeasurementTypeDetailScreen( + navController: NavController, + typeId: Int, + sharedViewModel: SharedViewModel, + settingsViewModel: SettingsViewModel, +) { + val context = LocalContext.current + + val measurementTypes by sharedViewModel.measurementTypes.collectAsState() + val existingType = remember(measurementTypes, typeId) { + measurementTypes.find { it.id == typeId } + } + val isEdit = typeId != -1 + + var name by remember { mutableStateOf(existingType?.getDisplayName(context).orEmpty()) } + var selectedUnit by remember { mutableStateOf(existingType?.unit ?: UnitType.NONE) } + var selectedInputType by remember { mutableStateOf(existingType?.inputType ?: InputFieldType.FLOAT) } + var selectedColor by remember { mutableStateOf(existingType?.color ?: 0xFF6200EE.toInt()) } // Default color + var selectedIcon by remember { mutableStateOf(existingType?.icon ?: "ic_weight") } // Default icon + var isEnabled by remember { mutableStateOf(existingType?.isEnabled ?: true) } // Default to true for new types + var isPinned by remember { mutableStateOf(existingType?.isPinned ?: false) } // Default to false for new types + + var expandedUnit by remember { mutableStateOf(false) } + var expandedInputType by remember { mutableStateOf(false) } + var showColorPicker by remember { mutableStateOf(false) } + var showIconPicker by remember { mutableStateOf(false) } + + val titleEdit = stringResource(R.string.measurement_type_detail_title_edit) + val titleAdd = stringResource(R.string.measurement_type_detail_title_add) + + LaunchedEffect(Unit) { + sharedViewModel.setTopBarTitle( + if (isEdit) titleEdit + else titleAdd + ) + sharedViewModel.setTopBarAction( + SharedViewModel.TopBarAction(icon = Icons.Default.Save, onClick = { + if (name.isNotBlank()) { + val updatedType = MeasurementType( + id = existingType?.id ?: 0, // Use 0 for new types, Room will autogenerate + name = name, + icon = selectedIcon, + color = selectedColor, + unit = selectedUnit, + inputType = selectedInputType, + displayOrder = existingType?.displayOrder ?: measurementTypes.size, + isEnabled = isEnabled, + isPinned = isPinned, + key = existingType?.key ?: MeasurementTypeKey.CUSTOM, // New types are custom + isDerived = existingType?.isDerived ?: false // New types are not derived by default + ) + + if (isEdit) { + settingsViewModel.updateMeasurementType(updatedType) + } else { + settingsViewModel.addMeasurementType(updatedType) + } + navController.popBackStack() + } else { + Toast.makeText(context, R.string.toast_enter_valid_data, Toast.LENGTH_SHORT).show() + } + }) + ) + } + + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedSettingRow(label = stringResource(R.string.measurement_type_label_enabled)) { + Switch( + checked = isEnabled, + onCheckedChange = { isEnabled = it } + ) + } + + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(R.string.measurement_type_label_name)) }, + modifier = Modifier.fillMaxWidth() + ) + + // Color Selector + OutlinedTextField( + value = String.format("#%06X", 0xFFFFFF and selectedColor), // Display color hex string + onValueChange = {}, // Read-only + label = { Text(stringResource(R.string.measurement_type_label_color)) }, + modifier = Modifier + .fillMaxWidth() + .clickable { showColorPicker = true }, + readOnly = true, + enabled = false, // To make it look like a display field that's clickable + trailingIcon = { + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(Color(selectedColor)) + .border(1.dp, Color.Gray, CircleShape) // Visually indicate the color + ) + }, + colors = TextFieldDefaults.colors( // Custom colors to make it look enabled despite being readOnly + disabledTextColor = LocalContentColor.current, + disabledIndicatorColor = MaterialTheme.colorScheme.outline, // Standard outline + disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, // Standard label color + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledContainerColor = Color.Transparent // No background fill + ) + ) + + // Icon Selector + OutlinedTextField( + value = selectedIcon, // Display selected icon name + onValueChange = {}, // Read-only + label = { Text(stringResource(R.string.measurement_type_label_icon)) }, + modifier = Modifier + .fillMaxWidth() + .clickable { showIconPicker = true }, + readOnly = true, + enabled = false, // To make it look like a display field + trailingIcon = { + Icon( + painter = runCatching { + painterResource(id = getIconResIdByName(selectedIcon)) + }.getOrElse { + // Fallback icon if resource name is invalid or not found + Icons.Filled.QuestionMark + } as Painter, // Cast is safe due to getOrElse structure + contentDescription = stringResource(R.string.content_desc_selected_icon_preview), + modifier = Modifier.size(24.dp) + ) + }, + colors = TextFieldDefaults.colors( // Custom colors for consistent look + disabledTextColor = LocalContentColor.current, + disabledIndicatorColor = MaterialTheme.colorScheme.outline, + disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledContainerColor = Color.Transparent + ) + ) + + // UnitType Dropdown + ExposedDropdownMenuBox( + expanded = expandedUnit, + onExpandedChange = { expandedUnit = !expandedUnit } + ) { + OutlinedTextField( + readOnly = true, + value = selectedUnit.name.lowercase().replaceFirstChar { it.uppercase() }, // Format for display + onValueChange = {}, + label = { Text(stringResource(R.string.measurement_type_label_unit)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedUnit) + }, + modifier = Modifier + .menuAnchor( // Required for ExposedDropdownMenu + type = MenuAnchorType.PrimaryNotEditable, + enabled = true + ) + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expandedUnit, + onDismissRequest = { expandedUnit = false } + ) { + UnitType.values().forEach { unit -> + DropdownMenuItem( + text = { Text(unit.name.lowercase().replaceFirstChar { it.uppercase() }) }, + onClick = { + selectedUnit = unit + expandedUnit = false + } + ) + } + } + } + + // InputFieldType Dropdown + ExposedDropdownMenuBox( + expanded = expandedInputType, + onExpandedChange = { expandedInputType = !expandedInputType } + ) { + OutlinedTextField( + readOnly = true, + value = selectedInputType.name.lowercase().replaceFirstChar { it.uppercase() }, // Format for display + onValueChange = {}, + label = { Text(stringResource(R.string.measurement_type_label_input_type)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedInputType) + }, + modifier = Modifier + .menuAnchor( // Required for ExposedDropdownMenu + type = MenuAnchorType.PrimaryNotEditable, + enabled = true + ) + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expandedInputType, + onDismissRequest = { expandedInputType = false } + ) { + InputFieldType.values().forEach { type -> + DropdownMenuItem( + text = { Text(type.name.lowercase().replaceFirstChar { it.uppercase() }) }, + onClick = { + selectedInputType = type + expandedInputType = false + } + ) + } + } + } + + OutlinedSettingRow(label = stringResource(R.string.measurement_type_label_pinned)) { + Switch( + checked = isPinned, + onCheckedChange = { isPinned = it } + ) + } + } + + // Color Picker Dialog + if (showColorPicker) { + ColorPickerDialog( + currentColor = Color(selectedColor), + onColorSelected = { + selectedColor = it.toArgb() + // showColorPicker = false // Keep picker open until explicitly dismissed by user + }, + onDismiss = { showColorPicker = false } + ) + } + + // Icon Picker Dialog + if (showIconPicker) { + IconPickerDialog( + currentIcon = selectedIcon, + onIconSelected = { + selectedIcon = it + showIconPicker = false // Close picker after selection + }, + onDismiss = { showIconPicker = false } + ) + } +} + +/** + * A private composable function that creates a row styled like an [OutlinedTextField] + * but designed to hold a label and a custom control (e.g., a [Switch]). + * + * @param label The text to display as the label for this setting row. + * @param modifier Modifier for this composable. + * @param controlContent A composable lambda that defines the control to be placed on the right side of the row. + */ +@Composable +private fun OutlinedSettingRow( + label: String, + modifier: Modifier = Modifier, + controlContent: @Composable () -> Unit +) { + Surface( // Surface for the border and background, mimicking OutlinedTextField + modifier = modifier + .fillMaxWidth() + .heightIn(min = OutlinedTextFieldDefaults.MinHeight), // Minimum height similar to OutlinedTextField + shape = OutlinedTextFieldDefaults.shape, // Shape similar to OutlinedTextField + color = MaterialTheme.colorScheme.surface, // Background color (can be customized) + border = BorderStroke( // Border + width = 1.dp, // OutlinedTextFieldDefaults.UnfocusedBorderThickness is internal, so using 1.dp + color = MaterialTheme.colorScheme.outline // Border color similar to OutlinedTextField + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( // Internal padding similar to OutlinedTextField + start = 16.dp, // Similar to OutlinedTextFieldTokens.InputLeadingPadding + end = 16.dp, // Similar to OutlinedTextFieldTokens.InputTrailingPadding + top = 8.dp, // Less top padding as the label is centered vertically + bottom = 8.dp + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween // Pushes label to start, control to end + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, // Style for the "label" + color = MaterialTheme.colorScheme.onSurfaceVariant // Color of the "label" + ) + controlContent() // The Switch or other control is placed here + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeSettingsScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeSettingsScreen.kt new file mode 100644 index 00000000..aaaf095d --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeSettingsScreen.kt @@ -0,0 +1,212 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.health.openscale.R +import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.ui.screen.SharedViewModel +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState + +/** + * Composable screen for managing and reordering measurement types. + * It displays a list of available measurement types, allowing users to + * edit, delete (custom types), and change their display order via drag-and-drop. + * + * @param sharedViewModel The [SharedViewModel] for accessing shared app state, like measurement types and setting top bar properties. + * @param settingsViewModel The [SettingsViewModel] for performing update or delete operations on measurement types. + * @param onEditType Callback invoked when the user taps the edit icon for a type or the add icon in the top bar. + * Passes the ID of the type to edit, or null to add a new type. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MeasurementTypeSettingsScreen( + sharedViewModel: SharedViewModel, + settingsViewModel: SettingsViewModel, + onEditType: (Int?) -> Unit +) { + val measurementTypes by sharedViewModel.measurementTypes.collectAsState() + // Remember and sort the list based on displayOrder. This list is used by the reorderable component. + var list by remember(measurementTypes) { + mutableStateOf(measurementTypes.sortedBy { it.displayOrder }) + } + + val lazyListState = rememberLazyListState() + // rememberReorderableLazyListState enables drag-and-drop reordering + val reorderableState = rememberReorderableLazyListState( + lazyListState = lazyListState, + onMove = { from, to -> + // Update the local list state when an item is moved + list = list.toMutableList().apply { + add(to.index, removeAt(from.index)) + }.also { updatedList -> + // Persist the new display order for each type in the updated list + updatedList.forEachIndexed { index, type -> + settingsViewModel.updateMeasurementType(type.copy(displayOrder = index)) + } + } + } + ) + + // Retrieve string for the top bar title in the Composable context + val screenTitle = stringResource(R.string.measurement_type_settings_title) + val dragHandleContentDesc = stringResource(R.string.content_desc_drag_handle_sort) + val editContentDesc = stringResource(R.string.content_desc_edit_type) + val deleteContentDesc = stringResource(R.string.content_desc_delete_type) + + LaunchedEffect(Unit) { + sharedViewModel.setTopBarTitle(screenTitle) + sharedViewModel.setTopBarAction( + SharedViewModel.TopBarAction(icon = Icons.Default.Add, onClick = { + onEditType(null) // Request to add a new type + }) + ) + } + + LazyColumn( + state = lazyListState, + modifier = Modifier + .padding(16.dp) + .fillMaxSize() + ) { + itemsIndexed(list, key = { _, item -> item.id }) { _, type -> + ReorderableItem(reorderableState, key = type.id) { isDragging -> + // Apply visual effects based on whether the type is enabled + val itemAlpha = if (type.isEnabled) 1f else 0.6f + val textColor = if (type.isEnabled) LocalContentColor.current + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + val iconBackgroundAlpha = if (type.isEnabled) 1f else 0.7f + val iconTintAlpha = if (type.isEnabled) 1f else 0.7f + + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .graphicsLayer(alpha = itemAlpha) // Apply transparency for disabled items + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Display colored circle with icon + Box( + modifier = Modifier + .size(48.dp) + .background( + Color(type.color).copy(alpha = iconBackgroundAlpha), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + val context = LocalContext.current + // Remember the icon resource ID to avoid repeated lookups + val iconId = remember(type.icon) { + context.resources.getIdentifier(type.icon, "drawable", context.packageName) + } + if (iconId != 0) { // Check if icon resource was found + Icon( + painter = painterResource(id = iconId), + contentDescription = null, // Decorative icon + tint = Color.Black.copy(alpha = iconTintAlpha), // Consider a more theme-aware tint + modifier = Modifier.size(32.dp) + ) + } + } + Spacer(Modifier.size(16.dp)) + + // Display measurement type name + Text( + text = type.getDisplayName(LocalContext.current), + modifier = Modifier.weight(1f), + color = textColor + ) + + // Drag handle for reordering + IconButton( + modifier = Modifier.draggableHandle(), // Provided by the reorderable library + onClick = {} // onClick is typically handled by the reorderable mechanism + ) { + Icon( + Icons.Default.DragHandle, + contentDescription = dragHandleContentDesc + ) + } + // Edit button + IconButton(onClick = { onEditType(type.id) }) { + Icon( + Icons.Default.Edit, + contentDescription = editContentDesc + ) + } + // Delete button, only for custom types + if (type.key == MeasurementTypeKey.CUSTOM) { + IconButton(onClick = { settingsViewModel.deleteMeasurementType(type) }) { + Icon( + Icons.Default.Delete, + contentDescription = deleteContentDesc + ) + } + } + } + } + } + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsScreen.kt new file mode 100644 index 00000000..39a737d5 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsScreen.kt @@ -0,0 +1,160 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.health.openscale.R +import com.health.openscale.ui.navigation.Routes +import com.health.openscale.ui.screen.SharedViewModel + +/** + * Represents an item in the main settings screen. + * + * @param label The text label displayed for the settings item. + * @param icon The [ImageVector] to be displayed as an icon for the item. + * @param route The navigation route associated with this settings item. + * @param contentDescription Optional content description for the icon, for accessibility. + */ +data class SettingsItem( + val label: String, + val icon: ImageVector, + val route: String, + val contentDescription: String? = null +) + +/** + * Composable function for the main settings screen. + * It displays a list of settings categories that the user can navigate to. + * + * @param navController The [NavController] used for navigating to different settings screens. + * @param sharedViewModel The [SharedViewModel] used to update shared UI elements like the top bar title. + * @param settingsViewModel The [SettingsViewModel], passed for consistency but not directly used in this screen's primary logic. + */ +@Composable +fun SettingsScreen( + navController: NavController, + sharedViewModel: SharedViewModel, + settingsViewModel: SettingsViewModel +) { + // Define strings for titles and content descriptions in the Composable context + val generalSettingsLabel = stringResource(R.string.settings_item_general) + val userSettingsLabel = stringResource(R.string.settings_item_user) + val measurementTypesLabel = stringResource(R.string.settings_item_measurement_types) + val bluetoothLabel = stringResource(R.string.settings_item_bluetooth) + val dataManagementLabel = stringResource(R.string.settings_item_data_management) + val aboutLabel = stringResource(R.string.settings_item_about) + + val items = listOf( + SettingsItem( + label = generalSettingsLabel, + icon = Icons.Default.Tune, + route = Routes.GENERAL_SETTINGS, + contentDescription = generalSettingsLabel + ), + SettingsItem( + label = userSettingsLabel, + icon = Icons.Default.Person, + route = Routes.USER_SETTINGS, + contentDescription = userSettingsLabel + ), + SettingsItem( + label = measurementTypesLabel, + icon = Icons.Default.Edit, + route = Routes.MEASUREMENT_TYPES, + contentDescription = measurementTypesLabel + ), + SettingsItem( + label = bluetoothLabel, + icon = Icons.Filled.Bluetooth, + route = Routes.BLUETOOTH_SETTINGS, + contentDescription = bluetoothLabel + ), + SettingsItem( + label = dataManagementLabel, + icon = Icons.Filled.Storage, + route = Routes.DATA_MANAGEMENT_SETTINGS, + contentDescription = dataManagementLabel + ), + SettingsItem( + label = aboutLabel, + icon = Icons.Default.Info, + route = Routes.ABOUT_SETTINGS, + contentDescription = aboutLabel + ) + ) + + val settingsScreenTitle = stringResource(R.string.route_title_settings) + LaunchedEffect(Unit) { + sharedViewModel.setTopBarTitle(settingsScreenTitle) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(all = 8.dp) // Add some overall padding to the column + ) { + items.forEach { item -> + Card( + modifier = Modifier + .fillMaxWidth() // Make card take full width + .padding(vertical = 8.dp) // Consistent vertical padding + .clickable { + navController.navigate(item.route) + } + ) { + ListItem( + headlineContent = { + Text( + text = item.label, + style = MaterialTheme.typography.titleMedium + ) + }, + leadingContent = { + Icon( + imageVector = item.icon, + contentDescription = item.contentDescription ?: item.label // Fallback CD + ) + } + ) + } + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt new file mode 100644 index 00000000..6886f348 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt @@ -0,0 +1,1098 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.settings + +import android.content.ContentResolver +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.doyaaaaaken.kotlincsv.dsl.csvReader +import com.github.doyaaaaaken.kotlincsv.dsl.csvWriter +import com.health.openscale.R +import com.health.openscale.core.data.InputFieldType +import com.health.openscale.core.data.Measurement +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.MeasurementValue +import com.health.openscale.core.data.User +import com.health.openscale.core.model.MeasurementWithValues +import com.health.openscale.core.utils.LogManager +import com.health.openscale.ui.screen.SharedViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.text.SimpleDateFormat +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeFormatterBuilder +import java.time.format.DateTimeParseException +import java.time.temporal.ChronoField +import java.util.Date +import java.util.Locale +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +/** + * Sealed class representing events related to Storage Access Framework (SAF) operations. + */ +sealed class SafEvent { + data class RequestCreateFile(val suggestedName: String, val actionId: String, val userId: Int) : SafEvent() + data class RequestOpenFile(val actionId: String, val userId: Int) : SafEvent() +} + +/** + * Sealed class for UI messages to be emitted to the UI layer. + * This allows sending either a direct string (rarely, for dynamic error messages not suitable for resources) + * or a resource ID with optional formatting arguments. + */ +sealed class UiMessageEvent { + data class Resource(val resId: Int, val formatArgs: List = emptyList()) : UiMessageEvent() + // data class Plain(val message: String) : UiMessageEvent() // If you ever need to send raw strings +} + +/** + * ViewModel for settings-related screens. + */ +class SettingsViewModel( + private val sharedViewModel: SharedViewModel +) : ViewModel() { + + private val repository = sharedViewModel.databaseRepository + private val userSettingsRepository = sharedViewModel.userSettingRepository + + private val _appLanguageCode = MutableStateFlow(getDefaultAppLanguage()) + val appLanguageCode: StateFlow = _appLanguageCode.asStateFlow() + + private val _uiMessageEvents = MutableSharedFlow() + val uiMessageEvents: SharedFlow = _uiMessageEvents.asSharedFlow() + + val allUsers: StateFlow> = sharedViewModel.allUsers + + private val _showUserSelectionDialogForExport = MutableStateFlow(false) + val showUserSelectionDialogForExport: StateFlow = _showUserSelectionDialogForExport.asStateFlow() + + private val _showUserSelectionDialogForImport = MutableStateFlow(false) + val showUserSelectionDialogForImport: StateFlow = _showUserSelectionDialogForImport.asStateFlow() + + private val _showUserSelectionDialogForDelete = MutableStateFlow(false) + val showUserSelectionDialogForDelete: StateFlow = _showUserSelectionDialogForDelete.asStateFlow() + + private val _userPendingDeletion = MutableStateFlow(null) + val userPendingDeletion: StateFlow = _userPendingDeletion.asStateFlow() + + private val _showDeleteConfirmationDialog = MutableStateFlow(false) + val showDeleteConfirmationDialog: StateFlow = _showDeleteConfirmationDialog.asStateFlow() + + private val _showDeleteEntireDatabaseConfirmationDialog = MutableStateFlow(false) + val showDeleteEntireDatabaseConfirmationDialog: StateFlow = _showDeleteEntireDatabaseConfirmationDialog.asStateFlow() + + private val _isLoadingExport = MutableStateFlow(false) + val isLoadingExport: StateFlow = _isLoadingExport.asStateFlow() + + private val _isLoadingImport = MutableStateFlow(false) + val isLoadingImport: StateFlow = _isLoadingImport.asStateFlow() + + private val _isLoadingDeletion = MutableStateFlow(false) + val isLoadingDeletion: StateFlow = _isLoadingDeletion.asStateFlow() + + private val _isLoadingBackup = MutableStateFlow(false) + val isLoadingBackup: StateFlow = _isLoadingBackup.asStateFlow() + + private val _isLoadingRestore = MutableStateFlow(false) + val isLoadingRestore: StateFlow = _isLoadingRestore.asStateFlow() + + private val _isLoadingEntireDatabaseDeletion = MutableStateFlow(false) + val isLoadingEntireDatabaseDeletion: StateFlow = _isLoadingEntireDatabaseDeletion.asStateFlow() + + companion object { + private const val TAG = "SettingsViewModel" + const val ACTION_ID_EXPORT_USER_DATA = "export_user_data" + const val ACTION_ID_IMPORT_USER_DATA = "import_user_data" + const val ACTION_ID_BACKUP_DB = "backup_database" + const val ACTION_ID_RESTORE_DB = "restore_database" + } + + private val _safEvent = MutableSharedFlow() + val safEvent = _safEvent.asSharedFlow() + + private var currentActionUserId: Int? = null + + private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE + private val timeFormatter: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_TIME + private val flexibleTimeFormatter: DateTimeFormatter = DateTimeFormatterBuilder() + .appendValue(ChronoField.HOUR_OF_DAY, 2) + .appendLiteral(':') + .appendValue(ChronoField.MINUTE_OF_HOUR, 2) + .optionalStart() + .appendLiteral(':') + .appendValue(ChronoField.SECOND_OF_MINUTE, 2) + .optionalStart() + .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true) + .optionalEnd() + .optionalEnd() + .toFormatter(Locale.ROOT) + + init { + LogManager.d(TAG, "Initializing SettingsViewModel...") + viewModelScope.launch { + userSettingsRepository.appLanguageCode + .map { storedLanguageCode -> + LogManager.d(TAG, "Observed stored language code from repository: $storedLanguageCode") + storedLanguageCode ?: getDefaultAppLanguage() + } + .catch { exception -> + LogManager.e(TAG, "Error collecting app language code from repository", exception) + emit(getDefaultAppLanguage()) + } + .collect { effectiveLanguageCode -> + if (_appLanguageCode.value != effectiveLanguageCode) { + _appLanguageCode.value = effectiveLanguageCode + LogManager.i(TAG, "App language in ViewModel updated to: $effectiveLanguageCode") + } else { + LogManager.d(TAG, "App language in ViewModel is already: $effectiveLanguageCode, no update needed.") + } + } + } + } + + fun setAppLanguage(languageCode: String) { + if (languageCode.isBlank()) { + LogManager.w(TAG, "Attempted to set a blank language code. Ignoring.") + return + } + + viewModelScope.launch { + try { + LogManager.d(TAG, "Attempting to set app language preference to: $languageCode via repository") + userSettingsRepository.setAppLanguageCode(languageCode) + LogManager.i(TAG, "Successfully requested to set app language preference to: $languageCode in repository.") + } catch (e: Exception) { + LogManager.e(TAG, "Failed to set app language preference to: $languageCode via repository", e) + } + } + } + + fun getDefaultAppLanguage(): String { + val supportedAppLanguages = listOf("en", "de", "es", "fr") + val systemLanguage = Locale.getDefault().language + val defaultLang = if (systemLanguage in supportedAppLanguages) { + systemLanguage + } else { + "en" + } + LogManager.d(TAG, "Determined default app language: $defaultLang (System: $systemLanguage)") + return defaultLang + } + + fun performCsvExport(userId: Int, uri: Uri, contentResolver: ContentResolver) { + viewModelScope.launch { + _isLoadingExport.value = true + LogManager.i(TAG, "Starting CSV export for user ID: $userId to URI: $uri") + try { + val allAppTypes: List = repository.getAllMeasurementTypes().first() + val exportableValueTypes = allAppTypes.filter { + it.key != null && it.key != MeasurementTypeKey.DATE && it.key != MeasurementTypeKey.TIME + } + val valueColumnKeys = exportableValueTypes + .mapNotNull { it.key?.name } + .distinct() + + val dateColumnKey = MeasurementTypeKey.DATE.name + val timeColumnKey = MeasurementTypeKey.TIME.name + + val allCsvColumnKeys = mutableListOf(dateColumnKey, timeColumnKey) + allCsvColumnKeys.addAll(valueColumnKeys.sorted()) + + if (valueColumnKeys.isEmpty()) { + LogManager.w(TAG, "No specific data fields (value columns) defined for export for user ID: $userId.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.export_error_no_specific_fields)) + } + + val userMeasurementsWithValues: List = + repository.getMeasurementsWithValuesForUser(userId).first() + + if (userMeasurementsWithValues.isEmpty()) { + LogManager.i(TAG, "No measurements found for User ID $userId to export.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.export_error_no_measurements)) + _isLoadingExport.value = false + return@launch + } + + val csvRowsData = mutableListOf>() + // ... (CSV data preparation logic as before) ... + userMeasurementsWithValues.forEach { measurementData -> + val mainTimestamp = measurementData.measurement.timestamp + val valuesMap = mutableMapOf() + val instant = Instant.ofEpochMilli(mainTimestamp) + val zonedDateTime = instant.atZone(ZoneId.systemDefault()) + + valuesMap[dateColumnKey] = dateFormatter.format(zonedDateTime) + valuesMap[timeColumnKey] = timeFormatter.format(zonedDateTime) + + measurementData.values.forEach { mvWithType -> + val typeEntity = mvWithType.type + val valueEntity = mvWithType.value + if (typeEntity.key != null && + typeEntity.key != MeasurementTypeKey.DATE && + typeEntity.key != MeasurementTypeKey.TIME && + valueColumnKeys.contains(typeEntity.key.name) + ) { + val valueAsString: String? = when (typeEntity.inputType) { + InputFieldType.TEXT -> valueEntity.textValue + InputFieldType.FLOAT -> valueEntity.floatValue?.toString() + InputFieldType.INT -> valueEntity.intValue?.toString() + InputFieldType.DATE -> valueEntity.dateValue?.let { + dateFormatter.format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) + } + InputFieldType.TIME -> valueEntity.dateValue?.let { + timeFormatter.format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) + } + } + typeEntity.key.name.let { keyName -> valuesMap[keyName] = valueAsString } + } + } + csvRowsData.add(valuesMap) + } + + + if (csvRowsData.isEmpty()) { + LogManager.w(TAG, "No exportable measurement values found for User ID $userId after transformation.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.export_error_no_exportable_values)) + _isLoadingExport.value = false + return@launch + } + + withContext(Dispatchers.IO) { + var exportSuccessful = false + contentResolver.openOutputStream(uri)?.use { outputStream -> + csvWriter().open(outputStream) { + writeRow(allCsvColumnKeys) + csvRowsData.forEach { rowMap -> + val dataRow = allCsvColumnKeys.map { key -> rowMap[key] } + writeRow(dataRow) + } + } + exportSuccessful = true + LogManager.d(TAG, "CSV data written successfully for User ID $userId to URI: $uri.") + } ?: run { + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.export_error_cannot_create_file)) + LogManager.e(TAG, "Export failed for user ID $userId: Could not open OutputStream for Uri: $uri") + } + + if (exportSuccessful) { + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.export_successful)) + } + } + } catch (e: Exception) { + LogManager.e(TAG, "Error during CSV export for User ID $userId to URI: $uri", e) + val errorMessage = e.localizedMessage ?: "Unknown error" // In a real app, use R.string.settings_unknown_error + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.export_error_generic, listOf(errorMessage))) + } finally { + _isLoadingExport.value = false + LogManager.i(TAG, "CSV export process finished for user ID: $userId.") + } + } + } + + fun performCsvImport(userId: Int, uri: Uri, contentResolver: ContentResolver) { + viewModelScope.launch { + _isLoadingImport.value = true + LogManager.i(TAG, "Starting CSV import for user ID: $userId from URI: $uri") + var linesSkippedMissingDate = 0 + var linesSkippedDateParseError = 0 + var valuesSkippedParseError = 0 + var importedMeasurementsCount = 0 + + try { + // ... (Rest of the import logic including CSV parsing as before) ... + val allAppTypes: List = repository.getAllMeasurementTypes().first() + val typeMapByKeyName = allAppTypes.filter { it.key != null }.associateBy { it.key!!.name } + + val dateColumnKey = MeasurementTypeKey.DATE.name + val timeColumnKey = MeasurementTypeKey.TIME.name + + val newMeasurementsToSave = mutableListOf>>() + + withContext(Dispatchers.IO) { + contentResolver.openInputStream(uri)?.use { inputStream -> + csvReader { + skipEmptyLine = true + quoteChar = '"' + }.open(inputStream) { + var header: List? = null + var dateColumnIndex = -1 + var timeColumnIndex = -1 + val valueColumnMap = mutableMapOf() + + readAllAsSequence().forEachIndexed { rowIndex, row -> + if (rowIndex == 0) { // Header row + header = row + dateColumnIndex = header?.indexOf(dateColumnKey) + ?: throw IOException("CSV header is missing the mandatory column '$dateColumnKey'.") + // ... (rest of header processing) + timeColumnIndex = header?.indexOf(timeColumnKey) ?: -1 + header?.forEachIndexed { colIdx, columnName -> + if (columnName != dateColumnKey && columnName != timeColumnKey) { + typeMapByKeyName[columnName]?.let { type -> + valueColumnMap[colIdx] = type + } ?: LogManager.w(TAG, "CSV import for user $userId: Column '$columnName' in CSV not found in known measurement types. It will be ignored.") + } + } + if (valueColumnMap.isEmpty() && header?.any { it != dateColumnKey && it != timeColumnKey } == true) { + LogManager.w(TAG, "CSV import for user $userId: No measurement value columns in CSV could be mapped to known types.") + } + return@forEachIndexed // Continue to next row + } + + if (header == null) throw IOException("CSV header not found or processed.") // Should not happen + + val dateString = row.getOrNull(dateColumnIndex) + if (dateString.isNullOrBlank()) { + LogManager.w(TAG, "CSV import for user $userId: Row ${rowIndex + 1} skipped: Date value is missing in mandatory column '$dateColumnKey'.") + linesSkippedMissingDate++ + return@forEachIndexed + } + // ... (rest of row processing, date/time parsing, value extraction) + val localDate = try { + LocalDate.parse(dateString, dateFormatter) + } catch (e: DateTimeParseException) { + LogManager.w(TAG, "CSV import for user $userId: Error parsing date '$dateString' (expected YYYY-MM-DD) in row ${rowIndex + 1}. Skipping row.", e) + linesSkippedDateParseError++ + return@forEachIndexed + } + + val timeString = if (timeColumnIndex != -1) row.getOrNull(timeColumnIndex) else null + val localTime: LocalTime = if (timeString.isNullOrBlank()) { + LocalTime.NOON // Default if time is missing or blank + } else { + try { LocalTime.parse(timeString, timeFormatter) } + catch (e1: DateTimeParseException) { + try { LocalTime.parse(timeString, flexibleTimeFormatter) } + catch (e2: DateTimeParseException) { + LogManager.w(TAG, "CSV import for user $userId: Time '$timeString' in row ${rowIndex + 1} could not be parsed. Using default.", e2) + LocalTime.NOON + } + } + } + + val localDateTime = LocalDateTime.of(localDate, localTime) + val timestampMillis = localDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + val measurement = Measurement(userId = userId, timestamp = timestampMillis) + val measurementValues = mutableListOf() + + valueColumnMap.forEach { (colIdx, type) -> + val valueString = row.getOrNull(colIdx) + if (!valueString.isNullOrBlank()) { + try { + val mv = MeasurementValue( + typeId = type.id, + measurementId = 0, // Will be set by Room + textValue = if (type.inputType == InputFieldType.TEXT) valueString else null, + floatValue = if (type.inputType == InputFieldType.FLOAT) valueString.toFloatOrNull() else null, + intValue = if (type.inputType == InputFieldType.INT) valueString.toIntOrNull() else null, + dateValue = when (type.inputType) { + InputFieldType.DATE -> LocalDate.parse(valueString, dateFormatter).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() + InputFieldType.TIME -> { + val parsedTime = try { LocalTime.parse(valueString, timeFormatter) } catch (e: Exception) { LocalTime.parse(valueString, flexibleTimeFormatter) } + parsedTime.atDate(LocalDate.of(1970, 1, 1)).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() } + else -> null + } + ) + var isValidValue = true + if (type.inputType == InputFieldType.FLOAT && mv.floatValue == null) isValidValue = false + if (type.inputType == InputFieldType.INT && mv.intValue == null) isValidValue = false + if (isValidValue) { + measurementValues.add(mv) + } else { + LogManager.w(TAG, "CSV import for user $userId: Could not parse value '$valueString' for type '${type.key?.name}' in row ${rowIndex + 1}.") + valuesSkippedParseError++ + } + } catch (e: Exception) { + LogManager.w(TAG, "CSV import for user $userId: Error processing value '$valueString' for type '${type.key?.name}' in row ${rowIndex + 1}.", e) + valuesSkippedParseError++ + } + } + } + if (measurementValues.isNotEmpty()) { + newMeasurementsToSave.add(measurement to measurementValues) + } else if (valueColumnMap.isNotEmpty()){ + LogManager.d(TAG,"CSV import for user $userId: Row ${rowIndex + 1} for $localDateTime resulted in no valid measurement values. Skipping.") + } + } + } + } ?: throw IOException("Could not open InputStream for Uri: $uri") + + if (newMeasurementsToSave.isNotEmpty()) { + repository.insertMeasurementsWithValues(newMeasurementsToSave) + importedMeasurementsCount = newMeasurementsToSave.size + LogManager.i(TAG, "CSV Import for User ID $userId successful. $importedMeasurementsCount measurements imported.") + + // Constructing the detailed message for UI + // This part is tricky if you want one single formatted string from resources. + // Often, it's better to send a base success message and log details, + // or have multiple UiMessageEvents if details are crucial for UI. + // Here's an attempt to build arguments for a potentially complex string resource: + val messageArgs = mutableListOf(importedMeasurementsCount) + var detailsForMessage = "" + if (linesSkippedMissingDate > 0) { + // This assumes you have a string like: "%1$d records. %2$d skipped (date), %3$d skipped (parse), %4$d values skipped." + // Or you emit separate messages. + // For simplicity, let's assume a main message and details are appended if they exist. + // This would require a more complex string resource or multiple resources. + // R.string.import_successful_details might take multiple args + detailsForMessage += " ($linesSkippedMissingDate rows skipped due to missing dates" + } + if (linesSkippedDateParseError > 0) { + detailsForMessage += if(detailsForMessage.contains("(")) ", " else " (" + detailsForMessage += "$linesSkippedDateParseError rows skipped due to date parsing errors" + } + if (valuesSkippedParseError > 0) { + detailsForMessage += if(detailsForMessage.contains("(")) ", " else " (" + detailsForMessage += "$valuesSkippedParseError values skipped" + } + if (detailsForMessage.isNotEmpty()) detailsForMessage += ")" + + + if (detailsForMessage.isNotEmpty()) { + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.import_successful_records_with_details, listOf(importedMeasurementsCount, detailsForMessage))) + } else { + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.import_successful_records, listOf(importedMeasurementsCount))) + } + + } else { + LogManager.w(TAG, "No valid data found in CSV for User ID $userId or all rows had errors.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.import_error_no_valid_data)) + } + } + } catch (e: Exception) { + LogManager.e(TAG, "Error during CSV import for User ID $userId from URI: $uri", e) + val userErrorMessage = when { + e is IOException && e.message?.contains("CSV header is missing the mandatory column 'date'") == true -> + // Assuming R.string.import_error_missing_date_column takes dateColumnKey as an argument + UiMessageEvent.Resource(R.string.import_error_missing_date_column) + e is IOException && e.message?.contains("Could not open InputStream") == true -> + UiMessageEvent.Resource(R.string.import_error_cannot_read_file) + else -> { + val errorMsg = e.localizedMessage ?: "Unknown error" // Use R.string.settings_unknown_error + UiMessageEvent.Resource(R.string.import_error_generic, listOf(errorMsg)) + } + } + _uiMessageEvents.emit(userErrorMessage) + } finally { + _isLoadingImport.value = false + LogManager.i(TAG, "CSV import process finished for user ID: $userId. Imported: $importedMeasurementsCount, Skipped (missing date): $linesSkippedMissingDate, Skipped (date parse error): $linesSkippedDateParseError, Values skipped (parse error): $valuesSkippedParseError.") + } + } + } + + fun startExportProcess() { + viewModelScope.launch { + if (allUsers.value.isEmpty()) { + LogManager.i(TAG, "Export process start: No users available for export.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.export_no_users_available)) + return@launch + } + if (allUsers.value.size == 1) { + val userId = allUsers.value.first().id + LogManager.d(TAG, "Export process start: Single user (ID: $userId) found, proceeding directly.") + initiateActualExport(userId) + } else { + currentActionUserId = null + _showUserSelectionDialogForExport.value = true + LogManager.d(TAG, "Export process start: Multiple users found, showing user selection dialog.") + } + } + } + + fun proceedWithExportForUser(userId: Int) { + _showUserSelectionDialogForExport.value = false + LogManager.i(TAG, "Proceeding with export for selected user ID: $userId.") + initiateActualExport(userId) + } + + fun cancelUserSelectionForExport() { + _showUserSelectionDialogForExport.value = false + currentActionUserId = null + LogManager.d(TAG, "User selection for export cancelled.") + } + + private fun initiateActualExport(userId: Int) { + currentActionUserId = userId + viewModelScope.launch { + val user = allUsers.value.find { it.id == userId } + val userNamePart = user?.name?.replace("\\s+".toRegex(), "_")?.take(20) ?: "user$userId" + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val suggestedName = "openScale_export_${userNamePart}_${timeStamp}.csv" + _safEvent.emit(SafEvent.RequestCreateFile(suggestedName, ACTION_ID_EXPORT_USER_DATA, userId)) + LogManager.i(TAG, "Initiating actual export for user ID: $userId. Suggested file name: $suggestedName. SAF event emitted.") + } + } + + fun startImportProcess() { + viewModelScope.launch { + if (allUsers.value.isEmpty()) { + LogManager.i(TAG, "Import process start: No users available for import.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.import_no_users_available)) + return@launch + } + if (allUsers.value.size == 1) { + val userId = allUsers.value.first().id + LogManager.d(TAG, "Import process start: Single user (ID: $userId) found, proceeding directly.") + initiateActualImport(userId) + } else { + currentActionUserId = null + _showUserSelectionDialogForImport.value = true + LogManager.d(TAG, "Import process start: Multiple users found, showing user selection dialog.") + } + } + } + + fun proceedWithImportForUser(userId: Int) { + _showUserSelectionDialogForImport.value = false + LogManager.i(TAG, "Proceeding with import for selected user ID: $userId.") + initiateActualImport(userId) + } + + fun cancelUserSelectionForImport() { + _showUserSelectionDialogForImport.value = false + currentActionUserId = null + LogManager.d(TAG, "User selection for import cancelled.") + } + + private fun initiateActualImport(userId: Int) { + currentActionUserId = userId + viewModelScope.launch { + _safEvent.emit(SafEvent.RequestOpenFile(ACTION_ID_IMPORT_USER_DATA, userId)) + LogManager.i(TAG, "Initiating actual import for user ID: $userId. SAF event emitted.") + } + } + + fun initiateDeleteAllUserDataProcess() { + viewModelScope.launch { + val userList = allUsers.value + if (userList.size > 1) { + LogManager.d(TAG, "Initiate delete user data: Multiple users found, showing selection dialog.") + _showUserSelectionDialogForDelete.value = true + } else if (userList.isNotEmpty()) { + val userToDelete = userList.first() + LogManager.d(TAG, "Initiate delete user data: Single user (ID: ${userToDelete.id}, Name: ${userToDelete.name}) found, proceeding to confirmation.") + _userPendingDeletion.value = userToDelete + _showDeleteConfirmationDialog.value = true + } else { + LogManager.i(TAG, "Initiate delete user data: No user data available to delete.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.delete_data_no_users_available)) + } + } + } + + fun proceedWithDeleteForUser(userId: Int) { + viewModelScope.launch { + val user = allUsers.value.find { it.id == userId } + _userPendingDeletion.value = user + _showUserSelectionDialogForDelete.value = false + if (user != null) { + LogManager.i(TAG, "Proceeding with delete confirmation for user ID: ${user.id}, Name: ${user.name}.") + _showDeleteConfirmationDialog.value = true + } else { + LogManager.w(TAG, "Proceed with delete: User ID $userId not found after selection.") + } + } + } + + fun cancelUserSelectionForDelete() { + _showUserSelectionDialogForDelete.value = false + _userPendingDeletion.value = null + LogManager.d(TAG, "User selection for delete cancelled.") + } + + fun confirmActualDeletion() { + _userPendingDeletion.value?.let { userToDelete -> + viewModelScope.launch { + _isLoadingDeletion.value = true + LogManager.i(TAG, "Confirming actual deletion of all data for user ID: ${userToDelete.id}, Name: ${userToDelete.name}.") + try { + val deletedRowCount = repository.deleteAllMeasurementsForUser(userToDelete.id) + if (deletedRowCount > 0) { + LogManager.i(TAG, "Data for User ${userToDelete.name} (ID: ${userToDelete.id}) successfully deleted. $deletedRowCount measurement records removed.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.delete_data_user_successful, listOf(userToDelete.name))) + } else { + LogManager.i(TAG, "No measurement data found to delete for User ${userToDelete.name} (ID: ${userToDelete.id}).") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.delete_data_user_no_data_found, listOf(userToDelete.name))) + } + } catch (e: Exception) { + LogManager.e(TAG, "Error deleting data for User ${userToDelete.name} (ID: ${userToDelete.id})", e) + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.delete_data_user_error, listOf(userToDelete.name))) + } finally { + _isLoadingDeletion.value = false + _showDeleteConfirmationDialog.value = false + _userPendingDeletion.value = null + LogManager.d(TAG, "Actual deletion process finished for user ID: ${userToDelete.id}.") + } + } + } ?: run { + viewModelScope.launch { + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.delete_data_error_no_user_selected)) + _showDeleteConfirmationDialog.value = false + } + LogManager.w(TAG, "confirmActualDeletion called without a user pending deletion.") + } + } + + fun cancelDeleteConfirmation() { + _showDeleteConfirmationDialog.value = false + LogManager.d(TAG, "Actual deletion confirmation cancelled for user: ${_userPendingDeletion.value?.name ?: "N/A"}.") + } + + fun startDatabaseRestore() { + viewModelScope.launch { + _safEvent.emit(SafEvent.RequestOpenFile(ACTION_ID_RESTORE_DB, userId = 0 )) + LogManager.i(TAG, "Database restore process started. SAF event emitted.") + } + } + + fun startDatabaseBackup() { + viewModelScope.launch { + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val dbName = repository.getDatabaseName() ?: "openscale_db" + val suggestedName = "${dbName}_backup_${timeStamp}.zip" + _safEvent.emit(SafEvent.RequestCreateFile(suggestedName, ACTION_ID_BACKUP_DB, userId = 0)) + LogManager.i(TAG, "Database backup process started. Suggested name: $suggestedName. SAF event emitted.") + } + } + + fun performDatabaseBackup(backupUri: Uri, applicationContext: android.content.Context, contentResolver: ContentResolver) { + viewModelScope.launch { + _isLoadingBackup.value = true + LogManager.i(TAG, "Performing database backup to URI: $backupUri") + try { + val dbName = repository.getDatabaseName() ?: run { + LogManager.e(TAG, "Database backup error: Database name could not be retrieved.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.backup_error_db_name_not_retrieved)) + _isLoadingBackup.value = false + return@launch + } + val dbFile = applicationContext.getDatabasePath(dbName) + val dbDir = dbFile.parentFile ?: run { + LogManager.e(TAG, "Database backup error: Database directory could not be determined for $dbName.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.backup_error_db_name_not_retrieved)) // Generic error might be better + _isLoadingBackup.value = false + return@launch + } + + + val filesToBackup = listOfNotNull( + dbFile, + File(dbDir, "$dbName-shm"), + File(dbDir, "$dbName-wal") + ) + + if (!dbFile.exists()) { + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.backup_error_main_db_not_found, listOf(dbName))) + LogManager.e(TAG, "Database backup error: Main DB file ${dbFile.absolutePath} not found.") + _isLoadingBackup.value = false + return@launch + } + LogManager.d(TAG, "Main DB file path for backup: ${dbFile.absolutePath}") + + withContext(Dispatchers.IO) { + var backupSuccessful = false + try { + contentResolver.openOutputStream(backupUri)?.use { outputStream -> + ZipOutputStream(outputStream).use { zipOutputStream -> + filesToBackup.forEach { file -> + if (file.exists() && file.isFile) { + try { + FileInputStream(file).use { fileInputStream -> + val entry = ZipEntry(file.name) + zipOutputStream.putNextEntry(entry) + fileInputStream.copyTo(zipOutputStream) + zipOutputStream.closeEntry() + LogManager.d(TAG, "Added ${file.name} to backup archive.") + } + } catch (e: Exception) { + // Log error for individual file but continue (especially for -shm or -wal) + LogManager.w(TAG, "Could not add ${file.name} to backup archive, continuing. Error: ${e.message}", e) + } + } else { + // Main DB file existence is checked above. This handles missing -shm or -wal. + if (file.name.endsWith("-shm") || file.name.endsWith("-wal")) { + LogManager.i(TAG, "Optional backup file ${file.name} not found, skipping.") + } + } + } + } + } ?: run { + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.backup_error_no_output_stream)) + LogManager.e(TAG, "Backup failed: Could not open OutputStream for Uri: $backupUri") + return@withContext // Exit IO context + } + backupSuccessful = true + } catch (e: IOException) { + LogManager.e(TAG, "IO Error during database backup zip process to URI $backupUri", e) + val errorMsg = e.localizedMessage ?: "Unknown I/O error" + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.backup_error_generic, listOf(errorMsg))) + return@withContext + } + + if (backupSuccessful) { + LogManager.i(TAG, "Database backup to $backupUri successful.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.backup_successful)) + } + } + } catch (e: Exception) { + LogManager.e(TAG, "General error during database backup preparation for URI $backupUri", e) + val errorMsg = e.localizedMessage ?: "Unknown error" + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.backup_error_generic, listOf(errorMsg))) + } finally { + _isLoadingBackup.value = false + LogManager.i(TAG, "Database backup process finished for URI: $backupUri.") + } + } + } + + fun performDatabaseRestore(restoreUri: Uri, applicationContext: android.content.Context, contentResolver: ContentResolver) { + viewModelScope.launch { + _isLoadingRestore.value = true + LogManager.i(TAG, "Performing database restore from URI: $restoreUri") + try { + val dbName = repository.getDatabaseName() ?: run { + LogManager.e(TAG, "Database restore error: Database name could not be retrieved.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.backup_error_db_name_not_retrieved)) // Re-use backup error string + _isLoadingRestore.value = false + return@launch + } + val dbFile = applicationContext.getDatabasePath(dbName) + val dbDir = dbFile.parentFile ?: run { + LogManager.e(TAG, "Database restore error: Database directory could not be determined for $dbName.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.backup_error_db_name_not_retrieved)) + _isLoadingRestore.value = false + return@launch + } + + // Close the database before attempting to overwrite files + LogManager.d(TAG, "Attempting to close database before restore.") + repository.closeDatabase() // Ensure this method exists and correctly closes Room + LogManager.i(TAG, "Database closed for restore operation.") + + withContext(Dispatchers.IO) { + var restoreSuccessful = false + var mainDbRestored = false + try { + contentResolver.openInputStream(restoreUri)?.use { inputStream -> + ZipInputStream(inputStream).use { zipInputStream -> + var entry: ZipEntry? = zipInputStream.nextEntry + while (entry != null) { + val outputFile = File(dbDir, entry.name) + // Basic path traversal protection + if (!outputFile.canonicalPath.startsWith(dbDir.canonicalPath)) { + LogManager.e(TAG, "Skipping restore of entry '${entry.name}' due to path traversal attempt.") + entry = zipInputStream.nextEntry + continue + } + + // Delete existing file before restoring (important for WAL mode) + if (outputFile.exists()) { + if (!outputFile.delete()) { + LogManager.w(TAG, "Could not delete existing file ${outputFile.name} before restore. Restore might fail or be incomplete.") + } + } + + FileOutputStream(outputFile).use { fileOutputStream -> + zipInputStream.copyTo(fileOutputStream) + } + LogManager.d(TAG, "Restored ${entry.name} from backup archive to ${outputFile.absolutePath}.") + if (entry.name == dbName) { + mainDbRestored = true + } + entry = zipInputStream.nextEntry + } + } + } ?: run { + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.restore_error_no_input_stream)) + LogManager.e(TAG, "Restore failed: Could not open InputStream for Uri: $restoreUri") + return@withContext + } + + if (!mainDbRestored) { + LogManager.e(TAG, "Restore failed: Main database file '$dbName' not found in the backup archive.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.restore_error_db_files_missing)) + // Attempt to clean up partially restored files might be needed here, or let the user handle it. + return@withContext + } + restoreSuccessful = true + + } catch (e: IOException) { + LogManager.e(TAG, "IO Error during database restore from URI $restoreUri", e) + val errorMsg = e.localizedMessage ?: "Unknown I/O error" + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.restore_error_generic, listOf(errorMsg))) + return@withContext + } catch (e: IllegalStateException) { // Can be thrown by ZipInputStream + LogManager.e(TAG, "Error processing ZIP file during restore from URI $restoreUri", e) + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.restore_error_zip_format)) + return@withContext + } + + + if (restoreSuccessful) { + LogManager.i(TAG, "Database restore from $restoreUri successful. App restart is required.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.restore_successful)) + // The app needs to be restarted for Room to pick up the new database files correctly. + // This usually involves sharedViewModel.requestAppRestart() or similar mechanism. + } + } + } catch (e: Exception) { + LogManager.e(TAG, "General error during database restore from URI $restoreUri", e) + val errorMsg = e.localizedMessage ?: "Unknown error" + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.restore_error_generic, listOf(errorMsg))) + } finally { + // Re-open the database regardless of success, unless app is restarting + // If an app restart is requested, reopening might not be necessary or could cause issues. + // However, if the restore failed and no restart is pending, the DB should be reopened. + if (!_isLoadingRestore.value) { // Check if not already restarting + try { + LogManager.d(TAG, "Attempting to re-open database after restore attempt.") + // This might require re-initialization of the Room database instance + // if the underlying files were changed. + // For simplicity, we assume the repository handles this. + // A full app restart is generally the safest way after a DB restore. + // TODO repository.reopenDatabase() // Ensure this method exists and correctly re-opens Room + LogManager.i(TAG, "Database re-opened after restore attempt.") + } catch (reopenError: Exception) { + LogManager.e(TAG, "Error re-opening database after restore attempt. App restart is highly recommended.", reopenError) + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.restore_error_generic, listOf("Error re-opening database."))) + } + } + _isLoadingRestore.value = false + LogManager.i(TAG, "Database restore process finished for URI: $restoreUri.") + } + } + } + + fun initiateDeleteEntireDatabaseProcess() { + LogManager.d(TAG, "Initiating delete entire database process. Showing confirmation dialog.") + _showDeleteEntireDatabaseConfirmationDialog.value = true + } + + fun cancelDeleteEntireDatabaseConfirmation() { + _showDeleteEntireDatabaseConfirmationDialog.value = false + LogManager.d(TAG, "Delete entire database confirmation cancelled.") + } + + fun confirmDeleteEntireDatabase(applicationContext: android.content.Context) { + viewModelScope.launch { + _isLoadingEntireDatabaseDeletion.value = true + _showDeleteEntireDatabaseConfirmationDialog.value = false + LogManager.i(TAG, "User confirmed deletion of the entire database.") + + try { + LogManager.d(TAG, "Attempting to close database before deletion.") + repository.closeDatabase() + LogManager.i(TAG, "Database closed for deletion.") + + withContext(Dispatchers.IO) { + val dbName = repository.getDatabaseName() // Get it before it's potentially gone + if (dbName == null) { + LogManager.e(TAG, "Failed to get database name. Cannot ensure complete deletion.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.delete_db_error)) // Generic error + return@withContext + } + val databaseDeleted = applicationContext.deleteDatabase(dbName) + + // Also try to delete -shm and -wal files explicitly, as deleteDatabase might not always get them. + val dbFile = applicationContext.getDatabasePath(dbName) + val dbDir = dbFile.parentFile + var shmDeleted = true + var walDeleted = true + if (dbDir != null && dbDir.exists()) { + val shmFile = File(dbDir, "$dbName-shm") + if (shmFile.exists()) shmDeleted = shmFile.delete() + val walFile = File(dbDir, "$dbName-wal") + if (walFile.exists()) walDeleted = walFile.delete() + } + + if (databaseDeleted) { + LogManager.i(TAG, "Entire database '$dbName' (and associated files: shm=$shmDeleted, wal=$walDeleted) successfully deleted.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.delete_db_successful)) + // App must be restarted as the database is gone. + // TODO sharedViewModel.requestAppRestart() + } else { + LogManager.e(TAG, "Failed to delete the entire database '$dbName'. deleteDatabase returned false.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.delete_db_error)) + } + } + } catch (e: Exception) { + LogManager.e(TAG, "Error during entire database deletion process.", e) + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.delete_db_error)) + } finally { + // No need to reopen DB here as it's supposed to be deleted. + // If deletion failed, the app state is uncertain, restart is still best. + _isLoadingEntireDatabaseDeletion.value = false + LogManager.i(TAG, "Entire database deletion process finished.") + } + } + } + + // --- User-Operationen (wiederhergestellt aus der ursprünglichen Version) --- + /** + * Adds a new user to the database. + * This is a suspend function as it involves a database write operation. + * + * @param user The [User] object to insert. + * @return The ID of the newly inserted user. + */ + suspend fun addUser(user: User): Long { + LogManager.d(TAG, "Adding new user: ${user.name}") + val newUserId = repository.insertUser(user) + LogManager.i(TAG, "User '${user.name}' added with ID: $newUserId") + // Optionally, trigger a refresh of allUsers or let the SharedViewModel handle it + // sharedViewModel.refreshUsers() + return newUserId + } + + /** + * Deletes a user and all their associated data from the database. + * This operation is performed in a background coroutine. + * + * @param user The [User] object to delete. + */ + fun deleteUser(user: User) { + viewModelScope.launch { + LogManager.d(TAG, "Attempting to delete user: ${user.name} (ID: ${user.id})") + try { + // Consider the implications: this will also delete all measurements for the user. + // You might want a confirmation dialog for this action elsewhere in the UI. + repository.deleteUser(user) + LogManager.i(TAG, "User '${user.name}' (ID: ${user.id}) and their data deleted successfully.") + // Optionally, emit a success message or trigger UI refresh + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.user_deleted_successfully, listOf(user.name))) + // sharedViewModel.refreshUsers() // Or handle user list updates through SharedViewModel + } catch (e: Exception) { + LogManager.e(TAG, "Error deleting user '${user.name}' (ID: ${user.id})", e) + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.user_deleted_error, listOf(user.name))) + } + } + } + + /** + * Updates an existing user's information in the database. + * This is a suspend function as it involves a database write operation. + * + * @param user The [User] object with updated information. + */ + suspend fun updateUser(user: User) { + LogManager.d(TAG, "Updating user: ${user.name} (ID: ${user.id})") + try { + repository.updateUser(user) + LogManager.i(TAG, "User '${user.name}' (ID: ${user.id}) updated successfully.") + // Optionally, emit a success message or trigger UI refresh + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.user_updated_successfully, listOf(user.name))) + // sharedViewModel.refreshUsers() + } catch (e: Exception) { + LogManager.e(TAG, "Error updating user '${user.name}' (ID: ${user.id})", e) + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.user_updated_error, listOf(user.name))) + } + } + + // --- MeasurementType-Operationen (wiederhergestellt aus der ursprünglichen Version) --- + /** + * Adds a new measurement type to the database. + * This operation is performed in a background coroutine. + * + * @param type The [MeasurementType] object to insert. + */ + fun addMeasurementType(type: MeasurementType) { + viewModelScope.launch { + LogManager.d(TAG, "Adding new measurement type (Key: ${type.key})") + try { + repository.insertMeasurementType(type) + LogManager.i(TAG, "Measurement type '${type.key}' added successfully.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.measurement_type_added_successfully, listOf(type.key.toString()))) + // Optionally, trigger a refresh of measurement types if displayed + // sharedViewModel.refreshMeasurementTypes() + } catch (e: Exception) { + LogManager.e(TAG, "Error adding measurement type '${type.key}'", e) + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.measurement_type_added_error, listOf(type.key.toString()))) + } + } + } + + /** + * Deletes a measurement type from the database. + * This operation is performed in a background coroutine. + * Consider the implications: associated measurement values might need handling. + * + * @param type The [MeasurementType] object to delete. + */ + fun deleteMeasurementType(type: MeasurementType) { + viewModelScope.launch { + LogManager.d(TAG, "Attempting to delete measurement type (ID: ${type.id})") + try { + // WARNING: Deleting a MeasurementType might orphan MeasurementValue entries + // or require cascading deletes/cleanup logic in the repository or database schema. + // Ensure this is handled correctly based on your app's requirements. + repository.deleteMeasurementType(type) + LogManager.i(TAG, "Measurement type (ID: ${type.id}) deleted successfully.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.measurement_type_deleted_successfully, listOf(type.key.toString()))) + // sharedViewModel.refreshMeasurementTypes() + } catch (e: Exception) { + LogManager.e(TAG, "Error deleting measurement type (ID: ${type.id})", e) + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.measurement_type_deleted_error, listOf(type.key.toString()))) + } + } + } + + /** + * Updates an existing measurement type in the database. + * This operation is performed in a background coroutine. + * + * @param type The [MeasurementType] object with updated information. + */ + fun updateMeasurementType(type: MeasurementType) { + viewModelScope.launch { + LogManager.d(TAG, "Updating measurement type (ID: ${type.id})") + try { + repository.updateMeasurementType(type) + LogManager.i(TAG, "Measurement type (ID: ${type.id}) updated successfully.") + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.measurement_type_updated_successfully, listOf(type.key.toString()))) + // sharedViewModel.refreshMeasurementTypes() + } catch (e: Exception) { + LogManager.e(TAG, "Error updating measurement type (ID: ${type.id})", e) + _uiMessageEvents.emit(UiMessageEvent.Resource(R.string.measurement_type_updated_error, listOf(type.key.toString()))) + } + } + } + +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/UserDetailScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/UserDetailScreen.kt new file mode 100644 index 00000000..9b3483c7 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/UserDetailScreen.kt @@ -0,0 +1,265 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.settings + +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavController +import com.health.openscale.R +import com.health.openscale.core.data.ActivityLevel +import com.health.openscale.core.data.GenderType +import com.health.openscale.core.data.User +import com.health.openscale.ui.screen.SharedViewModel +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Composable screen for adding a new user or editing an existing user's details. + * + * This screen provides input fields for user's name, height, gender, activity level, + * and birth date. It interacts with [SettingsViewModel] to save or update user data + * and with [SharedViewModel] to manage top bar actions and titles. + * + * @param navController The NavController used for navigation, e.g., to go back after saving. + * @param userId The ID of the user to edit. If -1, a new user is being added. + * @param sharedViewModel The ViewModel shared across different screens, used here for top bar configuration and user selection. + * @param settingsViewModel The ViewModel responsible for user data operations like adding or updating users. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UserDetailScreen( + navController: NavController, + userId: Int, + sharedViewModel: SharedViewModel, + settingsViewModel: SettingsViewModel +) { + val isEdit = userId != -1 + + // Retrieve the user from SharedViewModel if editing, or prepare for a new user. + val user by remember(userId) { + mutableStateOf(sharedViewModel.allUsers.value.find { it.id == userId }) + } + + var name by remember { mutableStateOf(user?.name.orEmpty()) } + var birthDate by remember { mutableStateOf(user?.birthDate ?: System.currentTimeMillis()) } + var gender by remember { mutableStateOf(user?.gender ?: GenderType.MALE) } + var height by remember { mutableStateOf(user?.heightCm?.toString().orEmpty()) } + var activityLevel by remember { mutableStateOf(user?.activityLevel ?: ActivityLevel.SEDENTARY) } + + val context = LocalContext.current + // Date formatter for displaying the birth date. Consider device locale. + val dateFormatter = remember { SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()) } + val datePickerState = rememberDatePickerState(initialSelectedDateMillis = birthDate) + var showDatePicker by remember { mutableStateOf(false) } + var activityLevelExpanded by remember { mutableStateOf(false) } + + if (showDatePicker) { + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + datePickerState.selectedDateMillis?.let { + birthDate = it + } + showDatePicker = false + }) { + Text(stringResource(id = R.string.dialog_ok)) + } + }, + dismissButton = { + TextButton(onClick = { showDatePicker = false }) { + Text(stringResource(id = R.string.cancel_button)) + } + } + ) { + DatePicker(state = datePickerState) + } + } + + val editUserTitle = stringResource(R.string.user_detail_edit_user_title) + val addUserTitle = stringResource(R.string.user_detail_add_user_title) + + // Effect to set the top bar title and save action. + // This runs when userId changes or the screen is first composed. + LaunchedEffect(userId) { + sharedViewModel.setTopBarTitle( + if (isEdit) editUserTitle + else addUserTitle + ) + sharedViewModel.setTopBarAction( + SharedViewModel.TopBarAction(icon = Icons.Default.Save, onClick = { + val validHeight = height.toFloatOrNull() + if (name.isNotBlank() && validHeight != null) { + val newUser = User( + id = user?.id ?: 0, // Use existing ID if editing, or 0 for Room to auto-generate + name = name, + birthDate = birthDate, + gender = gender, + heightCm = validHeight, + activityLevel = activityLevel + ) + settingsViewModel.viewModelScope.launch { + if (isEdit) { + settingsViewModel.updateUser(newUser) + } else { + val newUserId = settingsViewModel.addUser(newUser) + if (newUserId > 0) { + // If a new user was added, select them in SharedViewModel + sharedViewModel.selectUser(newUserId.toInt()) + } + } + } + navController.popBackStack() // Navigate back after saving + } else { + Toast.makeText( + context, + context.getString(R.string.user_detail_error_invalid_data), // "Please enter valid data" + Toast.LENGTH_SHORT + ).show() + } + }) + ) + } + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxSize() + .verticalScroll(scrollState), // Make the column scrollable + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(id = R.string.user_detail_label_name)) }, // "Name" + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = height, + onValueChange = { height = it }, + label = { Text(stringResource(id = R.string.user_detail_label_height_cm)) }, // "Height (cm)" + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + + Text(stringResource(id = R.string.user_detail_label_gender)) // "Gender" + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GenderType.values().forEach { option -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { gender = option } + .padding(end = 8.dp) + ) { + RadioButton( + selected = gender == option, + onClick = { gender = option } + ) + // Display gender options with first letter capitalized. + Text(option.name.lowercase().replaceFirstChar { it.uppercaseChar().toString() }) + } + } + } + + Text(stringResource(id = R.string.user_detail_label_activity_level)) // "Activity Level" + ExposedDropdownMenuBox( + expanded = activityLevelExpanded, + onExpandedChange = { activityLevelExpanded = !activityLevelExpanded }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = activityLevel.name.lowercase().replaceFirstChar { it.uppercaseChar().toString() }, + onValueChange = {}, // Input is read-only, selection via dropdown + readOnly = true, + label = { Text(stringResource(id = R.string.user_detail_label_select_level)) }, // "Select Level" + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = activityLevelExpanded) + }, + modifier = Modifier + .menuAnchor() // Anchors the dropdown menu to this text field + .fillMaxWidth() + ) + + ExposedDropdownMenu( + expanded = activityLevelExpanded, + onDismissRequest = { activityLevelExpanded = false } + ) { + ActivityLevel.values().forEach { selectionOption -> + DropdownMenuItem( + text = { Text(selectionOption.name.lowercase().replaceFirstChar { it.uppercaseChar().toString() }) }, + onClick = { + activityLevel = selectionOption + activityLevelExpanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } + + Text(stringResource(id = R.string.user_detail_label_birth_date)) // "Birth Date" + OutlinedTextField( + value = dateFormatter.format(Date(birthDate)), + onValueChange = {}, // Input is read-only, selection via DatePicker + modifier = Modifier + .fillMaxWidth() + .clickable { showDatePicker = true }, // Show DatePicker on click + enabled = false, // Visually indicates it's not directly editable + readOnly = true // Ensures it's not directly editable + ) + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/UserSettingsScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/UserSettingsScreen.kt new file mode 100644 index 00000000..ebe4d17d --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/UserSettingsScreen.kt @@ -0,0 +1,133 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.settings + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.health.openscale.R +import com.health.openscale.core.utils.CalculationUtil +import com.health.openscale.ui.screen.SharedViewModel +// import com.health.openscale.ui.screen.settings.SettingsViewModel // Already imported by IDE based on context + +/** + * Composable screen that displays a list of users. + * + * This screen allows users to view existing users, initiate editing of a user, + * or add a new user. It observes the list of users from [SharedViewModel] + * and uses [SettingsViewModel] for user deletion. + * + * @param sharedViewModel The ViewModel shared across different screens, used for top bar configuration and accessing the user list. + * @param settingsViewModel The ViewModel responsible for user-related settings operations, like deleting a user. + * @param onEditUser Callback invoked when the user taps the edit button for a user or the add user button. + * It receives the user's ID for editing, or null for adding a new user. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UserSettingsScreen( + sharedViewModel: SharedViewModel, + settingsViewModel: SettingsViewModel, + onEditUser: (userId: Int?) -> Unit +) { + val users by sharedViewModel.allUsers.collectAsState() + + // Pre-load strings for LaunchedEffect + val usersTitle = stringResource(id = R.string.user_settings_title) + val editActionContentDescription = stringResource(id = R.string.user_settings_content_description_edit) + val deleteActionContentDescription = stringResource(id = R.string.user_settings_content_description_delete) + val addUserContentDescription = stringResource(id = R.string.user_settings_content_description_add_user) + + + LaunchedEffect(Unit, usersTitle) { // Add usersTitle to keys to re-run if it could change (e.g. language change) + sharedViewModel.setTopBarTitle(usersTitle) // "Users" + sharedViewModel.setTopBarAction( + SharedViewModel.TopBarAction( + icon = Icons.Default.Add, + onClick = { + onEditUser(null) // null indicates adding a new user + }, + contentDescription = addUserContentDescription + ) + ) + } + + LazyColumn( + modifier = Modifier + .padding(16.dp) + ) { + items(users) { user -> + // Calculate age. This will be recalculated if user.birthDate changes. + val age = remember(user.birthDate) { + CalculationUtil.dateToAge(user.birthDate) + } + + ListItem( + headlineContent = { Text(user.name) }, + supportingContent = { + val heightText = if (user.heightCm != null) { + stringResource(R.string.height_value_cm, user.heightCm) + } else { + stringResource(R.string.not_available) + } + Text( + stringResource( + id = R.string.user_settings_item_details_conditional, + age, + heightText + ) + ) + } + , + trailingContent = { + Row { + IconButton(onClick = { onEditUser(user.id) }) { + Icon( + Icons.Default.Edit, + contentDescription = editActionContentDescription // "Edit" + ) + } + IconButton(onClick = { settingsViewModel.deleteUser(user) }) { + Icon( + Icons.Default.Delete, + contentDescription = deleteActionContentDescription // "Delete" + ) + } + } + } + ) + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/statistics/StatisticsScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/statistics/StatisticsScreen.kt new file mode 100644 index 00000000..2ad5bb61 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/statistics/StatisticsScreen.kt @@ -0,0 +1,445 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.statistics + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.health.openscale.R +import com.health.openscale.core.data.InputFieldType +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.database.UserPreferenceKeys +import com.health.openscale.ui.screen.EnrichedMeasurement +import com.health.openscale.ui.screen.SharedViewModel +import com.health.openscale.ui.screen.components.LineChart +import com.health.openscale.ui.screen.components.provideFilterTopBarAction +import com.health.openscale.ui.screen.components.rememberContextualTimeRangeFilter +import com.health.openscale.ui.screen.dialog.getIconResIdByName +import java.text.DecimalFormat +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * Data class to hold calculated statistics for a specific measurement type. + * + * @property minValue The minimum value recorded for the measurement type in the selected time range. + * @property maxValue The maximum value recorded. + * @property averageValue The average value. + * @property firstValue The first recorded value in the time range. + * @property firstValueDate The date of the first recorded value. + * @property lastValue The last recorded value in the time range. + * @property lastValueDate The date of the last recorded value. + * @property difference The difference between the last and first value. + */ +data class MeasurementStatistics( + val minValue: Float?, + val maxValue: Float?, + val averageValue: Float?, + val firstValue: Float?, + val firstValueDate: LocalDate?, + val lastValue: Float?, + val lastValueDate: LocalDate?, + val difference: Float? +) + +/** + * Composable screen that displays statistics for various enabled measurement types. + * + * This screen fetches time-filtered measurement data from the [SharedViewModel], + * calculates statistics for each relevant measurement type, and displays them + * in individual [StatisticCard] composables. It also provides a filter action + * in the top bar to change the time range for the statistics. + * + * @param sharedViewModel The ViewModel shared across screens, providing measurement data, + * measurement types, and handling top bar configuration. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StatisticsScreen(sharedViewModel: SharedViewModel) { + + val uiSelectedTimeRange by rememberContextualTimeRangeFilter( + screenContextName = UserPreferenceKeys.STATISTICS_SCREEN_CONTEXT, + userSettingsRepository = sharedViewModel.userSettingRepository + ) + + // Fetch time-filtered data from the ViewModel. + val timeFilteredData by sharedViewModel.getTimeFilteredEnrichedMeasurements(uiSelectedTimeRange) + .collectAsState(initial = emptyList()) + // Use the collected time-filtered data for statistics calculation. + val measurementsForStatistics = timeFilteredData + + val allAvailableMeasurementTypes by sharedViewModel.measurementTypes.collectAsState() + val isLoadingData by sharedViewModel.isBaseDataLoading.collectAsState() + + // Provide the filter action for the top bar. This action changes the filter in UserSettingRepository. + val filterAction = provideFilterTopBarAction( + sharedViewModel = sharedViewModel, + screenContextName = UserPreferenceKeys.STATISTICS_SCREEN_CONTEXT + ) + + val statisticsScreenTitle = stringResource(id = R.string.route_title_statistics) + val noRelevantMeasurementTypesMessage = stringResource(id = R.string.statistics_no_relevant_types) + + + LaunchedEffect(filterAction, statisticsScreenTitle) { + sharedViewModel.setTopBarTitle(statisticsScreenTitle) + val actions = mutableListOf() + filterAction?.let { actions.add(it) } + sharedViewModel.setTopBarActions(actions) + } + + // Filter for measurement types that are enabled and have a numeric input type (Float or Int). + val relevantTypesForStatsDisplay = remember(allAvailableMeasurementTypes) { + allAvailableMeasurementTypes.filter { type -> + type.isEnabled && (type.inputType == InputFieldType.FLOAT || type.inputType == InputFieldType.INT) + } + } + + Column(modifier = Modifier.fillMaxSize()) { + if (isLoadingData && measurementsForStatistics.isEmpty()) { + // Show a loading indicator if data is loading and no measurements are available yet. + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else if (measurementsForStatistics.isEmpty() && !isLoadingData && relevantTypesForStatsDisplay.isEmpty()) { + // Show a message if no relevant measurement types are configured or no data is present. + // This condition is refined to also check relevantTypesForStatsDisplay. + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), contentAlignment = Alignment.Center + ) { + Text(noRelevantMeasurementTypesMessage) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 8.dp) + ) { + items(relevantTypesForStatsDisplay, key = { it.id }) { measurementType -> + // Filter measurements relevant to the current measurement type. + val measurementsForThisType = remember(measurementsForStatistics, measurementType) { + measurementsForStatistics.filter { enrichedMeasurement -> + enrichedMeasurement.measurementWithValues.values.any { it.type.id == measurementType.id } + } + } + + // Calculate statistics for the current measurement type. + val statistics = remember(measurementsForThisType, measurementType) { + calculateStatisticsForType(measurementsForThisType, measurementType) + } + + // Display the statistic card if there are measurements for this type. + if (measurementsForThisType.isNotEmpty()) { + StatisticCard( + sharedViewModel = sharedViewModel, + measurementType = measurementType, + statistics = statistics, + screenContextForChart = UserPreferenceKeys.STATISTICS_SCREEN_CONTEXT + ) + } + } + } + } + } +} + +/** + * Calculates statistics for a given list of enriched measurements and a target measurement type. + * + * It extracts numeric values for the target type, sorts them by time, and then + * computes min, max, average, first value, last value, and the difference between + * the first and last values. + * + * @param enrichedMeasurements The list of [EnrichedMeasurement] objects to process. + * @param targetType The [MeasurementType] for which to calculate statistics. + * @return [MeasurementStatistics] containing the calculated values. + */ +fun calculateStatisticsForType( + enrichedMeasurements: List, + targetType: MeasurementType +): MeasurementStatistics { + // Map enriched measurements to pairs of (value, timestamp) for the target type. + val relevantValuesWithTime: List> = enrichedMeasurements.mapNotNull { enrichedMeasurement -> + val measurementTimestamp = enrichedMeasurement.measurementWithValues.measurement.timestamp + + // Find the MeasurementValue object for the targetType. + val measurementValueObject = enrichedMeasurement.measurementWithValues.values.find { it.type.id == targetType.id } + + if (measurementValueObject == null) { + return@mapNotNull null + } + + // Extract the numerical value from the MeasurementValue object. + val floatValue: Float? = when (targetType.inputType) { + InputFieldType.FLOAT -> measurementValueObject.value.floatValue + InputFieldType.INT -> measurementValueObject.value.intValue?.toFloat() + else -> null // Other types are not considered for these statistics. + } + + if (floatValue != null) { + Pair(floatValue, measurementTimestamp) + } else { + null + } + }.sortedBy { it.second } // Sort by timestamp. + + if (relevantValuesWithTime.isEmpty()) { + return MeasurementStatistics(null, null, null, null, null, null, null, null) + } + + val floatValuesOnly = relevantValuesWithTime.map { it.first } + + val minValue = floatValuesOnly.minOrNull() + val maxValue = floatValuesOnly.maxOrNull() + val averageValue = if (floatValuesOnly.isNotEmpty()) floatValuesOnly.average().toFloat() else null + + val firstEntry = relevantValuesWithTime.firstOrNull() + val lastEntry = relevantValuesWithTime.lastOrNull() + + val firstValue = firstEntry?.first + val firstValueDate = firstEntry?.second?.let { + Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault()).toLocalDate() + } + + val lastValue = lastEntry?.first + val lastValueDate = lastEntry?.second?.let { + Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault()).toLocalDate() + } + + val difference = if (firstValue != null && lastValue != null) { + lastValue - firstValue + } else { + null + } + + return MeasurementStatistics( + minValue = minValue, + maxValue = maxValue, + averageValue = averageValue, + firstValue = firstValue, + firstValueDate = firstValueDate, + lastValue = lastValue, + lastValueDate = lastValueDate, + difference = difference + ) +} + +/** + * Composable that displays a card with statistics for a single measurement type. + * + * The card includes the measurement type's name and icon, min/max/average values, + * a line chart showing the trend, and the first value, last value, and the + * difference between them. + * + * @param sharedViewModel The [SharedViewModel] instance. + * @param measurementType The [MeasurementType] for which statistics are displayed. + * @param statistics The calculated [MeasurementStatistics] for this type. + * @param screenContextForChart A context name string used for the embedded [LineChart]. + */ +@Composable +fun StatisticCard( + sharedViewModel: SharedViewModel, + measurementType: MeasurementType, + statistics: MeasurementStatistics, + screenContextForChart: String +) { + val unitSymbol = remember(measurementType.unit) { measurementType.unit.displayName } + // Decimal format for displaying values. + val decimalFormat = remember { DecimalFormat("#,##0.0#") } + + // Helper function to format a nullable Float value with its unit. + fun formatValueWithUnit(value: Float?, default: String = "-"): String { + return value?.let { "${decimalFormat.format(it)} $unitSymbol" } ?: default + } + + // Helper function to format a nullable Float value for the difference display (without unit initially). + fun formatValueForDiff(value: Float?, default: String = "-"): String { + return value?.let { decimalFormat.format(it) } ?: default + } + + val contentDescIncrease = stringResource(id = R.string.statistics_content_desc_increase) + val contentDescDecrease = stringResource(id = R.string.statistics_content_desc_decrease) + val contentDescNoChange = stringResource(id = R.string.statistics_content_desc_no_change) + + val statMinLabel = stringResource(id = R.string.statistics_label_min) + val statMaxLabel = stringResource(id = R.string.statistics_label_max) + val statAvgLabel = stringResource(id = R.string.statistics_label_average) + + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Column(modifier = Modifier.padding(12.dp)) { + // --- TOP ROW: Icon, Name, Min/Max/Avg --- + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { + // Icon and Name + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(Color(measurementType.color)), + contentAlignment = Alignment.Center + ) { + val iconId = remember(measurementType.icon) { getIconResIdByName(measurementType.icon) } + if (iconId != 0) { + Icon( + painter = painterResource(id = iconId), + contentDescription = measurementType.getDisplayName(LocalContext.current), // Icon related to measurement type + tint = Color.Black, // Assuming black provides good contrast on the colored background + modifier = Modifier.size(18.dp) + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = measurementType.getDisplayName(LocalContext.current), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + // Min/Max/Avg Values + Column(horizontalAlignment = Alignment.End) { + Text("$statMinLabel: ${formatValueWithUnit(statistics.minValue)}", style = MaterialTheme.typography.bodySmall) + Text("$statMaxLabel: ${formatValueWithUnit(statistics.maxValue)}", style = MaterialTheme.typography.bodySmall) + Text("$statAvgLabel: ${formatValueWithUnit(statistics.averageValue)}", style = MaterialTheme.typography.bodySmall) + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + // --- MIDDLE: LineChart --- + LineChart( + sharedViewModel = sharedViewModel, + screenContextName = screenContextForChart, + showFilterControls = false, // Filter controls are global for the screen + targetMeasurementTypeId = measurementType.id, + showYAxis = false, // Keep it compact + showFilterTitle = false, + modifier = Modifier + .fillMaxWidth() + .height(100.dp) // Fixed height for the chart + ) + + Spacer(modifier = Modifier.height(16.dp)) // Space after the chart + + // --- BOTTOM ROW: First Value (left), DIFFERENCE (center, optional), Last Value (right) --- + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // First Value (left aligned) + Text( + text = formatValueWithUnit(statistics.firstValue), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f) + ) + + // Difference (center aligned, shown if available) + if (statistics.difference != null) { + val diffValue = statistics.difference + val diffPrefix = if (diffValue > 0) "+" else "" // Add "+" for positive differences + + // Determine icon and content description based on the difference value + val (diffIcon, description) = when { + diffValue > 0 -> Icons.Filled.ArrowUpward to contentDescIncrease + diffValue < 0 -> Icons.Filled.ArrowDownward to contentDescDecrease + else -> Icons.Filled.Remove to contentDescNoChange + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = diffIcon, + contentDescription = description, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + // Display difference with sign and unit + text = "$diffPrefix${formatValueForDiff(diffValue)} $unitSymbol", + style = MaterialTheme.typography.bodySmall, + ) + } + } else { + // If no difference, occupy the space to maintain layout. + Spacer(Modifier.weight(1f)) + } + + // Last Value (right aligned) + Text( + text = formatValueWithUnit(statistics.lastValue), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + textAlign = TextAlign.End + ) + } + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt new file mode 100644 index 00000000..847aae73 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt @@ -0,0 +1,498 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.screen.components // Using package from the provided code + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.health.openscale.R +import com.health.openscale.core.data.InputFieldType +import com.health.openscale.core.data.Trend +import com.health.openscale.ui.navigation.Routes +import com.health.openscale.ui.screen.SharedViewModel +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Represents the data for a single cell in the table, excluding the date cell. + * + * @property typeId The ID of the measurement type this cell data represents. + * @property displayValue The formatted string value to display in the cell. + * @property unit The unit of the measurement. + * @property difference The difference from the previous measurement of the same type, if applicable. + * @property trend The trend (up, down, none, not applicable) compared to the previous measurement. + * @property originalInputType The original [InputFieldType] of the measurement. + */ +data class TableCellData( + val typeId: Int, + val displayValue: String, + val unit: String, + val difference: Float? = null, + val trend: Trend = Trend.NOT_APPLICABLE, + val originalInputType: InputFieldType +) + +/** + * Represents the internal data structure for a single row in the table. + * + * @property measurementId The unique ID of the measurement this row corresponds to. + * @property timestamp The timestamp of the measurement. + * @property formattedTimestamp The formatted date and time string for display. + * @property values A map where the key is the measurement type ID (`typeId`) and the value + * is the [TableCellData] for that type in this row. + */ +data class TableRowDataInternal( + val measurementId: Int, + val timestamp: Long, + val formattedTimestamp: String, + val values: Map +) + +/** + * Composable screen that displays measurement data in a tabular format. + * + * The table shows a fixed date column and scrollable columns for selected measurement types. + * Each cell can display the measured value and its trend/difference compared to the previous one. + * Users can filter which measurement types are displayed as columns. + * Tapping on a row navigates to the detailed view of that measurement. + * + * @param navController The NavController for navigation. + * @param sharedViewModel The [SharedViewModel] providing measurement data, types, and UI state. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TableScreen( + navController: NavController, + sharedViewModel: SharedViewModel +) { + val scope = rememberCoroutineScope() + val enrichedMeasurements by sharedViewModel.enrichedMeasurementsFlow.collectAsState() + val isLoading by sharedViewModel.isBaseDataLoading.collectAsState() + val allAvailableTypesFromVM by sharedViewModel.measurementTypes.collectAsState() + + // Holds the IDs of columns selected by the user via the filter row. + val selectedColumnIdsFromFilter = remember { mutableStateListOf() } + + // Determines the actual measurement types to display as columns based on user selection. + val displayedTypes = + remember(allAvailableTypesFromVM, selectedColumnIdsFromFilter.toList()) { + allAvailableTypesFromVM.filter { type -> + type.id in selectedColumnIdsFromFilter + } + } + + // Transforms enriched measurements into a list of TableRowDataInternal for easier rendering. + val tableData = remember(enrichedMeasurements, displayedTypes, allAvailableTypesFromVM) { + if (enrichedMeasurements.isEmpty() || displayedTypes.isEmpty()) { + emptyList() + } else { + // Date formatter for the timestamp column. + val dateFormatter = SimpleDateFormat("E, dd.MM.yy HH:mm", Locale.getDefault()) + + enrichedMeasurements.map { enrichedItem -> // enrichedItem is EnrichedMeasurement + val cellValues = displayedTypes.associate { colType -> // Iterate over sorted, displayed types + val typeId = colType.id + // Find the corresponding value with trend from the enrichedItem + val valueWithTrend = enrichedItem.valuesWithTrend.find { it.currentValue.type.id == typeId } + + if (valueWithTrend != null) { + val originalMeasurementValue = valueWithTrend.currentValue.value // This is MeasurementValue + val actualType = valueWithTrend.currentValue.type // This is MeasurementType + + val displayValueStr = when (actualType.inputType) { + InputFieldType.FLOAT -> originalMeasurementValue.floatValue?.let { "%.1f".format(Locale.getDefault(), it) } ?: "-" + InputFieldType.INT -> originalMeasurementValue.intValue?.toString() ?: "-" + InputFieldType.TEXT -> originalMeasurementValue.textValue ?: "-" + // Add other InputFieldTypes here if needed (DATE, TIME etc.) + else -> originalMeasurementValue.textValue ?: originalMeasurementValue.floatValue?.toString() ?: originalMeasurementValue.intValue?.toString() ?: "-" + } + val unitStr = if (displayValueStr != "-") actualType.unit.displayName else "" + + typeId to TableCellData( + typeId = typeId, + displayValue = displayValueStr, + unit = unitStr, + difference = valueWithTrend.difference, // Use directly + trend = valueWithTrend.trend, // Use directly + originalInputType = actualType.inputType + ) + } else { + // Fallback: No value for this type in this specific measurement + // (e.g., if the type was not measured). + // Use colType (the type from the column definition) for default info. + typeId to TableCellData( + typeId = typeId, + displayValue = "-", + unit = colType.unit.displayName, // Show unit even if no value, for consistency + difference = null, + trend = Trend.NOT_APPLICABLE, + originalInputType = colType.inputType + ) + } + } + TableRowDataInternal( + measurementId = enrichedItem.measurementWithValues.measurement.id, + timestamp = enrichedItem.measurementWithValues.measurement.timestamp, + formattedTimestamp = dateFormatter.format(Date(enrichedItem.measurementWithValues.measurement.timestamp)), + values = cellValues // cellValues is already Map + ) + } + } + } + + val tableScreenTitle = stringResource(id = R.string.route_title_table) + val noColumnsOrMeasurementsMessage = stringResource(id = R.string.table_message_no_columns_or_measurements) + val noMeasurementsMessage = stringResource(id = R.string.table_message_no_measurements) + val noColumnsSelectedMessage = stringResource(id = R.string.table_message_no_columns_selected) + val noDataForSelectionMessage = stringResource(id = R.string.table_message_no_data_for_selection) + val dateColumnHeader = stringResource(id = R.string.table_header_date) + + + LaunchedEffect(Unit, tableScreenTitle) { + sharedViewModel.setTopBarTitle(tableScreenTitle) + } + + val horizontalScrollState = rememberScrollState() + val dateColumnWidth = 130.dp + val minDataCellWidth = 110.dp // Slightly wider to accommodate value + difference + + Column(modifier = Modifier.fillMaxSize()) { + // --- FILTER SELECTION ROW --- + MeasurementTypeFilterRow( + allMeasurementTypesProvider = { allAvailableTypesFromVM }, + selectedTypeIdsFlowProvider = { sharedViewModel.userSettingRepository.selectedTableTypeIds }, + onPersistSelectedTypeIds = { idsToSave -> // idsToSave is Set + scope.launch { + sharedViewModel.userSettingRepository.saveSelectedTableTypeIds(idsToSave) + } + }, + // Logic to determine which types are available for selection in the filter row. + // Example: only show enabled types. + filterLogic = { allTypes -> + allTypes.filter { it.isEnabled } + }, + // Logic to determine which types are selected by default. + // Example: enabled types that are also marked as default for table view. + defaultSelectionLogic = { availableFilteredTypes -> + availableFilteredTypes.filter { it.isEnabled }.map { it.id } + }, + onSelectionChanged = { newSelectedIds -> + selectedColumnIdsFromFilter.clear() + selectedColumnIdsFromFilter.addAll(newSelectedIds) + }, + allowEmptySelection = false // Or true, depending on desired behavior + ) + HorizontalDivider() + + // --- TABLE CONTENT --- + if (isLoading) { + Box( + Modifier + .fillMaxSize() + .padding(16.dp), Alignment.Center + ) { + CircularProgressIndicator() + } + } else if (enrichedMeasurements.isEmpty() && displayedTypes.isEmpty()) { + Box( + Modifier + .fillMaxSize() + .padding(16.dp), Alignment.Center + ) { Text(noColumnsOrMeasurementsMessage) } + } else if (enrichedMeasurements.isEmpty()) { + Box( + Modifier + .fillMaxSize() + .padding(16.dp), Alignment.Center + ) { Text(noMeasurementsMessage) } + } else if (displayedTypes.isEmpty()) { + Box( + Modifier + .fillMaxSize() + .padding(16.dp), Alignment.Center + ) { Text(noColumnsSelectedMessage) } + } else if (tableData.isEmpty() && enrichedMeasurements.isNotEmpty() && displayedTypes.isNotEmpty()) { + // This case implies data exists, but not for the currently selected combination of columns. + Box( + Modifier + .fillMaxSize() + .padding(16.dp), Alignment.Center + ) { Text(noDataForSelectionMessage) } + } else { + // --- HEADER ROW --- + Row( + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(vertical = 8.dp) // Vertical padding for the header row + .height(IntrinsicSize.Min), // Ensures cells in row have same height, accommodating multi-line text + verticalAlignment = Alignment.CenterVertically + ) { + TableHeaderCellInternal( + text = dateColumnHeader, + modifier = Modifier + .width(dateColumnWidth) + .padding(horizontal = 6.dp) // Padding within the header cell + .fillMaxHeight(), + alignment = TextAlign.Start + ) + // Scrollable header cells for measurement types + Row( + Modifier + .weight(1f) + .horizontalScroll(horizontalScrollState) + ) { + displayedTypes.forEach { type -> + TableHeaderCellInternal( + text = type.getDisplayName(LocalContext.current), // Measurement type name as header + modifier = Modifier + .width(minDataCellWidth) + .padding(horizontal = 6.dp) // Padding within the header cell + .fillMaxHeight(), + alignment = TextAlign.End // Align numeric headers to the end + ) + } + } + } + HorizontalDivider() + + // --- DATA ROWS --- + LazyColumn(Modifier.fillMaxSize()) { + items(tableData, key = { it.measurementId }) { rowData -> + Row( + Modifier + .fillMaxWidth() + .clickable { + navController.navigate( + Routes.measurementDetail( + rowData.measurementId, + sharedViewModel.selectedUserId.value // Pass current user ID if needed by detail screen + ) + ) + } + .height(IntrinsicSize.Min), // Important for variable cell height based on content + verticalAlignment = Alignment.CenterVertically + ) { + // Fixed date cell + TableDataCellInternal( + cellData = null, // No TableCellData for the date itself + fixedText = rowData.formattedTimestamp, + modifier = Modifier + .width(dateColumnWidth) + .background(MaterialTheme.colorScheme.surface) // Ensure consistent background + .fillMaxHeight(), + alignment = TextAlign.Start, + isDateCell = true + ) + // Scrollable data cells + Row( + Modifier + .weight(1f) + .horizontalScroll(horizontalScrollState) + .fillMaxHeight() + ) { + displayedTypes.forEach { colType -> + val cellData = rowData.values[colType.id] + TableDataCellInternal( + cellData = cellData, + modifier = Modifier + .width(minDataCellWidth) + .fillMaxHeight(), // Ensures cells in row have same height + alignment = TextAlign.End // Numeric data usually aligned to end + ) + } + } + } + HorizontalDivider() + } + } + } + } +} + +/** + * A composable function for rendering a header cell in the table. + * + * @param text The text to display in the header cell. + * @param modifier The modifier to be applied to the Text composable. + * @param alignment The text alignment within the cell. + */ +@Composable +fun TableHeaderCellInternal( + text: String, + modifier: Modifier = Modifier, + alignment: TextAlign = TextAlign.Center +) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + textAlign = alignment, + maxLines = 2, // Allow up to two lines for longer headers + overflow = TextOverflow.Ellipsis, + modifier = modifier + .padding(vertical = 4.dp) // Vertical padding for text within the header cell + .fillMaxHeight() // Ensures the cell takes up the full height of the header row + ) +} + +/** + * A composable function for rendering a data cell in the table. + * + * This cell can display either a fixed text (for date cells) or formatted measurement data + * including value, unit, and trend indicator. + * + * @param cellData The [TableCellData] to display. Null for date cells if `fixedText` is provided. + * @param modifier The modifier to be applied to the cell's Box container. + * @param alignment The text alignment for the primary content of the cell. + * @param fixedText A fixed string to display, used primarily for the date cell. + * @param isDateCell A boolean indicating if this cell is the fixed date cell. + */ +@Composable +fun TableDataCellInternal( + cellData: TableCellData?, + modifier: Modifier = Modifier, + alignment: TextAlign = TextAlign.Start, + fixedText: String? = null, + isDateCell: Boolean = false +) { + Box( + modifier = modifier.padding(horizontal = 8.dp, vertical = 6.dp), // Padding inside each cell + // Date cells are aligned to CenterStart, value cells to TopEnd for better layout with potential difference text + contentAlignment = if (isDateCell) Alignment.CenterStart else Alignment.TopEnd + ) { + if (isDateCell && fixedText != null) { + // Display for the fixed date cell + Text( + text = fixedText, + style = MaterialTheme.typography.bodyMedium, + textAlign = alignment, + maxLines = 2, // Allow date to wrap if necessary + overflow = TextOverflow.Ellipsis + ) + } else if (cellData != null) { + // Display for measurement data cells + Column(horizontalAlignment = Alignment.End) { // Align content to the end (right) + Text( + text = "${cellData.displayValue}${cellData.unit}", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + // Display difference and trend if available + if (cellData.difference != null && cellData.trend != Trend.NOT_APPLICABLE) { + Spacer(modifier = Modifier.height(1.dp)) // Small space between value and difference + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + val trendIconVector = when (cellData.trend) { + Trend.UP -> Icons.Filled.ArrowUpward + Trend.DOWN -> Icons.Filled.ArrowDownward + else -> null // No icon for Trend.NONE or Trend.NOT_APPLICABLE + } + // Use a subtle color for the difference text and icon + val diffColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + val trendContentDescription = when (cellData.trend) { + Trend.UP -> stringResource(R.string.table_trend_up) + Trend.DOWN -> stringResource(R.string.table_trend_down) + else -> null + } + + if (trendIconVector != null && trendContentDescription != null) { + Icon( + imageVector = trendIconVector, + contentDescription = trendContentDescription, + tint = diffColor, + modifier = Modifier.size(12.dp) + ) + Spacer(modifier = Modifier.width(2.dp)) + } + Text( + text = (if (cellData.difference > 0 && cellData.trend != Trend.NONE) "+" else "") + // Add "+" for positive changes + when (cellData.originalInputType) { // Format difference based on original type + InputFieldType.FLOAT -> "%.1f".format(Locale.getDefault(), cellData.difference) + InputFieldType.INT -> cellData.difference.toInt().toString() + else -> "" // Should not happen for types with difference + } + " ${cellData.unit}", // Append unit to the difference + style = MaterialTheme.typography.bodySmall, + color = diffColor, + textAlign = TextAlign.End + ) + } + } else if (cellData.originalInputType == InputFieldType.FLOAT || cellData.originalInputType == InputFieldType.INT) { + // Add a spacer if there's no difference to maintain consistent cell height for numeric types + // The height should roughly match the space taken by the difference text and icon. + Spacer(modifier = Modifier.height((MaterialTheme.typography.bodySmall.fontSize.value + 4).dp)) // Adjust dp as needed + } + } + } else { + // Fallback for empty cells (should ideally not happen if data is processed correctly) + Text( + text = "-", // Placeholder for empty data + style = MaterialTheme.typography.bodyLarge, + textAlign = alignment, + modifier = Modifier.fillMaxHeight() // Maintain cell height + ) + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/theme/Color.kt b/android_app/app/src/main/java/com/health/openscale/ui/theme/Color.kt new file mode 100644 index 00000000..d840e4fb --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/theme/Color.kt @@ -0,0 +1,26 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.theme + +import androidx.compose.ui.graphics.Color + +val Blue = Color(0xff0099cc) +val LightBlue = Color(0xff33b5e5) +val White = Color(0xFFFFFBFE) + +val Black = Color(0xFF1E1E1E) \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/ui/theme/Theme.kt b/android_app/app/src/main/java/com/health/openscale/ui/theme/Theme.kt new file mode 100644 index 00000000..8445f8ff --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/theme/Theme.kt @@ -0,0 +1,65 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Blue, + secondary = LightBlue, + tertiary = White, + onPrimary = White, +) + +private val LightColorScheme = lightColorScheme( + primary = Blue, + secondary = Blue, + tertiary = White, +) + +@Composable +fun OpenScaleTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/ui/theme/Type.kt b/android_app/app/src/main/java/com/health/openscale/ui/theme/Type.kt new file mode 100644 index 00000000..46391667 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/theme/Type.kt @@ -0,0 +1,51 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/android_app/app/src/main/res/drawable-anydpi-v24/ic_notification_openscale_monochrome.xml b/android_app/app/src/main/res/drawable-anydpi-v24/ic_notification_openscale_monochrome.xml deleted file mode 100644 index ac2709fa..00000000 --- a/android_app/app/src/main/res/drawable-anydpi-v24/ic_notification_openscale_monochrome.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - diff --git a/android_app/app/src/main/res/drawable-hdpi/ic_notification_openscale_monochrome.png b/android_app/app/src/main/res/drawable-hdpi/ic_notification_openscale_monochrome.png deleted file mode 100644 index 20d34db1..00000000 Binary files a/android_app/app/src/main/res/drawable-hdpi/ic_notification_openscale_monochrome.png and /dev/null differ diff --git a/android_app/app/src/main/res/drawable-mdpi/ic_notification_openscale_monochrome.png b/android_app/app/src/main/res/drawable-mdpi/ic_notification_openscale_monochrome.png deleted file mode 100644 index cd864e7c..00000000 Binary files a/android_app/app/src/main/res/drawable-mdpi/ic_notification_openscale_monochrome.png and /dev/null differ diff --git a/android_app/app/src/main/res/drawable-xhdpi/ic_notification_openscale_monochrome.png b/android_app/app/src/main/res/drawable-xhdpi/ic_notification_openscale_monochrome.png deleted file mode 100644 index 5a074c02..00000000 Binary files a/android_app/app/src/main/res/drawable-xhdpi/ic_notification_openscale_monochrome.png and /dev/null differ diff --git a/android_app/app/src/main/res/drawable-xxhdpi/ic_notification_openscale_monochrome.png b/android_app/app/src/main/res/drawable-xxhdpi/ic_notification_openscale_monochrome.png deleted file mode 100644 index 019b86e9..00000000 Binary files a/android_app/app/src/main/res/drawable-xxhdpi/ic_notification_openscale_monochrome.png and /dev/null differ diff --git a/android_app/app/src/main/res/drawable/appwidget_bg.xml b/android_app/app/src/main/res/drawable/appwidget_bg.xml deleted file mode 100644 index 70d6ef5d..00000000 --- a/android_app/app/src/main/res/drawable/appwidget_bg.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - diff --git a/android_app/app/src/main/res/drawable/chart_marker.xml b/android_app/app/src/main/res/drawable/chart_marker.xml deleted file mode 100644 index a3870a3b..00000000 --- a/android_app/app/src/main/res/drawable/chart_marker.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_add.xml b/android_app/app/src/main/res/drawable/ic_add.xml deleted file mode 100644 index 70046c48..00000000 --- a/android_app/app/src/main/res/drawable/ic_add.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_bluetooth_connection_lost.xml b/android_app/app/src/main/res/drawable/ic_bluetooth_connection_lost.xml deleted file mode 100644 index 421fef71..00000000 --- a/android_app/app/src/main/res/drawable/ic_bluetooth_connection_lost.xml +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/android_app/app/src/main/res/drawable/ic_bluetooth_connection_success.xml b/android_app/app/src/main/res/drawable/ic_bluetooth_connection_success.xml deleted file mode 100644 index cb2f304d..00000000 --- a/android_app/app/src/main/res/drawable/ic_bluetooth_connection_success.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - diff --git a/android_app/app/src/main/res/drawable/ic_bluetooth_device_not_supported.xml b/android_app/app/src/main/res/drawable/ic_bluetooth_device_not_supported.xml deleted file mode 100644 index 836b5cb6..00000000 --- a/android_app/app/src/main/res/drawable/ic_bluetooth_device_not_supported.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_bluetooth_device_supported.xml b/android_app/app/src/main/res/drawable/ic_bluetooth_device_supported.xml deleted file mode 100644 index 7e5caacd..00000000 --- a/android_app/app/src/main/res/drawable/ic_bluetooth_device_supported.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_bluetooth_disabled.xml b/android_app/app/src/main/res/drawable/ic_bluetooth_disabled.xml deleted file mode 100644 index ba31bd4a..00000000 --- a/android_app/app/src/main/res/drawable/ic_bluetooth_disabled.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_bluetooth_searching.xml b/android_app/app/src/main/res/drawable/ic_bluetooth_searching.xml deleted file mode 100644 index 3d532d4c..00000000 --- a/android_app/app/src/main/res/drawable/ic_bluetooth_searching.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_calendar.xml b/android_app/app/src/main/res/drawable/ic_date.xml similarity index 100% rename from android_app/app/src/main/res/drawable/ic_calendar.xml rename to android_app/app/src/main/res/drawable/ic_date.xml diff --git a/android_app/app/src/main/res/drawable/ic_delete.xml b/android_app/app/src/main/res/drawable/ic_delete.xml deleted file mode 100644 index 3c4030b0..00000000 --- a/android_app/app/src/main/res/drawable/ic_delete.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_editable.xml b/android_app/app/src/main/res/drawable/ic_editable.xml deleted file mode 100644 index 2844bafe..00000000 --- a/android_app/app/src/main/res/drawable/ic_editable.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_expand.xml b/android_app/app/src/main/res/drawable/ic_expand.xml deleted file mode 100644 index 0f990b20..00000000 --- a/android_app/app/src/main/res/drawable/ic_expand.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/android_app/app/src/main/res/drawable/ic_expand_less.xml b/android_app/app/src/main/res/drawable/ic_expand_less.xml deleted file mode 100644 index a55069f0..00000000 --- a/android_app/app/src/main/res/drawable/ic_expand_less.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_expand_more.xml b/android_app/app/src/main/res/drawable/ic_expand_more.xml deleted file mode 100644 index adc215c4..00000000 --- a/android_app/app/src/main/res/drawable/ic_expand_more.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_lastmonth.xml b/android_app/app/src/main/res/drawable/ic_lastmonth.xml deleted file mode 100644 index 099a7530..00000000 --- a/android_app/app/src/main/res/drawable/ic_lastmonth.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - diff --git a/android_app/app/src/main/res/drawable/ic_launcher_foreground.xml b/android_app/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..8a004616 --- /dev/null +++ b/android_app/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android_app/app/src/main/res/drawable/ic_launcher_openscale.xml b/android_app/app/src/main/res/drawable/ic_launcher_openscale.xml deleted file mode 100644 index 0991fca5..00000000 --- a/android_app/app/src/main/res/drawable/ic_launcher_openscale.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android_app/app/src/main/res/drawable/ic_options.xml b/android_app/app/src/main/res/drawable/ic_options.xml deleted file mode 100644 index 454bd7de..00000000 --- a/android_app/app/src/main/res/drawable/ic_options.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_preference_donate.xml b/android_app/app/src/main/res/drawable/ic_preference_donate.xml deleted file mode 100644 index f2e51d22..00000000 --- a/android_app/app/src/main/res/drawable/ic_preference_donate.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_preferences_about.xml b/android_app/app/src/main/res/drawable/ic_preferences_about.xml deleted file mode 100644 index f6e2d050..00000000 --- a/android_app/app/src/main/res/drawable/ic_preferences_about.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_preferences_backup.xml b/android_app/app/src/main/res/drawable/ic_preferences_backup.xml deleted file mode 100644 index 65600a46..00000000 --- a/android_app/app/src/main/res/drawable/ic_preferences_backup.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/android_app/app/src/main/res/drawable/ic_preferences_bluetooth.xml b/android_app/app/src/main/res/drawable/ic_preferences_bluetooth.xml deleted file mode 100644 index 151fc623..00000000 --- a/android_app/app/src/main/res/drawable/ic_preferences_bluetooth.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/android_app/app/src/main/res/drawable/ic_preferences_graph.xml b/android_app/app/src/main/res/drawable/ic_preferences_graph.xml deleted file mode 100644 index 1654f14f..00000000 --- a/android_app/app/src/main/res/drawable/ic_preferences_graph.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/android_app/app/src/main/res/drawable/ic_preferences_help.xml b/android_app/app/src/main/res/drawable/ic_preferences_help.xml deleted file mode 100644 index d5fd2a8d..00000000 --- a/android_app/app/src/main/res/drawable/ic_preferences_help.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_preferences_home.xml b/android_app/app/src/main/res/drawable/ic_preferences_home.xml deleted file mode 100644 index 2c36c843..00000000 --- a/android_app/app/src/main/res/drawable/ic_preferences_home.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_preferences_measurement.xml b/android_app/app/src/main/res/drawable/ic_preferences_measurement.xml deleted file mode 100644 index 5224252b..00000000 --- a/android_app/app/src/main/res/drawable/ic_preferences_measurement.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/android_app/app/src/main/res/drawable/ic_preferences_nav_graph.xml b/android_app/app/src/main/res/drawable/ic_preferences_nav_graph.xml deleted file mode 100644 index 9f4d9243..00000000 --- a/android_app/app/src/main/res/drawable/ic_preferences_nav_graph.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/android_app/app/src/main/res/drawable/ic_preferences_reminder.xml b/android_app/app/src/main/res/drawable/ic_preferences_reminder.xml deleted file mode 100644 index 342545ec..00000000 --- a/android_app/app/src/main/res/drawable/ic_preferences_reminder.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/android_app/app/src/main/res/drawable/ic_preferences_settings.xml b/android_app/app/src/main/res/drawable/ic_preferences_settings.xml deleted file mode 100644 index be2cfd29..00000000 --- a/android_app/app/src/main/res/drawable/ic_preferences_settings.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_preferences_statistics.xml b/android_app/app/src/main/res/drawable/ic_preferences_statistics.xml deleted file mode 100644 index 347e0bab..00000000 --- a/android_app/app/src/main/res/drawable/ic_preferences_statistics.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/android_app/app/src/main/res/drawable/ic_preferences_table.xml b/android_app/app/src/main/res/drawable/ic_preferences_table.xml deleted file mode 100644 index 607aaf78..00000000 --- a/android_app/app/src/main/res/drawable/ic_preferences_table.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android_app/app/src/main/res/drawable/ic_preferences_users.xml b/android_app/app/src/main/res/drawable/ic_preferences_users.xml deleted file mode 100644 index 5d25b639..00000000 --- a/android_app/app/src/main/res/drawable/ic_preferences_users.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - diff --git a/android_app/app/src/main/res/drawable/ic_reorder.xml b/android_app/app/src/main/res/drawable/ic_reorder.xml deleted file mode 100644 index 45086bd7..00000000 --- a/android_app/app/src/main/res/drawable/ic_reorder.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_save.xml b/android_app/app/src/main/res/drawable/ic_save.xml deleted file mode 100644 index 1a8d86d2..00000000 --- a/android_app/app/src/main/res/drawable/ic_save.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_show.xml b/android_app/app/src/main/res/drawable/ic_show.xml deleted file mode 100644 index a3e222a2..00000000 --- a/android_app/app/src/main/res/drawable/ic_show.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_slide_group.xml b/android_app/app/src/main/res/drawable/ic_slide_group.xml deleted file mode 100644 index b20bbb03..00000000 --- a/android_app/app/src/main/res/drawable/ic_slide_group.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_slide_opensource.xml b/android_app/app/src/main/res/drawable/ic_slide_opensource.xml deleted file mode 100644 index 35127694..00000000 --- a/android_app/app/src/main/res/drawable/ic_slide_opensource.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_slide_privacy.xml b/android_app/app/src/main/res/drawable/ic_slide_privacy.xml deleted file mode 100644 index 4b4d6310..00000000 --- a/android_app/app/src/main/res/drawable/ic_slide_privacy.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/android_app/app/src/main/res/drawable/ic_slide_support.xml b/android_app/app/src/main/res/drawable/ic_slide_support.xml deleted file mode 100644 index 48d69581..00000000 --- a/android_app/app/src/main/res/drawable/ic_slide_support.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/android_app/app/src/main/res/drawable/ic_daysleft.xml b/android_app/app/src/main/res/drawable/ic_time.xml similarity index 100% rename from android_app/app/src/main/res/drawable/ic_daysleft.xml rename to android_app/app/src/main/res/drawable/ic_time.xml diff --git a/android_app/app/src/main/res/drawable/ic_user.xml b/android_app/app/src/main/res/drawable/ic_user.xml deleted file mode 100644 index f7b20227..00000000 --- a/android_app/app/src/main/res/drawable/ic_user.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/android_app/app/src/main/res/drawable/nav_item_colors.xml b/android_app/app/src/main/res/drawable/nav_item_colors.xml deleted file mode 100644 index 154000d2..00000000 --- a/android_app/app/src/main/res/drawable/nav_item_colors.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/android_app/app/src/main/res/layout/activity_main.xml b/android_app/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index ee09d5f7..00000000 --- a/android_app/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/android_app/app/src/main/res/layout/activity_slidetonavigation.xml b/android_app/app/src/main/res/layout/activity_slidetonavigation.xml deleted file mode 100644 index 58971b36..00000000 --- a/android_app/app/src/main/res/layout/activity_slidetonavigation.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/android_app/app/src/main/res/layout/chart_markerview.xml b/android_app/app/src/main/res/layout/chart_markerview.xml deleted file mode 100644 index 2d45b937..00000000 --- a/android_app/app/src/main/res/layout/chart_markerview.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - diff --git a/android_app/app/src/main/res/layout/drawer_header.xml b/android_app/app/src/main/res/layout/drawer_header.xml deleted file mode 100644 index 61f8718f..00000000 --- a/android_app/app/src/main/res/layout/drawer_header.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/android_app/app/src/main/res/layout/float_input_view.xml b/android_app/app/src/main/res/layout/float_input_view.xml deleted file mode 100644 index 32dceea2..00000000 --- a/android_app/app/src/main/res/layout/float_input_view.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - -