From 6a1ef81a0508f40b0937b27631ec177959863f13 Mon Sep 17 00:00:00 2001 From: Christian Breiteneder Date: Sat, 14 Mar 2026 13:56:34 +0100 Subject: [PATCH] Add Braun iCheck 7 BPW4500 blood pressure monitor support Add full Gadgetbridge integration for the Braun iCheck 7 BPW4500 blood pressure monitor using its proprietary BLE protocol (Kaz USA). New files: - BraunBPW4500DeviceCoordinator: device detection via BLE name "BPW4500", WHO blood pressure classification, GenericBloodPressureSampleProvider - BraunBPW4500DeviceSupport: BLE communication, 19-byte measurement packet parsing (systolic, diastolic), RTC sync - DashboardBloodPressureWidget: dashboard gauge with WHO color coding - BloodPressureChartFragment: line chart, measurement list, PDF and CSV export with user profile info Modified files: - DeviceType: register BRAUN_BPW4500 - DashboardFragment: register bloodpressure widget - DefaultChartsProvider: register BloodPressureChartFragment - arrays.xml: add blood pressure to dashboard and charts tab lists - strings.xml: add device name, bp_systolic, bp_diastolic strings - shared_paths.xml: add pdf/csv cache paths for FileProvider export Known limitation: offline stored measurements cannot be retrieved reliably as the device transmits history data before GATT service discovery completes in Gadgetbridge. fixed chart monthly/weekly view remodeled blood pressure fragments fix remaining review comments --- .../activities/DashboardFragment.java | 4 + .../charts/BloodPressureChartFragment.java | 366 ++++++++++++++ .../BloodPressureCollectionFragment.java | 43 ++ .../charts/BloodPressurePeriodFragment.java | 448 ++++++++++++++++++ .../charts/DefaultChartsProvider.kt | 5 + .../DashboardBloodPressureWidget.java | 119 +++++ .../adapter/BloodPressureFragmentAdapter.java | 42 ++ .../devices/AbstractDeviceCoordinator.java | 3 +- .../braun/BraunBPW4500DeviceCoordinator.java | 105 ++++ .../gadgetbridge/model/DeviceType.java | 2 + .../braun/BraunBPW4500DeviceSupport.java | 291 ++++++++++++ .../util/BloodPressureExportHelper.java | 356 ++++++++++++++ .../layout/fragment_blood_pressure_chart.xml | 161 +++++++ .../layout/fragment_blood_pressure_period.xml | 136 ++++++ .../main/res/layout/item_blood_pressure.xml | 49 ++ app/src/main/res/values/arrays.xml | 6 + app/src/main/res/values/colors.xml | 2 + app/src/main/res/values/strings.xml | 8 + app/src/main/res/values/values.xml | 1 + app/src/main/res/xml/shared_paths.xml | 1 + .../AbstractDeviceCoordinatorTest.java | 1 + 21 files changed, 2148 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BloodPressureChartFragment.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BloodPressureCollectionFragment.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BloodPressurePeriodFragment.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardBloodPressureWidget.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/BloodPressureFragmentAdapter.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/braun/BraunBPW4500DeviceCoordinator.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/braun/BraunBPW4500DeviceSupport.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BloodPressureExportHelper.java create mode 100644 app/src/main/res/layout/fragment_blood_pressure_chart.xml create mode 100644 app/src/main/res/layout/fragment_blood_pressure_period.xml create mode 100644 app/src/main/res/layout/item_blood_pressure.xml diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DashboardFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DashboardFragment.java index af539fec89..baf984680c 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DashboardFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DashboardFragment.java @@ -72,6 +72,7 @@ import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardCalori import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardDistanceWidget; import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardGoalsWidget; import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardHrvWidget; +import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardBloodPressureWidget; import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardPaiWidget; import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardSleepScoreWidget; import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardSleepWidget; @@ -310,6 +311,9 @@ public class DashboardFragment extends Fragment implements MenuProvider { case "hrv": widget = DashboardHrvWidget.newInstance(dashboardData); break; + case "bloodpressure": + widget = DashboardBloodPressureWidget.newInstance(dashboardData); + break; case "vo2max_running": widget = DashboardVO2MaxRunningWidget.newInstance(dashboardData); break; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BloodPressureChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BloodPressureChartFragment.java new file mode 100644 index 0000000000..bc65b31589 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BloodPressureChartFragment.java @@ -0,0 +1,366 @@ +/* + Copyright (C) 2026 Christian Breiteneder + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ +package nodomain.freeyourgadget.gadgetbridge.activities.charts; + +import android.graphics.Bitmap; +import android.graphics.Color; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import com.github.mikephil.charting.charts.Chart; +import com.github.mikephil.charting.charts.LineChart; +import com.github.mikephil.charting.components.LegendEntry; +import com.github.mikephil.charting.components.LimitLine; +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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.BloodPressureSample; +import nodomain.freeyourgadget.gadgetbridge.util.Accumulator; +import nodomain.freeyourgadget.gadgetbridge.util.BloodPressureExportHelper; + +public class BloodPressureChartFragment extends AbstractChartFragment { + protected static final Logger LOG = LoggerFactory.getLogger(BloodPressureChartFragment.class); + + static int DATA_INVALID = -1; + + private int BACKGROUND_COLOR; + private int CHART_TEXT_COLOR; + private int TEXT_COLOR; + private int LEGEND_TEXT_COLOR; + + private TimestampTranslation tsTranslation; + + private TextView mDateView; + private LineChart mChart; + private TextView mSystolicLast; + private TextView mDiastolicLast; + private TextView mAverage; + private TextView mMeasurementCount; + private LinearLayout mManualMeasurements; + private LinearLayout mManualMeasurementsList; + private BloodPressureChartsData currentData; + + @Override + protected void init() { + BACKGROUND_COLOR = GBApplication.getBackgroundColor(requireContext()); + LEGEND_TEXT_COLOR = TEXT_COLOR = GBApplication.getTextColor(requireContext()); + CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext()); + } + + @Override + public View onCreateView(final LayoutInflater inflater, + final ViewGroup container, + final Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_blood_pressure_chart, container, false); + + mDateView = rootView.findViewById(R.id.date_view); + mChart = rootView.findViewById(R.id.blood_pressure_line_chart); + mSystolicLast = rootView.findViewById(R.id.bp_systolic_last); + mDiastolicLast = rootView.findViewById(R.id.bp_diastolic_last); + mAverage = rootView.findViewById(R.id.bp_average); + mMeasurementCount = rootView.findViewById(R.id.bp_measurement_count); + mManualMeasurements = rootView.findViewById(R.id.manualMeasurements); + mManualMeasurementsList = rootView.findViewById(R.id.manualMeasurementsList); + + mManualMeasurements.setVisibility(View.GONE); + setupLineChart(); + + FloatingActionButton exportFab = rootView.findViewById(R.id.bp_export_fab); + exportFab.setOnClickListener(v -> showExportDialog()); + + refresh(); + return rootView; + } + + @Override + protected BloodPressureChartsData refreshInBackground(final ChartsHost chartsHost, final DBHandler db, final GBDevice device) { + Calendar day = Calendar.getInstance(); + day.setTime(getEndDate()); + day.set(Calendar.HOUR_OF_DAY, 0); + day.set(Calendar.MINUTE, 0); + day.set(Calendar.SECOND, 0); + int startTs = (int) (day.getTimeInMillis() / 1000); + int endTs = startTs + 24 * 60 * 60 - 1; + tsTranslation = new TimestampTranslation(); + tsTranslation.shorten(startTs); + String formattedDate = new SimpleDateFormat("E, MMM dd", Locale.getDefault()).format(chartsHost.getEndDate()); + mDateView.setText(formattedDate); + return fetchBloodPressureData(db, device, startTs, endTs); + } + + protected LineDataSet createDataSet(final List values, String label, int color) { + final LineDataSet lineDataSet = new LineDataSet(values, label); + lineDataSet.setColor(color); + lineDataSet.setDrawCircles(true); + lineDataSet.setCircleColor(color); + lineDataSet.setCircleRadius(3f); + lineDataSet.setDrawCircleHole(false); + lineDataSet.setLineWidth(2.2f); + lineDataSet.setFillAlpha(255); + lineDataSet.setValueTextColor(TEXT_COLOR); + lineDataSet.setAxisDependency(YAxis.AxisDependency.LEFT); + lineDataSet.setValueFormatter(new ValueFormatter() { + @Override + public String getFormattedValue(float value) { + return String.format(Locale.ROOT, "%d", (int) value); + } + }); + return lineDataSet; + } + + @Override + protected void updateChartsnUIThread(BloodPressureChartsData data) { + currentData = data; + mManualMeasurementsList.removeAllViews(); + mManualMeasurements.setVisibility(View.GONE); + + final String emptyValue = requireContext().getString(R.string.stats_empty_value); + mSystolicLast.setText(data.systolicLast > 0 ? String.valueOf(data.systolicLast) : emptyValue); + mDiastolicLast.setText(data.diastolicLast > 0 ? String.valueOf(data.diastolicLast) : emptyValue); + if (data.systolicAvg > 0 && data.diastolicAvg > 0) { + mAverage.setText(getString(R.string.blood_pressure_avg_format, data.systolicAvg, data.diastolicAvg)); + } else { + mAverage.setText(emptyValue); + } + mMeasurementCount.setText(String.valueOf(data.measurementCount)); + + mChart.setData(null); // workaround for https://github.com/PhilJay/MPAndroidChart/issues/2317 + mChart.getAxisLeft().removeAllLimitLines(); + + Date date = new Date((long) getTSEnd() * 1000); + String formattedDate = new SimpleDateFormat("E, MMM dd", Locale.getDefault()).format(date); + mDateView.setText(formattedDate); + + final int systolicColor = ContextCompat.getColor(requireContext(), R.color.blood_pressure_systolic_color); + final int diastolicColor = ContextCompat.getColor(requireContext(), R.color.blood_pressure_diastolic_color); + + final List legendEntries = new ArrayList<>(2); + final LegendEntry systolicEntry = new LegendEntry(); + systolicEntry.label = getString(R.string.blood_pressure_systolic); + systolicEntry.formColor = systolicColor; + legendEntries.add(systolicEntry); + final LegendEntry diastolicEntry = new LegendEntry(); + diastolicEntry.label = getString(R.string.blood_pressure_diastolic); + diastolicEntry.formColor = diastolicColor; + legendEntries.add(diastolicEntry); + mChart.getLegend().setTextColor(LEGEND_TEXT_COLOR); + mChart.getLegend().setCustom(legendEntries); + + final List lineDataSets = new ArrayList<>(); + final List systolicEntries = new ArrayList<>(); + final List diastolicEntries = new ArrayList<>(); + + for (final BloodPressureSample sample : data.samples) { + int ts = (int) (sample.getTimestamp() / 1000L); + int tsShorten = tsTranslation.shorten(ts); + + if (sample.getBpSystolic() > 0) { + systolicEntries.add(new Entry(tsShorten, sample.getBpSystolic())); + } + if (sample.getBpDiastolic() > 0) { + diastolicEntries.add(new Entry(tsShorten, sample.getBpDiastolic())); + } + } + + if (!systolicEntries.isEmpty()) { + lineDataSets.add(createDataSet(systolicEntries, getString(R.string.blood_pressure_systolic), systolicColor)); + } + if (!diastolicEntries.isEmpty()) { + lineDataSets.add(createDataSet(diastolicEntries, getString(R.string.blood_pressure_diastolic), diastolicColor)); + } + + mChart.getXAxis().setValueFormatter(new SampleXLabelFormatter(tsTranslation, "HH:mm")); + + if (!lineDataSets.isEmpty()) { + final LineData lineData = new LineData(lineDataSets); + mChart.setData(lineData); + } + + if (data.systolicAvg > 0 && GBApplication.getPrefs().getBoolean("charts_show_average", true)) { + final LimitLine avgLine = new LimitLine(data.systolicAvg); + avgLine.setLineColor(Color.GRAY); + avgLine.setLineWidth(1.5f); + avgLine.enableDashedLine(15f, 10f, 0f); + mChart.getAxisLeft().addLimitLine(avgLine); + } + } + + @Override + public String getTitle() { + return requireContext().getString(R.string.blood_pressure); + } + + private void showExportDialog() { + if (currentData == null || currentData.samples.isEmpty()) { + return; + } + String dateLabel = mDateView.getText().toString(); + String[] options = {"PDF", "CSV"}; + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.appmanager_app_share) + .setItems(options, (dialog, which) -> { + if (which == 0) { + BloodPressureExportHelper.exportPdf(requireContext(), currentData.samples, dateLabel, getWhiteChartBitmap()); + } else { + BloodPressureExportHelper.exportCsv(requireContext(), currentData.samples); + } + }) + .show(); + } + + private Bitmap getWhiteChartBitmap() { + // Temporarily switch to light colors for PDF export + mChart.setBackgroundColor(0xFFFFFFFF); + mChart.getXAxis().setTextColor(0xFF000000); + mChart.getAxisLeft().setTextColor(0xFF000000); + mChart.getAxisRight().setAxisLineColor(0xFF000000); + mChart.getLegend().setTextColor(0xFF000000); + mChart.invalidate(); + + Bitmap bitmap = mChart.getChartBitmap(); + + // Restore original colors + mChart.setBackgroundColor(BACKGROUND_COLOR); + mChart.getXAxis().setTextColor(CHART_TEXT_COLOR); + mChart.getAxisLeft().setTextColor(CHART_TEXT_COLOR); + mChart.getLegend().setTextColor(LEGEND_TEXT_COLOR); + mChart.invalidate(); + + return bitmap; + } + + private void setupLineChart() { + mChart.setBackgroundColor(BACKGROUND_COLOR); + mChart.getDescription().setText(""); + + final XAxis xAxisBottom = mChart.getXAxis(); + xAxisBottom.setPosition(XAxis.XAxisPosition.BOTTOM); + xAxisBottom.setDrawLabels(true); + xAxisBottom.setDrawGridLines(false); + xAxisBottom.setEnabled(true); + xAxisBottom.setDrawLimitLinesBehindData(true); + xAxisBottom.setTextColor(CHART_TEXT_COLOR); + xAxisBottom.setAxisMinimum(0f); + xAxisBottom.setAxisMaximum(86400f); + xAxisBottom.setLabelCount(7, true); + + final YAxis yAxisLeft = mChart.getAxisLeft(); + yAxisLeft.setDrawGridLines(true); + yAxisLeft.setAxisMaximum(200f); + yAxisLeft.setAxisMinimum(40f); + yAxisLeft.setDrawTopYLabelEntry(false); + yAxisLeft.setTextColor(CHART_TEXT_COLOR); + yAxisLeft.setEnabled(true); + + final YAxis yAxisRight = mChart.getAxisRight(); + yAxisRight.setEnabled(true); + yAxisRight.setDrawLabels(false); + yAxisRight.setDrawGridLines(false); + yAxisRight.setDrawAxisLine(true); + } + + @Override + protected void setupLegend(final Chart chart) {} + + @Override + protected void renderCharts() { + mChart.invalidate(); + } + + private List getSamples(final DBHandler db, final GBDevice device, int startTs, int endTs) { + final DeviceCoordinator coordinator = device.getDeviceCoordinator(); + final TimeSampleProvider sampleProvider = coordinator.getBloodPressureSampleProvider(device, db.getDaoSession()); + return sampleProvider.getAllSamples(startTs * 1000L, endTs * 1000L); + } + + private BloodPressureChartsData fetchBloodPressureData(DBHandler db, GBDevice device, int startTs, int endTs) { + List samples = getSamples(db, device, startTs, endTs); + + final Accumulator systolicAccumulator = new Accumulator(); + final Accumulator diastolicAccumulator = new Accumulator(); + int systolicLast = DATA_INVALID; + int diastolicLast = DATA_INVALID; + + for (final BloodPressureSample sample : samples) { + if (sample.getBpSystolic() > 0) { + systolicAccumulator.add(sample.getBpSystolic()); + systolicLast = sample.getBpSystolic(); + } + if (sample.getBpDiastolic() > 0) { + diastolicAccumulator.add(sample.getBpDiastolic()); + diastolicLast = sample.getBpDiastolic(); + } + } + + final int systolicAvg = systolicAccumulator.getCount() > 0 ? (int) Math.round(systolicAccumulator.getAverage()) : DATA_INVALID; + final int diastolicAvg = diastolicAccumulator.getCount() > 0 ? (int) Math.round(diastolicAccumulator.getAverage()) : DATA_INVALID; + final int measurementCount = samples.size(); + + return new BloodPressureChartsData(samples, systolicAvg, diastolicAvg, systolicLast, diastolicLast, measurementCount); + } + + protected static class BloodPressureChartsData extends ChartsData { + public List samples; + public final int systolicAvg; + public final int diastolicAvg; + public final int systolicLast; + public final int diastolicLast; + public final int measurementCount; + + public BloodPressureChartsData(List samples, int systolicAvg, int diastolicAvg, + int systolicLast, int diastolicLast, int measurementCount) { + this.samples = samples; + this.systolicAvg = systolicAvg; + this.diastolicAvg = diastolicAvg; + this.systolicLast = systolicLast; + this.diastolicLast = diastolicLast; + this.measurementCount = measurementCount; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BloodPressureCollectionFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BloodPressureCollectionFragment.java new file mode 100644 index 0000000000..223dd0a3af --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BloodPressureCollectionFragment.java @@ -0,0 +1,43 @@ +/* + Copyright (C) 2026 Christian Breiteneder + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ +package nodomain.freeyourgadget.gadgetbridge.activities.charts; + +import android.os.Bundle; + +import androidx.viewpager2.adapter.FragmentStateAdapter; + +import nodomain.freeyourgadget.gadgetbridge.adapter.BloodPressureFragmentAdapter; + +public class BloodPressureCollectionFragment extends AbstractCollectionFragment { + public BloodPressureCollectionFragment() { + } + + public static BloodPressureCollectionFragment newInstance(final boolean allowSwipe) { + final BloodPressureCollectionFragment fragment = new BloodPressureCollectionFragment(); + final Bundle args = new Bundle(); + args.putBoolean(ARG_ALLOW_SWIPE, allowSwipe); + fragment.setArguments(args); + return fragment; + } + + @Override + public FragmentStateAdapter getFragmentAdapter() { + return new BloodPressureFragmentAdapter(this); + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BloodPressurePeriodFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BloodPressurePeriodFragment.java new file mode 100644 index 0000000000..61c62c8f2d --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BloodPressurePeriodFragment.java @@ -0,0 +1,448 @@ +/* + Copyright (C) 2026 Christian Breiteneder + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ +package nodomain.freeyourgadget.gadgetbridge.activities.charts; + +import android.graphics.Bitmap; +import android.graphics.Paint; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import com.github.mikephil.charting.charts.Chart; +import com.github.mikephil.charting.charts.CombinedChart; +import com.github.mikephil.charting.components.LegendEntry; +import com.github.mikephil.charting.components.XAxis; +import com.github.mikephil.charting.components.YAxis; +import com.github.mikephil.charting.data.CandleData; +import com.github.mikephil.charting.data.CandleDataSet; +import com.github.mikephil.charting.data.CandleEntry; +import com.github.mikephil.charting.data.CombinedData; +import com.github.mikephil.charting.formatter.ValueFormatter; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.BloodPressureSample; +import nodomain.freeyourgadget.gadgetbridge.util.Accumulator; +import nodomain.freeyourgadget.gadgetbridge.util.BloodPressureExportHelper; +import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; + +public class BloodPressurePeriodFragment extends AbstractChartFragment { + protected static final Logger LOG = LoggerFactory.getLogger(BloodPressurePeriodFragment.class); + + static int SEC_PER_DAY = 24 * 60 * 60; + static int DATA_INVALID = -1; + + private int BACKGROUND_COLOR; + private int CHART_TEXT_COLOR; + private int LEGEND_TEXT_COLOR; + private int SYSTOLIC_COLOR; + private int DIASTOLIC_COLOR; + + private TextView mDateView; + private TextView mSystolicLast; + private TextView mDiastolicLast; + private TextView mAverage; + private TextView mMeasurementCount; + private CombinedChart mChart; + private int TOTAL_DAYS; + private List allSamples; + + @Override + protected boolean isSingleDay() { + return false; + } + + public static BloodPressurePeriodFragment newInstance(int totalDays) { + BloodPressurePeriodFragment fragment = new BloodPressurePeriodFragment(); + Bundle args = new Bundle(); + args.putInt("totalDays", totalDays); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + TOTAL_DAYS = getArguments() != null ? getArguments().getInt("totalDays") : 7; + } + + @Override + protected void init() { + BACKGROUND_COLOR = GBApplication.getBackgroundColor(requireContext()); + LEGEND_TEXT_COLOR = GBApplication.getTextColor(requireContext()); + CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext()); + SYSTOLIC_COLOR = ContextCompat.getColor(requireContext(), R.color.blood_pressure_systolic_color); + DIASTOLIC_COLOR = ContextCompat.getColor(requireContext(), R.color.blood_pressure_diastolic_color); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_blood_pressure_period, container, false); + + rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> + getChartsHost().enableSwipeRefresh(scrollY == 0) + ); + + mDateView = rootView.findViewById(R.id.date_view); + mSystolicLast = rootView.findViewById(R.id.bp_systolic_last); + mDiastolicLast = rootView.findViewById(R.id.bp_diastolic_last); + mAverage = rootView.findViewById(R.id.bp_average); + mMeasurementCount = rootView.findViewById(R.id.bp_measurement_count); + mChart = rootView.findViewById(R.id.blood_pressure_chart); + + setupChart(); + + FloatingActionButton exportFab = rootView.findViewById(R.id.bp_export_fab); + exportFab.setOnClickListener(v -> showExportDialog()); + + refresh(); + setupLegend(mChart); + + return rootView; + } + + @Override + public String getTitle() { + return getString(R.string.blood_pressure); + } + + private int getStartTs() { + Calendar day = Calendar.getInstance(); + day.setTime(getEndDate()); + day.set(Calendar.HOUR_OF_DAY, 0); + day.set(Calendar.MINUTE, 0); + day.set(Calendar.SECOND, 0); + return (int) (day.getTimeInMillis() / 1000) - SEC_PER_DAY * (TOTAL_DAYS - 1); + } + + private BloodPressureDayData fetchDataForDay(DBHandler db, GBDevice device, int startTs) { + int endTs = startTs + SEC_PER_DAY - 1; + List samples = getSamples(db, device, startTs, endTs); + + final Accumulator systolicAcc = new Accumulator(); + final Accumulator diastolicAcc = new Accumulator(); + + for (final BloodPressureSample sample : samples) { + if (sample.getBpSystolic() > 0) { + systolicAcc.add(sample.getBpSystolic()); + } + if (sample.getBpDiastolic() > 0) { + diastolicAcc.add(sample.getBpDiastolic()); + } + } + + final int systolicAvg = systolicAcc.getCount() > 0 ? (int) Math.round(systolicAcc.getAverage()) : DATA_INVALID; + final int systolicMin = systolicAcc.getCount() > 0 ? (int) Math.round(systolicAcc.getMin()) : DATA_INVALID; + final int systolicMax = systolicAcc.getCount() > 0 ? (int) Math.round(systolicAcc.getMax()) : DATA_INVALID; + final int diastolicAvg = diastolicAcc.getCount() > 0 ? (int) Math.round(diastolicAcc.getAverage()) : DATA_INVALID; + final int diastolicMin = diastolicAcc.getCount() > 0 ? (int) Math.round(diastolicAcc.getMin()) : DATA_INVALID; + final int diastolicMax = diastolicAcc.getCount() > 0 ? (int) Math.round(diastolicAcc.getMax()) : DATA_INVALID; + + return new BloodPressureDayData(systolicAvg, systolicMin, systolicMax, diastolicAvg, diastolicMin, diastolicMax, samples.size()); + } + + @Override + protected BloodPressurePeriodData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) { + final int startTs = getStartTs(); + final int endTs = startTs + SEC_PER_DAY * TOTAL_DAYS - 1; + + List result = new ArrayList<>(); + for (int i = 0; i < TOTAL_DAYS; i++) { + BloodPressureDayData dayData = fetchDataForDay(db, device, startTs + i * SEC_PER_DAY); + result.add(dayData); + } + + allSamples = getSamples(db, device, startTs, endTs); + + return new BloodPressurePeriodData(result); + } + + private List getSamples(DBHandler db, GBDevice device, int startTs, int endTs) { + final DeviceCoordinator coordinator = device.getDeviceCoordinator(); + final TimeSampleProvider sampleProvider = coordinator.getBloodPressureSampleProvider(device, db.getDaoSession()); + return sampleProvider.getAllSamples(startTs * 1000L, endTs * 1000L); + } + + @Override + protected void updateChartsnUIThread(BloodPressurePeriodData data) { + final int startTs = getStartTs(); + mDateView.setText(DateTimeUtils.formatDaysUntil(TOTAL_DAYS, getTSEnd())); + + final Accumulator systolicAvgAcc = new Accumulator(); + final Accumulator diastolicAvgAcc = new Accumulator(); + + final ArrayList systolicCandleEntries = new ArrayList<>(); + final ArrayList diastolicCandleEntries = new ArrayList<>(); + + for (int i = 0; i < data.days.size(); i++) { + final BloodPressureDayData dayData = data.days.get(i); + if (dayData.systolicMin > 0 && dayData.systolicMax > 0) { + systolicAvgAcc.add(dayData.systolicAvg); + systolicCandleEntries.add(new CandleEntry(i, dayData.systolicMax, dayData.systolicMin, dayData.systolicMin, dayData.systolicMax)); + } + if (dayData.diastolicMin > 0 && dayData.diastolicMax > 0) { + diastolicAvgAcc.add(dayData.diastolicAvg); + diastolicCandleEntries.add(new CandleEntry(i, dayData.diastolicMax, dayData.diastolicMin, dayData.diastolicMin, dayData.diastolicMax)); + } + } + + final String emptyValue = requireContext().getString(R.string.stats_empty_value); + final int systolicAvg = systolicAvgAcc.getCount() > 0 ? (int) Math.round(systolicAvgAcc.getAverage()) : DATA_INVALID; + final int diastolicAvg = diastolicAvgAcc.getCount() > 0 ? (int) Math.round(diastolicAvgAcc.getAverage()) : DATA_INVALID; + + // Last day with valid data + int systolicLast = DATA_INVALID; + int diastolicLast = DATA_INVALID; + int totalMeasurements = 0; + for (int i = data.days.size() - 1; i >= 0; i--) { + final BloodPressureDayData dayData = data.days.get(i); + if (dayData.systolicAvg > 0 && systolicLast == DATA_INVALID) { + systolicLast = dayData.systolicAvg; + } + if (dayData.diastolicAvg > 0 && diastolicLast == DATA_INVALID) { + diastolicLast = dayData.diastolicAvg; + } + totalMeasurements += dayData.measurementCount; + } + + mSystolicLast.setText(systolicLast > 0 ? String.valueOf(systolicLast) : emptyValue); + mDiastolicLast.setText(diastolicLast > 0 ? String.valueOf(diastolicLast) : emptyValue); + if (systolicAvg > 0 && diastolicAvg > 0) { + mAverage.setText(getString(R.string.blood_pressure_avg_format, systolicAvg, diastolicAvg)); + } else { + mAverage.setText(emptyValue); + } + mMeasurementCount.setText(String.valueOf(totalMeasurements)); + + mChart.getXAxis().setValueFormatter(createDayFormatter(startTs)); + + final CombinedData combinedData = new CombinedData(); + + // Systolic candle data (range bars) + if (!systolicCandleEntries.isEmpty()) { + CandleDataSet systolicCandleDataSet = new CandleDataSet(systolicCandleEntries, getString(R.string.blood_pressure_systolic)); + systolicCandleDataSet.setDrawValues(false); + systolicCandleDataSet.setDrawIcons(false); + systolicCandleDataSet.setAxisDependency(YAxis.AxisDependency.LEFT); + systolicCandleDataSet.setShadowColor(SYSTOLIC_COLOR); + systolicCandleDataSet.setShadowWidth(2f); + systolicCandleDataSet.setDecreasingColor(SYSTOLIC_COLOR); + systolicCandleDataSet.setDecreasingPaintStyle(Paint.Style.FILL); + systolicCandleDataSet.setIncreasingColor(SYSTOLIC_COLOR); + systolicCandleDataSet.setIncreasingPaintStyle(Paint.Style.FILL); + systolicCandleDataSet.setNeutralColor(SYSTOLIC_COLOR); + systolicCandleDataSet.setBarSpace(0.15f); + systolicCandleDataSet.setShowCandleBar(true); + + // Diastolic candle data as second set + if (!diastolicCandleEntries.isEmpty()) { + CandleDataSet diastolicCandleDataSet = new CandleDataSet(diastolicCandleEntries, getString(R.string.blood_pressure_diastolic)); + diastolicCandleDataSet.setDrawValues(false); + diastolicCandleDataSet.setDrawIcons(false); + diastolicCandleDataSet.setAxisDependency(YAxis.AxisDependency.LEFT); + diastolicCandleDataSet.setShadowColor(DIASTOLIC_COLOR); + diastolicCandleDataSet.setShadowWidth(2f); + diastolicCandleDataSet.setDecreasingColor(DIASTOLIC_COLOR); + diastolicCandleDataSet.setDecreasingPaintStyle(Paint.Style.FILL); + diastolicCandleDataSet.setIncreasingColor(DIASTOLIC_COLOR); + diastolicCandleDataSet.setIncreasingPaintStyle(Paint.Style.FILL); + diastolicCandleDataSet.setNeutralColor(DIASTOLIC_COLOR); + diastolicCandleDataSet.setBarSpace(0.15f); + diastolicCandleDataSet.setShowCandleBar(true); + combinedData.setData(new CandleData(systolicCandleDataSet, diastolicCandleDataSet)); + } else { + combinedData.setData(new CandleData(systolicCandleDataSet)); + } + } + + mChart.setData(combinedData); + } + + private void showExportDialog() { + if (allSamples == null || allSamples.isEmpty()) { + return; + } + String dateLabel = mDateView.getText().toString(); + String[] options = {"PDF", "CSV"}; + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.appmanager_app_share) + .setItems(options, (dialog, which) -> { + if (which == 0) { + BloodPressureExportHelper.exportPdf(requireContext(), allSamples, dateLabel, getWhiteChartBitmap()); + } else { + BloodPressureExportHelper.exportCsv(requireContext(), allSamples); + } + }) + .show(); + } + + private ValueFormatter createDayFormatter(final int startTs) { + final String fmt = TOTAL_DAYS == 7 ? "EEE" : "dd"; + final SimpleDateFormat formatDay = new SimpleDateFormat(fmt, Locale.getDefault()); + return new ValueFormatter() { + @Override + public String getFormattedValue(float value) { + int dayIndex = Math.round(value); + if (dayIndex < 0 || dayIndex >= TOTAL_DAYS) { + return ""; + } + int ts = startTs + SEC_PER_DAY * dayIndex; + return formatDay.format(new Date(ts * 1000L)); + } + }; + } + + private Bitmap getWhiteChartBitmap() { + // Temporarily switch to light colors for PDF export + mChart.setBackgroundColor(0xFFFFFFFF); + mChart.getXAxis().setTextColor(0xFF000000); + mChart.getAxisLeft().setTextColor(0xFF000000); + mChart.getAxisRight().setAxisLineColor(0xFF000000); + mChart.getLegend().setTextColor(0xFF000000); + mChart.invalidate(); + + Bitmap bitmap = mChart.getChartBitmap(); + + // Restore original colors + mChart.setBackgroundColor(BACKGROUND_COLOR); + mChart.getXAxis().setTextColor(CHART_TEXT_COLOR); + mChart.getAxisLeft().setTextColor(CHART_TEXT_COLOR); + mChart.getLegend().setTextColor(LEGEND_TEXT_COLOR); + mChart.invalidate(); + + return bitmap; + } + + private void setupChart() { + mChart.setBackgroundColor(BACKGROUND_COLOR); + mChart.getDescription().setEnabled(false); + mChart.setDrawOrder(new CombinedChart.DrawOrder[]{ + CombinedChart.DrawOrder.CANDLE + }); + + if (TOTAL_DAYS <= 7) { + mChart.setTouchEnabled(false); + mChart.setPinchZoom(false); + } + mChart.setDoubleTapToZoomEnabled(false); + + final XAxis xAxisBottom = mChart.getXAxis(); + xAxisBottom.setPosition(XAxis.XAxisPosition.BOTTOM); + xAxisBottom.setDrawLabels(true); + xAxisBottom.setDrawGridLines(false); + xAxisBottom.setEnabled(true); + xAxisBottom.setDrawLimitLinesBehindData(true); + xAxisBottom.setTextColor(CHART_TEXT_COLOR); + xAxisBottom.setGranularity(1f); + xAxisBottom.setGranularityEnabled(true); + xAxisBottom.setAxisMinimum(-0.5f); + xAxisBottom.setAxisMaximum(TOTAL_DAYS - 0.5f); + + final YAxis yAxisLeft = mChart.getAxisLeft(); + yAxisLeft.setDrawGridLines(true); + yAxisLeft.setAxisMaximum(200f); + yAxisLeft.setAxisMinimum(40f); + yAxisLeft.setDrawTopYLabelEntry(true); + yAxisLeft.setTextColor(CHART_TEXT_COLOR); + yAxisLeft.setEnabled(true); + yAxisLeft.setGranularity(10f); + yAxisLeft.setGranularityEnabled(true); + + final YAxis yAxisRight = mChart.getAxisRight(); + yAxisRight.setEnabled(true); + yAxisRight.setDrawLabels(false); + yAxisRight.setDrawGridLines(false); + yAxisRight.setDrawAxisLine(true); + } + + @Override + protected void setupLegend(Chart chart) { + List legendEntries = new ArrayList<>(2); + + LegendEntry systolicEntry = new LegendEntry(); + systolicEntry.label = getString(R.string.blood_pressure_systolic); + systolicEntry.formColor = SYSTOLIC_COLOR; + legendEntries.add(systolicEntry); + + LegendEntry diastolicEntry = new LegendEntry(); + diastolicEntry.label = getString(R.string.blood_pressure_diastolic); + diastolicEntry.formColor = DIASTOLIC_COLOR; + legendEntries.add(diastolicEntry); + + mChart.getLegend().setCustom(legendEntries); + mChart.getLegend().setTextColor(LEGEND_TEXT_COLOR); + mChart.getLegend().setWordWrapEnabled(true); + } + + @Override + protected void renderCharts() { + mChart.invalidate(); + } + + protected static class BloodPressurePeriodData extends ChartsData { + public List days; + + protected BloodPressurePeriodData(List days) { + this.days = days; + } + } + + protected static class BloodPressureDayData extends ChartsData { + public int systolicAvg; + public int systolicMin; + public int systolicMax; + public int diastolicAvg; + public int diastolicMin; + public int diastolicMax; + public int measurementCount; + + protected BloodPressureDayData(int systolicAvg, int systolicMin, int systolicMax, + int diastolicAvg, int diastolicMin, int diastolicMax, + int measurementCount) { + this.systolicAvg = systolicAvg; + this.systolicMin = systolicMin; + this.systolicMax = systolicMax; + this.diastolicAvg = diastolicAvg; + this.diastolicMin = diastolicMin; + this.diastolicMax = diastolicMax; + this.measurementCount = measurementCount; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/DefaultChartsProvider.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/DefaultChartsProvider.kt index f23069b4de..262557d8d0 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/DefaultChartsProvider.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/DefaultChartsProvider.kt @@ -67,6 +67,9 @@ open class DefaultChartsProvider : DeviceChartsProvider { if (coordinator.supportsTemperatureMeasurement(device)) { supportedCharts.add("temperature") } + if (coordinator.supportsBloodPressureMeasurement(device)) { + supportedCharts.add("bloodpressure") + } if (coordinator.supportsWeightMeasurement(device)) { supportedCharts.add("weight") } @@ -110,6 +113,7 @@ open class DefaultChartsProvider : DeviceChartsProvider { "livestats" -> context.getString(R.string.liveactivity_live_activity) "spo2" -> context.getString(R.string.pref_header_spo2) "temperature" -> context.getString(R.string.menuitem_temperature) + "bloodpressure" -> context.getString(R.string.blood_pressure) "cycling" -> context.getString(R.string.title_cycling) "weight" -> context.getString(R.string.menuitem_weight) "calories" -> context.getString(R.string.calories) @@ -144,6 +148,7 @@ open class DefaultChartsProvider : DeviceChartsProvider { TemperatureDailyFragment() else TemperatureChartFragment() } + "bloodpressure" -> BloodPressureCollectionFragment.newInstance(allowSwipe) "cycling" -> CyclingChartFragment() "weight" -> WeightChartFragment() "calories" -> CaloriesCollectionFragment.newInstance(allowSwipe) diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardBloodPressureWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardBloodPressureWidget.java new file mode 100644 index 0000000000..1669b077c6 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardBloodPressureWidget.java @@ -0,0 +1,119 @@ +/* + Copyright (C) 2026 Christian Breiteneder + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ +package nodomain.freeyourgadget.gadgetbridge.activities.dashboard; + +import android.os.Bundle; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.BloodPressureSample; + +public class DashboardBloodPressureWidget extends AbstractGaugeWidget { + private static final Logger LOG = LoggerFactory.getLogger(DashboardBloodPressureWidget.class); + + public DashboardBloodPressureWidget() { + super(R.string.blood_pressure, "bloodpressure"); + } + + public static DashboardBloodPressureWidget newInstance(final DashboardFragment.DashboardData dashboardData) { + final DashboardBloodPressureWidget fragment = new DashboardBloodPressureWidget(); + final Bundle args = new Bundle(); + args.putSerializable(ARG_DASHBOARD_DATA, dashboardData); + fragment.setArguments(args); + return fragment; + } + + @Override + protected boolean isSupportedBy(final GBDevice device) { + return device.getDeviceCoordinator().supportsBloodPressureMeasurement(device); + } + + @Override + protected void populateData(final DashboardFragment.DashboardData dashboardData) { + final List devices = getSupportedDevices(dashboardData); + int latestSystolic = 0; + int latestDiastolic = 0; + long latestTimestamp = 0; + + try (DBHandler dbHandler = GBApplication.acquireDbReadOnly()) { + for (final GBDevice dev : devices) { + final var provider = dev.getDeviceCoordinator() + .getBloodPressureSampleProvider(dev, dbHandler.getDaoSession()); + if (provider == null) continue; + final List samples = provider.getAllSamples( + dashboardData.timeFrom * 1000L, + dashboardData.timeTo * 1000L + ); + + if (!samples.isEmpty()) { + final BloodPressureSample latest = samples.get(samples.size() - 1); + if (latest.getTimestamp() > latestTimestamp) { + latestTimestamp = latest.getTimestamp(); + latestSystolic = latest.getBpSystolic(); + latestDiastolic = latest.getBpDiastolic(); + } + } + } + } catch (final Exception e) { + LOG.error("Could not get blood pressure samples", e); + } + + dashboardData.put("bp_systolic", latestSystolic); + dashboardData.put("bp_diastolic", latestDiastolic); + } + + @Override + protected void draw(final DashboardFragment.DashboardData dashboardData) { + final Integer systolic = (Integer) dashboardData.get("bp_systolic"); + final Integer diastolic = (Integer) dashboardData.get("bp_diastolic"); + + if (systolic != null && systolic > 0 && diastolic != null && diastolic > 0) { + setText(systolic + "/" + diastolic); + + // WHO classification: color and gauge position based on systolic/diastolic values + final int color; + final float gaugeValue; + if (systolic < 120 && diastolic < 80) { + color = android.graphics.Color.rgb(76, 175, 80); // green – Normal + gaugeValue = 0.25f; + } else if (systolic < 130 && diastolic < 80) { + color = android.graphics.Color.rgb(139, 195, 74); // lime – Elevated + gaugeValue = 0.45f; + } else if (systolic < 140 || diastolic < 90) { + color = android.graphics.Color.rgb(255, 152, 0); // orange – Stage 1 + gaugeValue = 0.65f; + } else { + color = android.graphics.Color.rgb(244, 67, 54); // red – Stage 2+ + gaugeValue = 0.88f; + } + drawSimpleGauge(color, gaugeValue); + } else { + setText(getString(R.string.stats_empty_value)); + drawSimpleGauge(android.graphics.Color.GRAY, -1); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/BloodPressureFragmentAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/BloodPressureFragmentAdapter.java new file mode 100644 index 0000000000..a1e434cd44 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/BloodPressureFragmentAdapter.java @@ -0,0 +1,42 @@ +/* + Copyright (C) 2026 Christian Breiteneder + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ +package nodomain.freeyourgadget.gadgetbridge.adapter; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; + +import nodomain.freeyourgadget.gadgetbridge.activities.charts.BloodPressureChartFragment; +import nodomain.freeyourgadget.gadgetbridge.activities.charts.BloodPressurePeriodFragment; + +public class BloodPressureFragmentAdapter extends NestedFragmentAdapter { + + public BloodPressureFragmentAdapter(Fragment fragment) { + super(fragment); + } + + @NonNull + @Override + public Fragment createFragment(int position) { + return switch (position) { + case 1 -> BloodPressurePeriodFragment.newInstance(7); + case 2 -> BloodPressurePeriodFragment.newInstance(30); + default -> new BloodPressureChartFragment(); + }; + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java index 4389f3427a..3ac5604526 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java @@ -697,7 +697,8 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator { supportsWeightMeasurement(device) || supportsActiveCalories(device) || supportsCyclingData(device) || - supportsRespiratoryRate(device); + supportsRespiratoryRate(device) || + supportsBloodPressureMeasurement(device); } @Override diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/braun/BraunBPW4500DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/braun/BraunBPW4500DeviceCoordinator.java new file mode 100644 index 0000000000..6e05790d72 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/braun/BraunBPW4500DeviceCoordinator.java @@ -0,0 +1,105 @@ +/* + Copyright (C) 2026 Christian Breiteneder + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ +package nodomain.freeyourgadget.gadgetbridge.devices.braun; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.regex.Pattern; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.sbm_67.GenericBloodPressureSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.GenericBloodPressureSampleDao; +import de.greenrobot.dao.AbstractDao; +import de.greenrobot.dao.Property; +import java.util.HashMap; +import java.util.Map; +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate; +import nodomain.freeyourgadget.gadgetbridge.model.DeviceType; +import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.braun.BraunBPW4500DeviceSupport; + +public class BraunBPW4500DeviceCoordinator extends AbstractBLEDeviceCoordinator { + + @Nullable + @Override + protected Pattern getSupportedDeviceName() { + return Pattern.compile("^BPW4500$"); + } + + @NonNull + @Override + public Class getDeviceSupportClass(GBDevice device) { + return BraunBPW4500DeviceSupport.class; + } + + @Override + public int getDeviceNameResource() { + return R.string.devicetype_braun_bpw4500; + } + + @Override + public DeviceKind getDeviceKind(@NonNull GBDevice device) { + return DeviceKind.BLOOD_PRESSURE_METER; + } + + @Override + public int getBondingStyle() { + return BONDING_STYLE_NONE; + } + + @Override + public boolean suggestUnbindBeforePair() { + return false; + } + + @Override + public boolean supportsBloodPressureMeasurement(@NonNull final GBDevice device) { + return true; + } + + @Override + public GenericBloodPressureSampleProvider getBloodPressureSampleProvider( + final GBDevice device, final DaoSession session) { + return new GenericBloodPressureSampleProvider(device, session); + } + + @Override + public String getManufacturer() { + return "Braun"; + } + + @Override + public Map, Property> getAllDeviceDao(@NonNull final DaoSession session) { + final Map, Property> daoMap = new HashMap<>(); + daoMap.put(session.getGenericBloodPressureSampleDao(), GenericBloodPressureSampleDao.Properties.DeviceId); + return daoMap; + } + + @Override + public GBDevice createDevice(final GBDeviceCandidate candidate, final DeviceType deviceType) { + final GBDevice device = super.createDevice(candidate, deviceType); + device.setAlias(GBApplication.getContext().getString(R.string.devicetype_braun_bpw4500)); + return device; + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java index e5089364cc..641e1f0a92 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java @@ -35,6 +35,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.atcbleoepl.ATCBLEOEPLCoordin import nodomain.freeyourgadget.gadgetbridge.devices.bandwpseries.BandWPSeriesDeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.binary_sensor.coordinator.BinarySensorCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.braun.BraunBPW4500DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.casio.ecbs100.CasioECBS100DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.casio.gb6900.CasioGB6900DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.casio.gbx100.CasioGBX100DeviceCoordinator; @@ -911,6 +912,7 @@ public enum DeviceType { SOLARFLOW(SolarFlowDeviceCoordinator.class), SANITAS_SBM_67(SanitasSBM67Coordinator.class), SILVERCREST_SBM_67(SilverCrestSBM67Coordinator.class), + BRAUN_BPW4500(BraunBPW4500DeviceCoordinator.class), TEST(TestDeviceCoordinator.class); private DeviceCoordinator coordinator; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/braun/BraunBPW4500DeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/braun/BraunBPW4500DeviceSupport.java new file mode 100644 index 0000000000..45290f0df6 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/braun/BraunBPW4500DeviceSupport.java @@ -0,0 +1,291 @@ +/* + Copyright (C) 2026 Christian Breiteneder + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package nodomain.freeyourgadget.gadgetbridge.service.devices.braun; + +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.widget.Toast; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.List; +import java.util.UUID; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo; +import nodomain.freeyourgadget.gadgetbridge.devices.sbm_67.GenericBloodPressureSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.GenericBloodPressureSample; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class BraunBPW4500DeviceSupport extends AbstractBTLESingleDeviceSupport { + + private static final Logger LOG = LoggerFactory.getLogger(BraunBPW4500DeviceSupport.class); + + // ------------------------------------------------------------------------- + // BLE Services + // ------------------------------------------------------------------------- + + private static final UUID SERVICE_MEASUREMENT = + UUID.fromString("56484aae-a8eb-4a97-ac19-a8ea6373e05a"); + + private static final UUID SERVICE_CONTROL = + UUID.fromString("bb647f01-d352-48de-9015-d055b1355d7b"); + + // ------------------------------------------------------------------------- + // Characteristics + // ------------------------------------------------------------------------- + + /** + * Live measurement notification (19 bytes, little-endian): + * [0] 0x1E flags + * [1..2] systolic (uint16 LE, mmHg) + * [3..4] diastolic (uint16 LE, mmHg) + * [5..6] 0x5A constant (protocol ID) + * [7..10] constant (unknown) + * [11] hour + * [12] minute + * [13] second + * [14..15] pulse rate (uint16 LE, bpm) + * [16..18] 0x00 padding + */ + private static final UUID CHAR_MEASUREMENT = + UUID.fromString("2db34480-bce5-4bb7-9f56-55bd202317c5"); + + /** + * Sync command: 0x01 = start sync | 0x02 = power off device (!) + */ + private static final UUID CHAR_COMMAND = + UUID.fromString("53ebc2ce-344c-4c5d-9d65-4740aa4660cd"); + + /** + * RTC register: [year_lo, year_hi, month(1-12), day, hour, minute, second, 0x00] + */ + private static final UUID CHAR_RTC = + UUID.fromString("6114ac81-2a71-4acc-ada5-555b63e8c5e1"); + + // ------------------------------------------------------------------------- + + private final List pendingSamples = new ArrayList<>(); + + private final BroadcastReceiver disconnectReceiver = new BroadcastReceiver() { + @Override + public void onReceive(final Context context, final Intent intent) { + if (GBDevice.ACTION_DEVICE_CHANGED.equals(intent.getAction())) { + final GBDevice.DeviceUpdateSubject subject = + (GBDevice.DeviceUpdateSubject) intent.getSerializableExtra(GBDevice.EXTRA_UPDATE_SUBJECT); + if (GBDevice.DeviceUpdateSubject.CONNECTION_STATE.equals(subject) && + GBDevice.State.NOT_CONNECTED.equals(gbDevice.getState()) && + !pendingSamples.isEmpty()) { + persistSamples(); + pendingSamples.clear(); + } + } + } + }; + + public BraunBPW4500DeviceSupport() { + super(LOG); + addSupportedService(SERVICE_MEASUREMENT); + addSupportedService(SERVICE_CONTROL); + + final IntentFilter filter = new IntentFilter(GBDevice.ACTION_DEVICE_CHANGED); + //noinspection deprecation + LocalBroadcastManager.getInstance(GBApplication.getContext()) + .registerReceiver(disconnectReceiver, filter); + } + + @Override + public boolean useAutoConnect() { + return false; + } + + @Override + protected TransactionBuilder initializeDevice(final TransactionBuilder builder) { + LOG.info("Initializing Braun BPW4500"); + pendingSamples.clear(); + + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext())); + + // Enable notifications for live measurements + final BluetoothGattCharacteristic measureChar = getCharacteristic(CHAR_MEASUREMENT); + if (measureChar != null) { + builder.notify(measureChar, true); + } else { + LOG.warn("Measurement characteristic not found!"); + } + + // Sync device clock + writeRTC(builder); + + // Report firmware version + final GBDeviceEventVersionInfo versionCmd = new GBDeviceEventVersionInfo(); + versionCmd.fwVersion = "N/A"; + versionCmd.hwVersion = "N/A"; + handleGBDeviceEvent(versionCmd); + + // Send sync command to trigger data transfer + final BluetoothGattCharacteristic cmdChar = getCharacteristic(CHAR_COMMAND); + if (cmdChar != null) { + builder.write(cmdChar, new byte[]{0x01}); + } + + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZED, getContext())); + + return builder; + } + + /** + * Writes the current system time to the device RTC register. + */ + private void writeRTC(final TransactionBuilder builder) { + final BluetoothGattCharacteristic rtcChar = getCharacteristic(CHAR_RTC); + if (rtcChar == null) { + LOG.warn("RTC characteristic not found"); + return; + } + final Calendar now = Calendar.getInstance(); + final int year = now.get(Calendar.YEAR); + final byte[] rtc = new byte[]{ + (byte) (year & 0xFF), + (byte) ((year >> 8) & 0xFF), + (byte) (now.get(Calendar.MONTH) + 1), + (byte) now.get(Calendar.DAY_OF_MONTH), + (byte) now.get(Calendar.HOUR_OF_DAY), + (byte) now.get(Calendar.MINUTE), + (byte) now.get(Calendar.SECOND), + 0x00 + }; + builder.write(rtcChar, rtc); + LOG.info("RTC written: {}-{}-{} {}:{}:{}", + year, + now.get(Calendar.MONTH) + 1, + now.get(Calendar.DAY_OF_MONTH), + now.get(Calendar.HOUR_OF_DAY), + now.get(Calendar.MINUTE), + now.get(Calendar.SECOND)); + } + + @Override + public boolean onCharacteristicChanged(final BluetoothGatt gatt, + final BluetoothGattCharacteristic characteristic, + final byte[] value) { + if (super.onCharacteristicChanged(gatt, characteristic, value)) { + return true; + } + + if (CHAR_MEASUREMENT.equals(characteristic.getUuid())) { + handleMeasurement(value); + return true; + } + + LOG.debug("Unhandled characteristic: {} = {}", characteristic.getUuid(), GB.hexdump(value)); + return false; + } + + /** + * Parses a 19-byte measurement packet from the BPW4500. + */ + private void handleMeasurement(final byte[] data) { + if (data == null || data.length < 19) { + LOG.warn("Invalid measurement packet length: {}", data == null ? 0 : data.length); + return; + } + if ((data[0] & 0xFF) != 0x1E) { + LOG.warn("Unknown packet type: 0x{}", Integer.toHexString(data[0] & 0xFF)); + return; + } + + final int systolic = (data[1] & 0xFF) | ((data[2] & 0xFF) << 8); + final int diastolic = (data[3] & 0xFF) | ((data[4] & 0xFF) << 8); + final int hour = data[11] & 0xFF; + final int minute = data[12] & 0xFF; + final int second = data[13] & 0xFF; + final int pulse = (data[14] & 0xFF) | ((data[15] & 0xFF) << 8); + + LOG.info("Measurement: {}/{} mmHg, pulse: {} bpm, time: {}:{}:{}", + systolic, diastolic, pulse, hour, minute, second); + + // Use system date with time from device + final Calendar ts = Calendar.getInstance(); + ts.set(Calendar.HOUR_OF_DAY, hour); + ts.set(Calendar.MINUTE, minute); + ts.set(Calendar.SECOND, second); + ts.set(Calendar.MILLISECOND, 0); + + final GenericBloodPressureSample sample = new GenericBloodPressureSample(); + sample.setTimestamp(ts.getTimeInMillis()); + sample.setBpSystolic(systolic); + sample.setBpDiastolic(diastolic); + sample.setPulseRate(pulse); + sample.setMeanArterialPressure(0); + sample.setUserIndex(0); + sample.setMeasurementStatus(0); + + pendingSamples.add(sample); + + // Persist immediately as the device disconnects after transmission + persistSamples(); + pendingSamples.clear(); + + GB.signalActivityDataFinish(getDevice()); + } + + private void persistSamples() { + if (pendingSamples.isEmpty()) return; + + try (DBHandler handler = GBApplication.acquireDB()) { + final DaoSession session = handler.getDaoSession(); + final GenericBloodPressureSampleProvider provider = + new GenericBloodPressureSampleProvider(getDevice(), session); + provider.persistForDevice(getContext(), getDevice(), pendingSamples); + LOG.info("Persisted {} blood pressure samples", pendingSamples.size()); + } catch (final Exception e) { + GB.toast(getContext(), "Error saving blood pressure data", Toast.LENGTH_LONG, GB.ERROR, e); + } + } + + @Override + public void dispose() { + try { + //noinspection deprecation + LocalBroadcastManager.getInstance(GBApplication.getContext()) + .unregisterReceiver(disconnectReceiver); + } catch (final Exception e) { + LOG.error("Failed to unregister receiver", e); + } + super.dispose(); + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BloodPressureExportHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BloodPressureExportHelper.java new file mode 100644 index 0000000000..42d84fb8d9 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BloodPressureExportHelper.java @@ -0,0 +1,356 @@ +/* + Copyright (C) 2026 Christian Breiteneder + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ +package nodomain.freeyourgadget.gadgetbridge.util; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.pdf.PdfDocument; +import android.net.Uri; + +import androidx.core.content.FileProvider; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.FileWriter; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser; +import nodomain.freeyourgadget.gadgetbridge.model.BloodPressureSample; + +public class BloodPressureExportHelper { + private static final Logger LOG = LoggerFactory.getLogger(BloodPressureExportHelper.class); + + private static final String DATE_PATTERN = "yyyy-MM-dd HH:mm:ss"; + private static final String FILE_DATE_PATTERN = "yyyy-MM-dd"; + + private static SimpleDateFormat getDateFormat() { + return new SimpleDateFormat(DATE_PATTERN, Locale.getDefault()); + } + + private static SimpleDateFormat getFileDateFormat() { + return new SimpleDateFormat(FILE_DATE_PATTERN, Locale.getDefault()); + } + + public static void exportCsv(Context context, List samples) { + try { + File exportDir = new File(context.getCacheDir(), "csv"); + if (!exportDir.exists() && !exportDir.mkdirs()) { + LOG.error("Failed to create export directory for csv file"); + return; + } + + Date lastSampleDate = new Date(samples.get(samples.size() - 1).getTimestamp()); + String fileName = "blood_pressure_" + getFileDateFormat().format(lastSampleDate) + ".csv"; + File file = new File(exportDir, fileName); + + String colDate = context.getString(R.string.blood_pressure_export_date); + String colTime = context.getString(R.string.pref_header_time); + String colSystolic = context.getString(R.string.blood_pressure_systolic) + " (mmHg)"; + String colDiastolic = context.getString(R.string.blood_pressure_diastolic) + " (mmHg)"; + + SimpleDateFormat dateFormat = getDateFormat(); + + FileWriter writer = new FileWriter(file); + writer.append(colDate).append(",") + .append(colTime).append(",") + .append(colSystolic).append(",") + .append(colDiastolic).append("\n"); + + for (BloodPressureSample sample : samples) { + String dateTime = dateFormat.format(new Date(sample.getTimestamp())); + String[] parts = dateTime.split(" "); + writer.append(parts[0]) + .append(",") + .append(parts[1]) + .append(",") + .append(String.valueOf(sample.getBpSystolic())) + .append(",") + .append(String.valueOf(sample.getBpDiastolic())) + .append("\n"); + } + + writer.flush(); + writer.close(); + + shareFile(context, file, "text/csv"); + } catch (IOException e) { + LOG.error("Error exporting CSV", e); + } + } + + public static void exportPdf(Context context, List samples, String dateLabel, Bitmap chartBitmap) { + try { + File exportDir = new File(context.getCacheDir(), "pdf"); + if (!exportDir.exists() && !exportDir.mkdirs()) { + LOG.error("Failed to create export directory for pdf file"); + return; + } + + Date lastSampleDate = new Date(samples.get(samples.size() - 1).getTimestamp()); + String fileName = "blood_pressure_" + getFileDateFormat().format(lastSampleDate) + ".pdf"; + File file = new File(exportDir, fileName); + + // Localized strings + String reportTitle = context.getString(R.string.blood_pressure); + String colDate = context.getString(R.string.blood_pressure_export_date); + String colTime = context.getString(R.string.pref_header_time); + String colSystolic = context.getString(R.string.blood_pressure_systolic) + " (mmHg)"; + String colDiastolic = context.getString(R.string.blood_pressure_diastolic) + " (mmHg)"; + + // User info + ActivityUser user = new ActivityUser(); + String userName = user.getName(); + String userDob = user.getDateOfBirth() != null ? user.getDateOfBirth().toString() : ""; + + SimpleDateFormat dateFormat = getDateFormat(); + + PdfDocument document = new PdfDocument(); + + int pageWidth = 595; // A4 + int pageHeight = 842; + int margin = 40; + int rowHeight = 18; + + Paint titlePaint = new Paint(); + titlePaint.setTextSize(18); + titlePaint.setFakeBoldText(true); + titlePaint.setAntiAlias(true); + + Paint subtitlePaint = new Paint(); + subtitlePaint.setTextSize(11); + subtitlePaint.setAntiAlias(true); + subtitlePaint.setColor(0xFF666666); + + Paint userPaint = new Paint(); + userPaint.setTextSize(11); + userPaint.setAntiAlias(true); + + Paint headerPaint = new Paint(); + headerPaint.setTextSize(10); + headerPaint.setFakeBoldText(true); + headerPaint.setAntiAlias(true); + + Paint cellPaint = new Paint(); + cellPaint.setTextSize(9); + cellPaint.setAntiAlias(true); + + Paint linePaint = new Paint(); + linePaint.setColor(0xFFCCCCCC); + linePaint.setStrokeWidth(0.5f); + + // === PAGE 1: Header + Chart + beginning of table === + + // Calculate chart height + int chartHeight = 0; + int chartTop; + if (chartBitmap != null) { + float aspectRatio = (float) chartBitmap.getHeight() / chartBitmap.getWidth(); + int chartWidth = pageWidth - 2 * margin; + chartHeight = (int) (chartWidth * aspectRatio); + chartHeight = Math.min(chartHeight, 250); + } + + // Layout positions + float yPos = margin; + + // Title + yPos += 20; + float titleY = yPos; + yPos += 8; + + // Date label + yPos += 14; + float dateLabelY = yPos; + yPos += 6; + + // User info + if (userName != null && !userName.isEmpty()) { + yPos += 16; + } + if (!userDob.isEmpty()) { + yPos += 14; + } + + // Chart + yPos += 10; + chartTop = (int) yPos; + if (chartBitmap != null) { + yPos += chartHeight + 10; + } + + // Table starts here + float tableStartY = yPos + 10; + float usableForTable = pageHeight - tableStartY - margin; + int rowsOnFirstPage = Math.max(0, (int) (usableForTable / rowHeight) - 1); + + int totalRows = samples.size(); + int remainingRows = totalRows - rowsOnFirstPage; + int rowsPerFullPage = (pageHeight - margin * 2 - 30) / rowHeight; + int fullTablePages = remainingRows > 0 ? (int) Math.ceil((double) remainingRows / rowsPerFullPage) : 0; + int totalPages = 1 + fullTablePages; + + float col2 = margin + 130; + float col3 = margin + 260; + float col4 = margin + 390; + + // --- Draw page 1 --- + PdfDocument.PageInfo pageInfo = new PdfDocument.PageInfo.Builder(pageWidth, pageHeight, 1).create(); + PdfDocument.Page pdfPage = document.startPage(pageInfo); + Canvas canvas = pdfPage.getCanvas(); + + // Title + canvas.drawText(reportTitle, margin, titleY, titlePaint); + + // Date label + canvas.drawText(dateLabel, margin, dateLabelY, subtitlePaint); + + // User info (right-aligned) + float rightX = pageWidth - margin; + if (userName != null && !userName.isEmpty()) { + float nameWidth = userPaint.measureText(userName); + canvas.drawText(userName, rightX - nameWidth, titleY, userPaint); + } + if (!userDob.isEmpty()) { + String dobText = context.getString(R.string.blood_pressure_export_dob, userDob); + float dobWidth = subtitlePaint.measureText(dobText); + canvas.drawText(dobText, rightX - dobWidth, dateLabelY, subtitlePaint); + } + + // Chart bitmap + if (chartBitmap != null) { + int chartWidth = pageWidth - 2 * margin; + Rect destRect = new Rect(margin, chartTop, margin + chartWidth, chartTop + chartHeight); + + // White background (chart may use dark theme) + Bitmap whiteBgBitmap = Bitmap.createBitmap(chartBitmap.getWidth(), chartBitmap.getHeight(), Bitmap.Config.ARGB_8888); + Canvas bmpCanvas = new Canvas(whiteBgBitmap); + bmpCanvas.drawColor(0xFFFFFFFF); + bmpCanvas.drawBitmap(chartBitmap, 0, 0, null); + + canvas.drawBitmap(whiteBgBitmap, null, destRect, null); + whiteBgBitmap.recycle(); + } + + // Table header + float tableHeaderY = tableStartY + 12; + canvas.drawText(colDate, margin, tableHeaderY, headerPaint); + canvas.drawText(colTime, col2, tableHeaderY, headerPaint); + canvas.drawText(colSystolic, col3, tableHeaderY, headerPaint); + canvas.drawText(colDiastolic, col4, tableHeaderY, headerPaint); + canvas.drawLine(margin, tableHeaderY + 4, pageWidth - margin, tableHeaderY + 4, linePaint); + + // Table rows on page 1 + int sampleIndex = 0; + int rowsToDraw = Math.min(rowsOnFirstPage, totalRows); + for (int row = 0; row < rowsToDraw; row++) { + BloodPressureSample sample = samples.get(sampleIndex); + float y = tableHeaderY + 20 + (row * rowHeight); + + String dateTime = dateFormat.format(new Date(sample.getTimestamp())); + String[] parts = dateTime.split(" "); + + canvas.drawText(parts[0], margin, y, cellPaint); + canvas.drawText(parts[1], col2, y, cellPaint); + canvas.drawText(String.valueOf(sample.getBpSystolic()), col3, y, cellPaint); + canvas.drawText(String.valueOf(sample.getBpDiastolic()), col4, y, cellPaint); + canvas.drawLine(margin, y + 4, pageWidth - margin, y + 4, linePaint); + + sampleIndex++; + } + + drawPageNumber(canvas, subtitlePaint, 1, totalPages, pageWidth, pageHeight, margin); + document.finishPage(pdfPage); + + // === ADDITIONAL PAGES === + while (sampleIndex < totalRows) { + int currentPage = (sampleIndex - rowsOnFirstPage) / rowsPerFullPage + 2; + PdfDocument.PageInfo nextPageInfo = new PdfDocument.PageInfo.Builder(pageWidth, pageHeight, currentPage).create(); + PdfDocument.Page nextPage = document.startPage(nextPageInfo); + Canvas nextCanvas = nextPage.getCanvas(); + + float nextHeaderY = margin + 16; + nextCanvas.drawText(colDate, margin, nextHeaderY, headerPaint); + nextCanvas.drawText(colTime, col2, nextHeaderY, headerPaint); + nextCanvas.drawText(colSystolic, col3, nextHeaderY, headerPaint); + nextCanvas.drawText(colDiastolic, col4, nextHeaderY, headerPaint); + nextCanvas.drawLine(margin, nextHeaderY + 4, pageWidth - margin, nextHeaderY + 4, linePaint); + + int rowsThisPage = Math.min(rowsPerFullPage, totalRows - sampleIndex); + + for (int row = 0; row < rowsThisPage; row++) { + BloodPressureSample sample = samples.get(sampleIndex); + float y = nextHeaderY + 20 + (row * rowHeight); + + String dateTime = dateFormat.format(new Date(sample.getTimestamp())); + String[] parts = dateTime.split(" "); + + nextCanvas.drawText(parts[0], margin, y, cellPaint); + nextCanvas.drawText(parts[1], col2, y, cellPaint); + nextCanvas.drawText(String.valueOf(sample.getBpSystolic()), col3, y, cellPaint); + nextCanvas.drawText(String.valueOf(sample.getBpDiastolic()), col4, y, cellPaint); + nextCanvas.drawLine(margin, y + 4, pageWidth - margin, y + 4, linePaint); + + sampleIndex++; + } + + drawPageNumber(nextCanvas, subtitlePaint, currentPage, totalPages, pageWidth, pageHeight, margin); + document.finishPage(nextPage); + } + + FileOutputStream fos = new FileOutputStream(file); + document.writeTo(fos); + document.close(); + fos.close(); + + shareFile(context, file, "application/pdf"); + } catch (IOException e) { + LOG.error("Error exporting PDF", e); + } + } + + private static void drawPageNumber(Canvas canvas, Paint paint, int current, int total, int pageWidth, int pageHeight, int margin) { + String pageNum = current + " / " + total; + float pageNumWidth = paint.measureText(pageNum); + canvas.drawText(pageNum, pageWidth - margin - pageNumWidth, pageHeight - margin + 10, paint); + } + + private static void shareFile(Context context, File file, String mimeType) { + Uri uri = FileProvider.getUriForFile(context, + context.getApplicationContext().getPackageName() + ".screenshot_provider", + file); + + Intent shareIntent = new Intent(Intent.ACTION_SEND); + shareIntent.setType(mimeType); + shareIntent.putExtra(Intent.EXTRA_STREAM, uri); + shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + context.startActivity(Intent.createChooser(shareIntent, null)); + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_blood_pressure_chart.xml b/app/src/main/res/layout/fragment_blood_pressure_chart.xml new file mode 100644 index 0000000000..d73a91878b --- /dev/null +++ b/app/src/main/res/layout/fragment_blood_pressure_chart.xml @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_blood_pressure_period.xml b/app/src/main/res/layout/fragment_blood_pressure_period.xml new file mode 100644 index 0000000000..d9625363c7 --- /dev/null +++ b/app/src/main/res/layout/fragment_blood_pressure_period.xml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_blood_pressure.xml b/app/src/main/res/layout/item_blood_pressure.xml new file mode 100644 index 0000000000..267069eb47 --- /dev/null +++ b/app/src/main/res/layout/item_blood_pressure.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index e1b1eecfe5..04e2918442 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -3441,6 +3441,7 @@ @string/menuitem_weight @string/watchface_widget_type_calories @string/respiratoryrate + @string/blood_pressure @@ -3462,6 +3463,7 @@ @string/p_weight @string/p_calories @string/p_respiratory_rate + @string/p_bloodpressure @@ -3484,6 +3486,7 @@ @string/p_weight @string/p_calories @string/p_respiratory_rate + @string/p_bloodpressure @@ -5078,6 +5081,7 @@ @string/menuitem_stress_segmented @string/menuitem_stress_breakdown @string/hrv + @string/blood_pressure @string/menuitem_pai @string/menuitem_vo2_max @string/vo2max_running @@ -5099,6 +5103,7 @@ stress_segmented stress_breakdown hrv + bloodpressure pai vo2max vo2max_running @@ -5117,6 +5122,7 @@ bodyenergy stress_segmented hrv + bloodpressure vo2max calories_active calories_segmented diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 31fe46b350..864e46ea7c 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -79,6 +79,8 @@ #aad93832 #630b0d #ff9393 + #d93832 + #3F84E5 #858585 #ffe2e2e5 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aa046d1d44..1bb6d0eea7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1054,6 +1054,13 @@ Active goal Total burnt Blood pressure + Systolic + Diastolic + Measurements + Date + DOB: %s + %1$d/%2$d + bloodpressure Measuring Measurement results Movement intensity @@ -2230,6 +2237,7 @@ Garmin HRM 200 Coospo H9Z SolarFlow + Braun iCheck 7 BPW4500 Nothing Status diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml index 5c211460fa..8e4effcbc8 100644 --- a/app/src/main/res/values/values.xml +++ b/app/src/main/res/values/values.xml @@ -38,6 +38,7 @@ nfc hr spo2 + bloodpressure pai stress period diff --git a/app/src/main/res/xml/shared_paths.xml b/app/src/main/res/xml/shared_paths.xml index 5c852e2e08..62f98e3162 100644 --- a/app/src/main/res/xml/shared_paths.xml +++ b/app/src/main/res/xml/shared_paths.xml @@ -8,4 +8,5 @@ + diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java index ea4d0814d0..aa81327d61 100644 --- a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java @@ -187,6 +187,7 @@ public class AbstractDeviceCoordinatorTest extends TestBase { put("C20", DeviceType.C20); // #4070 put("C 20", DeviceType.C20); // #5495 put("OV-Touch2.6_LE", DeviceType.OVTOUCH26); // #5628 + put("BPW4500", DeviceType.BRAUN_BPW4500); // #5886 }}; for (Map.Entry e : bluetoothNameToExpectedType.entrySet()) {