From e535e5e4b7ad5ceb61e31991d9f4f90eb3135fad Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sat, 2 Aug 2025 15:31:47 +0200 Subject: [PATCH] Complete rewrite of the entire openScale application from Java to Kotlin. This is the initial commit for the Kotlin version on this branch, aiming for improved code quality, conciseness, and modern Android development practices. --- android_app/app/build.gradle | 157 -- android_app/app/build.gradle.kts | 93 ++ android_app/app/proguard-rules.pro | 21 + .../1.json | 182 -- .../2.json | 195 --- .../3.json | 255 --- .../4.json | 262 --- .../5.json | 280 ---- .../6.json | 287 ---- .../openscale/DatabaseMigrationTest.java | 264 --- .../com/health/openscale/DatabaseTest.java | 243 --- .../openscale/TrisaBodyAnalyzeLibTest.java | 180 -- .../openscale/gui/AddMeasurementTest.java | 218 --- .../com/health/openscale/gui/AddUserTest.java | 194 --- .../openscale/gui/ScreenshotRecorder.java | 319 ---- .../com/health/openscale/gui/TestData.java | 141 -- android_app/app/src/main/AndroidManifest.xml | 84 +- .../java/com/health/openscale/MainActivity.kt | 207 +++ .../health/openscale/core/Application.java | 48 - .../com/health/openscale/core/OpenScale.java | 774 --------- .../core/alarm/AlarmBackupHandler.java | 127 -- .../openscale/core/alarm/AlarmEntry.java | 90 - .../core/alarm/AlarmEntryReader.java | 106 -- .../openscale/core/alarm/AlarmHandler.java | 195 --- .../core/alarm/ReminderBootReceiver.java | 55 - .../bluetooth/BluetoothActiveEraBF06.java | 326 ---- .../core/bluetooth/BluetoothBeurerBF105.java | 153 -- .../core/bluetooth/BluetoothBeurerBF500.java | 121 -- .../core/bluetooth/BluetoothBeurerBF600.java | 119 -- .../core/bluetooth/BluetoothBeurerBF950.java | 48 - .../bluetooth/BluetoothBeurerSanitas.java | 1009 ------------ .../bluetooth/BluetoothBroadcastScale.java | 178 -- .../bluetooth/BluetoothCustomOpenScale.java | 161 -- .../core/bluetooth/BluetoothDebug.java | 191 --- .../core/bluetooth/BluetoothDigooDGSO38H.java | 133 -- .../core/bluetooth/BluetoothES26BBB.java | 246 --- .../core/bluetooth/BluetoothESCS20M.java | 176 -- .../bluetooth/BluetoothExcelvanCF36xBLE.java | 145 -- .../core/bluetooth/BluetoothExingtechY1.java | 112 -- .../core/bluetooth/BluetoothFactory.java | 177 -- .../core/bluetooth/BluetoothHesley.java | 96 -- .../bluetooth/BluetoothHoffenBBS8107.java | 217 --- .../core/bluetooth/BluetoothHuaweiAH100.java | 805 --------- .../core/bluetooth/BluetoothIhealthHS3.java | 264 --- .../core/bluetooth/BluetoothInlife.java | 235 --- .../core/bluetooth/BluetoothMGB.java | 201 --- .../bluetooth/BluetoothMedisanaBS44x.java | 127 -- .../core/bluetooth/BluetoothMiScale.java | 246 --- .../core/bluetooth/BluetoothMiScale2.java | 239 --- .../core/bluetooth/BluetoothOKOK.java | 178 -- .../core/bluetooth/BluetoothOKOK2.java | 202 --- .../core/bluetooth/BluetoothOneByone.java | 231 --- .../core/bluetooth/BluetoothOneByoneNew.java | 333 ---- .../core/bluetooth/BluetoothQNScale.java | 283 ---- .../core/bluetooth/BluetoothRenphoScale.java | 255 --- .../core/bluetooth/BluetoothSanitasSBF72.java | 138 -- .../core/bluetooth/BluetoothSenssun.java | 234 --- .../core/bluetooth/BluetoothSinocare.java | 119 -- .../core/bluetooth/BluetoothSoehnle.java | 275 ---- .../BluetoothStandardWeightProfile.java | 869 ---------- .../bluetooth/BluetoothTrisaBodyAnalyze.java | 360 ---- .../core/bluetooth/BluetoothYoda1Scale.java | 109 -- .../core/bluetooth/ScaleCommnuicator.kt | 135 ++ .../openscale/core/bluetooth/ScaleFactory.kt | 210 +++ .../core/bluetooth/data/ScaleMeasurement.java | 124 ++ .../core/bluetooth/data/ScaleUser.java | 134 ++ .../core/bluetooth/lib/MiScaleLib.java | 173 -- .../core/bluetooth/lib/OneByoneLib.java | 254 --- .../core/bluetooth/lib/OneByoneNewLib.java | 201 --- .../core/bluetooth/lib/SoehnleLib.java | 147 -- .../bluetooth/lib/TrisaBodyAnalyzeLib.java | 79 - .../bluetooth/{lib => libs}/YunmaiLib.java | 28 +- .../bluetooth/scales/DummyScaleHandler.kt | 92 ++ .../bluetooth/scales/ModernScaleAdapter.kt | 467 ++++++ .../bluetooth/scales/ScaleDeviceHandler.kt | 282 ++++ .../BluetoothCommunication.java | 130 +- .../{ => scalesJava}/BluetoothGattUuid.java | 27 +- .../BluetoothYunmaiSE_Mini.java | 73 +- .../scalesJava/LegacyScaleAdapter.kt | 379 +++++ .../core/bodymetric/BFDeurenberg.java | 36 - .../core/bodymetric/BFDeurenbergII.java | 35 - .../openscale/core/bodymetric/BFEddy.java | 35 - .../core/bodymetric/BFGallagher.java | 37 - .../core/bodymetric/BFGallagherAsian.java | 37 - .../core/bodymetric/EstimatedFatMetric.java | 44 - .../core/bodymetric/EstimatedLBMMetric.java | 42 - .../core/bodymetric/EstimatedWaterMetric.java | 42 - .../openscale/core/bodymetric/LBMBoer.java | 38 - .../openscale/core/bodymetric/LBMHume.java | 38 - .../core/bodymetric/LBMWeightMinusFat.java | 42 - .../openscale/core/bodymetric/TBWBehnke.java | 35 - .../core/bodymetric/TBWDelwaideCrenier.java | 31 - .../core/bodymetric/TBWHumeWeyers.java | 35 - .../core/bodymetric/TBWLeeSongKim.java | 35 - .../com/health/openscale/core/data/Enums.kt | 171 ++ .../health/openscale/core/data/Measurement.kt | 37 + .../openscale/core/data/MeasurementType.kt | 61 + .../openscale/core/data/MeasurementValue.kt | 50 + .../com/health/openscale/core/data/User.kt | 31 + .../openscale/core/database/AppDatabase.java | 213 --- .../openscale/core/database/AppDatabase.kt | 115 ++ .../core/database/DatabaseConverters.kt | 53 + .../core/database/DatabaseRepository.kt | 476 ++++++ .../openscale/core/database/MeasurementDao.kt | 136 ++ .../core/database/MeasurementTypeDao.kt | 45 + .../core/database/MeasurementValueDao.kt | 43 + .../core/database/ScaleDatabaseProvider.java | 178 -- .../core/database/ScaleMeasurementDAO.java | 83 - .../openscale/core/database/ScaleUserDAO.java | 53 - .../health/openscale/core/database/UserDao.kt | 44 + .../core/database/UserSettingsRepository.kt | 338 ++++ .../core/datatypes/ScaleMeasurement.java | 525 ------ .../openscale/core/datatypes/ScaleUser.java | 298 ---- .../core/evaluation/EvaluationResult.java | 34 - .../core/evaluation/EvaluationSheet.java | 325 ---- .../openscale/core/model/MeasurementModel.kt | 43 + .../openscale/core/utils/Converters.java | 407 ----- .../health/openscale/core/utils/Converters.kt | 135 ++ .../openscale/core/utils/CsvHelper.java | 134 -- .../openscale/core/utils/DateTimeHelpers.java | 55 - .../health/openscale/core/utils/LogManager.kt | 387 +++++ .../core/utils/PolynomialFitter.java | 215 --- .../com/health/openscale/core/utils/Utils.kt | 120 ++ .../health/openscale/gui/MainActivity.java | 1078 ------------ .../openscale/gui/graph/GraphFragment.java | 473 ------ .../gui/measurement/BMIMeasurementView.java | 73 - .../gui/measurement/BMRMeasurementView.java | 78 - .../measurement/BicepsMeasurementView.java | 69 - .../gui/measurement/BoneMeasurementView.java | 79 - .../measurement/Caliper1MeasurementView.java | 73 - .../measurement/Caliper2MeasurementView.java | 73 - .../measurement/Caliper3MeasurementView.java | 73 - .../measurement/CaloriesMeasurementView.java | 73 - .../gui/measurement/ChartActionBarView.java | 149 -- .../gui/measurement/ChartMarkerView.java | 93 -- .../gui/measurement/ChartMeasurementView.java | 708 -------- .../gui/measurement/ChestMeasurementView.java | 69 - .../measurement/CommentMeasurementView.java | 103 -- .../gui/measurement/DateMeasurementView.java | 126 -- .../FatCaliperMeasurementView.java | 82 - .../gui/measurement/FatMeasurementView.java | 99 -- .../gui/measurement/FloatMeasurementView.java | 793 --------- .../gui/measurement/HipMeasurementView.java | 69 - .../gui/measurement/LBMMeasurementView.java | 91 - .../gui/measurement/LinearGaugeView.java | 262 --- .../measurement/MeasurementEntryFragment.java | 439 ----- .../gui/measurement/MeasurementView.java | 545 ------ .../measurement/MeasurementViewSettings.java | 253 --- .../MeasurementViewUpdateListener.java | 20 - .../measurement/MuscleMeasurementView.java | 77 - .../gui/measurement/NeckMeasurementView.java | 69 - .../gui/measurement/TDEEMeasurementView.java | 78 - .../gui/measurement/ThighMeasurementView.java | 69 - .../gui/measurement/TimeMeasurementView.java | 131 -- .../gui/measurement/UserMeasurementView.java | 123 -- .../VisceralFatMeasurementView.java | 68 - .../gui/measurement/WHRMeasurementView.java | 73 - .../gui/measurement/WHtRMeasurementView.java | 73 - .../gui/measurement/WaistMeasurementView.java | 69 - .../gui/measurement/WaterMeasurementView.java | 99 -- .../measurement/WeightMeasurementView.java | 69 - .../gui/overview/OverviewAdapter.java | 217 --- .../gui/overview/OverviewFragment.java | 410 ----- .../gui/preferences/AboutPreferences.java | 171 -- .../gui/preferences/BackupPreferences.java | 321 ---- .../gui/preferences/BluetoothPreferences.java | 104 -- .../BluetoothSettingsFragment.java | 546 ------ .../gui/preferences/GeneralPreferences.java | 63 - .../gui/preferences/GraphPreferences.java | 68 - .../gui/preferences/MainPreferences.java | 147 -- .../MeasurementDetailPreferences.java | 52 - .../preferences/MeasurementPreferences.java | 337 ---- .../gui/preferences/ReminderPreferences.java | 204 --- .../gui/preferences/TimePreference.java | 89 - .../gui/preferences/TimePreferenceDialog.java | 100 -- .../gui/preferences/UserSettingsFragment.java | 518 ------ .../gui/preferences/UsersPreferences.java | 156 -- .../gui/slides/AppIntroActivity.java | 68 - .../gui/slides/BluetoothIntroSlide.java | 100 -- .../gui/slides/MetricsIntroSlide.java | 68 - .../gui/slides/OpenSourceIntroSlide.java | 68 - .../gui/slides/PrivacyIntroSlide.java | 68 - .../gui/slides/SlideToNavigationAdapter.java | 76 - .../gui/slides/SupportIntroSlide.java | 68 - .../openscale/gui/slides/UserIntroSlide.java | 165 -- .../gui/slides/WelcomeIntroSlide.java | 56 - .../gui/statistic/StatisticAdapter.java | 207 --- .../gui/statistic/StatisticsFragment.java | 290 ---- .../gui/table/StickyHeaderTableView.java | 1466 ----------------- .../openscale/gui/table/TableFragment.java | 169 -- .../health/openscale/gui/utils/ColorUtil.java | 59 - .../openscale/gui/widget/WidgetConfigure.java | 127 -- .../openscale/gui/widget/WidgetProvider.java | 206 --- .../openscale/ui/navigation/AppNavigation.kt | 614 +++++++ .../health/openscale/ui/navigation/Routes.kt | 90 + .../openscale/ui/screen/SharedViewModel.kt | 614 +++++++ .../bluetooth/BluetoothConnectionManager.kt | 507 ++++++ .../bluetooth/BluetoothScannerManager.kt | 345 ++++ .../ui/screen/bluetooth/BluetoothViewModel.kt | 567 +++++++ .../ui/screen/components/LineChart.kt | 779 +++++++++ .../components/MeasurementTypeFilterRow.kt | 268 +++ .../ui/screen/dialog/ColorPickerDialog.kt | 118 ++ .../ui/screen/dialog/DateInputDialog.kt | 104 ++ .../ui/screen/dialog/IconPickerDialog.kt | 116 ++ .../ui/screen/dialog/NumberInputDialog.kt | 156 ++ .../ui/screen/dialog/TextInputDialog.kt | 98 ++ .../ui/screen/dialog/TimeInputDialog.kt | 164 ++ .../openscale/ui/screen/graph/GraphScreen.kt | 72 + .../overview/MeasurementDetailScreen.kt | 557 +++++++ .../ui/screen/overview/OverviewScreen.kt | 871 ++++++++++ .../ui/screen/settings/AboutScreen.kt | 206 +++ .../ui/screen/settings/BluetoothScreen.kt | 505 ++++++ .../settings/DataManagementSettingsScreen.kt | 659 ++++++++ .../screen/settings/GeneralSettingsScreen.kt | 304 ++++ .../settings/MeasurementTypeDetailScreen.kt | 386 +++++ .../settings/MeasurementTypeSettingsScreen.kt | 212 +++ .../ui/screen/settings/SettingsScreen.kt | 160 ++ .../ui/screen/settings/SettingsViewModel.kt | 1098 ++++++++++++ .../ui/screen/settings/UserDetailScreen.kt | 265 +++ .../ui/screen/settings/UserSettingsScreen.kt | 133 ++ .../ui/screen/statistics/StatisticsScreen.kt | 445 +++++ .../openscale/ui/screen/table/TableScreen.kt | 498 ++++++ .../com/health/openscale/ui/theme/Color.kt | 26 + .../com/health/openscale/ui/theme/Theme.kt | 65 + .../com/health/openscale/ui/theme/Type.kt | 51 + .../ic_notification_openscale_monochrome.xml | 17 - .../ic_notification_openscale_monochrome.png | Bin 399 -> 0 bytes .../ic_notification_openscale_monochrome.png | Bin 257 -> 0 bytes .../ic_notification_openscale_monochrome.png | Bin 530 -> 0 bytes .../ic_notification_openscale_monochrome.png | Bin 797 -> 0 bytes .../src/main/res/drawable/appwidget_bg.xml | 13 - .../src/main/res/drawable/chart_marker.xml | 13 - .../app/src/main/res/drawable/ic_add.xml | 5 - .../drawable/ic_bluetooth_connection_lost.xml | 67 - .../ic_bluetooth_connection_success.xml | 22 - .../ic_bluetooth_device_not_supported.xml | 5 - .../ic_bluetooth_device_supported.xml | 5 - .../res/drawable/ic_bluetooth_disabled.xml | 9 - .../res/drawable/ic_bluetooth_searching.xml | 9 - .../drawable/{ic_calendar.xml => ic_date.xml} | 0 .../app/src/main/res/drawable/ic_delete.xml | 10 - .../app/src/main/res/drawable/ic_editable.xml | 10 - .../app/src/main/res/drawable/ic_expand.xml | 12 - .../src/main/res/drawable/ic_expand_less.xml | 10 - .../src/main/res/drawable/ic_expand_more.xml | 10 - .../src/main/res/drawable/ic_lastmonth.xml | 21 - .../res/drawable/ic_launcher_foreground.xml | 54 + .../res/drawable/ic_launcher_openscale.xml | 54 - .../app/src/main/res/drawable/ic_options.xml | 10 - .../res/drawable/ic_preference_donate.xml | 5 - .../res/drawable/ic_preferences_about.xml | 5 - .../res/drawable/ic_preferences_backup.xml | 15 - .../res/drawable/ic_preferences_bluetooth.xml | 54 - .../res/drawable/ic_preferences_graph.xml | 12 - .../main/res/drawable/ic_preferences_help.xml | 10 - .../main/res/drawable/ic_preferences_home.xml | 10 - .../drawable/ic_preferences_measurement.xml | 12 - .../res/drawable/ic_preferences_nav_graph.xml | 12 - .../res/drawable/ic_preferences_reminder.xml | 15 - .../res/drawable/ic_preferences_settings.xml | 10 - .../drawable/ic_preferences_statistics.xml | 12 - .../res/drawable/ic_preferences_table.xml | 6 - .../res/drawable/ic_preferences_users.xml | 27 - .../app/src/main/res/drawable/ic_reorder.xml | 11 - .../app/src/main/res/drawable/ic_save.xml | 10 - .../app/src/main/res/drawable/ic_show.xml | 10 - .../src/main/res/drawable/ic_slide_group.xml | 4 - .../main/res/drawable/ic_slide_opensource.xml | 9 - .../main/res/drawable/ic_slide_privacy.xml | 18 - .../main/res/drawable/ic_slide_support.xml | 5 - .../drawable/{ic_daysleft.xml => ic_time.xml} | 0 .../app/src/main/res/drawable/ic_user.xml | 18 - .../src/main/res/drawable/nav_item_colors.xml | 5 - .../app/src/main/res/layout/activity_main.xml | 67 - .../res/layout/activity_slidetonavigation.xml | 27 - .../src/main/res/layout/chart_markerview.xml | 27 - .../app/src/main/res/layout/drawer_header.xml | 23 - .../src/main/res/layout/float_input_view.xml | 48 - .../res/layout/fragment_bluetoothsettings.xml | 38 - .../main/res/layout/fragment_dataentry.xml | 57 - .../src/main/res/layout/fragment_graph.xml | 159 -- .../src/main/res/layout/fragment_overview.xml | 109 -- .../main/res/layout/fragment_statistics.xml | 75 - .../src/main/res/layout/fragment_table.xml | 38 - .../main/res/layout/fragment_usersettings.xml | 341 ---- .../app/src/main/res/layout/item_overview.xml | 96 -- .../src/main/res/layout/item_statistic.xml | 86 - .../src/main/res/layout/preference_info.xml | 19 - .../layout/preference_measurement_order.xml | 86 - .../main/res/layout/preference_timepicker.xml | 9 - .../src/main/res/layout/slide_bluetooth.xml | 95 -- .../app/src/main/res/layout/slide_metrics.xml | 70 - .../src/main/res/layout/slide_opensource.xml | 71 - .../app/src/main/res/layout/slide_privacy.xml | 70 - .../app/src/main/res/layout/slide_support.xml | 70 - .../app/src/main/res/layout/slide_user.xml | 85 - .../app/src/main/res/layout/slide_welcome.xml | 46 - .../app/src/main/res/layout/spinner_item.xml | 8 - .../layout/user_preference_widget_layout.xml | 19 - .../app/src/main/res/layout/widget.xml | 91 - .../main/res/layout/widget_configuration.xml | 56 - .../app/src/main/res/menu/action_menu.xml | 33 - .../app/src/main/res/menu/dataentry_menu.xml | 32 - .../src/main/res/menu/drawer_bottom_view.xml | 24 - .../app/src/main/res/menu/drawer_view.xml | 45 - .../app/src/main/res/menu/graph_menu.xml | 19 - .../app/src/main/res/menu/overview_menu.xml | 28 - .../app/src/main/res/menu/userentry_menu.xml | 19 - .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../ic_launcher_openscale_round.xml | 6 - .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 2454 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 4548 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 1662 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 2784 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 3160 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 6048 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 4782 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 8888 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 6136 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 12124 bytes .../main/res/navigation/mobile_navigation.xml | 206 --- .../main/res/navigation/slide_navigation.xml | 22 - .../app/src/main/res/values-ar/strings.xml | 304 ---- .../src/main/res/values-bn-rBD/strings.xml | 184 --- .../app/src/main/res/values-ca/strings.xml | 304 ---- .../app/src/main/res/values-cs/strings.xml | 285 ---- .../app/src/main/res/values-da/strings.xml | 211 --- .../app/src/main/res/values-de/strings.xml | 666 ++++---- .../app/src/main/res/values-el/strings.xml | 282 ---- .../app/src/main/res/values-eo/strings.xml | 157 -- .../app/src/main/res/values-es/strings.xml | 304 ---- .../app/src/main/res/values-eu/strings.xml | 37 - .../app/src/main/res/values-fi/strings.xml | 195 --- .../app/src/main/res/values-fr/strings.xml | 298 ---- .../app/src/main/res/values-gl/strings.xml | 262 --- .../app/src/main/res/values-hr/strings.xml | 304 ---- .../app/src/main/res/values-hu/strings.xml | 304 ---- .../app/src/main/res/values-id/strings.xml | 160 -- .../app/src/main/res/values-it/strings.xml | 304 ---- .../app/src/main/res/values-iw/strings.xml | 304 ---- .../app/src/main/res/values-ja/strings.xml | 259 --- .../app/src/main/res/values-ko/strings.xml | 209 --- .../app/src/main/res/values-lt/strings.xml | 161 -- .../app/src/main/res/values-ml/strings.xml | 90 - .../app/src/main/res/values-nb/strings.xml | 304 ---- .../app/src/main/res/values-night/themes.xml | 107 -- .../app/src/main/res/values-nl/strings.xml | 304 ---- .../app/src/main/res/values-pl/strings.xml | 304 ---- .../src/main/res/values-pt-rBR/strings.xml | 288 ---- .../app/src/main/res/values-pt/strings.xml | 304 ---- .../app/src/main/res/values-ro/strings.xml | 191 --- .../app/src/main/res/values-ru/strings.xml | 304 ---- .../app/src/main/res/values-sk/strings.xml | 234 --- .../app/src/main/res/values-sl/strings.xml | 278 ---- .../app/src/main/res/values-sr/strings.xml | 75 - .../app/src/main/res/values-sv/strings.xml | 304 ---- .../app/src/main/res/values-ta/strings.xml | 279 ---- .../app/src/main/res/values-tr/strings.xml | 304 ---- .../app/src/main/res/values-uk/strings.xml | 304 ---- .../app/src/main/res/values-vi/strings.xml | 209 --- .../src/main/res/values-zh-rCN/strings.xml | 304 ---- .../src/main/res/values-zh-rTW/strings.xml | 217 --- .../app/src/main/res/values/arrays.xml | 131 -- .../res/values/attrs_linear_gauge_view.xml | 4 - .../app/src/main/res/values/colors.xml | 74 +- .../app/src/main/res/values/dimens.xml | 5 - .../res/values/ic_launcher_background.xml | 4 + .../app/src/main/res/values/strings.xml | 648 ++++---- .../app/src/main/res/values/themes.xml | 76 +- .../src/main/res/values/type_auto_backup.xml | 15 - .../app/src/main/res/values/type_weekdays.xml | 24 - .../src/main/res/xml/about_preferences.xml | 33 - .../src/main/res/xml/backup_preferences.xml | 18 - .../app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/bluetooth_preferences.xml | 32 - .../main/res/xml/data_extraction_rules.xml | 19 + .../app/src/main/res/xml/file_paths.xml | 23 + .../src/main/res/xml/file_provider_paths.xml | 4 - .../src/main/res/xml/general_preferences.xml | 29 - .../src/main/res/xml/graph_preferences.xml | 65 - .../app/src/main/res/xml/main_preferences.xml | 35 - .../xml/measurement_detail_preferences.xml | 3 - .../main/res/xml/measurement_preferences.xml | 13 - .../src/main/res/xml/reminder_preferences.xml | 34 - .../src/main/res/xml/users_preferences.xml | 14 - .../app/src/main/res/xml/widget_info.xml | 10 - .../openscale/BluetoothGattUuidTest.java | 47 - .../com/health/openscale/ConvertersTest.java | 206 --- .../com/health/openscale/CsvHelperTest.java | 203 --- .../health/openscale/DateTimeHelpersTest.java | 109 -- .../com/health/openscale/MeasurementTest.java | 81 - android_app/build.gradle | 19 - android_app/build.gradle.kts | 6 + android_app/gradle.properties | 25 +- android_app/gradle/libs.versions.toml | 57 + android_app/gradle/wrapper/gradle-wrapper.jar | Bin 43583 -> 59203 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 +- android_app/gradlew | 285 ++-- android_app/gradlew.bat | 37 +- android_app/settings.gradle | 1 - android_app/settings.gradle.kts | 25 + 402 files changed, 16742 insertions(+), 46418 deletions(-) delete mode 100644 android_app/app/build.gradle create mode 100644 android_app/app/build.gradle.kts create mode 100644 android_app/app/proguard-rules.pro delete mode 100644 android_app/app/schemas/com.health.openscale.core.database.AppDatabase/1.json delete mode 100644 android_app/app/schemas/com.health.openscale.core.database.AppDatabase/2.json delete mode 100644 android_app/app/schemas/com.health.openscale.core.database.AppDatabase/3.json delete mode 100644 android_app/app/schemas/com.health.openscale.core.database.AppDatabase/4.json delete mode 100644 android_app/app/schemas/com.health.openscale.core.database.AppDatabase/5.json delete mode 100644 android_app/app/schemas/com.health.openscale.core.database.AppDatabase/6.json delete mode 100644 android_app/app/src/androidTest/java/com/health/openscale/DatabaseMigrationTest.java delete mode 100644 android_app/app/src/androidTest/java/com/health/openscale/DatabaseTest.java delete mode 100644 android_app/app/src/androidTest/java/com/health/openscale/TrisaBodyAnalyzeLibTest.java delete mode 100644 android_app/app/src/androidTest/java/com/health/openscale/gui/AddMeasurementTest.java delete mode 100644 android_app/app/src/androidTest/java/com/health/openscale/gui/AddUserTest.java delete mode 100644 android_app/app/src/androidTest/java/com/health/openscale/gui/ScreenshotRecorder.java delete mode 100644 android_app/app/src/androidTest/java/com/health/openscale/gui/TestData.java create mode 100644 android_app/app/src/main/java/com/health/openscale/MainActivity.kt delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/Application.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/OpenScale.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/alarm/AlarmBackupHandler.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/alarm/AlarmEntry.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/alarm/AlarmEntryReader.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/alarm/AlarmHandler.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/alarm/ReminderBootReceiver.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothActiveEraBF06.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerBF105.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerBF500.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerBF600.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerBF950.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerSanitas.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBroadcastScale.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothCustomOpenScale.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothDebug.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothDigooDGSO38H.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothES26BBB.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothESCS20M.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothExcelvanCF36xBLE.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothExingtechY1.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothFactory.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothHesley.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothHoffenBBS8107.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothHuaweiAH100.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothIhealthHS3.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothInlife.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothMGB.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothMedisanaBS44x.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothMiScale.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothMiScale2.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothOKOK.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothOKOK2.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothOneByone.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothOneByoneNew.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothQNScale.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothRenphoScale.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothSanitasSBF72.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothSenssun.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothSinocare.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothSoehnle.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothStandardWeightProfile.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothTrisaBodyAnalyze.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothYoda1Scale.java create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleCommnuicator.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleMeasurement.java create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleUser.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/MiScaleLib.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/OneByoneLib.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/OneByoneNewLib.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/SoehnleLib.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/TrisaBodyAnalyzeLib.java rename android_app/app/src/main/java/com/health/openscale/core/bluetooth/{lib => libs}/YunmaiLib.java (81%) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DummyScaleHandler.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt rename android_app/app/src/main/java/com/health/openscale/core/bluetooth/{ => scalesJava}/BluetoothCommunication.java (78%) rename android_app/app/src/main/java/com/health/openscale/core/bluetooth/{ => scalesJava}/BluetoothGattUuid.java (85%) rename android_app/app/src/main/java/com/health/openscale/core/bluetooth/{ => scalesJava}/BluetoothYunmaiSE_Mini.java (71%) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/LegacyScaleAdapter.kt delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bodymetric/BFDeurenberg.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bodymetric/BFDeurenbergII.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bodymetric/BFEddy.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bodymetric/BFGallagher.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bodymetric/BFGallagherAsian.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bodymetric/EstimatedFatMetric.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bodymetric/EstimatedLBMMetric.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bodymetric/EstimatedWaterMetric.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bodymetric/LBMBoer.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bodymetric/LBMHume.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bodymetric/LBMWeightMinusFat.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bodymetric/TBWBehnke.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bodymetric/TBWDelwaideCrenier.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bodymetric/TBWHumeWeyers.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/bodymetric/TBWLeeSongKim.java create mode 100644 android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/data/Measurement.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/data/MeasurementType.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/data/MeasurementValue.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/data/User.kt delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.java create mode 100644 android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/database/DatabaseConverters.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/database/DatabaseRepository.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/database/MeasurementDao.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/database/MeasurementTypeDao.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/database/MeasurementValueDao.kt delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/database/ScaleDatabaseProvider.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/database/ScaleMeasurementDAO.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/database/ScaleUserDAO.java create mode 100644 android_app/app/src/main/java/com/health/openscale/core/database/UserDao.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/database/UserSettingsRepository.kt delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/datatypes/ScaleMeasurement.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/datatypes/ScaleUser.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/evaluation/EvaluationResult.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/evaluation/EvaluationSheet.java create mode 100644 android_app/app/src/main/java/com/health/openscale/core/model/MeasurementModel.kt delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/utils/Converters.java create mode 100644 android_app/app/src/main/java/com/health/openscale/core/utils/Converters.kt delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/utils/CsvHelper.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/utils/DateTimeHelpers.java create mode 100644 android_app/app/src/main/java/com/health/openscale/core/utils/LogManager.kt delete mode 100644 android_app/app/src/main/java/com/health/openscale/core/utils/PolynomialFitter.java create mode 100644 android_app/app/src/main/java/com/health/openscale/core/utils/Utils.kt delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/MainActivity.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/graph/GraphFragment.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/BMIMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/BMRMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/BicepsMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/BoneMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/Caliper1MeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/Caliper2MeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/Caliper3MeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/CaloriesMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/ChartActionBarView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/ChartMarkerView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/ChartMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/ChestMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/CommentMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/DateMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/FatCaliperMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/FatMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/FloatMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/HipMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/LBMMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/LinearGaugeView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/MeasurementEntryFragment.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/MeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/MeasurementViewSettings.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/MeasurementViewUpdateListener.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/MuscleMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/NeckMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/TDEEMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/ThighMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/TimeMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/UserMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/VisceralFatMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/WHRMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/WHtRMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/WaistMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/WaterMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/measurement/WeightMeasurementView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/overview/OverviewAdapter.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/overview/OverviewFragment.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/preferences/AboutPreferences.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/preferences/BackupPreferences.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/preferences/BluetoothPreferences.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/preferences/BluetoothSettingsFragment.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/preferences/GeneralPreferences.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/preferences/GraphPreferences.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/preferences/MainPreferences.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/preferences/MeasurementDetailPreferences.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/preferences/MeasurementPreferences.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/preferences/ReminderPreferences.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/preferences/TimePreference.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/preferences/TimePreferenceDialog.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/preferences/UserSettingsFragment.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/preferences/UsersPreferences.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/slides/AppIntroActivity.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/slides/BluetoothIntroSlide.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/slides/MetricsIntroSlide.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/slides/OpenSourceIntroSlide.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/slides/PrivacyIntroSlide.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/slides/SlideToNavigationAdapter.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/slides/SupportIntroSlide.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/slides/UserIntroSlide.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/slides/WelcomeIntroSlide.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/statistic/StatisticAdapter.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/statistic/StatisticsFragment.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/table/StickyHeaderTableView.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/table/TableFragment.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/utils/ColorUtil.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/widget/WidgetConfigure.java delete mode 100644 android_app/app/src/main/java/com/health/openscale/gui/widget/WidgetProvider.java create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/navigation/Routes.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/SharedViewModel.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothConnectionManager.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothScannerManager.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothViewModel.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/components/LineChart.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementTypeFilterRow.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/ColorPickerDialog.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/DateInputDialog.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/IconPickerDialog.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/NumberInputDialog.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/TextInputDialog.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/TimeInputDialog.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/graph/GraphScreen.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/settings/AboutScreen.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/settings/DataManagementSettingsScreen.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/settings/GeneralSettingsScreen.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeDetailScreen.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeSettingsScreen.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsScreen.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/settings/UserDetailScreen.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/settings/UserSettingsScreen.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/statistics/StatisticsScreen.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/theme/Color.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/theme/Theme.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/theme/Type.kt delete mode 100644 android_app/app/src/main/res/drawable-anydpi-v24/ic_notification_openscale_monochrome.xml delete mode 100644 android_app/app/src/main/res/drawable-hdpi/ic_notification_openscale_monochrome.png delete mode 100644 android_app/app/src/main/res/drawable-mdpi/ic_notification_openscale_monochrome.png delete mode 100644 android_app/app/src/main/res/drawable-xhdpi/ic_notification_openscale_monochrome.png delete mode 100644 android_app/app/src/main/res/drawable-xxhdpi/ic_notification_openscale_monochrome.png delete mode 100644 android_app/app/src/main/res/drawable/appwidget_bg.xml delete mode 100644 android_app/app/src/main/res/drawable/chart_marker.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_add.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_bluetooth_connection_lost.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_bluetooth_connection_success.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_bluetooth_device_not_supported.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_bluetooth_device_supported.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_bluetooth_disabled.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_bluetooth_searching.xml rename android_app/app/src/main/res/drawable/{ic_calendar.xml => ic_date.xml} (100%) delete mode 100644 android_app/app/src/main/res/drawable/ic_delete.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_editable.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_expand.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_expand_less.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_expand_more.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_lastmonth.xml create mode 100644 android_app/app/src/main/res/drawable/ic_launcher_foreground.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_launcher_openscale.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_options.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_preference_donate.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_preferences_about.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_preferences_backup.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_preferences_bluetooth.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_preferences_graph.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_preferences_help.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_preferences_home.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_preferences_measurement.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_preferences_nav_graph.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_preferences_reminder.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_preferences_settings.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_preferences_statistics.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_preferences_table.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_preferences_users.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_reorder.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_save.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_show.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_slide_group.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_slide_opensource.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_slide_privacy.xml delete mode 100644 android_app/app/src/main/res/drawable/ic_slide_support.xml rename android_app/app/src/main/res/drawable/{ic_daysleft.xml => ic_time.xml} (100%) delete mode 100644 android_app/app/src/main/res/drawable/ic_user.xml delete mode 100644 android_app/app/src/main/res/drawable/nav_item_colors.xml delete mode 100644 android_app/app/src/main/res/layout/activity_main.xml delete mode 100644 android_app/app/src/main/res/layout/activity_slidetonavigation.xml delete mode 100644 android_app/app/src/main/res/layout/chart_markerview.xml delete mode 100644 android_app/app/src/main/res/layout/drawer_header.xml delete mode 100644 android_app/app/src/main/res/layout/float_input_view.xml delete mode 100644 android_app/app/src/main/res/layout/fragment_bluetoothsettings.xml delete mode 100644 android_app/app/src/main/res/layout/fragment_dataentry.xml delete mode 100644 android_app/app/src/main/res/layout/fragment_graph.xml delete mode 100644 android_app/app/src/main/res/layout/fragment_overview.xml delete mode 100644 android_app/app/src/main/res/layout/fragment_statistics.xml delete mode 100644 android_app/app/src/main/res/layout/fragment_table.xml delete mode 100644 android_app/app/src/main/res/layout/fragment_usersettings.xml delete mode 100644 android_app/app/src/main/res/layout/item_overview.xml delete mode 100644 android_app/app/src/main/res/layout/item_statistic.xml delete mode 100644 android_app/app/src/main/res/layout/preference_info.xml delete mode 100644 android_app/app/src/main/res/layout/preference_measurement_order.xml delete mode 100644 android_app/app/src/main/res/layout/preference_timepicker.xml delete mode 100644 android_app/app/src/main/res/layout/slide_bluetooth.xml delete mode 100644 android_app/app/src/main/res/layout/slide_metrics.xml delete mode 100644 android_app/app/src/main/res/layout/slide_opensource.xml delete mode 100644 android_app/app/src/main/res/layout/slide_privacy.xml delete mode 100644 android_app/app/src/main/res/layout/slide_support.xml delete mode 100644 android_app/app/src/main/res/layout/slide_user.xml delete mode 100644 android_app/app/src/main/res/layout/slide_welcome.xml delete mode 100644 android_app/app/src/main/res/layout/spinner_item.xml delete mode 100644 android_app/app/src/main/res/layout/user_preference_widget_layout.xml delete mode 100644 android_app/app/src/main/res/layout/widget.xml delete mode 100644 android_app/app/src/main/res/layout/widget_configuration.xml delete mode 100644 android_app/app/src/main/res/menu/action_menu.xml delete mode 100644 android_app/app/src/main/res/menu/dataentry_menu.xml delete mode 100644 android_app/app/src/main/res/menu/drawer_bottom_view.xml delete mode 100644 android_app/app/src/main/res/menu/drawer_view.xml delete mode 100644 android_app/app/src/main/res/menu/graph_menu.xml delete mode 100644 android_app/app/src/main/res/menu/overview_menu.xml delete mode 100644 android_app/app/src/main/res/menu/userentry_menu.xml create mode 100644 android_app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml delete mode 100644 android_app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_openscale_round.xml create mode 100644 android_app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android_app/app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 android_app/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 android_app/app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 android_app/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 android_app/app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 android_app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 android_app/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 android_app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 android_app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 android_app/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp delete mode 100644 android_app/app/src/main/res/navigation/mobile_navigation.xml delete mode 100644 android_app/app/src/main/res/navigation/slide_navigation.xml delete mode 100644 android_app/app/src/main/res/values-ar/strings.xml delete mode 100644 android_app/app/src/main/res/values-bn-rBD/strings.xml delete mode 100644 android_app/app/src/main/res/values-ca/strings.xml delete mode 100644 android_app/app/src/main/res/values-cs/strings.xml delete mode 100644 android_app/app/src/main/res/values-da/strings.xml delete mode 100644 android_app/app/src/main/res/values-el/strings.xml delete mode 100644 android_app/app/src/main/res/values-eo/strings.xml delete mode 100644 android_app/app/src/main/res/values-es/strings.xml delete mode 100644 android_app/app/src/main/res/values-eu/strings.xml delete mode 100644 android_app/app/src/main/res/values-fi/strings.xml delete mode 100644 android_app/app/src/main/res/values-fr/strings.xml delete mode 100644 android_app/app/src/main/res/values-gl/strings.xml delete mode 100644 android_app/app/src/main/res/values-hr/strings.xml delete mode 100644 android_app/app/src/main/res/values-hu/strings.xml delete mode 100644 android_app/app/src/main/res/values-id/strings.xml delete mode 100644 android_app/app/src/main/res/values-it/strings.xml delete mode 100644 android_app/app/src/main/res/values-iw/strings.xml delete mode 100644 android_app/app/src/main/res/values-ja/strings.xml delete mode 100644 android_app/app/src/main/res/values-ko/strings.xml delete mode 100644 android_app/app/src/main/res/values-lt/strings.xml delete mode 100644 android_app/app/src/main/res/values-ml/strings.xml delete mode 100644 android_app/app/src/main/res/values-nb/strings.xml delete mode 100644 android_app/app/src/main/res/values-night/themes.xml delete mode 100644 android_app/app/src/main/res/values-nl/strings.xml delete mode 100644 android_app/app/src/main/res/values-pl/strings.xml delete mode 100644 android_app/app/src/main/res/values-pt-rBR/strings.xml delete mode 100644 android_app/app/src/main/res/values-pt/strings.xml delete mode 100644 android_app/app/src/main/res/values-ro/strings.xml delete mode 100644 android_app/app/src/main/res/values-ru/strings.xml delete mode 100644 android_app/app/src/main/res/values-sk/strings.xml delete mode 100644 android_app/app/src/main/res/values-sl/strings.xml delete mode 100644 android_app/app/src/main/res/values-sr/strings.xml delete mode 100644 android_app/app/src/main/res/values-sv/strings.xml delete mode 100644 android_app/app/src/main/res/values-ta/strings.xml delete mode 100644 android_app/app/src/main/res/values-tr/strings.xml delete mode 100644 android_app/app/src/main/res/values-uk/strings.xml delete mode 100644 android_app/app/src/main/res/values-vi/strings.xml delete mode 100644 android_app/app/src/main/res/values-zh-rCN/strings.xml delete mode 100644 android_app/app/src/main/res/values-zh-rTW/strings.xml delete mode 100644 android_app/app/src/main/res/values/arrays.xml delete mode 100644 android_app/app/src/main/res/values/attrs_linear_gauge_view.xml delete mode 100644 android_app/app/src/main/res/values/dimens.xml create mode 100644 android_app/app/src/main/res/values/ic_launcher_background.xml delete mode 100644 android_app/app/src/main/res/values/type_auto_backup.xml delete mode 100644 android_app/app/src/main/res/values/type_weekdays.xml delete mode 100644 android_app/app/src/main/res/xml/about_preferences.xml delete mode 100644 android_app/app/src/main/res/xml/backup_preferences.xml create mode 100644 android_app/app/src/main/res/xml/backup_rules.xml delete mode 100644 android_app/app/src/main/res/xml/bluetooth_preferences.xml create mode 100644 android_app/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 android_app/app/src/main/res/xml/file_paths.xml delete mode 100644 android_app/app/src/main/res/xml/file_provider_paths.xml delete mode 100644 android_app/app/src/main/res/xml/general_preferences.xml delete mode 100644 android_app/app/src/main/res/xml/graph_preferences.xml delete mode 100644 android_app/app/src/main/res/xml/main_preferences.xml delete mode 100644 android_app/app/src/main/res/xml/measurement_detail_preferences.xml delete mode 100644 android_app/app/src/main/res/xml/measurement_preferences.xml delete mode 100644 android_app/app/src/main/res/xml/reminder_preferences.xml delete mode 100644 android_app/app/src/main/res/xml/users_preferences.xml delete mode 100644 android_app/app/src/main/res/xml/widget_info.xml delete mode 100644 android_app/app/src/test/java/com/health/openscale/BluetoothGattUuidTest.java delete mode 100644 android_app/app/src/test/java/com/health/openscale/ConvertersTest.java delete mode 100644 android_app/app/src/test/java/com/health/openscale/CsvHelperTest.java delete mode 100644 android_app/app/src/test/java/com/health/openscale/DateTimeHelpersTest.java delete mode 100644 android_app/app/src/test/java/com/health/openscale/MeasurementTest.java delete mode 100644 android_app/build.gradle create mode 100644 android_app/build.gradle.kts create mode 100644 android_app/gradle/libs.versions.toml delete mode 100644 android_app/settings.gradle create mode 100644 android_app/settings.gradle.kts 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 20d34db11fadb7f031bdd60b2b7e428ae6bf1bc5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 399 zcmV;A0dW3_P)!INs4`@Y#wzwp_#7J#PULDsi(Kjy|23 z!Np&F_8dHZ66>AM%(FQt%tUmD;>%~?K3rt_xqQQ?TY5U4fjIU^K|*CgcL;FnA%Wv~ z;~F-vJrc}d7JH;-?h+b!9mf?oaP1;$IgZ@_q8Atn9ueIkcz4SyM8YdP^UNW3rNK;? z$6jlY(#Z;eHK`pxkKeLxITqBzovV=nBvj&NC8X3S=5P#q&a*$UDSjpN2u!Gi36(IR5++o_gi4rD2@@*u tH-y4(002ovPDHLkV1j%QtVI9- 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 cd864e7cbd7925bdfde3acfa5e7f10399bedda2b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 257 zcmV+c0sj7pP)jNXMH-4L>;34kNaI?7q1N5-ZnUJIaLdl7>@8y#|h!ZXihpYfN$Uuf%H7_65)YzLF z4yy!U#SM+2X5(VOYQeWuL6$1WQUzJI74V~lq-4fF{a^A1g;+P5?^#`k00000NkvXX Hu0mjf`Dts~ 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 5a074c025eb4bf7e492fb3c9aad9810582166619..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 530 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezr3(Fy8cZaSW-5dpol+_mF{rt7M~~ zk&}A@i&=x<#{BX0PsEdsTPGkxOCcr>wrJx8umEuydlTV;xKb_*Tar_|2xLw=ZT*>>9UU#>b_u zr6pH$pD-^d_?Y;6Z&UHFohNU^F9==8tg7VQ6?gH+ zPR0)Ng63V_7dbNyPtu5Lk=|hXGw{zGh9i;-Dk@_xy!~?F==B*YA@`as zn0@0QQ~AnQ3%2ZEw{$)p z`6eSvL{dRy z!{NtUtyr_mPYC$0DP#P})|I@Kdj;F6L~_X=>})^cp6eHEzV&{=@5znp|2#W&^ysNq e>q725V9zN#|3I0o=npWi89ZJ6T-G@yGywpM*!24V 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 019b86e98a5e6f9495a71d0f3856b4d8dd006218..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 797 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@Zgyv2V0z-|;uum9_jYz;;cWvRS7Rr) z4;-=wl}nlnn$wt*n^Typo6DHXo70;6F64dio>Jsf@3bQ2VrJ2y-H%l_p5T8KIb~^a zNx3+S`Hooaw%<mqCfzpM?r+mByes#`;@bO3afgaC4&3&*Sq`0bC+g3n{YRHO2J&VFTy5TYWw${e5<&k~j`{{=Y^m;L8&Z8-kH1UkaG$tt>vV-Vw;NKM=Jndl{W0&?*989Gy%9yL zRpwl8c+IMM>Z0H*}z5DyfkGcN5tj6he=26BMW1i`muRK0`LH`0nv(Wv_`EMMo z&OiCf5hRq_<2h-S|NF%nbJHK3JLvq1M>s~^!*fcqZ@~0}uiZ`7tv|YC)t893njUkL zn^GtHO-c3rvhmhB_0R4XJnL?3ligO^T))ZJ z*Zj46-?H0h3cr-|TXtR9srCZsm^tec{=Gde*QUYx_ld@#pS&-$feu`6ZBnNAGBK9p z+myu~bMu@2HHpM(RsDNWn7{3CW#%`x8}7_`$}bixyjjesc!HhA$ldzm3ek`6)3XBq zP2ceSv6p-!^X>x2%3Trt%S^XibidrJ!0}JLp5f@U$J>RXdgXx0p25@A&t;ucLK6V^ Ct!8@w 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 @@ - - - -