package com.jsbogang.womp.health;
|
|
import android.content.BroadcastReceiver;
|
import android.content.Context;
|
import android.content.Intent;
|
import android.content.IntentFilter;
|
import android.graphics.Color;
|
import android.os.Bundle;
|
import android.view.LayoutInflater;
|
import android.view.View;
|
import android.view.ViewGroup;
|
import android.widget.TextView;
|
|
import androidx.annotation.NonNull;
|
import androidx.annotation.Nullable;
|
import androidx.fragment.app.Fragment;
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
import androidx.recyclerview.widget.RecyclerView;
|
|
import com.github.mikephil.charting.charts.LineChart;
|
import com.github.mikephil.charting.components.Description;
|
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.IndexAxisValueFormatter;
|
import com.github.mikephil.charting.formatter.ValueFormatter;
|
import com.jsbogang.womp.android.R;
|
import com.jsbogang.womp.ble.AlarmPrefs;
|
import com.jsbogang.womp.ble.DevicePrefs;
|
import com.jsbogang.womp.ble.VeepooBleManager;
|
import com.jsbogang.womp.workbench.WompConfig;
|
import com.jsbogang.womp.workbench.WompKeepAliveService;
|
|
import java.text.SimpleDateFormat;
|
import java.util.ArrayList;
|
import java.util.Calendar;
|
import java.util.Date;
|
import java.util.LinkedHashMap;
|
import java.util.List;
|
import java.util.Locale;
|
import java.util.Map;
|
|
public class HealthFragment extends Fragment {
|
private static final int CHART_DAYS = 7;
|
private TextView tvStatus;
|
private TextView tvSync;
|
private View unboundPanel;
|
private View boundPanel;
|
private View boundActions;
|
private ScanAdapter adapter;
|
private final Map<String, ScanRow> devices = new LinkedHashMap<>();
|
|
@Nullable
|
@Override
|
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
return inflater.inflate(R.layout.fragment_health, container, false);
|
}
|
|
@Override
|
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
tvStatus = view.findViewById(R.id.tv_device_status);
|
tvSync = view.findViewById(R.id.tv_sync_status);
|
unboundPanel = view.findViewById(R.id.unbound_panel);
|
boundPanel = view.findViewById(R.id.bound_panel);
|
boundActions = view.findViewById(R.id.bound_actions);
|
|
RecyclerView rv = view.findViewById(R.id.rv_devices);
|
rv.setLayoutManager(new LinearLayoutManager(requireContext()));
|
rv.setNestedScrollingEnabled(false);
|
adapter = new ScanAdapter(row -> {
|
VeepooBleManager.get().connect(row.mac, row.name, "0000");
|
tvSync.setText("正在连接 " + row.name);
|
WompKeepAliveService.start(requireContext());
|
});
|
rv.setAdapter(adapter);
|
|
view.findViewById(R.id.btn_scan).setOnClickListener(v -> {
|
devices.clear();
|
adapter.submit(new ArrayList<>());
|
tvSync.setText(getString(R.string.device_scanning));
|
VeepooBleManager.get().startScan();
|
});
|
view.findViewById(R.id.btn_sync).setOnClickListener(v -> HealthSyncer.get().syncAll());
|
view.findViewById(R.id.btn_alarm).setOnClickListener(v ->
|
startActivity(new Intent(requireContext(), AlarmSettingsActivity.class)));
|
view.findViewById(R.id.btn_unbind).setOnClickListener(v -> {
|
VeepooBleManager.get().unbind();
|
refreshBoundUi();
|
});
|
View simPanel = view.findViewById(R.id.debug_sim_panel);
|
if (simPanel != null) {
|
simPanel.setVisibility(WompConfig.DEBUG_UPLOAD_TOAST ? View.VISIBLE : View.GONE);
|
}
|
view.findViewById(R.id.btn_sim_hr).setOnClickListener(v ->
|
HealthUploader.get().simulateAbnormal("hr"));
|
view.findViewById(R.id.btn_sim_bp).setOnClickListener(v ->
|
HealthUploader.get().simulateAbnormal("bp"));
|
view.findViewById(R.id.btn_sim_spo2).setOnClickListener(v ->
|
HealthUploader.get().simulateAbnormal("spo2"));
|
|
bindMetric(view.findViewById(R.id.card_steps), getString(R.string.today_steps));
|
bindMetric(view.findViewById(R.id.card_hr), getString(R.string.heart_rate));
|
bindMetric(view.findViewById(R.id.card_bp), getString(R.string.blood_pressure));
|
bindMetric(view.findViewById(R.id.card_spo2), getString(R.string.blood_oxygen));
|
bindMetric(view.findViewById(R.id.card_glucose), getString(R.string.blood_glucose));
|
bindMetric(view.findViewById(R.id.card_sleep), getString(R.string.sleep));
|
|
view.post(() -> {
|
if (!isAdded() || getView() == null) {
|
return;
|
}
|
setupChart(view.findViewById(R.id.chart_steps));
|
setupChart(view.findViewById(R.id.chart_hr));
|
setupChart(view.findViewById(R.id.chart_bp_sys));
|
setupChart(view.findViewById(R.id.chart_bp_dia));
|
setupChart(view.findViewById(R.id.chart_spo2));
|
refreshBoundUi();
|
loadTodayAndCharts(view);
|
});
|
refreshBoundUi();
|
}
|
|
@Override
|
public void onStart() {
|
super.onStart();
|
IntentFilter f = new IntentFilter();
|
f.addAction(VeepooBleManager.ACTION_SCAN);
|
f.addAction(VeepooBleManager.ACTION_CONN);
|
f.addAction(HealthSyncer.ACTION_HEALTH);
|
LocalBroadcastManager.getInstance(requireContext()).registerReceiver(receiver, f);
|
refreshBoundUi();
|
reloadFromLocal();
|
}
|
|
public void reloadFromLocal() {
|
if (getView() != null) {
|
loadTodayAndCharts(getView());
|
}
|
}
|
|
@Override
|
public void onStop() {
|
LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(receiver);
|
super.onStop();
|
}
|
|
private final BroadcastReceiver receiver = new BroadcastReceiver() {
|
@Override
|
public void onReceive(Context context, Intent intent) {
|
if (intent == null || getView() == null) {
|
return;
|
}
|
String a = intent.getAction();
|
if (VeepooBleManager.ACTION_SCAN.equals(a)) {
|
String mac = intent.getStringExtra(VeepooBleManager.EXTRA_MAC);
|
String name = intent.getStringExtra(VeepooBleManager.EXTRA_NAME);
|
int rssi = intent.getIntExtra(VeepooBleManager.EXTRA_RSSI, 0);
|
if (mac != null) {
|
devices.put(mac, new ScanRow(name, mac, rssi));
|
adapter.submit(new ArrayList<>(devices.values()));
|
}
|
} else if (VeepooBleManager.ACTION_CONN.equals(a)) {
|
tvSync.setText(intent.getStringExtra(VeepooBleManager.EXTRA_MSG));
|
refreshBoundUi();
|
if (intent.getBooleanExtra(VeepooBleManager.EXTRA_READY, false)) {
|
loadTodayAndCharts(getView());
|
}
|
} else if (HealthSyncer.ACTION_HEALTH.equals(a)) {
|
String msg = intent.getStringExtra(HealthSyncer.EXTRA_MSG);
|
if (msg != null && tvSync != null) {
|
tvSync.setText(msg);
|
}
|
loadTodayAndCharts(getView());
|
}
|
}
|
};
|
|
private void refreshBoundUi() {
|
boolean bound = DevicePrefs.isBound(requireContext());
|
unboundPanel.setVisibility(bound ? View.GONE : View.VISIBLE);
|
boundPanel.setVisibility(bound ? View.VISIBLE : View.GONE);
|
boundActions.setVisibility(bound ? View.VISIBLE : View.GONE);
|
if (bound) {
|
String st = VeepooBleManager.get().isReady() ? "已连接" :
|
(VeepooBleManager.get().isConnected() ? "连接中" : "未连接");
|
tvStatus.setText(DevicePrefs.getName(requireContext()) + " " + DevicePrefs.getMac(requireContext())
|
+ "\n" + st);
|
} else {
|
tvStatus.setText(R.string.device_unbound);
|
}
|
}
|
|
private void loadTodayAndCharts(View root) {
|
if (root == null || !DevicePrefs.isBound(requireContext()) || AppDatabase.get() == null) {
|
return;
|
}
|
String mac = DevicePrefs.getMac(requireContext());
|
long start = HealthSyncer.startOfDay(new Date());
|
HealthSample hr = AppDatabase.dao().latestHeart(mac, start, start + 86400000L);
|
HealthSample bp = AppDatabase.dao().latestBp(mac, start, start + 86400000L);
|
HealthSample ox = AppDatabase.dao().latestSpo2(mac, start, start + 86400000L);
|
HealthSample glu = AppDatabase.dao().latestGlucose(mac, start, start + 86400000L);
|
int steps = HealthSyncer.daySteps(requireContext(), mac, start, start + 86400000L);
|
if (steps > 0) {
|
setMetric(root.findViewById(R.id.card_steps), String.valueOf(steps));
|
}
|
if (hr != null && hr.heartRate > 0) {
|
setMetric(root.findViewById(R.id.card_hr), hr.heartRate + " 次/分");
|
}
|
if (bp != null && bp.bpHigh > 0) {
|
setMetric(root.findViewById(R.id.card_bp), bp.bpHigh + "/" + bp.bpLow);
|
}
|
if (ox != null && ox.spo2 > 0) {
|
setMetric(root.findViewById(R.id.card_spo2), String.format(Locale.CHINA, "%.0f%%", ox.spo2));
|
}
|
if (glu != null && glu.glucose > 0) {
|
setMetric(root.findViewById(R.id.card_glucose), String.format(Locale.CHINA, "%.1f", glu.glucose));
|
}
|
String today = new SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(new Date());
|
SleepSession sleep = AppDatabase.dao().sleepOf(mac, today);
|
if (sleep == null || sleep.allMinutes <= 0) {
|
java.util.Calendar c = java.util.Calendar.getInstance();
|
c.add(java.util.Calendar.DAY_OF_YEAR, -1);
|
sleep = AppDatabase.dao().sleepOf(mac, new SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(c.getTime()));
|
}
|
if (sleep != null && sleep.allMinutes > 0) {
|
setMetric(root.findViewById(R.id.card_sleep), formatSleepHours(sleep.allMinutes));
|
}
|
|
long weekFrom = start - (CHART_DAYS - 1L) * 86400000L;
|
List<HealthSample> week = AppDatabase.dao().samplesInRange(mac, weekFrom, start + 86400000L);
|
String[] labels = dayLabels();
|
fillStepsChart(root.findViewById(R.id.chart_steps), mac, labels);
|
fillHrChart(root.findViewById(R.id.chart_hr), week, labels);
|
fillBpSysChart(root.findViewById(R.id.chart_bp_sys), week, labels);
|
fillBpDiaChart(root.findViewById(R.id.chart_bp_dia), week, labels);
|
fillSpo2Chart(root.findViewById(R.id.chart_spo2), week, labels);
|
}
|
|
private String formatSleepHours(int minutes) {
|
if (minutes <= 0) {
|
return "--";
|
}
|
int h = minutes / 60;
|
int m = minutes % 60;
|
if (m == 0) {
|
return h + " 小时";
|
}
|
return String.format(Locale.CHINA, "%.1f 小时", minutes / 60f);
|
}
|
|
private String[] dayLabels() {
|
String[] labels = new String[CHART_DAYS];
|
long todayStart = HealthSyncer.startOfDay(new Date());
|
long firstStart = todayStart - (CHART_DAYS - 1L) * 86400000L;
|
SimpleDateFormat fmt = new SimpleDateFormat("MM-dd", Locale.CHINA);
|
Calendar c = Calendar.getInstance();
|
for (int d = 0; d < CHART_DAYS; d++) {
|
c.setTimeInMillis(firstStart + d * 86400000L);
|
labels[d] = d == CHART_DAYS - 1 ? "今天" : fmt.format(c.getTime());
|
}
|
return labels;
|
}
|
|
private interface MetricGet { float get(HealthSample s); }
|
|
private float[] dailySeries(List<HealthSample> data, MetricGet get, boolean average) {
|
long todayStart = HealthSyncer.startOfDay(new Date());
|
long firstStart = todayStart - (CHART_DAYS - 1L) * 86400000L;
|
float[] sums = new float[CHART_DAYS];
|
float[] maxes = new float[CHART_DAYS];
|
int[] counts = new int[CHART_DAYS];
|
if (data != null) {
|
for (HealthSample s : data) {
|
float v = get.get(s);
|
if (v <= 0) {
|
continue;
|
}
|
int idx = (int) ((s.timestamp - firstStart) / 86400000L);
|
if (idx < 0 || idx >= CHART_DAYS) {
|
continue;
|
}
|
sums[idx] += v;
|
counts[idx]++;
|
if (v > maxes[idx]) {
|
maxes[idx] = v;
|
}
|
}
|
}
|
float[] out = new float[CHART_DAYS];
|
for (int d = 0; d < CHART_DAYS; d++) {
|
if (counts[d] <= 0) {
|
continue;
|
}
|
if (average) {
|
out[d] = sums[d] / counts[d];
|
} else if (maxes[d] > 0 && sums[d] > maxes[d] * 2f) {
|
out[d] = maxes[d];
|
} else {
|
out[d] = sums[d];
|
}
|
}
|
return out;
|
}
|
|
private LineDataSet makeSet(float[] values, int color, String label, boolean skipZero) {
|
List<Entry> entries = new ArrayList<>();
|
for (int d = 0; d < values.length; d++) {
|
if (skipZero && values[d] <= 0) {
|
continue;
|
}
|
entries.add(new Entry(d, values[d]));
|
}
|
if (entries.isEmpty()) {
|
entries.add(new Entry(0, 0));
|
}
|
LineDataSet set = new LineDataSet(entries, label);
|
set.setColor(color);
|
set.setCircleColor(color);
|
set.setCircleRadius(3.5f);
|
set.setDrawCircles(true);
|
set.setDrawValues(true);
|
set.setValueTextSize(9f);
|
set.setValueTextColor(Color.parseColor("#333333"));
|
set.setValueFormatter(valueFormatter());
|
set.setLineWidth(1.8f);
|
return set;
|
}
|
|
private ValueFormatter valueFormatter() {
|
return new ValueFormatter() {
|
@Override
|
public String getFormattedValue(float value) {
|
if (value <= 0.01f) {
|
return "";
|
}
|
if (Math.abs(value - Math.round(value)) < 0.05f) {
|
return String.valueOf(Math.round(value));
|
}
|
return String.format(Locale.CHINA, "%.1f", value);
|
}
|
};
|
}
|
|
private void applyXAxis(LineChart chart, String[] labels) {
|
XAxis x = chart.getXAxis();
|
x.setValueFormatter(new IndexAxisValueFormatter(labels));
|
x.setGranularity(1f);
|
x.setLabelCount(CHART_DAYS, true);
|
x.setPosition(XAxis.XAxisPosition.BOTTOM);
|
x.setDrawGridLines(false);
|
x.setAvoidFirstLastClipping(true);
|
x.setAxisMinimum(-0.15f);
|
x.setAxisMaximum(CHART_DAYS - 0.85f);
|
}
|
|
private void addLimit(YAxis axis, float y, String label, int color) {
|
LimitLine ll = new LimitLine(y, label);
|
ll.setLineColor(color);
|
ll.setLineWidth(1f);
|
ll.enableDashedLine(10f, 6f, 0f);
|
ll.setTextColor(color);
|
ll.setTextSize(8f);
|
ll.setLabelPosition(LimitLine.LimitLabelPosition.RIGHT_TOP);
|
axis.addLimitLine(ll);
|
}
|
|
private void fillHrChart(LineChart chart, List<HealthSample> week, String[] labels) {
|
if (chart == null) {
|
return;
|
}
|
float[] vals = dailySeries(week, s -> s.heartRate, true);
|
LineDataSet set = makeSet(vals, Color.RED, "心率", true);
|
chart.setData(new LineData(set));
|
applyXAxis(chart, labels);
|
YAxis y = chart.getAxisLeft();
|
y.removeAllLimitLines();
|
y.setDrawLimitLinesBehindData(true);
|
y.setAxisMinimum(40f);
|
y.setAxisMaximum(160f);
|
int low = AlarmPrefs.hrLow(requireContext());
|
int high = AlarmPrefs.hrHigh(requireContext());
|
addLimit(y, low, "正常下限 " + low, Color.parseColor("#43A047"));
|
addLimit(y, high, "正常上限 " + high, Color.parseColor("#43A047"));
|
chart.getLegend().setEnabled(false);
|
chart.invalidate();
|
}
|
|
private void fillBpSysChart(LineChart chart, List<HealthSample> week, String[] labels) {
|
if (chart == null) {
|
return;
|
}
|
float[] sys = dailySeries(week, s -> s.bpHigh, true);
|
chart.setData(new LineData(makeSet(sys, Color.parseColor("#C2185B"), "收缩压/高压", true)));
|
applyXAxis(chart, labels);
|
YAxis y = chart.getAxisLeft();
|
y.removeAllLimitLines();
|
y.setDrawLimitLinesBehindData(true);
|
y.setAxisMinimum(70f);
|
y.setAxisMaximum(180f);
|
int sysHigh = AlarmPrefs.bpHigh(requireContext());
|
addLimit(y, WompConfig.BP_SYS_LOW_DEFAULT, "下限 " + WompConfig.BP_SYS_LOW_DEFAULT,
|
Color.parseColor("#43A047"));
|
addLimit(y, sysHigh, "上限 " + sysHigh, Color.parseColor("#C2185B"));
|
chart.getLegend().setEnabled(false);
|
chart.invalidate();
|
}
|
|
private void fillBpDiaChart(LineChart chart, List<HealthSample> week, String[] labels) {
|
if (chart == null) {
|
return;
|
}
|
float[] dia = dailySeries(week, s -> s.bpLow, true);
|
chart.setData(new LineData(makeSet(dia, Color.parseColor("#1976D2"), "舒张压/低压", true)));
|
applyXAxis(chart, labels);
|
YAxis y = chart.getAxisLeft();
|
y.removeAllLimitLines();
|
y.setDrawLimitLinesBehindData(true);
|
y.setAxisMinimum(40f);
|
y.setAxisMaximum(110f);
|
int diaLow = AlarmPrefs.bpLow(requireContext());
|
addLimit(y, diaLow, "下限 " + diaLow, Color.parseColor("#1976D2"));
|
addLimit(y, WompConfig.BP_DIA_HIGH_DEFAULT, "上限 " + WompConfig.BP_DIA_HIGH_DEFAULT,
|
Color.parseColor("#43A047"));
|
chart.getLegend().setEnabled(false);
|
chart.invalidate();
|
}
|
|
private void fillSpo2Chart(LineChart chart, List<HealthSample> week, String[] labels) {
|
if (chart == null) {
|
return;
|
}
|
float[] vals = dailySeries(week, s -> s.spo2, true);
|
LineDataSet set = makeSet(vals, Color.rgb(0, 150, 136), "血氧", true);
|
chart.setData(new LineData(set));
|
applyXAxis(chart, labels);
|
YAxis y = chart.getAxisLeft();
|
y.removeAllLimitLines();
|
y.setDrawLimitLinesBehindData(true);
|
y.setAxisMinimum(80f);
|
y.setAxisMaximum(100f);
|
int low = Math.round(AlarmPrefs.spo2Low(requireContext()));
|
addLimit(y, low, "正常下限 " + low + "%", Color.parseColor("#E65100"));
|
addLimit(y, WompConfig.SPO2_NORMAL, "正常 " + (int) WompConfig.SPO2_NORMAL + "%",
|
Color.parseColor("#2E7D32"));
|
chart.getLegend().setEnabled(false);
|
chart.invalidate();
|
}
|
|
private void fillStepsChart(LineChart chart, String mac, String[] labels) {
|
if (chart == null) {
|
return;
|
}
|
long todayStart = HealthSyncer.startOfDay(new Date());
|
long firstStart = todayStart - (CHART_DAYS - 1L) * 86400000L;
|
float[] vals = new float[CHART_DAYS];
|
for (int d = 0; d < CHART_DAYS; d++) {
|
long from = firstStart + d * 86400000L;
|
vals[d] = HealthSyncer.daySteps(requireContext(), mac, from, from + 86400000L);
|
}
|
LineDataSet set = makeSet(vals, Color.BLUE, "步数", false);
|
chart.setData(new LineData(set));
|
applyXAxis(chart, labels);
|
YAxis y = chart.getAxisLeft();
|
y.removeAllLimitLines();
|
y.setAxisMinimum(0f);
|
chart.getLegend().setEnabled(false);
|
chart.invalidate();
|
}
|
|
private void setupChart(LineChart chart) {
|
if (chart == null) {
|
return;
|
}
|
chart.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
|
Description d = new Description();
|
d.setText("");
|
d.setEnabled(false);
|
chart.setDescription(d);
|
chart.getLegend().setEnabled(false);
|
chart.getAxisRight().setEnabled(false);
|
chart.getXAxis().setPosition(XAxis.XAxisPosition.BOTTOM);
|
chart.setNoDataText("暂无数据");
|
chart.setExtraBottomOffset(8f);
|
chart.setExtraTopOffset(18f);
|
chart.setExtraRightOffset(8f);
|
chart.setData(new LineData());
|
}
|
|
private void bindMetric(View card, String title) {
|
if (card == null) {
|
return;
|
}
|
TextView t = card.findViewById(R.id.metric_title);
|
if (t != null) {
|
t.setText(title);
|
}
|
}
|
|
private void setMetric(View card, String value) {
|
if (card == null) {
|
return;
|
}
|
TextView t = card.findViewById(R.id.metric_value);
|
if (t != null) {
|
t.setText(value);
|
}
|
}
|
|
static class ScanRow {
|
final String name;
|
final String mac;
|
final int rssi;
|
|
ScanRow(String name, String mac, int rssi) {
|
this.name = name == null || name.isEmpty() ? "未知设备" : name;
|
this.mac = mac;
|
this.rssi = rssi;
|
}
|
}
|
|
static class ScanAdapter extends RecyclerView.Adapter<ScanAdapter.VH> {
|
interface OnPick { void onPick(ScanRow row); }
|
private final List<ScanRow> items = new ArrayList<>();
|
private final OnPick onPick;
|
|
ScanAdapter(OnPick onPick) {
|
this.onPick = onPick;
|
}
|
|
void submit(List<ScanRow> rows) {
|
items.clear();
|
items.addAll(rows);
|
notifyDataSetChanged();
|
}
|
|
@NonNull
|
@Override
|
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_scan_device, parent, false);
|
return new VH(v);
|
}
|
|
@Override
|
public void onBindViewHolder(@NonNull VH holder, int position) {
|
ScanRow row = items.get(position);
|
holder.name.setText(row.name + " RSSI " + row.rssi);
|
holder.mac.setText(row.mac);
|
holder.itemView.setOnClickListener(v -> onPick.onPick(row));
|
}
|
|
@Override
|
public int getItemCount() {
|
return items.size();
|
}
|
|
static class VH extends RecyclerView.ViewHolder {
|
TextView name;
|
TextView mac;
|
VH(View itemView) {
|
super(itemView);
|
name = itemView.findViewById(R.id.tv_name);
|
mac = itemView.findViewById(R.id.tv_mac);
|
}
|
}
|
}
|
}
|