mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
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
This commit is contained in:
+4
@@ -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;
|
||||
|
||||
+366
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<BloodPressureChartFragment.BloodPressureChartsData> {
|
||||
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<Entry> 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<LegendEntry> 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<ILineDataSet> lineDataSets = new ArrayList<>();
|
||||
final List<Entry> systolicEntries = new ArrayList<>();
|
||||
final List<Entry> 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<? extends BloodPressureSample> getSamples(final DBHandler db, final GBDevice device, int startTs, int endTs) {
|
||||
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
|
||||
final TimeSampleProvider<? extends BloodPressureSample> 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<? extends BloodPressureSample> 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<? extends BloodPressureSample> samples;
|
||||
public final int systolicAvg;
|
||||
public final int diastolicAvg;
|
||||
public final int systolicLast;
|
||||
public final int diastolicLast;
|
||||
public final int measurementCount;
|
||||
|
||||
public BloodPressureChartsData(List<? extends BloodPressureSample> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
+448
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<BloodPressurePeriodFragment.BloodPressurePeriodData> {
|
||||
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<? extends BloodPressureSample> 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<? extends BloodPressureSample> 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<BloodPressureDayData> 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<? extends BloodPressureSample> getSamples(DBHandler db, GBDevice device, int startTs, int endTs) {
|
||||
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
|
||||
final TimeSampleProvider<? extends BloodPressureSample> 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<CandleEntry> systolicCandleEntries = new ArrayList<>();
|
||||
final ArrayList<CandleEntry> 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<LegendEntry> 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<BloodPressureDayData> days;
|
||||
|
||||
protected BloodPressurePeriodData(List<BloodPressureDayData> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -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)
|
||||
|
||||
+119
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<GBDevice> 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<? extends BloodPressureSample> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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();
|
||||
};
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -697,7 +697,8 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
|
||||
supportsWeightMeasurement(device) ||
|
||||
supportsActiveCalories(device) ||
|
||||
supportsCyclingData(device) ||
|
||||
supportsRespiratoryRate(device);
|
||||
supportsRespiratoryRate(device) ||
|
||||
supportsBloodPressureMeasurement(device);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+105
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<? extends DeviceSupport> 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<AbstractDao<?, ?>, Property> getAllDeviceDao(@NonNull final DaoSession session) {
|
||||
final Map<AbstractDao<?, ?>, 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+291
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<GenericBloodPressureSample> 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();
|
||||
}
|
||||
}
|
||||
+356
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<? extends BloodPressureSample> 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<? extends BloodPressureSample> 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/date_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="15dp"
|
||||
android:layout_marginBottom="20dp"
|
||||
android:gravity="center"
|
||||
android:textColor="?attr/textColorPrimary"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="300sp">
|
||||
|
||||
<com.github.mikephil.charting.charts.LineChart
|
||||
android:id="@+id/blood_pressure_line_chart"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="fill_parent"
|
||||
android:layout_weight="2" />
|
||||
</LinearLayout>
|
||||
|
||||
<GridLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="15dp"
|
||||
android:background="@color/gauge_line_color"
|
||||
android:columnCount="2">
|
||||
|
||||
<LinearLayout
|
||||
style="@style/GridTile"
|
||||
android:layout_marginEnd="1dp"
|
||||
android:layout_marginTop="2dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bp_systolic_last"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/stats_empty_value"
|
||||
android:textColor="?attr/textColorPrimary"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/blood_pressure_systolic"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
style="@style/GridTile"
|
||||
android:layout_marginStart="1dp"
|
||||
android:layout_marginTop="2dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bp_diastolic_last"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/stats_empty_value"
|
||||
android:textColor="?attr/textColorPrimary"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/blood_pressure_diastolic"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
style="@style/GridTile"
|
||||
android:layout_marginEnd="1dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bp_average"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/stats_empty_value"
|
||||
android:textColor="?attr/textColorPrimary"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/hr_average"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
style="@style/GridTile"
|
||||
android:layout_marginStart="1dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bp_measurement_count"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textColor="?attr/textColorPrimary"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/blood_pressure_measurement_count"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</GridLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/manualMeasurements"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="@string/manual_measurements"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/manualMeasurementsList"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical" />
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/bp_export_fab"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:contentDescription="@string/appmanager_app_share"
|
||||
app:srcCompat="@drawable/ic_share" />
|
||||
|
||||
</RelativeLayout>
|
||||
@@ -0,0 +1,136 @@
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/date_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="15dp"
|
||||
android:layout_marginBottom="20dp"
|
||||
android:gravity="center"
|
||||
android:textColor="?attr/textColorPrimary"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="300sp">
|
||||
|
||||
<com.github.mikephil.charting.charts.CombinedChart
|
||||
android:id="@+id/blood_pressure_chart"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="fill_parent"
|
||||
android:layout_weight="2" />
|
||||
</LinearLayout>
|
||||
|
||||
<GridLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="15dp"
|
||||
android:background="@color/gauge_line_color"
|
||||
android:columnCount="2">
|
||||
|
||||
<LinearLayout
|
||||
style="@style/GridTile"
|
||||
android:layout_marginEnd="1dp"
|
||||
android:layout_marginTop="2dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bp_systolic_last"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/stats_empty_value"
|
||||
android:textColor="?attr/textColorPrimary"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/blood_pressure_systolic"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
style="@style/GridTile"
|
||||
android:layout_marginStart="1dp"
|
||||
android:layout_marginTop="2dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bp_diastolic_last"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/stats_empty_value"
|
||||
android:textColor="?attr/textColorPrimary"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/blood_pressure_diastolic"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
style="@style/GridTile"
|
||||
android:layout_marginEnd="1dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bp_average"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/stats_empty_value"
|
||||
android:textColor="?attr/textColorPrimary"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/hr_average"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
style="@style/GridTile"
|
||||
android:layout_marginStart="1dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bp_measurement_count"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textColor="?attr/textColorPrimary"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/blood_pressure_measurement_count"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</GridLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/bp_export_fab"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:contentDescription="@string/appmanager_app_share"
|
||||
app:srcCompat="@drawable/ic_share" />
|
||||
|
||||
</RelativeLayout>
|
||||
@@ -0,0 +1,49 @@
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/date_view"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#4CAF50"
|
||||
android:textSize="14sp"
|
||||
android:layout_gravity="center_horizontal" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bp_item_date"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1.5"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bp_item_systolic"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textAlignment="center"
|
||||
android:textColor="#F44336"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bp_item_diastolic"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textAlignment="center"
|
||||
android:textColor="#2196F3"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bp_item_pulse"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textAlignment="center"
|
||||
android:textColor="#4CAF50"
|
||||
android:textSize="13sp" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -3441,6 +3441,7 @@
|
||||
<item>@string/menuitem_weight</item>
|
||||
<item>@string/watchface_widget_type_calories</item>
|
||||
<item>@string/respiratoryrate</item>
|
||||
<item>@string/blood_pressure</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="pref_charts_tabs_values">
|
||||
@@ -3462,6 +3463,7 @@
|
||||
<item>@string/p_weight</item>
|
||||
<item>@string/p_calories</item>
|
||||
<item>@string/p_respiratory_rate</item>
|
||||
<item>@string/p_bloodpressure</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="pref_charts_tabs_items_default">
|
||||
@@ -3484,6 +3486,7 @@
|
||||
<item>@string/p_weight</item>
|
||||
<item>@string/p_calories</item>
|
||||
<item>@string/p_respiratory_rate</item>
|
||||
<item>@string/p_bloodpressure</item>
|
||||
</string-array>
|
||||
|
||||
|
||||
@@ -5078,6 +5081,7 @@
|
||||
<item>@string/menuitem_stress_segmented</item>
|
||||
<item>@string/menuitem_stress_breakdown</item>
|
||||
<item>@string/hrv</item>
|
||||
<item>@string/blood_pressure</item>
|
||||
<item>@string/menuitem_pai</item>
|
||||
<item>@string/menuitem_vo2_max</item>
|
||||
<item>@string/vo2max_running</item>
|
||||
@@ -5099,6 +5103,7 @@
|
||||
<item>stress_segmented</item>
|
||||
<item>stress_breakdown</item>
|
||||
<item>hrv</item>
|
||||
<item>bloodpressure</item>
|
||||
<item>pai</item>
|
||||
<item>vo2max</item>
|
||||
<item>vo2max_running</item>
|
||||
@@ -5117,6 +5122,7 @@
|
||||
<item>bodyenergy</item>
|
||||
<item>stress_segmented</item>
|
||||
<item>hrv</item>
|
||||
<item>bloodpressure</item>
|
||||
<item>vo2max</item>
|
||||
<item>calories_active</item>
|
||||
<item>calories_segmented</item>
|
||||
|
||||
@@ -79,6 +79,8 @@
|
||||
<color name="spo2_color" type="color">#aad93832</color>
|
||||
<color name="spo2_avg_color_light" type="color">#630b0d</color>
|
||||
<color name="spo2_avg_color_dark" type="color">#ff9393</color>
|
||||
<color name="blood_pressure_systolic_color" type="color">#d93832</color>
|
||||
<color name="blood_pressure_diastolic_color" type="color">#3F84E5</color>
|
||||
|
||||
<color name="value_line_color" type="color">#858585</color>
|
||||
<color name="row_separator_light" type="color">#ffe2e2e5</color>
|
||||
|
||||
@@ -1054,6 +1054,13 @@
|
||||
<string name="active_calories_goal">Active goal</string>
|
||||
<string name="total_calories_burnt">Total burnt</string>
|
||||
<string name="blood_pressure">Blood pressure</string>
|
||||
<string name="blood_pressure_systolic">Systolic</string>
|
||||
<string name="blood_pressure_diastolic">Diastolic</string>
|
||||
<string name="blood_pressure_measurement_count">Measurements</string>
|
||||
<string name="blood_pressure_export_date">Date</string>
|
||||
<string name="blood_pressure_export_dob">DOB: %s</string>
|
||||
<string name="blood_pressure_avg_format">%1$d/%2$d</string>
|
||||
<string name="p_bloodpressure" translatable="false">bloodpressure</string>
|
||||
<string name="getting_heart_rate">Measuring</string>
|
||||
<string name="heart_rate_result">Measurement results</string>
|
||||
<string name="movement_intensity">Movement intensity</string>
|
||||
@@ -2230,6 +2237,7 @@
|
||||
<string name="devicetype_garmin_hrm_200" translatable="false">Garmin HRM 200</string>
|
||||
<string name="devicetype_coospo_h9z" translatable="false">Coospo H9Z</string>
|
||||
<string name="devicetype_solarflow">SolarFlow</string>
|
||||
<string name="devicetype_braun_bpw4500" translatable="false">Braun iCheck 7 BPW4500</string>
|
||||
<!-- Menus on the smart device -->
|
||||
<string name="menuitem_nothing">Nothing</string>
|
||||
<string name="menuitem_status">Status</string>
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<item name="p_menuitem_nfc" type="string">nfc</item>
|
||||
<item name="p_menuitem_hr" type="string">hr</item>
|
||||
<item name="p_menuitem_spo2" type="string">spo2</item>
|
||||
<item name="p_menuitem_bloodpressure" type="string">bloodpressure</item>
|
||||
<item name="p_menuitem_pai" type="string">pai</item>
|
||||
<item name="p_menuitem_stress" type="string">stress</item>
|
||||
<item name="p_menuitem_cycles" type="string">period</item>
|
||||
|
||||
@@ -8,4 +8,5 @@
|
||||
<cache-path name="csv" path="csv/" />
|
||||
<cache-path name="audio" path="audio/" />
|
||||
<cache-path name="images" path="images/" />
|
||||
<cache-path name="pdf" path="pdf/" />
|
||||
</paths>
|
||||
|
||||
+1
@@ -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<String, DeviceType> e : bluetoothNameToExpectedType.entrySet()) {
|
||||
|
||||
Reference in New Issue
Block a user