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 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 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 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 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 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 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 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 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 { interface OnPick { void onPick(ScanRow row); } private final List items = new ArrayList<>(); private final OnPick onPick; ScanAdapter(OnPick onPick) { this.onPick = onPick; } void submit(List 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); } } } }