feat(sensor): SensorScreen with DialTile + LiveDot

This commit is contained in:
fightrate-bot committed 2026-06-16 01:57:06 -03:00
1 parent 139294a6bd
commit 06475346da
1 file changed
+78 -95
+78 -95
View File
@@ -1,16 +1,14 @@
import React from 'react'; import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { import {
Activity,
Battery,
Bluetooth, Bluetooth,
RadioReceiver, RadioReceiver,
ShieldCheck,
Signal,
} from 'lucide-react-native'; } from 'lucide-react-native';
import { type HeartRateMonitorState } from '../ble/useHeartRateMonitor'; import { type HeartRateMonitorState } from '../ble/useHeartRateMonitor';
import { formatRssi } from '../ble/rssi'; import { formatRssi } from '../ble/rssi';
import { ActionButton } from '../components/ActionButton'; import { ActionButton } from '../components/ActionButton';
import { DialTile } from '../components/DialTile';
import { LiveDot } from '../components/LiveDot';
import { colors, radius, spacing, typography } from '../theme'; import { colors, radius, spacing, typography } from '../theme';
type Props = { type Props = {
@@ -22,47 +20,60 @@ export function SensorScreen({ monitor, onMonitorAction }: Props) {
const device = monitor.connectedDevice; const device = monitor.connectedDevice;
const services = monitor.services.length const services = monitor.services.length
? monitor.services ? monitor.services
: ['Heart Rate Service · 0x180D · aguardando conexão']; : ['Heart Rate Service \· 0x180D \· aguardando conex\ão'];
const characteristics = monitor.characteristics.length const characteristics = monitor.characteristics.length
? monitor.characteristics ? monitor.characteristics
: ['Heart Rate Measurement · 0x2A37 · notify esperado']; : ['Heart Rate Measurement \· 0x2A37 \· notify esperado'];
const isReceiving = monitor.phase === 'receiving';
return ( return (
<View style={styles.screen}> <View style={styles.screen}>
<View style={styles.sensorConsole}> <View style={styles.sensorConsole}>
<View style={styles.consoleTopline}> <View style={styles.consoleTopline}>
<View> <View style={styles.consoleLeft}>
<Text style={styles.eyebrow}>SENSOR REAL</Text> <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}> <Text style={styles.title}>
{friendlyDeviceTitle(device?.name)} {friendlyDeviceTitle(device?.name)}
</Text> </Text>
</View> </View>
<View style={styles.phaseBadge}> <View style={styles.phaseBadge}>
<Bluetooth color={colors.warning} size={16} strokeWidth={2.4} /> <Bluetooth color={colors.warning} size={14} strokeWidth={2.4} />
<Text style={styles.phaseText}>{phaseLabel(monitor.phase)}</Text> <Text style={styles.phaseText}>{phaseLabel(monitor.phase)}</Text>
{isReceiving ? <LiveDot live size={5} /> : null}
</View> </View>
</View> </View>
<View style={styles.signalGrid}> <View style={styles.signalColumn}>
<SignalDatum <DialTile
icon="signal"
label="RSSI" label="RSSI"
value={formatRssi(device?.rssi)} value={formatRssi(device?.rssi)}
tone="amber"
/> />
<SignalDatum <DialTile
icon="activity" label="LEITURAS"
label="Leituras"
value={`${monitor.samples.length}`} value={`${monitor.samples.length}`}
tone="amber"
/> />
<SignalDatum <DialTile
icon="battery" label="BATERIA"
label="Bateria"
value={formatBattery(device?.batteryPercent)} value={formatBattery(device?.batteryPercent)}
tone="amber"
/> />
<SignalDatum <DialTile
icon="shield" label="FUNDO"
label="Fundo"
value={monitor.batteryOptimizationIgnored ? 'liberado' : 'restrito'} value={monitor.batteryOptimizationIgnored ? 'liberado' : 'restrito'}
tone={monitor.batteryOptimizationIgnored ? 'green' : 'amber'}
/> />
</View> </View>
@@ -82,8 +93,8 @@ export function SensorScreen({ monitor, onMonitorAction }: Props) {
/> />
) : ( ) : (
<View style={styles.livePill}> <View style={styles.livePill}>
<Activity color={colors.success} size={15} strokeWidth={2.4} /> <LiveDot live size={6} />
<Text style={styles.livePillText}>Monitor contínuo ativo</Text> <Text style={styles.livePillText}>Monitor cont\ínuo ativo</Text>
</View> </View>
)} )}
{!monitor.batteryOptimizationIgnored ? ( {!monitor.batteryOptimizationIgnored ? (
@@ -102,7 +113,7 @@ export function SensorScreen({ monitor, onMonitorAction }: Props) {
{monitor.discoveredDevices.length > 0 ? ( {monitor.discoveredDevices.length > 0 ? (
<View style={styles.discoveryBlock}> <View style={styles.discoveryBlock}>
<Text style={styles.blockLabel}>ENCONTRADOS AGORA</Text> <Text style={styles.blockLabel}>ENCONTRADOS AGORA</Text>
{monitor.discoveredDevices.map(candidate => ( {monitor.discoveredDevices.map((candidate) => (
<Pressable <Pressable
key={candidate.id} key={candidate.id}
onPress={() => onPress={() =>
@@ -116,7 +127,7 @@ export function SensorScreen({ monitor, onMonitorAction }: Props) {
{friendlyDeviceTitle(candidate.name)} {friendlyDeviceTitle(candidate.name)}
</Text> </Text>
<Text style={styles.deviceMeta}> <Text style={styles.deviceMeta}>
{candidate.name} · {formatRssi(candidate.rssi)} {candidate.name} \· {formatRssi(candidate.rssi)}
</Text> </Text>
</View> </View>
<View style={styles.connectPill}> <View style={styles.connectPill}>
@@ -127,34 +138,8 @@ export function SensorScreen({ monitor, onMonitorAction }: Props) {
</View> </View>
) : null} ) : null}
<BleInventoryBlock title="SERVIÇOS BLE" rows={services} /> <BleInventoryBlock title="SERVI\ÇOS BLE" rows={services} />
<BleInventoryBlock title="CARACTERÍSTICAS" rows={characteristics} /> <BleInventoryBlock title="CARACTER\ÍSTICAS" rows={characteristics} />
</View>
);
}
function SignalDatum({
icon,
label,
value,
}: {
readonly icon: 'signal' | 'battery' | 'shield' | 'activity';
readonly label: string;
readonly value: string;
}) {
const Icon =
icon === 'signal'
? Signal
: icon === 'battery'
? Battery
: icon === 'activity'
? Activity
: ShieldCheck;
return (
<View style={styles.signalDatum}>
<Icon color={colors.warning} size={17} strokeWidth={2.3} />
<Text style={styles.signalValue}>{value}</Text>
<Text style={styles.signalLabel}>{label}</Text>
</View> </View>
); );
} }
@@ -169,7 +154,7 @@ function BleInventoryBlock({
return ( return (
<View style={styles.diagnosticBlock}> <View style={styles.diagnosticBlock}>
<Text style={styles.blockLabel}>{title}</Text> <Text style={styles.blockLabel}>{title}</Text>
{rows.slice(0, 8).map(row => { {rows.slice(0, 8).map((row) => {
const parsed = parseBleRow(row); const parsed = parseBleRow(row);
return ( return (
<View key={row} style={styles.diagnosticRow}> <View key={row} style={styles.diagnosticRow}>
@@ -177,7 +162,7 @@ function BleInventoryBlock({
<View style={styles.diagnosticBody}> <View style={styles.diagnosticBody}>
<Text style={styles.diagnosticTitle}>{parsed.name}</Text> <Text style={styles.diagnosticTitle}>{parsed.name}</Text>
<Text style={styles.diagnosticText}> <Text style={styles.diagnosticText}>
{parsed.uuid} · {parsed.detail} {parsed.uuid} \· {parsed.detail}
</Text> </Text>
</View> </View>
</View> </View>
@@ -189,36 +174,36 @@ function BleInventoryBlock({
function friendlyDeviceTitle(name?: string | null) { function friendlyDeviceTitle(name?: string | null) {
if (!name) { if (!name) {
return 'Cinta cardíaca BLE'; return 'Cinta card\íaca BLE';
} }
if (/magene|h003/i.test(name)) { if (/magene|h003/i.test(name)) {
return 'Magene H003'; return 'Magene H003';
} }
if (/heart|hrm/i.test(name)) { if (/heart|hrm/i.test(name)) {
return 'Cinta cardíaca BLE'; return 'Cinta card\íaca BLE';
} }
return name; return name;
} }
function parseBleRow(row: string) { function parseBleRow(row: string) {
const [name = row, uuid = 'BLE', ...rest] = row.split(' · '); const [name = row, uuid = 'BLE', ...rest] = row.split(' \· ');
return { return {
name, name,
uuid, uuid,
detail: rest.join(' · ') || 'aguardando leitura', detail: rest.join(' \· ') || 'aguardando leitura',
}; };
} }
function phaseLabel(phase: HeartRateMonitorState['phase']) { function phaseLabel(phase: HeartRateMonitorState['phase']) {
const labels: Record<HeartRateMonitorState['phase'], string> = { const labels: Record<HeartRateMonitorState['phase'], string> = {
idle: 'parado', idle: 'parado',
'requesting-permission': 'permissões', 'requesting-permission': 'permiss\ões',
'bluetooth-off': 'bluetooth off', 'bluetooth-off': 'bluetooth off',
scanning: 'buscando', scanning: 'buscando',
connecting: 'conectando', connecting: 'conectando',
connected: 'conectado', connected: 'conectado',
receiving: 'recebendo', receiving: 'recebendo',
'permission-denied': 'sem permissão', 'permission-denied': 'sem permiss\ão',
'sensor-not-found': 'sem sensor', 'sensor-not-found': 'sem sensor',
error: 'erro', error: 'erro',
}; };
@@ -227,15 +212,15 @@ function phaseLabel(phase: HeartRateMonitorState['phase']) {
function sensorExplanation(monitor: HeartRateMonitorState) { function sensorExplanation(monitor: HeartRateMonitorState) {
if (monitor.phase === 'receiving' && monitor.samples.length > 0) { if (monitor.phase === 'receiving' && monitor.samples.length > 0) {
return `Amostras reais chegando: ${monitor.samples.length}. Cada BPM reconhecido está sendo salvo em SQLite.`; return `Amostras reais chegando: ${monitor.samples.length}. Cada BPM reconhecido est\á sendo salvo em SQLite.`;
} }
if (monitor.phase === 'receiving' || monitor.phase === 'connected') { 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.'; return 'Sensor reconhecido. O app est\á armando a leitura 2A37; se n\ão vier BPM, reativa notify e reconecta sozinho.';
} }
if (monitor.phase === 'scanning') { if (monitor.phase === 'scanning') {
return 'Busca nativa BLE ativa. Se o app for bloqueado, a notificação mantém o serviço vivo.'; 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.'; return 'A tela traduz nomes t\écnicos: 180D \é o servi\ço card\íaco; 2A37 \é o dado de BPM.';
} }
function formatBattery(percent?: number | null) { function formatBattery(percent?: number | null) {
@@ -259,54 +244,52 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
gap: spacing.md, gap: spacing.md,
alignItems: 'center',
},
consoleLeft: {
flex: 1,
},
eyebrowRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
eyebrowDot: {
fontSize: 18,
lineHeight: 18,
fontWeight: '900',
}, },
eyebrow: { eyebrow: {
...typography.label, ...typography.label,
color: colors.action, color: colors.action,
}, },
title: { title: {
...typography.detailNumeric,
color: colors.text, color: colors.text,
fontSize: 34, fontSize: 22,
lineHeight: 36, lineHeight: 26,
fontWeight: '900', letterSpacing: -0.8,
letterSpacing: -2,
marginTop: spacing.xs, marginTop: spacing.xs,
}, },
phaseBadge: { phaseBadge: {
paddingVertical: spacing.xs, paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: spacing.xs, gap: 6,
height: 36, height: 32,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: colors.line,
backgroundColor: colors.surface,
}, },
phaseText: { phaseText: {
...typography.label, ...typography.label,
color: colors.text, color: colors.text,
}, },
signalGrid: { signalColumn: {
flexDirection: 'row', borderTopWidth: 1,
flexWrap: 'wrap',
gap: spacing.xs,
},
signalDatum: {
width: '48.5%',
minHeight: 76,
borderWidth: 1,
borderColor: colors.line, borderColor: colors.line,
padding: spacing.sm,
gap: spacing.xxs,
justifyContent: 'center',
},
signalValue: {
color: colors.text,
fontSize: 18,
lineHeight: 22,
fontWeight: '900',
marginTop: spacing.xs,
},
signalLabel: {
...typography.label,
color: colors.textMuted,
}, },
explainer: { explainer: {
...typography.body, ...typography.body,
@@ -325,7 +308,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: spacing.xs, gap: 6,
}, },
livePillText: { livePillText: {
...typography.label, ...typography.label,