feat(capture): CaptureScreen with LiveDot, JetBrains Mono BPM, stopAlert ring

This commit is contained in:
fightrate-bot committed 2026-06-16 01:54:20 -03:00
1 parent e7a20cc42c
commit cd278aa2b1
1 file changed
+75 -93
+75 -93
View File
@@ -9,6 +9,8 @@ import {
} from 'lucide-react-native';
import { type HeartRateMonitorState } from '../ble/useHeartRateMonitor';
import { ActionButton } from '../components/ActionButton';
import { DialTile } from '../components/DialTile';
import { LiveDot } from '../components/LiveDot';
import { TrainingChart } from '../components/TrainingChart';
import { colors, radius, spacing, typography } from '../theme';
import {
@@ -35,7 +37,7 @@ export function CaptureScreen({
() =>
monitor.startedAt
? monitor.samples.filter(
sample => sample.timestamp >= monitor.startedAt!,
(sample) => sample.timestamp >= monitor.startedAt!,
)
: monitor.samples,
[monitor.samples, monitor.startedAt],
@@ -48,20 +50,35 @@ export function CaptureScreen({
? 'Fechar janela de treino'
: 'Iniciar janela de treino';
const isReceiving = monitor.phase === 'receiving';
const isLive = monitor.isMonitoring || monitor.isCapturing;
return (
<View style={styles.screen}>
<View style={styles.topRail}>
<View style={styles.stateBlock}>
<Text style={styles.eyebrow}>{status.label}</Text>
<View style={styles.eyebrowRow}>
<Text
style={[
styles.eyebrowDot,
{ color: isReceiving ? colors.success : colors.textDim },
]}
>
{'\•'}
</Text>
<Text style={styles.eyebrow}>{status.label}</Text>
</View>
<Text style={styles.sensorName}>{formatDeviceName(monitor)}</Text>
</View>
<View style={styles.timeStack}>
<Timer color={colors.warning} size={16} strokeWidth={2.4} />
<Text style={styles.timerText}>
{monitor.isCapturing
? formatDuration(workoutElapsedMs)
: formatDuration(monitorElapsedMs)}
</Text>
<View style={styles.timerRow}>
<LiveDot live={isLive} size={6} />
<Text style={styles.timerText}>
{monitor.isCapturing
? formatDuration(workoutElapsedMs)
: formatDuration(monitorElapsedMs)}
</Text>
</View>
<Text style={styles.timerLabel}>
{monitor.isCapturing ? 'janela' : 'monitor'}
</Text>
@@ -69,7 +86,11 @@ export function CaptureScreen({
</View>
<View style={styles.bpmRow}>
<Text style={styles.bpmValue}>{monitor.currentBpm ?? BPM_EMPTY}</Text>
<View style={styles.bpmBlock}>
<Text style={styles.bpmValue}>
{monitor.currentBpm ?? BPM_EMPTY}
</Text>
</View>
<View style={styles.bpmSide}>
<Text style={styles.bpmUnit}>BPM</Text>
<Text style={styles.statusLine}>
@@ -80,7 +101,7 @@ export function CaptureScreen({
<View style={styles.captureRail}>
<RailMetric
label="MÉDIA"
label="M\ÉDIA"
value={windowMetrics.averageBpm ?? BPM_EMPTY}
/>
<RailMetric
@@ -96,21 +117,15 @@ export function CaptureScreen({
<TrainingChart samples={windowSamples} />
<View style={styles.systemStrip}>
<SystemSignal
icon="radio"
<View style={styles.dialColumn}>
<DialTile
label="BLE"
value={getBleShortState(monitor.phase, monitor.isMonitoring)}
tone={monitor.phase === 'receiving' ? 'green' : 'amber'}
/>
<SystemSignal
icon="battery"
label="Bateria"
value={getBatteryLabel(monitor)}
/>
<SystemSignal
icon="shield"
label="Fundo"
<DialTile label="BATERIA" value={getBatteryLabel(monitor)} />
<DialTile
label="FUNDO"
value={monitor.batteryOptimizationIgnored ? 'liberado' : 'otimizar'}
tone={monitor.batteryOptimizationIgnored ? 'green' : 'amber'}
/>
@@ -124,10 +139,10 @@ export function CaptureScreen({
style={styles.batteryPrompt}
>
<Text style={styles.batteryPromptTitle}>
Manter com tela bloqueada
Manter com tela bloq
</Text>
<Text style={styles.batteryPromptText}>
Toque para abrir a configuração de bateria e remover restrições do
Toque para abrir a configura\ç\ão de bateria e remover restri\ç\ões do
FightRate.
</Text>
</Pressable>
@@ -137,7 +152,7 @@ export function CaptureScreen({
{!monitor.isMonitoring ? (
<ActionButton
fullWidth
label="Ativar monitor contínuo"
label="Ativar monitor cont\ínuo"
onPress={() => {
onMonitorAction().catch(() => undefined);
}}
@@ -149,15 +164,15 @@ export function CaptureScreen({
onPress={() => {
onCaptureAction().catch(() => undefined);
}}
variant={monitor.isCapturing ? 'stop' : 'primary'}
variant={monitor.isCapturing ? 'stopAlert' : 'primary'}
/>
</View>
<View style={styles.truthLine}>
<Activity color={colors.warning} size={18} strokeWidth={2.5} />
<Text style={styles.truthText}>
Enquanto a cinta estiver reconhecida, todo BPM real é salvo. O botão
de treino só marca a janela para análise.
Cinta reconhecida = cada BPM real \é salvo. Toque em Iniciar apenas
para destacar uma janela para an\álise.
</Text>
</View>
</View>
@@ -185,36 +200,11 @@ function RailMetric({
);
}
function SystemSignal({
icon,
label,
value,
tone = 'amber',
}: {
readonly icon: 'radio' | 'battery' | 'shield';
readonly label: string;
readonly value: string;
readonly tone?: 'amber' | 'green';
}) {
const color = tone === 'green' ? colors.success : colors.warning;
const Icon =
icon === 'radio' ? RadioTower : icon === 'battery' ? Battery : ShieldCheck;
return (
<View style={styles.systemSignal}>
<Icon color={color} size={16} strokeWidth={2.3} />
<View>
<Text style={styles.signalLabel}>{label}</Text>
<Text style={styles.signalValue}>{value}</Text>
</View>
</View>
);
}
function getStatusCopy(monitor: HeartRateMonitorState) {
if (monitor.isCapturing) {
return {
label: 'JANELA DE TREINO',
detail: 'Treino destacado enquanto o monitor contínuo segue gravando.',
detail: 'Treino destacado enquanto o monitor cont\ínuo segue gravando.',
};
}
if (monitor.phase === 'receiving') {
@@ -226,20 +216,20 @@ function getStatusCopy(monitor: HeartRateMonitorState) {
if (monitor.phase === 'scanning') {
return {
label: 'PROCURANDO CINTA',
detail: 'O serviço continua procurando até encontrar a cinta.',
detail: 'O servi\ço continua procurando at\é encontrar a cinta.',
};
}
if (monitor.phase === 'connecting') {
return {
label: 'CONECTANDO 2A37',
detail:
'Abrindo Heart Rate Service 180D e armando notify da característica 2A37.',
'Abrindo Heart Rate Service 180D e armando notify da caracter\ística 2A37.',
};
}
if (monitor.phase === 'permission-denied') {
return {
label: 'PERMISSÃO NEGADA',
detail: 'Autorize Bluetooth, Localização e Notificações.',
label: 'PERMISS\ÃO NEGADA',
detail: 'Autorize Bluetooth, Localiza\ç\ão e Notifica\ç\ões.',
};
}
if (monitor.phase === 'bluetooth-off') {
@@ -251,21 +241,21 @@ function getStatusCopy(monitor: HeartRateMonitorState) {
if (monitor.phase === 'error') {
return {
label: 'ERRO DE CAPTURA',
detail: monitor.errorMessage ?? 'Falha no serviço BLE.',
detail: monitor.errorMessage ?? 'Falha no servi\ço BLE.',
};
}
return {
label: 'MONITOR DESATIVADO',
detail: 'Ative o monitor contínuo; depois marque a janela de treino.',
detail: 'Ative o monitor cont\ínuo; depois marque a janela de treino.',
};
}
function formatDeviceName(monitor: HeartRateMonitorState) {
const name = monitor.connectedDevice?.name;
if (!name) return 'Magene H003 ou cinta BLE 180D';
if (/magene|h003/i.test(name)) return 'Magene H003 · cinta cardíaca';
if (/heart|hrm/i.test(name)) return `${name} · cinta cardíaca`;
return `${name} · BLE`;
if (/magene|h003/i.test(name)) return 'Magene H003 \· cinta card\íaca';
if (/heart|hrm/i.test(name)) return `${name} \· cinta card\íaca`;
return `${name} \· BLE`;
}
function getBleShortState(
@@ -319,6 +309,16 @@ const styles = StyleSheet.create({
stateBlock: {
flex: 1,
},
eyebrowRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
eyebrowDot: {
fontSize: 18,
lineHeight: 18,
fontWeight: '900',
},
eyebrow: {
...typography.label,
color: colors.action,
@@ -336,6 +336,11 @@ const styles = StyleSheet.create({
gap: spacing.xxs,
minWidth: 84,
},
timerRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
timerText: {
...typography.label,
color: colors.text,
@@ -350,11 +355,11 @@ const styles = StyleSheet.create({
alignItems: 'flex-end',
gap: spacing.md,
},
bpmBlock: {
flex: 1,
},
bpmValue: {
...typography.display,
fontSize: 98,
lineHeight: 96,
color: colors.text,
...typography.displayNumeric,
},
bpmSide: {
flex: 1,
@@ -381,11 +386,7 @@ const styles = StyleSheet.create({
paddingRight: spacing.sm,
},
railValue: {
color: colors.text,
fontSize: 28,
lineHeight: 30,
fontWeight: '900',
letterSpacing: -1.2,
...typography.metricNumeric,
},
railValueHot: {
color: colors.action,
@@ -395,29 +396,10 @@ const styles = StyleSheet.create({
color: colors.textMuted,
marginTop: spacing.xxs,
},
systemStrip: {
flexDirection: 'row',
gap: spacing.xs,
},
systemSignal: {
flex: 1,
dialColumn: {
borderTopWidth: 1,
borderTopColor: colors.line,
paddingTop: spacing.sm,
flexDirection: 'row',
gap: spacing.xs,
alignItems: 'center',
},
signalLabel: {
...typography.label,
color: colors.textDim,
fontSize: 9,
},
signalValue: {
color: colors.text,
fontSize: 13,
lineHeight: 17,
fontWeight: '800',
borderBottomWidth: 1,
borderColor: colors.line,
},
batteryPrompt: {
borderWidth: 1,