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