407 lines
11 KiB
TypeScript
407 lines
11 KiB
TypeScript
import React from 'react';
|
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
import {
|
|
Bluetooth,
|
|
RadioReceiver,
|
|
} from 'lucide-react-native';
|
|
import { type HeartRateMonitorState } from '../ble/useHeartRateMonitor';
|
|
import { formatRssi } from '../ble/rssi';
|
|
import { ActionButton } from '../components/ActionButton';
|
|
import { DialTile } from '../components/DialTile';
|
|
import { LiveDot } from '../components/LiveDot';
|
|
import { colors, radius, spacing, typography } from '../theme';
|
|
|
|
type Props = {
|
|
readonly monitor: HeartRateMonitorState;
|
|
readonly onMonitorAction: () => Promise<void>;
|
|
};
|
|
|
|
export function SensorScreen({ monitor, onMonitorAction }: Props) {
|
|
const device = monitor.connectedDevice;
|
|
const services = monitor.services.length
|
|
? monitor.services
|
|
: ['Heart Rate Service \· 0x180D \· aguardando conex\ão'];
|
|
const characteristics = monitor.characteristics.length
|
|
? monitor.characteristics
|
|
: ['Heart Rate Measurement \· 0x2A37 \· notify esperado'];
|
|
|
|
const isReceiving = monitor.phase === 'receiving';
|
|
|
|
return (
|
|
<View style={styles.screen}>
|
|
<View style={styles.sensorConsole}>
|
|
<View style={styles.consoleTopline}>
|
|
<View style={styles.consoleLeft}>
|
|
<View style={styles.eyebrowRow}>
|
|
<Text
|
|
style={[
|
|
styles.eyebrowDot,
|
|
{ color: isReceiving ? colors.success : colors.textDim },
|
|
]}
|
|
>
|
|
{'\•'}
|
|
</Text>
|
|
<Text style={styles.eyebrow}>SENSOR REAL</Text>
|
|
</View>
|
|
<Text style={styles.title}>
|
|
{friendlyDeviceTitle(device?.name)}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.phaseBadge}>
|
|
<Bluetooth color={colors.warning} size={14} strokeWidth={2.4} />
|
|
<Text style={styles.phaseText}>{phaseLabel(monitor.phase)}</Text>
|
|
{isReceiving ? <LiveDot live size={5} /> : null}
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.signalColumn}>
|
|
<DialTile
|
|
label="RSSI"
|
|
value={formatRssi(device?.rssi)}
|
|
tone="amber"
|
|
/>
|
|
<DialTile
|
|
label="LEITURAS"
|
|
value={`${monitor.samples.length}`}
|
|
tone="amber"
|
|
/>
|
|
<DialTile
|
|
label="BATERIA"
|
|
value={formatBattery(device?.batteryPercent)}
|
|
tone="amber"
|
|
/>
|
|
<DialTile
|
|
label="FUNDO"
|
|
value={monitor.batteryOptimizationIgnored ? 'liberado' : 'restrito'}
|
|
tone={monitor.batteryOptimizationIgnored ? 'green' : 'amber'}
|
|
/>
|
|
</View>
|
|
|
|
<Text style={styles.explainer}>
|
|
{monitor.errorMessage ?? sensorExplanation(monitor)}
|
|
</Text>
|
|
|
|
<View style={styles.actionRow}>
|
|
{!monitor.isMonitoring ? (
|
|
<ActionButton
|
|
label="Buscar sensor"
|
|
onPress={() => {
|
|
onMonitorAction().catch(() => undefined);
|
|
}}
|
|
showIcon={false}
|
|
variant="primary"
|
|
/>
|
|
) : (
|
|
<View style={styles.livePill}>
|
|
<LiveDot live size={6} />
|
|
<Text style={styles.livePillText}>Monitor cont\ínuo ativo</Text>
|
|
</View>
|
|
)}
|
|
{!monitor.batteryOptimizationIgnored ? (
|
|
<Pressable
|
|
onPress={() =>
|
|
monitor.openBatterySettings().catch(() => undefined)
|
|
}
|
|
style={styles.secondaryButton}
|
|
>
|
|
<Text style={styles.secondaryButtonText}>Bateria</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
|
|
{monitor.discoveredDevices.length > 0 ? (
|
|
<View style={styles.discoveryBlock}>
|
|
<Text style={styles.blockLabel}>ENCONTRADOS AGORA</Text>
|
|
{monitor.discoveredDevices.map((candidate) => (
|
|
<Pressable
|
|
key={candidate.id}
|
|
onPress={() =>
|
|
monitor.connectToDevice(candidate.id).catch(() => undefined)
|
|
}
|
|
style={styles.deviceRow}
|
|
>
|
|
<View style={styles.deviceDot} />
|
|
<View style={styles.deviceInfo}>
|
|
<Text style={styles.deviceName}>
|
|
{friendlyDeviceTitle(candidate.name)}
|
|
</Text>
|
|
<Text style={styles.deviceMeta}>
|
|
{candidate.name} \· {formatRssi(candidate.rssi)}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.connectPill}>
|
|
<Text style={styles.connectPillText}>Conectar</Text>
|
|
</View>
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
) : null}
|
|
|
|
<BleInventoryBlock title="SERVI\ÇOS BLE" rows={services} />
|
|
<BleInventoryBlock title="CARACTER\ÍSTICAS" rows={characteristics} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function BleInventoryBlock({
|
|
title,
|
|
rows,
|
|
}: {
|
|
readonly title: string;
|
|
readonly rows: readonly string[];
|
|
}) {
|
|
return (
|
|
<View style={styles.diagnosticBlock}>
|
|
<Text style={styles.blockLabel}>{title}</Text>
|
|
{rows.slice(0, 8).map((row) => {
|
|
const parsed = parseBleRow(row);
|
|
return (
|
|
<View key={row} style={styles.diagnosticRow}>
|
|
<RadioReceiver color={colors.textDim} size={14} strokeWidth={2.2} />
|
|
<View style={styles.diagnosticBody}>
|
|
<Text style={styles.diagnosticTitle}>{parsed.name}</Text>
|
|
<Text style={styles.diagnosticText}>
|
|
{parsed.uuid} \· {parsed.detail}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function friendlyDeviceTitle(name?: string | null) {
|
|
if (!name) {
|
|
return 'Cinta card\íaca BLE';
|
|
}
|
|
if (/magene|h003/i.test(name)) {
|
|
return 'Magene H003';
|
|
}
|
|
if (/heart|hrm/i.test(name)) {
|
|
return 'Cinta card\íaca BLE';
|
|
}
|
|
return name;
|
|
}
|
|
|
|
function parseBleRow(row: string) {
|
|
const [name = row, uuid = 'BLE', ...rest] = row.split(' \· ');
|
|
return {
|
|
name,
|
|
uuid,
|
|
detail: rest.join(' \· ') || 'aguardando leitura',
|
|
};
|
|
}
|
|
|
|
function phaseLabel(phase: HeartRateMonitorState['phase']) {
|
|
const labels: Record<HeartRateMonitorState['phase'], string> = {
|
|
idle: 'parado',
|
|
'requesting-permission': 'permiss\ões',
|
|
'bluetooth-off': 'bluetooth off',
|
|
scanning: 'buscando',
|
|
connecting: 'conectando',
|
|
connected: 'conectado',
|
|
receiving: 'recebendo',
|
|
'permission-denied': 'sem permiss\ão',
|
|
'sensor-not-found': 'sem sensor',
|
|
error: 'erro',
|
|
};
|
|
return labels[phase];
|
|
}
|
|
|
|
function sensorExplanation(monitor: HeartRateMonitorState) {
|
|
if (monitor.phase === 'receiving' && monitor.samples.length > 0) {
|
|
return `Amostras reais chegando: ${monitor.samples.length}. Cada BPM reconhecido est\á sendo salvo em SQLite.`;
|
|
}
|
|
if (monitor.phase === 'receiving' || monitor.phase === 'connected') {
|
|
return 'Sensor reconhecido. O app est\á armando a leitura 2A37; se n\ão vier BPM, reativa notify e reconecta sozinho.';
|
|
}
|
|
if (monitor.phase === 'scanning') {
|
|
return 'Busca nativa BLE ativa. Se o app for bloqueado, a notifica\ç\ão mant\ém o servi\ço vivo.';
|
|
}
|
|
return 'A tela traduz nomes t\écnicos: 180D \é o servi\ço card\íaco; 2A37 \é o dado de BPM.';
|
|
}
|
|
|
|
function formatBattery(percent?: number | null) {
|
|
if (typeof percent === 'number') {
|
|
return `${percent}%`;
|
|
}
|
|
return '--';
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
screen: {
|
|
gap: spacing.sm,
|
|
},
|
|
sensorConsole: {
|
|
borderTopWidth: 1,
|
|
borderColor: colors.line,
|
|
paddingVertical: spacing.md,
|
|
gap: spacing.md,
|
|
},
|
|
consoleTopline: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
gap: spacing.md,
|
|
alignItems: 'center',
|
|
},
|
|
consoleLeft: {
|
|
flex: 1,
|
|
},
|
|
eyebrowRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
},
|
|
eyebrowDot: {
|
|
fontSize: 18,
|
|
lineHeight: 18,
|
|
fontWeight: '900',
|
|
},
|
|
eyebrow: {
|
|
...typography.label,
|
|
color: colors.action,
|
|
},
|
|
title: {
|
|
...typography.detailNumeric,
|
|
color: colors.text,
|
|
fontSize: 22,
|
|
lineHeight: 26,
|
|
letterSpacing: -0.8,
|
|
marginTop: spacing.xs,
|
|
},
|
|
phaseBadge: {
|
|
paddingVertical: spacing.xs,
|
|
paddingHorizontal: spacing.sm,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
height: 32,
|
|
borderRadius: radius.xl,
|
|
borderWidth: 1,
|
|
borderColor: colors.line,
|
|
backgroundColor: colors.surface,
|
|
},
|
|
phaseText: {
|
|
...typography.label,
|
|
color: colors.text,
|
|
},
|
|
signalColumn: {
|
|
borderTopWidth: 1,
|
|
borderColor: colors.line,
|
|
},
|
|
explainer: {
|
|
...typography.body,
|
|
color: colors.textMuted,
|
|
},
|
|
actionRow: {
|
|
flexDirection: 'row',
|
|
gap: spacing.sm,
|
|
alignItems: 'center',
|
|
},
|
|
livePill: {
|
|
minHeight: 48,
|
|
flex: 1,
|
|
borderWidth: 1,
|
|
borderColor: colors.line,
|
|
paddingHorizontal: spacing.md,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
},
|
|
livePillText: {
|
|
...typography.label,
|
|
color: colors.success,
|
|
},
|
|
secondaryButton: {
|
|
minHeight: 48,
|
|
borderWidth: 1,
|
|
borderColor: colors.line,
|
|
borderRadius: radius.xl,
|
|
paddingHorizontal: spacing.md,
|
|
justifyContent: 'center',
|
|
},
|
|
secondaryButtonText: {
|
|
...typography.label,
|
|
color: colors.warning,
|
|
},
|
|
discoveryBlock: {
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.line,
|
|
paddingTop: spacing.md,
|
|
gap: spacing.sm,
|
|
},
|
|
blockLabel: {
|
|
...typography.label,
|
|
color: colors.warning,
|
|
},
|
|
deviceRow: {
|
|
flexDirection: 'row',
|
|
gap: spacing.sm,
|
|
alignItems: 'center',
|
|
paddingVertical: spacing.sm,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.line,
|
|
},
|
|
deviceDot: {
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: 5,
|
|
backgroundColor: colors.success,
|
|
},
|
|
deviceInfo: {
|
|
flex: 1,
|
|
},
|
|
deviceName: {
|
|
color: colors.text,
|
|
fontSize: 18,
|
|
lineHeight: 22,
|
|
fontWeight: '900',
|
|
},
|
|
deviceMeta: {
|
|
...typography.body,
|
|
color: colors.textMuted,
|
|
marginTop: spacing.xxs,
|
|
},
|
|
connectPill: {
|
|
borderWidth: 1,
|
|
borderColor: colors.line,
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: spacing.xs,
|
|
},
|
|
connectPillText: {
|
|
...typography.label,
|
|
color: colors.warning,
|
|
},
|
|
diagnosticBlock: {
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.line,
|
|
paddingTop: spacing.sm,
|
|
gap: spacing.xs,
|
|
},
|
|
diagnosticRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.xs,
|
|
paddingVertical: spacing.xs,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.line,
|
|
},
|
|
diagnosticBody: {
|
|
flex: 1,
|
|
gap: 2,
|
|
},
|
|
diagnosticTitle: {
|
|
...typography.label,
|
|
color: colors.text,
|
|
},
|
|
diagnosticText: {
|
|
flex: 1,
|
|
color: colors.textMuted,
|
|
fontSize: 12,
|
|
lineHeight: 16,
|
|
fontWeight: '700',
|
|
},
|
|
});
|