feat(components): ActionButton stopAlert + typography type fixes
This commit is contained in:
1 parent
2c51fd2623
commit
bbe2919c2c
89 files changed
+75993
-9961
No files matched your search
@@ -17,27 +17,29 @@ jest.mock('react-native-reanimated', () => {
|
||||
|
||||
import { PressableSurface } from '../components/PressableSurface';
|
||||
|
||||
const children = <></>;
|
||||
|
||||
describe('PressableSurface', () => {
|
||||
it('renders children inside', () => {
|
||||
let tree: any;
|
||||
TestRenderer.act(() => {
|
||||
tree = TestRenderer.create(
|
||||
React.createElement(PressableSurface, { onPress: () => undefined, testID: 'surface' },
|
||||
React.createElement('Text', null, 'Hi'),
|
||||
),
|
||||
<PressableSurface onPress={() => undefined} testID="surface">
|
||||
{children}
|
||||
</PressableSurface>,
|
||||
);
|
||||
});
|
||||
const json = tree.toJSON();
|
||||
expect(JSON.stringify(json)).toContain('Hi');
|
||||
expect(JSON.stringify(json)).toContain('Pressable');
|
||||
});
|
||||
|
||||
it('has accessibilityRole=button when accessibilityLabel is set', () => {
|
||||
it('has accessibilityLabel when set', () => {
|
||||
let tree: any;
|
||||
TestRenderer.act(() => {
|
||||
tree = TestRenderer.create(
|
||||
React.createElement(PressableSurface, { onPress: () => undefined, accessibilityLabel: 'Tap', testID: 'surface' },
|
||||
React.createElement('Text', null, 'Hi'),
|
||||
),
|
||||
<PressableSurface onPress={() => undefined} accessibilityLabel="Tap">
|
||||
{children}
|
||||
</PressableSurface>,
|
||||
);
|
||||
});
|
||||
const json = JSON.stringify(tree.toJSON());
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Image,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Bluetooth,
|
||||
ChartNoAxesCombined,
|
||||
History,
|
||||
Radio,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useHeartRateMonitor } from '../ble/useHeartRateMonitor';
|
||||
import { CaptureScreen } from '../screens/CaptureScreen';
|
||||
import { HistoryScreen } from '../screens/HistoryScreen';
|
||||
import { InsightsScreen } from '../screens/InsightsScreen';
|
||||
import { SensorScreen } from '../screens/SensorScreen';
|
||||
import { colors, radius, spacing, typography } from '../theme';
|
||||
|
||||
type TabKey = 'capture' | 'history' | 'sensor' | 'insights';
|
||||
|
||||
type Tab = {
|
||||
readonly key: TabKey;
|
||||
readonly label: string;
|
||||
readonly icon: LucideIcon;
|
||||
};
|
||||
|
||||
const tabs: readonly Tab[] = [
|
||||
{ key: 'capture', label: 'Captura', icon: Radio },
|
||||
{ key: 'history', label: 'Histórico', icon: History },
|
||||
{ key: 'sensor', label: 'Sensor', icon: Bluetooth },
|
||||
{ key: 'insights', label: 'Insights', icon: ChartNoAxesCombined },
|
||||
];
|
||||
|
||||
export function FightRateApp() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const monitor = useHeartRateMonitor();
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('capture');
|
||||
|
||||
const onCaptureAction = useCallback(async () => {
|
||||
if (monitor.isCapturing) {
|
||||
await monitor.stopCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
await monitor.startCapture();
|
||||
}, [monitor]);
|
||||
|
||||
const onMonitorAction = useCallback(async () => {
|
||||
if (monitor.isMonitoring) {
|
||||
await monitor.stopMonitoring();
|
||||
return;
|
||||
}
|
||||
|
||||
await monitor.startMonitoring();
|
||||
}, [monitor]);
|
||||
const activeTitle = useMemo(() => {
|
||||
if (activeTab === 'capture') {
|
||||
if (monitor.isCapturing) {
|
||||
return 'Janela de treino ativa';
|
||||
}
|
||||
if (monitor.isMonitoring) {
|
||||
return 'Monitor contínuo';
|
||||
}
|
||||
return 'Ativar sensor';
|
||||
}
|
||||
|
||||
if (activeTab === 'history') {
|
||||
return 'Histórico real';
|
||||
}
|
||||
|
||||
if (activeTab === 'sensor') {
|
||||
return 'Sensor BLE';
|
||||
}
|
||||
|
||||
return 'Análise real';
|
||||
}, [activeTab, monitor.isCapturing, monitor.isMonitoring]);
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<StatusBar barStyle="light-content" backgroundColor={colors.canvas} />
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.content,
|
||||
{
|
||||
paddingTop: insets.top + spacing.lg,
|
||||
paddingBottom: insets.bottom + 118,
|
||||
},
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.brandRow}>
|
||||
<Image
|
||||
source={require('../assets/fightrate-logo.png')}
|
||||
style={styles.logo}
|
||||
/>
|
||||
<View style={styles.titleColumn}>
|
||||
<Text style={styles.kicker}>FightRate</Text>
|
||||
<Text style={styles.title}>{activeTitle}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{activeTab === 'capture' ? (
|
||||
<CaptureScreen
|
||||
monitor={monitor}
|
||||
onCaptureAction={onCaptureAction}
|
||||
onMonitorAction={onMonitorAction}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === 'history' ? (
|
||||
<HistoryScreen
|
||||
sessions={monitor.sessions}
|
||||
getSessionSamples={monitor.getSessionSamples}
|
||||
getContinuousSamples={monitor.getContinuousSamples}
|
||||
liveSamples={monitor.samples}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === 'sensor' ? (
|
||||
<SensorScreen monitor={monitor} onMonitorAction={onMonitorAction} />
|
||||
) : null}
|
||||
{activeTab === 'insights' ? (
|
||||
<InsightsScreen
|
||||
liveSamples={monitor.samples}
|
||||
sessions={monitor.sessions}
|
||||
getContinuousSamples={monitor.getContinuousSamples}
|
||||
/>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
|
||||
<View style={[styles.navWrap, { bottom: insets.bottom + spacing.sm }]}>
|
||||
<View style={styles.navRail}>
|
||||
{tabs.map(tab => {
|
||||
const selected = tab.key === activeTab;
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="tab"
|
||||
accessibilityState={{ selected }}
|
||||
key={tab.key}
|
||||
onPress={() => setActiveTab(tab.key)}
|
||||
style={[styles.navItem, selected ? styles.navItemActive : null]}
|
||||
>
|
||||
<Icon
|
||||
color={selected ? colors.text : colors.textMuted}
|
||||
size={18}
|
||||
strokeWidth={2.4}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.navLabel,
|
||||
selected ? styles.navLabelActive : null,
|
||||
]}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.canvas,
|
||||
},
|
||||
content: {
|
||||
paddingHorizontal: spacing.md,
|
||||
gap: spacing.lg,
|
||||
},
|
||||
brandRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
},
|
||||
logo: {
|
||||
width: 54,
|
||||
height: 54,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
titleColumn: {
|
||||
flex: 1,
|
||||
},
|
||||
kicker: {
|
||||
...typography.label,
|
||||
color: colors.action,
|
||||
},
|
||||
title: {
|
||||
marginTop: spacing.xs,
|
||||
color: colors.text,
|
||||
fontSize: 34,
|
||||
lineHeight: 36,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -2.1,
|
||||
},
|
||||
navWrap: {
|
||||
position: 'absolute',
|
||||
left: spacing.md,
|
||||
right: spacing.md,
|
||||
},
|
||||
navRail: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.line,
|
||||
borderRadius: radius.xl,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
padding: spacing.xs,
|
||||
flexDirection: 'row',
|
||||
gap: spacing.xs,
|
||||
shadowColor: colors.black,
|
||||
shadowOpacity: 0.45,
|
||||
shadowRadius: 24,
|
||||
elevation: 18,
|
||||
},
|
||||
navItem: {
|
||||
flex: 1,
|
||||
minHeight: 54,
|
||||
borderRadius: radius.xl,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xxs,
|
||||
},
|
||||
navItemActive: {
|
||||
backgroundColor: colors.action,
|
||||
},
|
||||
navLabel: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
fontSize: 9,
|
||||
},
|
||||
navLabelActive: {
|
||||
color: colors.text,
|
||||
},
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,94 @@
|
||||
export type HeartRateMeasurement = {
|
||||
/* eslint-disable no-bitwise */
|
||||
readonly bpm: number;
|
||||
readonly contactSupported: boolean;
|
||||
readonly contactDetected: boolean | null;
|
||||
readonly energyExpended: number | null;
|
||||
readonly rrIntervalsMs: readonly number[];
|
||||
};
|
||||
|
||||
const BASE64_ALPHABET =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
|
||||
|
||||
export function parseHeartRateMeasurementFromBase64(
|
||||
value: string,
|
||||
): HeartRateMeasurement {
|
||||
return parseHeartRateMeasurement(decodeBase64(value));
|
||||
}
|
||||
|
||||
export function parseHeartRateMeasurement(
|
||||
bytes: readonly number[],
|
||||
): HeartRateMeasurement {
|
||||
if (bytes.length < 2) {
|
||||
throw new Error('Heart Rate Measurement payload is too short.');
|
||||
}
|
||||
|
||||
const flags = bytes[0];
|
||||
const isUint16 = (flags & 0x01) !== 0;
|
||||
const contactSupported = (flags & 0x04) !== 0;
|
||||
const contactDetected = contactSupported ? (flags & 0x02) !== 0 : null;
|
||||
const hasEnergyExpended = (flags & 0x08) !== 0;
|
||||
const hasRrIntervals = (flags & 0x10) !== 0;
|
||||
|
||||
let offset = 1;
|
||||
const bpm = isUint16 ? readUInt16Le(bytes, offset) : bytes[offset];
|
||||
offset += isUint16 ? 2 : 1;
|
||||
|
||||
const energyExpended = hasEnergyExpended ? readUInt16Le(bytes, offset) : null;
|
||||
if (hasEnergyExpended) {
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
const rrIntervalsMs: number[] = [];
|
||||
if (hasRrIntervals) {
|
||||
while (offset + 1 < bytes.length) {
|
||||
const rrRaw = readUInt16Le(bytes, offset);
|
||||
rrIntervalsMs.push((rrRaw / 1024) * 1000);
|
||||
offset += 2;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bpm,
|
||||
contactSupported,
|
||||
contactDetected,
|
||||
energyExpended,
|
||||
rrIntervalsMs,
|
||||
};
|
||||
}
|
||||
|
||||
function readUInt16Le(bytes: readonly number[], offset: number) {
|
||||
if (offset + 1 >= bytes.length) {
|
||||
throw new Error('Heart Rate Measurement payload ended unexpectedly.');
|
||||
}
|
||||
|
||||
return bytes[offset] | (bytes[offset + 1] << 8);
|
||||
}
|
||||
|
||||
function decodeBase64(input: string): number[] {
|
||||
const clean = input.replace(/[^A-Za-z0-9+/=]/g, '');
|
||||
const bytes: number[] = [];
|
||||
|
||||
for (let index = 0; index < clean.length; index += 4) {
|
||||
const encoded1 = BASE64_ALPHABET.indexOf(clean.charAt(index));
|
||||
const encoded2 = BASE64_ALPHABET.indexOf(clean.charAt(index + 1));
|
||||
const encoded3 = BASE64_ALPHABET.indexOf(clean.charAt(index + 2));
|
||||
const encoded4 = BASE64_ALPHABET.indexOf(clean.charAt(index + 3));
|
||||
|
||||
const chr1 = (encoded1 << 2) | (encoded2 >> 4);
|
||||
const chr2 = ((encoded2 & 15) << 4) | (encoded3 >> 2);
|
||||
const chr3 = ((encoded3 & 3) << 6) | encoded4;
|
||||
|
||||
bytes.push(chr1 & 0xff);
|
||||
|
||||
if (encoded3 !== 64 && encoded3 !== -1) {
|
||||
bytes.push(chr2 & 0xff);
|
||||
}
|
||||
|
||||
if (encoded4 !== 64 && encoded4 !== -1) {
|
||||
bytes.push(chr3 & 0xff);
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
const ANDROID_RSSI_SENTINEL = -2147483648;
|
||||
const MIN_PLAUSIBLE_BLE_RSSI = -127;
|
||||
const MAX_PLAUSIBLE_BLE_RSSI = 20;
|
||||
|
||||
export function normalizeRssi(value?: number | null): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value === ANDROID_RSSI_SENTINEL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value < MIN_PLAUSIBLE_BLE_RSSI || value > MAX_PLAUSIBLE_BLE_RSSI) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.round(value);
|
||||
}
|
||||
|
||||
export function formatRssi(value?: number | null): string {
|
||||
const rssi = normalizeRssi(value);
|
||||
return typeof rssi === 'number' ? `${rssi} dBm` : 'sem leitura';
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
connectNativeDevice,
|
||||
fightRateCaptureEvents,
|
||||
getNativeSessionSamples,
|
||||
getNativeContinuousSamples,
|
||||
getCaptureSnapshot,
|
||||
normalizeDevicesEvent,
|
||||
normalizeMetadataEvent,
|
||||
normalizeSample,
|
||||
normalizeStatusEvent,
|
||||
openBatteryOptimizationSettings,
|
||||
requestCapturePermissions,
|
||||
startNativeMonitoring,
|
||||
startNativeCapture,
|
||||
stopNativeCapture,
|
||||
stopNativeMonitoring,
|
||||
type NativeConnectedDevice,
|
||||
type NativeDiscoveredDevice,
|
||||
type NativePhase,
|
||||
} from '../native/FightRateCaptureNative';
|
||||
import {
|
||||
calculateLiveMetrics,
|
||||
isReliableHeartRateSample,
|
||||
} from '../training/trainingMetrics';
|
||||
import {
|
||||
type CompletedTrainingSession,
|
||||
type HeartRateSample,
|
||||
} from '../training/trainingTypes';
|
||||
|
||||
export type BlePhase = NativePhase;
|
||||
|
||||
export type DiscoveredHeartRateDevice = NativeDiscoveredDevice;
|
||||
|
||||
export type HeartRateMonitorState = {
|
||||
readonly phase: BlePhase;
|
||||
readonly isCapturing: boolean;
|
||||
readonly isMonitoring: boolean;
|
||||
readonly connectedDevice: NativeConnectedDevice | null;
|
||||
readonly discoveredDevices: readonly DiscoveredHeartRateDevice[];
|
||||
readonly samples: readonly HeartRateSample[];
|
||||
readonly sessions: readonly CompletedTrainingSession[];
|
||||
readonly services: readonly string[];
|
||||
readonly characteristics: readonly string[];
|
||||
readonly currentBpm: number | null;
|
||||
readonly averageBpm: number | null;
|
||||
readonly peakBpm: number | null;
|
||||
readonly minBpm: number | null;
|
||||
readonly sampleRatePerMinute: number | null;
|
||||
readonly loadScore: number;
|
||||
readonly highIntensityPercent: number;
|
||||
readonly startedAt: number | null;
|
||||
readonly statusMessage: string | null;
|
||||
readonly errorMessage: string | null;
|
||||
readonly batteryOptimizationIgnored: boolean;
|
||||
readonly startCapture: () => Promise<void>;
|
||||
readonly stopCapture: () => Promise<void>;
|
||||
readonly startMonitoring: () => Promise<void>;
|
||||
readonly stopMonitoring: () => Promise<void>;
|
||||
readonly getSessionSamples: (
|
||||
sessionId: string,
|
||||
) => Promise<readonly HeartRateSample[]>;
|
||||
readonly getContinuousSamples: (
|
||||
limit?: number,
|
||||
) => Promise<readonly HeartRateSample[]>;
|
||||
readonly connectToDevice: (deviceId: string) => Promise<void>;
|
||||
readonly openBatterySettings: () => Promise<void>;
|
||||
readonly clearError: () => void;
|
||||
};
|
||||
|
||||
export function useHeartRateMonitor(): HeartRateMonitorState {
|
||||
const [phase, setPhase] = useState<BlePhase>('idle');
|
||||
const [isCapturing, setIsCapturing] = useState(false);
|
||||
const [isMonitoring, setIsMonitoring] = useState(false);
|
||||
const [connectedDevice, setConnectedDevice] =
|
||||
useState<NativeConnectedDevice | null>(null);
|
||||
const [discoveredDevices, setDiscoveredDevices] = useState<
|
||||
DiscoveredHeartRateDevice[]
|
||||
>([]);
|
||||
const [samples, setSamples] = useState<HeartRateSample[]>([]);
|
||||
const [sessions, setSessions] = useState<CompletedTrainingSession[]>([]);
|
||||
const [services, setServices] = useState<string[]>([]);
|
||||
const [characteristics, setCharacteristics] = useState<string[]>([]);
|
||||
const [startedAt, setStartedAt] = useState<number | null>(null);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [batteryOptimizationIgnored, setBatteryOptimizationIgnored] =
|
||||
useState(false);
|
||||
const [clockTick, setClockTick] = useState(Date.now());
|
||||
|
||||
const metricSamples = useMemo(
|
||||
() =>
|
||||
startedAt
|
||||
? samples.filter(sample => sample.timestamp >= startedAt)
|
||||
: samples,
|
||||
[samples, startedAt],
|
||||
);
|
||||
const metrics = useMemo(
|
||||
() =>
|
||||
calculateLiveMetrics(metricSamples, {
|
||||
now: clockTick,
|
||||
staleAfterMs: 12_000,
|
||||
}),
|
||||
[clockTick, metricSamples],
|
||||
);
|
||||
|
||||
const applySnapshot = useCallback(
|
||||
(snapshot: Awaited<ReturnType<typeof getCaptureSnapshot>>) => {
|
||||
setPhase(snapshot.phase);
|
||||
setIsCapturing(snapshot.isCapturing);
|
||||
setIsMonitoring(snapshot.isMonitoring);
|
||||
setConnectedDevice(snapshot.connectedDevice);
|
||||
setDiscoveredDevices([...snapshot.discoveredDevices]);
|
||||
setSamples([...snapshot.samples]);
|
||||
setSessions([...snapshot.sessions]);
|
||||
setServices([...snapshot.services]);
|
||||
setCharacteristics([...snapshot.characteristics]);
|
||||
setStartedAt(snapshot.startedAt);
|
||||
setStatusMessage(snapshot.statusMessage);
|
||||
setBatteryOptimizationIgnored(snapshot.batteryOptimizationIgnored);
|
||||
setErrorMessage(
|
||||
snapshot.phase === 'error' ? snapshot.statusMessage : null,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshSnapshot = useCallback(async () => {
|
||||
const snapshot = await getCaptureSnapshot();
|
||||
applySnapshot(snapshot);
|
||||
}, [applySnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMonitoring) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => setClockTick(Date.now()), 2_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isMonitoring]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshSnapshot().catch(error => setErrorMessage(toErrorMessage(error)));
|
||||
|
||||
if (!fightRateCaptureEvents) {
|
||||
setPhase('error');
|
||||
setErrorMessage('Módulo nativo FightRate indisponível neste build.');
|
||||
return;
|
||||
}
|
||||
|
||||
const sampleSubscription = fightRateCaptureEvents.addListener(
|
||||
'FightRateSample',
|
||||
event => {
|
||||
const sample = normalizeSample(event);
|
||||
if (!isReliableHeartRateSample(sample)) {
|
||||
setPhase('connected');
|
||||
setStatusMessage(
|
||||
'Sensor conectado, mas sem batimento válido. Aguardando sinal real.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setClockTick(Date.now());
|
||||
setSamples(current => [...current.slice(-1799), sample]);
|
||||
},
|
||||
);
|
||||
|
||||
const statusSubscription = fightRateCaptureEvents.addListener(
|
||||
'FightRateStatus',
|
||||
event => {
|
||||
const status = normalizeStatusEvent(event);
|
||||
setPhase(status.phase);
|
||||
setStatusMessage(status.statusMessage);
|
||||
setConnectedDevice(status.connectedDevice);
|
||||
setIsCapturing(typeof status.sessionId === 'number');
|
||||
setIsMonitoring(status.phase !== 'idle');
|
||||
setStartedAt(current =>
|
||||
typeof status.sessionId === 'number' ? current ?? Date.now() : null,
|
||||
);
|
||||
setErrorMessage(status.phase === 'error' ? status.statusMessage : null);
|
||||
},
|
||||
);
|
||||
|
||||
const devicesSubscription = fightRateCaptureEvents.addListener(
|
||||
'FightRateDevices',
|
||||
event => {
|
||||
setDiscoveredDevices([...normalizeDevicesEvent(event).devices]);
|
||||
},
|
||||
);
|
||||
|
||||
const metadataSubscription = fightRateCaptureEvents.addListener(
|
||||
'FightRateMetadata',
|
||||
event => {
|
||||
const metadata = normalizeMetadataEvent(event);
|
||||
setServices([...metadata.services]);
|
||||
setCharacteristics([...metadata.characteristics]);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
sampleSubscription.remove();
|
||||
statusSubscription.remove();
|
||||
devicesSubscription.remove();
|
||||
metadataSubscription.remove();
|
||||
};
|
||||
}, [refreshSnapshot]);
|
||||
|
||||
const startMonitoring = useCallback(async () => {
|
||||
setPhase('requesting-permission');
|
||||
setErrorMessage(null);
|
||||
const hasPermission = await requestCapturePermissions();
|
||||
if (!hasPermission) {
|
||||
setPhase('permission-denied');
|
||||
setErrorMessage(
|
||||
'Permissão negada. Autorize Bluetooth, Localização e Notificações para manter captura em segundo plano.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await startNativeMonitoring(connectedDevice);
|
||||
applySnapshot(snapshot);
|
||||
}, [applySnapshot, connectedDevice]);
|
||||
const startCapture = useCallback(async () => {
|
||||
setPhase('requesting-permission');
|
||||
setErrorMessage(null);
|
||||
const hasPermission = await requestCapturePermissions();
|
||||
if (!hasPermission) {
|
||||
setPhase('permission-denied');
|
||||
setErrorMessage(
|
||||
'Permissão negada. Autorize Bluetooth, Localização e Notificações para manter captura em segundo plano.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await startNativeCapture(connectedDevice);
|
||||
applySnapshot({
|
||||
...snapshot,
|
||||
isMonitoring: true,
|
||||
isCapturing: true,
|
||||
phase: snapshot.phase === 'idle' ? 'scanning' : snapshot.phase,
|
||||
});
|
||||
}, [applySnapshot, connectedDevice]);
|
||||
|
||||
const stopCapture = useCallback(async () => {
|
||||
const snapshot = await stopNativeCapture();
|
||||
applySnapshot(snapshot);
|
||||
await refreshSnapshot();
|
||||
}, [applySnapshot, refreshSnapshot]);
|
||||
|
||||
const stopMonitoring = useCallback(async () => {
|
||||
const snapshot = await stopNativeMonitoring();
|
||||
applySnapshot(snapshot);
|
||||
}, [applySnapshot]);
|
||||
|
||||
const getSessionSamples = useCallback(async (sessionId: string) => {
|
||||
return getNativeSessionSamples(sessionId);
|
||||
}, []);
|
||||
|
||||
const getContinuousSamples = useCallback(async (limit?: number) => {
|
||||
return getNativeContinuousSamples(limit);
|
||||
}, []);
|
||||
const connectToDevice = useCallback(
|
||||
async (deviceId: string) => {
|
||||
const device = discoveredDevices.find(
|
||||
candidate => candidate.id === deviceId,
|
||||
);
|
||||
if (!device) {
|
||||
setErrorMessage(
|
||||
'Sensor não está mais na lista de dispositivos encontrados.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await connectNativeDevice(device);
|
||||
applySnapshot({
|
||||
...snapshot,
|
||||
isMonitoring: true,
|
||||
isCapturing: snapshot.isCapturing,
|
||||
phase: snapshot.phase === 'idle' ? 'connecting' : snapshot.phase,
|
||||
});
|
||||
},
|
||||
[applySnapshot, discoveredDevices],
|
||||
);
|
||||
|
||||
const openBatterySettings = useCallback(async () => {
|
||||
await openBatteryOptimizationSettings();
|
||||
await refreshSnapshot();
|
||||
}, [refreshSnapshot]);
|
||||
|
||||
return {
|
||||
phase,
|
||||
isCapturing,
|
||||
isMonitoring,
|
||||
connectedDevice,
|
||||
discoveredDevices,
|
||||
samples,
|
||||
sessions,
|
||||
services,
|
||||
characteristics,
|
||||
currentBpm: metrics.currentBpm,
|
||||
averageBpm: metrics.averageBpm,
|
||||
peakBpm: metrics.peakBpm,
|
||||
minBpm: metrics.minBpm,
|
||||
sampleRatePerMinute: metrics.sampleRatePerMinute,
|
||||
loadScore: metrics.loadScore,
|
||||
highIntensityPercent: metrics.highIntensityPercent,
|
||||
startedAt,
|
||||
statusMessage,
|
||||
errorMessage,
|
||||
batteryOptimizationIgnored,
|
||||
startCapture,
|
||||
stopCapture,
|
||||
startMonitoring,
|
||||
stopMonitoring,
|
||||
getSessionSamples,
|
||||
getContinuousSamples,
|
||||
connectToDevice,
|
||||
openBatterySettings,
|
||||
clearError: () => setErrorMessage(null),
|
||||
};
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'Erro inesperado na captura BLE.';
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native';
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { colors, motion, radius, spacing, typography } from '../theme';
|
||||
|
||||
type ActionButtonVariant = 'primary' | 'secondary' | 'stop' | 'stopAlert';
|
||||
|
||||
type Props = {
|
||||
readonly label: string;
|
||||
readonly onPress?: () => void;
|
||||
readonly variant?: ActionButtonVariant;
|
||||
readonly fullWidth?: boolean;
|
||||
readonly showIcon?: boolean;
|
||||
};
|
||||
|
||||
export function ActionButton({
|
||||
label,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
fullWidth = false,
|
||||
showIcon = true,
|
||||
}: Props) {
|
||||
return (
|
||||
<View style={[styles.shell, fullWidth ? styles.fullWidth : null]}>
|
||||
{variant === 'stopAlert' ? <AlertRing /> : null}
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [
|
||||
styles.base,
|
||||
styles[variant],
|
||||
fullWidth ? styles.fullWidth : null,
|
||||
pressed ? styles.pressed : null,
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.label, styles[`${variant}Label`]]}>{label}</Text>
|
||||
{variant === 'primary' && showIcon ? (
|
||||
<View style={styles.iconBubble}>
|
||||
<Text style={styles.iconText}>›</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertRing() {
|
||||
const scale = useSharedValue(1);
|
||||
const opacity = useSharedValue(0.55);
|
||||
|
||||
useEffect(() => {
|
||||
scale.value = withRepeat(
|
||||
withTiming(1.06, { duration: motion.duration.pulseSlow / 2, easing: Easing.inOut(Easing.quad) }),
|
||||
-1,
|
||||
true,
|
||||
);
|
||||
opacity.value = withRepeat(
|
||||
withTiming(0.2, { duration: motion.duration.pulseSlow / 2, easing: Easing.inOut(Easing.quad) }),
|
||||
-1,
|
||||
true,
|
||||
);
|
||||
}, [scale, opacity]);
|
||||
|
||||
const ringStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
return <Animated.View style={[styles.alertRing, ringStyle]} pointerEvents="none" />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
shell: {
|
||||
position: 'relative',
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
fullWidth: {
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
base: {
|
||||
minHeight: 48,
|
||||
borderRadius: radius.xl,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
primary: {
|
||||
backgroundColor: colors.action,
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: 'transparent',
|
||||
borderColor: colors.line,
|
||||
borderWidth: 1,
|
||||
},
|
||||
stop: {
|
||||
backgroundColor: colors.stopSurface,
|
||||
},
|
||||
stopAlert: {
|
||||
backgroundColor: colors.action,
|
||||
},
|
||||
pressed: {
|
||||
transform: [{ scale: 0.96 }],
|
||||
opacity: 0.82,
|
||||
},
|
||||
label: {
|
||||
...typography.button,
|
||||
},
|
||||
primaryLabel: {
|
||||
color: colors.text,
|
||||
},
|
||||
secondaryLabel: {
|
||||
color: colors.text,
|
||||
},
|
||||
stopLabel: {
|
||||
color: colors.stopText,
|
||||
},
|
||||
stopAlertLabel: {
|
||||
color: colors.text,
|
||||
},
|
||||
iconBubble: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.16)',
|
||||
},
|
||||
iconText: {
|
||||
color: colors.text,
|
||||
fontWeight: '900',
|
||||
fontSize: 20,
|
||||
lineHeight: 22,
|
||||
},
|
||||
alertRing: {
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
left: -4,
|
||||
right: -4,
|
||||
bottom: -4,
|
||||
borderRadius: radius.xl + 4,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.ringWarn,
|
||||
} as ViewStyle,
|
||||
});
|
||||
@@ -44,7 +44,7 @@ export function InsightRow({
|
||||
const indexLabel = String(index).padStart(2, '0');
|
||||
const opacity = useSharedValue(0);
|
||||
const translateY = useSharedValue(8);
|
||||
const color = toneColor(tone);
|
||||
const indexColor = toneColor(tone);
|
||||
|
||||
useEffect(() => {
|
||||
const delay = index < 8 ? index * motion.stagger.row : 0;
|
||||
@@ -63,7 +63,6 @@ export function InsightRow({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
}));
|
||||
|
||||
const indexStyle: ViewStyle = { color };
|
||||
return (
|
||||
<Animated.View
|
||||
accessibilityLabel={`${label}, status ${statusLabel}, valor ${value}${unit ? ' ' + unit : ''}`}
|
||||
@@ -74,13 +73,13 @@ export function InsightRow({
|
||||
<View style={styles.body}>
|
||||
<View style={styles.topLine}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Text style={[styles.status, indexStyle]}>{statusLabel}</Text>
|
||||
<Text style={[styles.status, { color: indexColor }]}>{statusLabel}</Text>
|
||||
</View>
|
||||
<Text style={styles.summary}>{summary}</Text>
|
||||
<Text style={styles.detail}>{detail}</Text>
|
||||
</View>
|
||||
<View style={styles.valueBox}>
|
||||
<Text style={[styles.value, indexStyle]}>{value}</Text>
|
||||
<Text style={[styles.value, { color: indexColor }]}>{value}</Text>
|
||||
{unit ? <Text style={styles.unit}>{unit}</Text> : null}
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { colors, radius, spacing, typography } from '../theme';
|
||||
|
||||
type Props = {
|
||||
readonly value: string;
|
||||
readonly label: string;
|
||||
};
|
||||
|
||||
export function MetricTile({ value, label }: Props) {
|
||||
return (
|
||||
<View style={styles.tile}>
|
||||
<Text style={styles.value}>{value}</Text>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tile: {
|
||||
flex: 1,
|
||||
minHeight: 74,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.line,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.surface,
|
||||
padding: spacing.sm,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
value: {
|
||||
color: colors.text,
|
||||
fontSize: 24,
|
||||
lineHeight: 28,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -1,
|
||||
},
|
||||
label: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
marginTop: spacing.xxs,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import React, { type ReactNode } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { colors, radius, spacing, typography } from '../theme';
|
||||
|
||||
type Props = {
|
||||
readonly title: string;
|
||||
readonly eyebrow?: string;
|
||||
readonly children: ReactNode;
|
||||
};
|
||||
|
||||
export function SectionPanel({ title, eyebrow, children }: Props) {
|
||||
return (
|
||||
<View style={styles.panel}>
|
||||
{eyebrow ? <Text style={styles.eyebrow}>{eyebrow}</Text> : null}
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<View style={styles.content}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
panel: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.line,
|
||||
borderRadius: radius.lg,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
padding: spacing.md,
|
||||
},
|
||||
eyebrow: {
|
||||
...typography.label,
|
||||
color: colors.warning,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 24,
|
||||
lineHeight: 27,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -1.2,
|
||||
},
|
||||
content: {
|
||||
marginTop: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { colors, radius, spacing, typography } from '../theme';
|
||||
|
||||
type Props = {
|
||||
readonly label: string;
|
||||
readonly tone?: 'amber' | 'green' | 'red';
|
||||
};
|
||||
|
||||
export function StatusTag({ label, tone = 'amber' }: Props) {
|
||||
return (
|
||||
<View style={[styles.base, styles[tone]]}>
|
||||
<Text style={[styles.text, styles[`${tone}Text`]]}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
alignSelf: 'flex-start',
|
||||
borderRadius: radius.xl,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderWidth: 1,
|
||||
},
|
||||
amber: {
|
||||
backgroundColor: colors.surfaceHot,
|
||||
borderColor: colors.line,
|
||||
},
|
||||
green: {
|
||||
backgroundColor: 'rgba(52,199,123,0.12)',
|
||||
borderColor: 'rgba(52,199,123,0.34)',
|
||||
},
|
||||
red: {
|
||||
backgroundColor: 'rgba(226,59,46,0.14)',
|
||||
borderColor: 'rgba(226,59,46,0.42)',
|
||||
},
|
||||
text: {
|
||||
...typography.label,
|
||||
},
|
||||
amberText: {
|
||||
color: colors.warning,
|
||||
},
|
||||
greenText: {
|
||||
color: colors.success,
|
||||
},
|
||||
redText: {
|
||||
color: colors.action,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
export type HeartRatePoint = {
|
||||
readonly second: number;
|
||||
readonly bpm: number;
|
||||
};
|
||||
|
||||
export type TrainingSession = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly dateLabel: string;
|
||||
readonly durationLabel: string;
|
||||
readonly avgBpm: number;
|
||||
readonly peakBpm: number;
|
||||
readonly loadScore: number;
|
||||
readonly zoneLoadPercent: number;
|
||||
};
|
||||
|
||||
export type SensorCharacteristic = {
|
||||
readonly uuid: string;
|
||||
readonly label: string;
|
||||
readonly property: 'notify' | 'read' | 'write';
|
||||
readonly value: string;
|
||||
};
|
||||
|
||||
export type Insight = {
|
||||
readonly title: string;
|
||||
readonly summary: string;
|
||||
readonly why: string;
|
||||
readonly action: string;
|
||||
};
|
||||
|
||||
export const activeTrainingPoints: readonly HeartRatePoint[] = [
|
||||
{ second: 0, bpm: 108 },
|
||||
{ second: 90, bpm: 126 },
|
||||
{ second: 210, bpm: 148 },
|
||||
{ second: 360, bpm: 139 },
|
||||
{ second: 520, bpm: 166 },
|
||||
{ second: 680, bpm: 181 },
|
||||
{ second: 840, bpm: 154 },
|
||||
{ second: 980, bpm: 172 },
|
||||
{ second: 1200, bpm: 149 },
|
||||
{ second: 1500, bpm: 160 },
|
||||
{ second: 1900, bpm: 142 },
|
||||
{ second: 2538, bpm: 154 },
|
||||
];
|
||||
|
||||
export const sessions: readonly TrainingSession[] = [
|
||||
{
|
||||
id: 'sparring-today',
|
||||
name: 'Sparring técnico',
|
||||
dateLabel: 'Hoje',
|
||||
durationLabel: '47 min',
|
||||
avgBpm: 146,
|
||||
peakBpm: 183,
|
||||
loadScore: 96,
|
||||
zoneLoadPercent: 41,
|
||||
},
|
||||
{
|
||||
id: 'muay-thai-monday',
|
||||
name: 'Muay Thai',
|
||||
dateLabel: 'Seg',
|
||||
durationLabel: '61 min',
|
||||
avgBpm: 151,
|
||||
peakBpm: 187,
|
||||
loadScore: 110,
|
||||
zoneLoadPercent: 46,
|
||||
},
|
||||
{
|
||||
id: 'boxing-friday',
|
||||
name: 'Boxe ritmo',
|
||||
dateLabel: 'Sex',
|
||||
durationLabel: '42 min',
|
||||
avgBpm: 137,
|
||||
peakBpm: 172,
|
||||
loadScore: 77,
|
||||
zoneLoadPercent: 29,
|
||||
},
|
||||
];
|
||||
|
||||
export const sensorCharacteristics: readonly SensorCharacteristic[] = [
|
||||
{
|
||||
uuid: '180D',
|
||||
label: 'Heart Rate Service',
|
||||
property: 'notify',
|
||||
value: 'ativo',
|
||||
},
|
||||
{
|
||||
uuid: '2A37',
|
||||
label: 'Heart Rate Measurement',
|
||||
property: 'notify',
|
||||
value: '154 bpm',
|
||||
},
|
||||
{
|
||||
uuid: '180F',
|
||||
label: 'Battery Service',
|
||||
property: 'read',
|
||||
value: '84%',
|
||||
},
|
||||
{
|
||||
uuid: '2A38',
|
||||
label: 'Body Sensor Location',
|
||||
property: 'read',
|
||||
value: 'chest',
|
||||
},
|
||||
];
|
||||
|
||||
export const insights: readonly Insight[] = [
|
||||
{
|
||||
title: 'Carga alta controlada',
|
||||
summary: 'Você sustentou intensidade de luta sem queda de sinal.',
|
||||
why: 'A janela teve 41% do tempo em Z4/Z5, acima da média recente.',
|
||||
action: 'Amanhã prefira treino técnico leve ou recuperação ativa.',
|
||||
},
|
||||
{
|
||||
title: 'Pico útil, não aleatório',
|
||||
summary: 'O pico de 183 bpm aconteceu dentro do bloco mais intenso.',
|
||||
why: 'Picos contextualizados ajudam a separar esforço real de ruído.',
|
||||
action: 'Compare esse bloco com próximos sparrings.',
|
||||
},
|
||||
];
|
||||
@@ -17,11 +17,15 @@ export function useMotionPreference(): boolean {
|
||||
const listener: MotionPreferenceListener = (value) => {
|
||||
if (mounted) setReduceMotion(value);
|
||||
};
|
||||
AccessibilityInfo.addEventListener('reduceMotionChanged', listener);
|
||||
const sub = AccessibilityInfo.addEventListener
|
||||
? AccessibilityInfo.addEventListener('reduceMotionChanged', listener)
|
||||
: undefined;
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
AccessibilityInfo.removeEventListener('reduceMotionChanged', listener);
|
||||
if (sub && typeof (sub as { remove?: () => void }).remove === 'function') {
|
||||
(sub as { remove: () => void }).remove();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -8,12 +8,7 @@ import {
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
type AnimatedStyle = {
|
||||
transform: Array<{ scale: number }>;
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
export function usePulseAnimation(active: boolean, durationMs = 1600): AnimatedStyle {
|
||||
export function usePulseAnimation(active: boolean, durationMs = 1600) {
|
||||
const scale = useSharedValue(1);
|
||||
const opacity = useSharedValue(active ? 1 : 0.5);
|
||||
|
||||
@@ -37,7 +32,7 @@ export function usePulseAnimation(active: boolean, durationMs = 1600): AnimatedS
|
||||
}
|
||||
}, [active, durationMs, scale, opacity]);
|
||||
|
||||
return useAnimatedStyle<AnimatedStyle>(() => ({
|
||||
return useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import {
|
||||
NativeEventEmitter,
|
||||
NativeModules,
|
||||
PermissionsAndroid,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { normalizeRssi } from '../ble/rssi';
|
||||
import {
|
||||
type CompletedTrainingSession,
|
||||
type HeartRateSample,
|
||||
} from '../training/trainingTypes';
|
||||
|
||||
export type NativePhase =
|
||||
| 'idle'
|
||||
| 'requesting-permission'
|
||||
| 'bluetooth-off'
|
||||
| 'scanning'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'receiving'
|
||||
| 'permission-denied'
|
||||
| 'sensor-not-found'
|
||||
| 'error';
|
||||
|
||||
export type NativeDiscoveredDevice = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly rssi: number | null;
|
||||
readonly serviceUuids?: readonly string[];
|
||||
};
|
||||
|
||||
export type NativeConnectedDevice = NativeDiscoveredDevice & {
|
||||
readonly batteryPercent?: number | null;
|
||||
readonly bodySensorLocation?: string | null;
|
||||
};
|
||||
|
||||
export type CaptureSnapshot = {
|
||||
readonly phase: NativePhase;
|
||||
readonly isMonitoring: boolean;
|
||||
readonly isCapturing: boolean;
|
||||
readonly startedAt: number | null;
|
||||
readonly statusMessage: string | null;
|
||||
readonly connectedDevice: NativeConnectedDevice | null;
|
||||
readonly discoveredDevices: readonly NativeDiscoveredDevice[];
|
||||
readonly samples: readonly HeartRateSample[];
|
||||
readonly sessions: readonly CompletedTrainingSession[];
|
||||
readonly services: readonly string[];
|
||||
readonly characteristics: readonly string[];
|
||||
readonly batteryOptimizationIgnored: boolean;
|
||||
};
|
||||
|
||||
export type NativeStatusEvent = {
|
||||
readonly phase: NativePhase;
|
||||
readonly statusMessage: string | null;
|
||||
readonly sessionId: number | null;
|
||||
readonly connectedDevice: NativeConnectedDevice | null;
|
||||
};
|
||||
|
||||
export type NativeDevicesEvent = {
|
||||
readonly devices: readonly NativeDiscoveredDevice[];
|
||||
};
|
||||
|
||||
export type NativeMetadataEvent = {
|
||||
readonly services: readonly string[];
|
||||
readonly characteristics: readonly string[];
|
||||
};
|
||||
|
||||
type FightRateCaptureModule = {
|
||||
readonly startMonitoring: (
|
||||
deviceId: string | null,
|
||||
deviceName: string | null,
|
||||
) => Promise<CaptureSnapshot>;
|
||||
readonly startCapture: (
|
||||
deviceId: string | null,
|
||||
deviceName: string | null,
|
||||
) => Promise<CaptureSnapshot>;
|
||||
readonly startWorkout?: (
|
||||
deviceId: string | null,
|
||||
deviceName: string | null,
|
||||
) => Promise<CaptureSnapshot>;
|
||||
readonly connectToDevice: (
|
||||
deviceId: string,
|
||||
deviceName: string | null,
|
||||
) => Promise<CaptureSnapshot>;
|
||||
readonly stopCapture: () => Promise<CaptureSnapshot>;
|
||||
readonly stopWorkout?: () => Promise<CaptureSnapshot>;
|
||||
readonly stopMonitoring?: () => Promise<CaptureSnapshot>;
|
||||
readonly getSessionSamples: (sessionId: string) => Promise<HeartRateSample[]>;
|
||||
readonly getContinuousSamples: (limit: number) => Promise<HeartRateSample[]>;
|
||||
readonly getSnapshot: () => Promise<CaptureSnapshot>;
|
||||
readonly isIgnoringBatteryOptimizations: () => Promise<boolean>;
|
||||
readonly openBatteryOptimizationSettings: () => Promise<boolean>;
|
||||
readonly addListener: (eventName: string) => void;
|
||||
readonly removeListeners: (count: number) => void;
|
||||
};
|
||||
|
||||
const nativeModule = NativeModules.FightRateCapture as
|
||||
| FightRateCaptureModule
|
||||
| undefined;
|
||||
|
||||
export const fightRateCaptureEvents = nativeModule
|
||||
? new NativeEventEmitter(nativeModule)
|
||||
: null;
|
||||
|
||||
export async function getCaptureSnapshot(): Promise<CaptureSnapshot> {
|
||||
if (!nativeModule) {
|
||||
return emptySnapshot('Módulo nativo FightRate indisponível.');
|
||||
}
|
||||
|
||||
const snapshot = await nativeModule.getSnapshot();
|
||||
return normalizeSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export async function startNativeMonitoring(
|
||||
device?: NativeDiscoveredDevice | null,
|
||||
): Promise<CaptureSnapshot> {
|
||||
if (!nativeModule) {
|
||||
return emptySnapshot('Módulo nativo FightRate indisponível.');
|
||||
}
|
||||
|
||||
const snapshot = await nativeModule.startMonitoring(
|
||||
device?.id ?? null,
|
||||
device?.name ?? null,
|
||||
);
|
||||
return normalizeSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export async function startNativeCapture(
|
||||
device?: NativeDiscoveredDevice | null,
|
||||
): Promise<CaptureSnapshot> {
|
||||
if (!nativeModule) {
|
||||
return emptySnapshot('Módulo nativo FightRate indisponível.');
|
||||
}
|
||||
|
||||
const starter = nativeModule.startWorkout ?? nativeModule.startCapture;
|
||||
const snapshot = await starter(device?.id ?? null, device?.name ?? null);
|
||||
return normalizeSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export async function connectNativeDevice(
|
||||
device: NativeDiscoveredDevice,
|
||||
): Promise<CaptureSnapshot> {
|
||||
if (!nativeModule) {
|
||||
return emptySnapshot('Módulo nativo FightRate indisponível.');
|
||||
}
|
||||
|
||||
const snapshot = await nativeModule.connectToDevice(device.id, device.name);
|
||||
return normalizeSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export async function stopNativeCapture(): Promise<CaptureSnapshot> {
|
||||
if (!nativeModule) {
|
||||
return emptySnapshot('Módulo nativo FightRate indisponível.');
|
||||
}
|
||||
|
||||
const stopper = nativeModule.stopWorkout ?? nativeModule.stopCapture;
|
||||
const snapshot = await stopper();
|
||||
return normalizeSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export async function stopNativeMonitoring(): Promise<CaptureSnapshot> {
|
||||
if (!nativeModule) {
|
||||
return emptySnapshot('Módulo nativo FightRate indisponível.');
|
||||
}
|
||||
|
||||
const snapshot = nativeModule.stopMonitoring
|
||||
? await nativeModule.stopMonitoring()
|
||||
: await nativeModule.stopCapture();
|
||||
return normalizeSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export async function getNativeSessionSamples(
|
||||
sessionId: string,
|
||||
): Promise<HeartRateSample[]> {
|
||||
if (!nativeModule) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const samples = await nativeModule.getSessionSamples(sessionId);
|
||||
return Array.isArray(samples) ? samples.map(normalizeSample) : [];
|
||||
}
|
||||
|
||||
export async function getNativeContinuousSamples(
|
||||
limit = 0,
|
||||
): Promise<HeartRateSample[]> {
|
||||
if (!nativeModule) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const samples = await nativeModule.getContinuousSamples(limit);
|
||||
return Array.isArray(samples) ? samples.map(normalizeSample) : [];
|
||||
}
|
||||
|
||||
export async function openBatteryOptimizationSettings() {
|
||||
if (!nativeModule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return nativeModule.openBatteryOptimizationSettings();
|
||||
}
|
||||
|
||||
export async function requestCapturePermissions() {
|
||||
if (Platform.OS !== 'android') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const apiLevel = Number(Platform.Version);
|
||||
const permissions = [PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION];
|
||||
|
||||
if (apiLevel >= 31) {
|
||||
permissions.push(
|
||||
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
|
||||
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
|
||||
);
|
||||
}
|
||||
|
||||
if (apiLevel >= 33) {
|
||||
permissions.push(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS);
|
||||
}
|
||||
|
||||
const result = await PermissionsAndroid.requestMultiple(permissions);
|
||||
return permissions.every(
|
||||
permission => result[permission] === PermissionsAndroid.RESULTS.GRANTED,
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeSample(
|
||||
sample: Partial<HeartRateSample>,
|
||||
): HeartRateSample {
|
||||
return {
|
||||
id: typeof sample.id === 'number' ? sample.id : undefined,
|
||||
sessionId:
|
||||
typeof sample.sessionId === 'number' && sample.sessionId >= 0
|
||||
? sample.sessionId
|
||||
: undefined,
|
||||
bpm: Number(sample.bpm ?? 0),
|
||||
timestamp: Number(sample.timestamp ?? Date.now()),
|
||||
contactDetected:
|
||||
typeof sample.contactDetected === 'boolean'
|
||||
? sample.contactDetected
|
||||
: null,
|
||||
rrIntervalsMs: Array.isArray(sample.rrIntervalsMs)
|
||||
? sample.rrIntervalsMs.map(Number)
|
||||
: [],
|
||||
deviceName: sample.deviceName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeStatusEvent(
|
||||
event: Partial<NativeStatusEvent>,
|
||||
): NativeStatusEvent {
|
||||
return {
|
||||
phase: normalizePhase(event.phase),
|
||||
statusMessage: event.statusMessage ?? null,
|
||||
sessionId:
|
||||
typeof event.sessionId === 'number' && event.sessionId >= 0
|
||||
? event.sessionId
|
||||
: null,
|
||||
connectedDevice: normalizeConnectedDevice(event.connectedDevice),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDevicesEvent(
|
||||
event: Partial<NativeDevicesEvent>,
|
||||
): NativeDevicesEvent {
|
||||
return {
|
||||
devices: Array.isArray(event.devices)
|
||||
? event.devices.map(normalizeDevice)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeMetadataEvent(
|
||||
event: Partial<NativeMetadataEvent>,
|
||||
): NativeMetadataEvent {
|
||||
return {
|
||||
services: Array.isArray(event.services) ? event.services.map(String) : [],
|
||||
characteristics: Array.isArray(event.characteristics)
|
||||
? event.characteristics.map(String)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSnapshot(
|
||||
snapshot: Partial<CaptureSnapshot>,
|
||||
): CaptureSnapshot {
|
||||
return {
|
||||
phase: normalizePhase(snapshot.phase),
|
||||
isMonitoring: Boolean(
|
||||
snapshot.isMonitoring ??
|
||||
(snapshot.phase ? snapshot.phase !== 'idle' : false),
|
||||
),
|
||||
isCapturing: Boolean(snapshot.isCapturing),
|
||||
startedAt:
|
||||
typeof snapshot.startedAt === 'number' && snapshot.startedAt > 0
|
||||
? snapshot.startedAt
|
||||
: null,
|
||||
statusMessage: snapshot.statusMessage ?? null,
|
||||
connectedDevice: normalizeConnectedDevice(snapshot.connectedDevice),
|
||||
discoveredDevices: Array.isArray(snapshot.discoveredDevices)
|
||||
? snapshot.discoveredDevices.map(normalizeDevice)
|
||||
: [],
|
||||
samples: Array.isArray(snapshot.samples)
|
||||
? snapshot.samples.map(normalizeSample)
|
||||
: [],
|
||||
sessions: Array.isArray(snapshot.sessions)
|
||||
? snapshot.sessions.map(normalizeSession)
|
||||
: [],
|
||||
services: Array.isArray(snapshot.services)
|
||||
? snapshot.services.map(String)
|
||||
: [],
|
||||
characteristics: Array.isArray(snapshot.characteristics)
|
||||
? snapshot.characteristics.map(String)
|
||||
: [],
|
||||
batteryOptimizationIgnored: Boolean(snapshot.batteryOptimizationIgnored),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSession(
|
||||
session: Partial<CompletedTrainingSession>,
|
||||
): CompletedTrainingSession {
|
||||
return {
|
||||
id: String(session.id ?? `session-${session.startedAt ?? Date.now()}`),
|
||||
startedAt: Number(session.startedAt ?? 0),
|
||||
endedAt: Number(session.endedAt ?? session.startedAt ?? 0),
|
||||
durationMs: Number(session.durationMs ?? 0),
|
||||
sampleCount: Number(session.sampleCount ?? 0),
|
||||
averageBpm: Number(session.averageBpm ?? 0),
|
||||
peakBpm: Number(session.peakBpm ?? 0),
|
||||
minBpm: Number(session.minBpm ?? 0),
|
||||
deviceName: session.deviceName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeConnectedDevice(
|
||||
device: NativeConnectedDevice | null | undefined,
|
||||
): NativeConnectedDevice | null {
|
||||
const hasIdentity = Boolean(device?.id || device?.name);
|
||||
if (!device || !hasIdentity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...normalizeDevice(device),
|
||||
batteryPercent:
|
||||
typeof device.batteryPercent === 'number' && device.batteryPercent >= 0
|
||||
? device.batteryPercent
|
||||
: null,
|
||||
bodySensorLocation: device.bodySensorLocation ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDevice(
|
||||
device: Partial<NativeDiscoveredDevice>,
|
||||
): NativeDiscoveredDevice {
|
||||
return {
|
||||
id: String(device.id ?? ''),
|
||||
name: String(device.name ?? 'Sensor BLE sem nome'),
|
||||
rssi: normalizeRssi(device.rssi),
|
||||
serviceUuids: Array.isArray(device.serviceUuids)
|
||||
? device.serviceUuids.map(String)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePhase(phase: unknown): NativePhase {
|
||||
if (nativePhases.includes(phase as NativePhase)) {
|
||||
return phase as NativePhase;
|
||||
}
|
||||
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
const nativePhases: readonly NativePhase[] = [
|
||||
'idle',
|
||||
'requesting-permission',
|
||||
'bluetooth-off',
|
||||
'scanning',
|
||||
'connecting',
|
||||
'connected',
|
||||
'receiving',
|
||||
'permission-denied',
|
||||
'sensor-not-found',
|
||||
'error',
|
||||
];
|
||||
|
||||
function emptySnapshot(statusMessage: string): CaptureSnapshot {
|
||||
return {
|
||||
phase: 'error',
|
||||
isMonitoring: false,
|
||||
isCapturing: false,
|
||||
startedAt: null,
|
||||
statusMessage,
|
||||
connectedDevice: null,
|
||||
discoveredDevices: [],
|
||||
samples: [],
|
||||
sessions: [],
|
||||
services: [],
|
||||
characteristics: [],
|
||||
batteryOptimizationIgnored: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import {
|
||||
Activity,
|
||||
Battery,
|
||||
RadioTower,
|
||||
ShieldCheck,
|
||||
Timer,
|
||||
} from 'lucide-react-native';
|
||||
import { type HeartRateMonitorState } from '../ble/useHeartRateMonitor';
|
||||
import { ActionButton } from '../components/ActionButton';
|
||||
import { TrainingChart } from '../components/TrainingChart';
|
||||
import { colors, radius, spacing, typography } from '../theme';
|
||||
import {
|
||||
calculateLiveMetrics,
|
||||
formatDuration,
|
||||
} from '../training/trainingMetrics';
|
||||
|
||||
const BPM_EMPTY = '--';
|
||||
|
||||
type Props = {
|
||||
readonly monitor: HeartRateMonitorState;
|
||||
readonly onCaptureAction: () => Promise<void>;
|
||||
readonly onMonitorAction: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function CaptureScreen({
|
||||
monitor,
|
||||
onCaptureAction,
|
||||
onMonitorAction,
|
||||
}: Props) {
|
||||
const now = useTicker(monitor.isMonitoring || monitor.isCapturing);
|
||||
const status = getStatusCopy(monitor);
|
||||
const windowSamples = useMemo(
|
||||
() =>
|
||||
monitor.startedAt
|
||||
? monitor.samples.filter(
|
||||
sample => sample.timestamp >= monitor.startedAt!,
|
||||
)
|
||||
: monitor.samples,
|
||||
[monitor.samples, monitor.startedAt],
|
||||
);
|
||||
const windowMetrics = calculateLiveMetrics(windowSamples);
|
||||
const firstSampleAt = monitor.samples[0]?.timestamp ?? null;
|
||||
const monitorElapsedMs = firstSampleAt ? now - firstSampleAt : 0;
|
||||
const workoutElapsedMs = monitor.startedAt ? now - monitor.startedAt : 0;
|
||||
const actionLabel = monitor.isCapturing
|
||||
? 'Fechar janela de treino'
|
||||
: 'Iniciar janela de treino';
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={styles.topRail}>
|
||||
<View style={styles.stateBlock}>
|
||||
<Text style={styles.eyebrow}>{status.label}</Text>
|
||||
<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>
|
||||
<Text style={styles.timerLabel}>
|
||||
{monitor.isCapturing ? 'janela' : 'monitor'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.bpmRow}>
|
||||
<Text style={styles.bpmValue}>{monitor.currentBpm ?? BPM_EMPTY}</Text>
|
||||
<View style={styles.bpmSide}>
|
||||
<Text style={styles.bpmUnit}>BPM</Text>
|
||||
<Text style={styles.statusLine}>
|
||||
{monitor.errorMessage ?? status.detail}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.captureRail}>
|
||||
<RailMetric
|
||||
label="MÉDIA"
|
||||
value={windowMetrics.averageBpm ?? BPM_EMPTY}
|
||||
/>
|
||||
<RailMetric
|
||||
label="PICO"
|
||||
value={windowMetrics.peakBpm ?? BPM_EMPTY}
|
||||
tone="hot"
|
||||
/>
|
||||
<RailMetric
|
||||
label="AMOSTRAS"
|
||||
value={windowSamples.length || BPM_EMPTY}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TrainingChart samples={windowSamples} />
|
||||
|
||||
<View style={styles.systemStrip}>
|
||||
<SystemSignal
|
||||
icon="radio"
|
||||
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"
|
||||
value={monitor.batteryOptimizationIgnored ? 'liberado' : 'otimizar'}
|
||||
tone={monitor.batteryOptimizationIgnored ? 'green' : 'amber'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{!monitor.batteryOptimizationIgnored ? (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
monitor.openBatterySettings().catch(() => undefined);
|
||||
}}
|
||||
style={styles.batteryPrompt}
|
||||
>
|
||||
<Text style={styles.batteryPromptTitle}>
|
||||
Manter com tela bloqueada
|
||||
</Text>
|
||||
<Text style={styles.batteryPromptText}>
|
||||
Toque para abrir a configuração de bateria e remover restrições do
|
||||
FightRate.
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<View style={styles.actionGrid}>
|
||||
{!monitor.isMonitoring ? (
|
||||
<ActionButton
|
||||
fullWidth
|
||||
label="Ativar monitor contínuo"
|
||||
onPress={() => {
|
||||
onMonitorAction().catch(() => undefined);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<ActionButton
|
||||
fullWidth
|
||||
label={actionLabel}
|
||||
onPress={() => {
|
||||
onCaptureAction().catch(() => undefined);
|
||||
}}
|
||||
variant={monitor.isCapturing ? 'stop' : '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.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function RailMetric({
|
||||
label,
|
||||
value,
|
||||
tone = 'normal',
|
||||
}: {
|
||||
readonly label: string;
|
||||
readonly value: string | number;
|
||||
readonly tone?: 'normal' | 'hot';
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.railMetric}>
|
||||
<Text
|
||||
style={[styles.railValue, tone === 'hot' ? styles.railValueHot : null]}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
<Text style={styles.railLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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.',
|
||||
};
|
||||
}
|
||||
if (monitor.phase === 'receiving') {
|
||||
return {
|
||||
label: 'LEITURA ATIVA',
|
||||
detail: 'BPM real chegando e sendo salvo continuamente em SQLite.',
|
||||
};
|
||||
}
|
||||
if (monitor.phase === 'scanning') {
|
||||
return {
|
||||
label: 'PROCURANDO 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.',
|
||||
};
|
||||
}
|
||||
if (monitor.phase === 'permission-denied') {
|
||||
return {
|
||||
label: 'PERMISSÃO NEGADA',
|
||||
detail: 'Autorize Bluetooth, Localização e Notificações.',
|
||||
};
|
||||
}
|
||||
if (monitor.phase === 'bluetooth-off') {
|
||||
return {
|
||||
label: 'BLUETOOTH DESLIGADO',
|
||||
detail: 'Ligue o Bluetooth antes de ativar o monitor.',
|
||||
};
|
||||
}
|
||||
if (monitor.phase === 'error') {
|
||||
return {
|
||||
label: 'ERRO DE CAPTURA',
|
||||
detail: monitor.errorMessage ?? 'Falha no serviço BLE.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: 'MONITOR DESATIVADO',
|
||||
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`;
|
||||
}
|
||||
|
||||
function getBleShortState(
|
||||
phase: HeartRateMonitorState['phase'],
|
||||
isMonitoring: boolean,
|
||||
) {
|
||||
if (!isMonitoring) return 'parado';
|
||||
if (phase === 'receiving') return 'lendo';
|
||||
if (phase === 'scanning') return 'busca';
|
||||
if (phase === 'connecting') return 'conectando';
|
||||
if (phase === 'connected') return '2A37';
|
||||
if (phase === 'error') return 'erro';
|
||||
return 'ativo';
|
||||
}
|
||||
|
||||
function getBatteryLabel(monitor: HeartRateMonitorState) {
|
||||
const percent = monitor.connectedDevice?.batteryPercent;
|
||||
if (typeof percent === 'number') return `${percent}%`;
|
||||
return 'sem leitura';
|
||||
}
|
||||
|
||||
function useTicker(active: boolean) {
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setNow(Date.now());
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [active]);
|
||||
|
||||
return now;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
topRail: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
paddingVertical: spacing.md,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
},
|
||||
stateBlock: {
|
||||
flex: 1,
|
||||
},
|
||||
eyebrow: {
|
||||
...typography.label,
|
||||
color: colors.action,
|
||||
},
|
||||
sensorName: {
|
||||
color: colors.text,
|
||||
fontSize: 18,
|
||||
lineHeight: 23,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.5,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
timeStack: {
|
||||
alignItems: 'flex-end',
|
||||
gap: spacing.xxs,
|
||||
minWidth: 84,
|
||||
},
|
||||
timerText: {
|
||||
...typography.label,
|
||||
color: colors.text,
|
||||
},
|
||||
timerLabel: {
|
||||
...typography.label,
|
||||
color: colors.textDim,
|
||||
fontSize: 9,
|
||||
},
|
||||
bpmRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
gap: spacing.md,
|
||||
},
|
||||
bpmValue: {
|
||||
...typography.display,
|
||||
fontSize: 98,
|
||||
lineHeight: 96,
|
||||
color: colors.text,
|
||||
},
|
||||
bpmSide: {
|
||||
flex: 1,
|
||||
paddingBottom: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
bpmUnit: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
statusLine: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
captureRail: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
railMetric: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingRight: spacing.sm,
|
||||
},
|
||||
railValue: {
|
||||
color: colors.text,
|
||||
fontSize: 28,
|
||||
lineHeight: 30,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -1.2,
|
||||
},
|
||||
railValueHot: {
|
||||
color: colors.action,
|
||||
},
|
||||
railLabel: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
marginTop: spacing.xxs,
|
||||
},
|
||||
systemStrip: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
systemSignal: {
|
||||
flex: 1,
|
||||
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',
|
||||
},
|
||||
batteryPrompt: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.warning,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.surfaceHot,
|
||||
padding: spacing.md,
|
||||
},
|
||||
batteryPromptTitle: {
|
||||
...typography.label,
|
||||
color: colors.warning,
|
||||
},
|
||||
batteryPromptText: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
actionGrid: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
truthLine: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
truthText: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,922 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { Activity, ChevronDown, ChevronUp } from 'lucide-react-native';
|
||||
import {
|
||||
calculateLiveMetrics,
|
||||
calculateZones,
|
||||
formatDuration,
|
||||
} from '../training/trainingMetrics';
|
||||
import {
|
||||
type CompletedTrainingSession,
|
||||
type HeartRateSample,
|
||||
} from '../training/trainingTypes';
|
||||
import {
|
||||
buildContinuousUsageHistory,
|
||||
filterSamplesByRange,
|
||||
type ContinuousUsageBlock,
|
||||
} from '../training/continuousUsage';
|
||||
import { TrainingChart } from '../components/TrainingChart';
|
||||
import { colors, spacing, typography } from '../theme';
|
||||
|
||||
type HistoryMode = 'windows' | 'continuous' | 'range';
|
||||
|
||||
type Props = {
|
||||
readonly sessions: readonly CompletedTrainingSession[];
|
||||
readonly getSessionSamples: (
|
||||
sessionId: string,
|
||||
) => Promise<readonly HeartRateSample[]>;
|
||||
readonly getContinuousSamples: (
|
||||
limit?: number,
|
||||
) => Promise<readonly HeartRateSample[]>;
|
||||
readonly liveSamples: readonly HeartRateSample[];
|
||||
};
|
||||
|
||||
export function HistoryScreen({
|
||||
sessions,
|
||||
getSessionSamples,
|
||||
getContinuousSamples,
|
||||
liveSamples,
|
||||
}: Props) {
|
||||
const [mode, setMode] = useState<HistoryMode>('windows');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [selectedSamples, setSelectedSamples] = useState<
|
||||
readonly HeartRateSample[]
|
||||
>([]);
|
||||
const [selectedUsageId, setSelectedUsageId] = useState<string | null>(null);
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
const [loadingContinuous, setLoadingContinuous] = useState(true);
|
||||
const [continuousSamples, setContinuousSamples] = useState<
|
||||
readonly HeartRateSample[]
|
||||
>([]);
|
||||
const [rangeStartText, setRangeStartText] = useState(() =>
|
||||
formatRangeInput(Date.now() - 60 * 60 * 1000),
|
||||
);
|
||||
const [rangeEndText, setRangeEndText] = useState(() =>
|
||||
formatRangeInput(Date.now()),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
setLoadingContinuous(true);
|
||||
|
||||
getContinuousSamples()
|
||||
.then(samples => {
|
||||
if (mounted) {
|
||||
setContinuousSamples(samples);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) {
|
||||
setLoadingContinuous(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [getContinuousSamples]);
|
||||
|
||||
const mergedContinuousSamples = useMemo(
|
||||
() => mergeSamples(continuousSamples, liveSamples),
|
||||
[continuousSamples, liveSamples],
|
||||
);
|
||||
|
||||
const usageBlocks = useMemo(
|
||||
() => buildContinuousUsageHistory(mergedContinuousSamples),
|
||||
[mergedContinuousSamples],
|
||||
);
|
||||
const rangeBounds = useMemo(
|
||||
() => parseHistoryRange(rangeStartText, rangeEndText),
|
||||
[rangeStartText, rangeEndText],
|
||||
);
|
||||
const rangeSamples = useMemo(
|
||||
() =>
|
||||
rangeBounds
|
||||
? filterSamplesByRange(mergedContinuousSamples, rangeBounds)
|
||||
: [],
|
||||
[mergedContinuousSamples, rangeBounds],
|
||||
);
|
||||
const selectedMetrics = useMemo(
|
||||
() => calculateLiveMetrics(selectedSamples),
|
||||
[selectedSamples],
|
||||
);
|
||||
const zones = useMemo(
|
||||
() => calculateZones(selectedSamples),
|
||||
[selectedSamples],
|
||||
);
|
||||
const selectedUsage =
|
||||
usageBlocks.find(block => block.id === selectedUsageId) ?? null;
|
||||
|
||||
const openSession = async (session: CompletedTrainingSession) => {
|
||||
if (selectedId === session.id) {
|
||||
setSelectedId(null);
|
||||
setSelectedSamples([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingId(session.id);
|
||||
setSelectedId(session.id);
|
||||
try {
|
||||
const samples = await getSessionSamples(session.id);
|
||||
setSelectedSamples(samples);
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openUsageBlock = (block: ContinuousUsageBlock) => {
|
||||
if (selectedUsageId === block.id) {
|
||||
setSelectedUsageId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedUsageId(block.id);
|
||||
};
|
||||
|
||||
const totalWindowSamples = sessions.reduce(
|
||||
(sum, session) => sum + session.sampleCount,
|
||||
0,
|
||||
);
|
||||
const totalWindowDuration = sessions.reduce(
|
||||
(sum, session) => sum + session.durationMs,
|
||||
0,
|
||||
);
|
||||
const totalContinuousDuration = usageBlocks.reduce(
|
||||
(sum, block) => sum + block.durationMs,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={styles.modeSwitch}>
|
||||
<ModeChip
|
||||
active={mode === 'windows'}
|
||||
label="Janelas"
|
||||
onPress={() => setMode('windows')}
|
||||
/>
|
||||
<ModeChip
|
||||
active={mode === 'continuous'}
|
||||
label="Uso total"
|
||||
onPress={() => setMode('continuous')}
|
||||
/>
|
||||
<ModeChip
|
||||
active={mode === 'range'}
|
||||
label="Faixa"
|
||||
onPress={() => setMode('range')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{mode === 'windows' ? (
|
||||
<WindowHistory
|
||||
loadingId={loadingId}
|
||||
onOpenSession={openSession}
|
||||
selectedId={selectedId}
|
||||
selectedMetrics={selectedMetrics}
|
||||
selectedSamples={selectedSamples}
|
||||
sessions={sessions}
|
||||
totalDuration={totalWindowDuration}
|
||||
totalSamples={totalWindowSamples}
|
||||
zones={zones}
|
||||
/>
|
||||
) : null}
|
||||
{mode === 'continuous' ? (
|
||||
<ContinuousHistory
|
||||
blocks={usageBlocks}
|
||||
loading={loadingContinuous}
|
||||
onOpenBlock={openUsageBlock}
|
||||
selectedBlock={selectedUsage}
|
||||
selectedUsageId={selectedUsageId}
|
||||
totalDuration={totalContinuousDuration}
|
||||
totalSamples={mergedContinuousSamples.length}
|
||||
/>
|
||||
) : null}
|
||||
{mode === 'range' ? (
|
||||
<RangeHistory
|
||||
endText={rangeEndText}
|
||||
rangeBounds={rangeBounds}
|
||||
samples={rangeSamples}
|
||||
setEndText={setRangeEndText}
|
||||
setStartText={setRangeStartText}
|
||||
startText={rangeStartText}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeChip({
|
||||
active,
|
||||
label,
|
||||
onPress,
|
||||
}: {
|
||||
readonly active: boolean;
|
||||
readonly label: string;
|
||||
readonly onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: active }}
|
||||
onPress={onPress}
|
||||
style={[styles.modeChip, active ? styles.modeChipActive : null]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.modeChipText, active ? styles.modeChipTextActive : null]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function RangeHistory({
|
||||
endText,
|
||||
rangeBounds,
|
||||
samples,
|
||||
setEndText,
|
||||
setStartText,
|
||||
startText,
|
||||
}: {
|
||||
readonly endText: string;
|
||||
readonly rangeBounds: { readonly start: number; readonly end: number } | null;
|
||||
readonly samples: readonly HeartRateSample[];
|
||||
readonly setEndText: (value: string) => void;
|
||||
readonly setStartText: (value: string) => void;
|
||||
readonly startText: string;
|
||||
}) {
|
||||
const metrics = calculateLiveMetrics(samples);
|
||||
|
||||
return (
|
||||
<View style={styles.rangePanel}>
|
||||
<View style={styles.archiveHeader}>
|
||||
<Text style={styles.eyebrow}>FAIXA CUSTOMIZADA</Text>
|
||||
<Text style={styles.archiveLabel}>
|
||||
Dia, hora e minuto. Use AAAA-MM-DD HH:mm para início e fim.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.rangeGrid}>
|
||||
<RangeInput
|
||||
label="INÍCIO"
|
||||
onChangeText={setStartText}
|
||||
value={startText}
|
||||
/>
|
||||
<RangeInput label="FIM" onChangeText={setEndText} value={endText} />
|
||||
</View>
|
||||
|
||||
{!rangeBounds ? (
|
||||
<Text style={styles.rangeWarning}>
|
||||
Informe as duas datas no formato AAAA-MM-DD HH:mm.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{rangeBounds && samples.length > 0 ? (
|
||||
<View style={styles.rangeResult}>
|
||||
<TrainingChart samples={samples} />
|
||||
<View style={styles.detailRail}>
|
||||
<DetailMetric label="média" value={metrics.averageBpm ?? '--'} />
|
||||
<DetailMetric label="pico" value={metrics.peakBpm ?? '--'} />
|
||||
<DetailMetric label="amostras" value={samples.length} />
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{rangeBounds && samples.length === 0 ? (
|
||||
<EmptyHistory
|
||||
eyebrow="SEM AMOSTRAS"
|
||||
title="Nada nesta faixa"
|
||||
copy="A consulta usa apenas BPM real salvo no histórico contínuo. Ajuste o início ou fim para encontrar dados."
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function RangeInput({
|
||||
label,
|
||||
onChangeText,
|
||||
value,
|
||||
}: {
|
||||
readonly label: string;
|
||||
readonly onChangeText: (value: string) => void;
|
||||
readonly value: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.rangeInputBlock}>
|
||||
<Text style={styles.rangeInputLabel}>{label}</Text>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
keyboardType="numbers-and-punctuation"
|
||||
onChangeText={onChangeText}
|
||||
placeholder="2026-06-10 09:30"
|
||||
placeholderTextColor={colors.textDim}
|
||||
style={styles.rangeInput}
|
||||
value={value}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowHistory({
|
||||
loadingId,
|
||||
onOpenSession,
|
||||
selectedId,
|
||||
selectedMetrics,
|
||||
selectedSamples,
|
||||
sessions,
|
||||
totalDuration,
|
||||
totalSamples,
|
||||
zones,
|
||||
}: {
|
||||
readonly loadingId: string | null;
|
||||
readonly onOpenSession: (session: CompletedTrainingSession) => void;
|
||||
readonly selectedId: string | null;
|
||||
readonly selectedMetrics: ReturnType<typeof calculateLiveMetrics>;
|
||||
readonly selectedSamples: readonly HeartRateSample[];
|
||||
readonly sessions: readonly CompletedTrainingSession[];
|
||||
readonly totalDuration: number;
|
||||
readonly totalSamples: number;
|
||||
readonly zones: ReturnType<typeof calculateZones>;
|
||||
}) {
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<EmptyHistory
|
||||
eyebrow="JANELAS SALVAS"
|
||||
title="Nenhuma janela salva"
|
||||
copy="O monitor grava continuamente; a janela só destaca um intervalo que você abriu e fechou para análise."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={styles.archiveHeader}>
|
||||
<Text style={styles.eyebrow}>JANELAS SALVAS</Text>
|
||||
<Text style={styles.archiveNumber}>{sessions.length}</Text>
|
||||
<Text style={styles.archiveLabel}>
|
||||
{formatDuration(totalDuration)} · {totalSamples} amostras reais
|
||||
destacadas
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.timeline}>
|
||||
{sessions.map(session => {
|
||||
const isOpen = selectedId === session.id;
|
||||
return (
|
||||
<View key={session.id} style={styles.timelineNode}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => onOpenSession(session)}
|
||||
style={({ pressed }) => [
|
||||
styles.sessionStrip,
|
||||
pressed ? styles.pressed : null,
|
||||
]}
|
||||
>
|
||||
<View style={styles.dateBlock}>
|
||||
<Text style={styles.sessionDay}>
|
||||
{formatSessionDay(session.startedAt)}
|
||||
</Text>
|
||||
<Text style={styles.sessionHour}>
|
||||
{formatSessionHour(session.startedAt)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.sessionBody}>
|
||||
<Text style={styles.sessionTitle}>
|
||||
{session.deviceName || 'Cinta cardíaca BLE'}
|
||||
</Text>
|
||||
<Text style={styles.sessionMeta}>
|
||||
{formatDuration(session.durationMs)} · {session.sampleCount}{' '}
|
||||
amostras · toque para detalhes
|
||||
</Text>
|
||||
<View style={styles.metricLine}>
|
||||
<Text style={styles.metricText}>
|
||||
MÉD {session.averageBpm}
|
||||
</Text>
|
||||
<Text style={styles.metricTextHot}>
|
||||
PICO {session.peakBpm}
|
||||
</Text>
|
||||
<Text style={styles.metricText}>MIN {session.minBpm}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{isOpen ? <OpenIcon /> : <ClosedIcon />}
|
||||
</Pressable>
|
||||
|
||||
{isOpen ? (
|
||||
<WindowDetail
|
||||
loading={loadingId === session.id}
|
||||
metrics={selectedMetrics}
|
||||
samples={selectedSamples}
|
||||
zones={zones}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ContinuousHistory({
|
||||
blocks,
|
||||
loading,
|
||||
onOpenBlock,
|
||||
selectedBlock,
|
||||
selectedUsageId,
|
||||
totalDuration,
|
||||
totalSamples,
|
||||
}: {
|
||||
readonly blocks: readonly ContinuousUsageBlock[];
|
||||
readonly loading: boolean;
|
||||
readonly onOpenBlock: (block: ContinuousUsageBlock) => void;
|
||||
readonly selectedBlock: ContinuousUsageBlock | null;
|
||||
readonly selectedUsageId: string | null;
|
||||
readonly totalDuration: number;
|
||||
readonly totalSamples: number;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.loadingLine}>
|
||||
<Activity color={colors.warning} size={16} strokeWidth={2.4} />
|
||||
<Text style={styles.copy}>Carregando histórico contínuo real...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (blocks.length === 0) {
|
||||
return (
|
||||
<EmptyHistory
|
||||
eyebrow="USO TOTAL"
|
||||
title="Sem uso contínuo ainda"
|
||||
copy="Conecte a cinta. Todo BPM real recebido fora ou dentro de uma janela aparecerá aqui."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={styles.archiveHeader}>
|
||||
<Text style={styles.eyebrow}>USO TOTAL</Text>
|
||||
<Text style={styles.archiveNumber}>{blocks.length}</Text>
|
||||
<Text style={styles.archiveLabel}>
|
||||
{formatDuration(totalDuration)} · {totalSamples} amostras contínuas
|
||||
salvas
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.timeline}>
|
||||
{blocks.map(block => {
|
||||
const isOpen = selectedUsageId === block.id;
|
||||
return (
|
||||
<View key={block.id} style={styles.timelineNode}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => onOpenBlock(block)}
|
||||
style={({ pressed }) => [
|
||||
styles.sessionStrip,
|
||||
pressed ? styles.pressed : null,
|
||||
]}
|
||||
>
|
||||
<View style={styles.dateBlock}>
|
||||
<Text style={styles.sessionDay}>
|
||||
{formatSessionDay(block.startedAt)}
|
||||
</Text>
|
||||
<Text style={styles.sessionHour}>
|
||||
{formatSessionHour(block.startedAt)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.sessionBody}>
|
||||
<Text style={styles.sessionTitle}>
|
||||
{block.deviceName || 'Uso contínuo'}
|
||||
</Text>
|
||||
<Text style={styles.sessionMeta}>
|
||||
{formatUsageRange(block)} · {block.sampleCount} amostras ·
|
||||
toque para detalhes
|
||||
</Text>
|
||||
<View style={styles.metricLine}>
|
||||
<Text style={styles.metricText}>
|
||||
MÉD {block.averageBpm}
|
||||
</Text>
|
||||
<Text style={styles.metricTextHot}>
|
||||
PICO {block.peakBpm}
|
||||
</Text>
|
||||
<Text style={styles.metricText}>MIN {block.minBpm}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{isOpen ? <OpenIcon /> : <ClosedIcon />}
|
||||
</Pressable>
|
||||
|
||||
{isOpen && selectedBlock ? (
|
||||
<UsageDetail block={selectedBlock} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowDetail({
|
||||
loading,
|
||||
metrics,
|
||||
samples,
|
||||
zones,
|
||||
}: {
|
||||
readonly loading: boolean;
|
||||
readonly metrics: ReturnType<typeof calculateLiveMetrics>;
|
||||
readonly samples: readonly HeartRateSample[];
|
||||
readonly zones: ReturnType<typeof calculateZones>;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.detailPanel}>
|
||||
{loading ? (
|
||||
<View style={styles.loadingLine}>
|
||||
<Activity color={colors.warning} size={16} strokeWidth={2.4} />
|
||||
<Text style={styles.copy}>Carregando amostras salvas...</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<TrainingChart samples={samples} />
|
||||
<View style={styles.detailRail}>
|
||||
<DetailMetric label="carga" value={metrics.loadScore || '--'} />
|
||||
<DetailMetric label="alta" value={`${metrics.highIntensityPercent}%`} />
|
||||
<DetailMetric
|
||||
label="taxa"
|
||||
value={`${metrics.sampleRatePerMinute ?? '--'}/min`}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.zoneStack}>
|
||||
{zones.map(zone => (
|
||||
<View key={zone.key} style={styles.zoneRow}>
|
||||
<Text style={styles.zoneLabel}>{zone.label}</Text>
|
||||
<Text style={styles.zoneValue}>
|
||||
{zone.percent}% · {formatDuration(zone.seconds * 1000)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageDetail({ block }: { readonly block: ContinuousUsageBlock }) {
|
||||
const metrics = calculateLiveMetrics(block.samples);
|
||||
|
||||
return (
|
||||
<View style={styles.detailPanel}>
|
||||
<TrainingChart samples={block.samples} />
|
||||
<View style={styles.detailRail}>
|
||||
<DetailMetric
|
||||
label="duração"
|
||||
value={formatDuration(block.durationMs)}
|
||||
/>
|
||||
<DetailMetric label="amostras" value={block.sampleCount} />
|
||||
<DetailMetric
|
||||
label="taxa"
|
||||
value={`${metrics.sampleRatePerMinute ?? '--'}/min`}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyHistory({
|
||||
copy,
|
||||
eyebrow,
|
||||
title,
|
||||
}: {
|
||||
readonly copy: string;
|
||||
readonly eyebrow: string;
|
||||
readonly title: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.emptyBlock}>
|
||||
<Text style={styles.eyebrow}>{eyebrow}</Text>
|
||||
<Text style={styles.emptyTitle}>{title}</Text>
|
||||
<Text style={styles.copy}>{copy}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailMetric({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
readonly label: string;
|
||||
readonly value: string | number;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.detailMetric}>
|
||||
<Text style={styles.detailValue}>{value}</Text>
|
||||
<Text style={styles.detailLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function OpenIcon() {
|
||||
return <ChevronUp color={colors.warning} size={18} strokeWidth={2.6} />;
|
||||
}
|
||||
|
||||
function ClosedIcon() {
|
||||
return <ChevronDown color={colors.textMuted} size={18} strokeWidth={2.6} />;
|
||||
}
|
||||
|
||||
function formatSessionDay(timestamp: number) {
|
||||
return new Date(timestamp).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatSessionHour(timestamp: number) {
|
||||
return new Date(timestamp).toLocaleTimeString('pt-BR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatUsageRange(block: ContinuousUsageBlock) {
|
||||
return `${formatDuration(block.durationMs)} · ${formatSessionHour(
|
||||
block.startedAt,
|
||||
)}-${formatSessionHour(block.endedAt)}`;
|
||||
}
|
||||
|
||||
function mergeSamples(
|
||||
persistedSamples: readonly HeartRateSample[],
|
||||
liveSamples: readonly HeartRateSample[],
|
||||
) {
|
||||
const map = new Map<string, HeartRateSample>();
|
||||
|
||||
[...persistedSamples, ...liveSamples].forEach(sample => {
|
||||
const key = sample.id
|
||||
? `id:${sample.id}`
|
||||
: `ts:${sample.timestamp}:${sample.bpm}`;
|
||||
map.set(key, sample);
|
||||
});
|
||||
|
||||
return [...map.values()].sort(
|
||||
(left, right) => left.timestamp - right.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
function parseHistoryRange(startText: string, endText: string) {
|
||||
const start = parseRangeInput(startText);
|
||||
const end = parseRangeInput(endText);
|
||||
|
||||
if (start === null || end === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function parseRangeInput(value: string) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})$/.exec(
|
||||
value.trim(),
|
||||
);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, year, month, day, hour, minute] = match.map(Number);
|
||||
const timestamp = new Date(
|
||||
year,
|
||||
month - 1,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
0,
|
||||
0,
|
||||
).getTime();
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
}
|
||||
|
||||
function formatRangeInput(timestamp: number) {
|
||||
const date = new Date(timestamp);
|
||||
const year = date.getFullYear();
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${date.getDate()}`.padStart(2, '0');
|
||||
const hour = `${date.getHours()}`.padStart(2, '0');
|
||||
const minute = `${date.getMinutes()}`.padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hour}:${minute}`;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
modeSwitch: {
|
||||
borderTopWidth: 0,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
flexDirection: 'row',
|
||||
paddingVertical: spacing.xs,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
modeChip: {
|
||||
flex: 1,
|
||||
minHeight: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
modeChipActive: {
|
||||
borderColor: colors.line,
|
||||
backgroundColor: colors.surfaceHot,
|
||||
},
|
||||
modeChipText: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
modeChipTextActive: {
|
||||
color: colors.warning,
|
||||
},
|
||||
emptyBlock: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
paddingVertical: spacing.xl,
|
||||
minHeight: 260,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
eyebrow: {
|
||||
...typography.label,
|
||||
color: colors.action,
|
||||
},
|
||||
emptyTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 44,
|
||||
lineHeight: 45,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -2.6,
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
copy: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
flex: 1,
|
||||
},
|
||||
archiveHeader: {
|
||||
borderTopWidth: 0,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
archiveNumber: {
|
||||
...typography.display,
|
||||
color: colors.text,
|
||||
fontSize: 82,
|
||||
lineHeight: 84,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
archiveLabel: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
timeline: {
|
||||
gap: 0,
|
||||
},
|
||||
timelineNode: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.line,
|
||||
},
|
||||
sessionStrip: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.72,
|
||||
},
|
||||
dateBlock: {
|
||||
width: 68,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: colors.line,
|
||||
paddingRight: spacing.sm,
|
||||
},
|
||||
sessionDay: {
|
||||
color: colors.warning,
|
||||
fontSize: 20,
|
||||
lineHeight: 23,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.8,
|
||||
},
|
||||
sessionHour: {
|
||||
...typography.label,
|
||||
color: colors.textDim,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
sessionBody: {
|
||||
flex: 1,
|
||||
},
|
||||
sessionTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 22,
|
||||
lineHeight: 25,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -1,
|
||||
},
|
||||
sessionMeta: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
marginTop: spacing.xxs,
|
||||
},
|
||||
metricLine: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
metricText: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
metricTextHot: {
|
||||
...typography.label,
|
||||
color: colors.action,
|
||||
},
|
||||
rangePanel: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
rangeGrid: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
rangeInputBlock: {
|
||||
flex: 1,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
rangeInputLabel: {
|
||||
...typography.label,
|
||||
color: colors.warning,
|
||||
},
|
||||
rangeInput: {
|
||||
minHeight: 44,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.line,
|
||||
color: colors.text,
|
||||
paddingHorizontal: spacing.sm,
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
rangeWarning: {
|
||||
...typography.body,
|
||||
color: colors.warning,
|
||||
},
|
||||
rangeResult: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
detailPanel: {
|
||||
paddingBottom: spacing.lg,
|
||||
gap: spacing.md,
|
||||
},
|
||||
loadingLine: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
detailRail: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 0,
|
||||
borderColor: colors.line,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
detailMetric: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
detailValue: {
|
||||
color: colors.text,
|
||||
fontSize: 27,
|
||||
lineHeight: 30,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -1.2,
|
||||
},
|
||||
detailLabel: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
zoneStack: {
|
||||
gap: spacing.xs,
|
||||
},
|
||||
zoneRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.line,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
zoneLabel: {
|
||||
...typography.label,
|
||||
color: colors.text,
|
||||
},
|
||||
zoneValue: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,630 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import {
|
||||
Activity,
|
||||
ChartNoAxesCombined,
|
||||
Gauge,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react-native';
|
||||
import {
|
||||
buildInsightDashboard,
|
||||
formatDuration,
|
||||
} from '../training/trainingMetrics';
|
||||
import {
|
||||
type CompletedTrainingSession,
|
||||
type HeartRateSample,
|
||||
type InsightMetricRow,
|
||||
type TrainingInsight,
|
||||
} from '../training/trainingTypes';
|
||||
import { colors, spacing, typography } from '../theme';
|
||||
|
||||
const CONTINUOUS_SAMPLE_LIMIT = 2_000;
|
||||
|
||||
type Props = {
|
||||
readonly liveSamples: readonly HeartRateSample[];
|
||||
readonly sessions: readonly CompletedTrainingSession[];
|
||||
readonly getContinuousSamples: (
|
||||
limit?: number,
|
||||
) => Promise<readonly HeartRateSample[]>;
|
||||
};
|
||||
|
||||
export function InsightsScreen({
|
||||
liveSamples,
|
||||
sessions,
|
||||
getContinuousSamples,
|
||||
}: Props) {
|
||||
const [continuousSamples, setContinuousSamples] = useState<
|
||||
readonly HeartRateSample[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
getContinuousSamples(CONTINUOUS_SAMPLE_LIMIT)
|
||||
.then(samples => {
|
||||
if (mounted) {
|
||||
setContinuousSamples(samples);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) {
|
||||
setContinuousSamples([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [getContinuousSamples, sessions.length]);
|
||||
|
||||
const analysisSamples = useMemo(
|
||||
() => mergeSamples(continuousSamples, liveSamples),
|
||||
[continuousSamples, liveSamples],
|
||||
);
|
||||
|
||||
const dashboard = useMemo(
|
||||
() =>
|
||||
buildInsightDashboard({
|
||||
liveSamples,
|
||||
continuousSamples: analysisSamples,
|
||||
sessions,
|
||||
}),
|
||||
[analysisSamples, liveSamples, sessions],
|
||||
);
|
||||
|
||||
const latestSession = sessions[0] ?? null;
|
||||
const loadRow = dashboard.rows.find(row => row.key === 'load') ?? null;
|
||||
const hrvRow = dashboard.rows.find(row => row.key === 'hrv') ?? null;
|
||||
|
||||
if (dashboard.sampleCount === 0 && sessions.length === 0) {
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={styles.emptyInstrument}>
|
||||
<Text style={styles.eyebrow}>ANÁLISE BLOQUEADA</Text>
|
||||
<Text style={styles.emptyTitle}>Sem dado real, sem conclusão</Text>
|
||||
<Text style={styles.copy}>
|
||||
Ative o monitor contínuo. O FightRate não inventa pico, zona, carga,
|
||||
VFC ou recuperação.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={styles.instrumentHeader}>
|
||||
<View style={styles.headerRail} />
|
||||
<View style={styles.headerCopy}>
|
||||
<Text style={styles.eyebrow}>PAINEL DE CÁLCULO</Text>
|
||||
<Text style={styles.headerTitle}>Matriz científica</Text>
|
||||
<Text style={styles.copy}>
|
||||
Leituras calculadas a partir de BPM timestampado real. VFC só é
|
||||
liberada quando o sensor entrega RR intervals.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.headerScore}>
|
||||
<ChartNoAxesCombined
|
||||
color={colors.warning}
|
||||
size={20}
|
||||
strokeWidth={2.4}
|
||||
/>
|
||||
<Text style={styles.scoreValue}>{loadRow?.value ?? '--'}</Text>
|
||||
<Text style={styles.scoreLabel}>carga</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.evidenceStrip}>
|
||||
<EvidenceCell label="amostras" value={dashboard.sampleCount} />
|
||||
<EvidenceCell
|
||||
label="janela"
|
||||
value={formatDuration(dashboard.durationMs)}
|
||||
/>
|
||||
<EvidenceCell label="range" value={formatTimeRange(dashboard)} />
|
||||
</View>
|
||||
|
||||
<View style={styles.metricMatrix}>
|
||||
<View style={styles.matrixHeader}>
|
||||
<Text style={styles.sectionLabel}>CÁLCULOS</Text>
|
||||
<Text style={styles.matrixNote}>valor · status · leitura</Text>
|
||||
</View>
|
||||
{dashboard.rows.map((row, index) => (
|
||||
<InstrumentMetricRow key={row.key} index={index + 1} row={row} />
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.zonePanel}>
|
||||
<View style={styles.matrixHeader}>
|
||||
<Text style={styles.sectionLabel}>ZONAS OPERACIONAIS</Text>
|
||||
<Text style={styles.matrixNote}>tempo real em cada trilho</Text>
|
||||
</View>
|
||||
{dashboard.zones.map(zone => (
|
||||
<View key={zone.key} style={styles.zoneRow}>
|
||||
<Text style={styles.zoneLabel}>{zone.label}</Text>
|
||||
<View style={styles.zoneTrack}>
|
||||
<View style={[styles.zoneFill, { width: `${zone.percent}%` }]} />
|
||||
</View>
|
||||
<Text style={styles.zoneValue}>
|
||||
{zone.percent}% · {formatDuration(zone.seconds * 1000)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.protocolPanel}>
|
||||
<View style={styles.protocolIcon}>
|
||||
{hrvRow?.status === 'ok' ? (
|
||||
<ShieldCheck color={colors.success} size={18} strokeWidth={2.4} />
|
||||
) : (
|
||||
<Gauge color={colors.warning} size={18} strokeWidth={2.4} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.protocolCopy}>
|
||||
<Text style={styles.protocolTitle}>Protocolo de honestidade</Text>
|
||||
<Text style={styles.copy}>
|
||||
Métricas dependentes de perfil ou RR ficam marcadas como
|
||||
necessárias. Isso impede conclusões falsas sobre FCmáx, Karvonen,
|
||||
TRIMP completo ou VFC.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.insightStack}>
|
||||
<Text style={styles.sectionLabel}>LEITURAS OPERACIONAIS</Text>
|
||||
{dashboard.narrative.length > 0 ? (
|
||||
dashboard.narrative.map((insight, index) => (
|
||||
<InsightNote
|
||||
key={insight.title}
|
||||
insight={insight}
|
||||
index={index + 1}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<View style={styles.waitingPanel}>
|
||||
<Activity color={colors.warning} size={18} strokeWidth={2.5} />
|
||||
<Text style={styles.copy}>
|
||||
Receba mais amostras reais para gerar leitura de tendência e ação.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{latestSession ? (
|
||||
<View style={styles.lastSessionStrip}>
|
||||
<Text style={styles.eyebrow}>ÚLTIMA JANELA SALVA</Text>
|
||||
<Text style={styles.lastSessionText}>
|
||||
{formatDuration(latestSession.durationMs)} · média{' '}
|
||||
{latestSession.averageBpm} · pico {latestSession.peakBpm} ·{' '}
|
||||
{latestSession.sampleCount} amostras
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceCell({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
readonly label: string;
|
||||
readonly value: string | number;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.evidenceCell}>
|
||||
<Text style={styles.evidenceValue}>{value}</Text>
|
||||
<Text style={styles.evidenceLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function InstrumentMetricRow({
|
||||
row,
|
||||
index,
|
||||
}: {
|
||||
readonly row: InsightMetricRow;
|
||||
readonly index: number;
|
||||
}) {
|
||||
const toneColor = colorForTone(row.tone);
|
||||
return (
|
||||
<View style={styles.metricRow}>
|
||||
<View style={[styles.metricIndexRail, { borderColor: toneColor }]}>
|
||||
<Text style={styles.metricIndex}>
|
||||
{index.toString().padStart(2, '0')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metricMain}>
|
||||
<View style={styles.metricTopLine}>
|
||||
<Text style={styles.metricLabel}>{row.label}</Text>
|
||||
<Text style={[styles.statusPill, { color: toneColor }]}>
|
||||
{row.statusLabel}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.metricSummary}>{row.summary}</Text>
|
||||
<Text style={styles.metricDetail}>{row.detail}</Text>
|
||||
</View>
|
||||
<View style={styles.metricValueBox}>
|
||||
<Text style={[styles.metricValue, { color: toneColor }]}>
|
||||
{row.value}
|
||||
</Text>
|
||||
{row.unit ? <Text style={styles.metricUnit}>{row.unit}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function InsightNote({
|
||||
insight,
|
||||
index,
|
||||
}: {
|
||||
readonly insight: TrainingInsight;
|
||||
readonly index: number;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.insightNote}>
|
||||
<Text style={styles.noteIndex}>{index.toString().padStart(2, '0')}</Text>
|
||||
<View style={styles.noteBody}>
|
||||
<Text style={styles.insightTitle}>{insight.title}</Text>
|
||||
<Text style={styles.noteText}>{insight.why}</Text>
|
||||
<Text style={styles.noteAction}>{insight.action}</Text>
|
||||
<Text style={styles.confidence}>{insight.confidence}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function mergeSamples(
|
||||
persistedSamples: readonly HeartRateSample[],
|
||||
liveSamples: readonly HeartRateSample[],
|
||||
) {
|
||||
const byKey = new Map<string, HeartRateSample>();
|
||||
|
||||
[...persistedSamples, ...liveSamples].forEach(sample => {
|
||||
byKey.set(`${sample.timestamp}:${sample.bpm}`, sample);
|
||||
});
|
||||
|
||||
return [...byKey.values()].sort(
|
||||
(left, right) => left.timestamp - right.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
function colorForTone(rowTone: InsightMetricRow['tone']) {
|
||||
if (rowTone === 'good') {
|
||||
return colors.success;
|
||||
}
|
||||
if (rowTone === 'danger') {
|
||||
return colors.action;
|
||||
}
|
||||
if (rowTone === 'warning') {
|
||||
return colors.warning;
|
||||
}
|
||||
return colors.text;
|
||||
}
|
||||
|
||||
function formatTimeRange(dashboard: {
|
||||
readonly startedAt: number | null;
|
||||
readonly endedAt: number | null;
|
||||
}) {
|
||||
if (dashboard.startedAt === null || dashboard.endedAt === null) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
const start = new Date(dashboard.startedAt).toLocaleTimeString('pt-BR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const end = new Date(dashboard.endedAt).toLocaleTimeString('pt-BR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return `${start}–${end}`;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
gap: spacing.lg,
|
||||
},
|
||||
emptyInstrument: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
paddingVertical: spacing.xl,
|
||||
minHeight: 260,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
eyebrow: {
|
||||
...typography.label,
|
||||
color: colors.action,
|
||||
},
|
||||
emptyTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 42,
|
||||
lineHeight: 43,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -2.5,
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
copy: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
instrumentHeader: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
paddingVertical: spacing.md,
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
alignItems: 'stretch',
|
||||
},
|
||||
headerRail: {
|
||||
width: 5,
|
||||
backgroundColor: colors.action,
|
||||
},
|
||||
headerCopy: {
|
||||
flex: 1,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
headerTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 36,
|
||||
lineHeight: 37,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -2,
|
||||
},
|
||||
headerScore: {
|
||||
minWidth: 86,
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: colors.line,
|
||||
paddingLeft: spacing.md,
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
scoreValue: {
|
||||
color: colors.text,
|
||||
fontSize: 34,
|
||||
lineHeight: 36,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -1.6,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
scoreLabel: {
|
||||
...typography.label,
|
||||
color: colors.textDim,
|
||||
},
|
||||
evidenceStrip: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
evidenceCell: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.xs,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: colors.line,
|
||||
},
|
||||
evidenceValue: {
|
||||
color: colors.text,
|
||||
fontSize: 18,
|
||||
lineHeight: 22,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.8,
|
||||
},
|
||||
evidenceLabel: {
|
||||
...typography.label,
|
||||
color: colors.textDim,
|
||||
marginTop: 2,
|
||||
},
|
||||
metricMatrix: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
},
|
||||
matrixHeader: {
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.line,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
sectionLabel: {
|
||||
...typography.label,
|
||||
color: colors.warning,
|
||||
},
|
||||
matrixNote: {
|
||||
...typography.label,
|
||||
color: colors.textDim,
|
||||
textAlign: 'right',
|
||||
flexShrink: 1,
|
||||
},
|
||||
metricRow: {
|
||||
minHeight: 92,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.line,
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
metricIndexRail: {
|
||||
width: 34,
|
||||
borderLeftWidth: 3,
|
||||
justifyContent: 'flex-start',
|
||||
paddingLeft: spacing.xs,
|
||||
},
|
||||
metricIndex: {
|
||||
color: colors.textDim,
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
fontWeight: '900',
|
||||
},
|
||||
metricMain: {
|
||||
flex: 1,
|
||||
gap: spacing.xxs,
|
||||
},
|
||||
metricTopLine: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
metricLabel: {
|
||||
...typography.label,
|
||||
color: colors.text,
|
||||
flexShrink: 1,
|
||||
},
|
||||
statusPill: {
|
||||
...typography.label,
|
||||
textAlign: 'right',
|
||||
},
|
||||
metricSummary: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
lineHeight: 20,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.45,
|
||||
},
|
||||
metricDetail: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
metricValueBox: {
|
||||
width: 74,
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
metricValue: {
|
||||
fontSize: 26,
|
||||
lineHeight: 30,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -1.2,
|
||||
textAlign: 'right',
|
||||
},
|
||||
metricUnit: {
|
||||
...typography.label,
|
||||
color: colors.textDim,
|
||||
textAlign: 'right',
|
||||
},
|
||||
zonePanel: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
},
|
||||
zoneRow: {
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.line,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
zoneLabel: {
|
||||
...typography.label,
|
||||
color: colors.text,
|
||||
},
|
||||
zoneTrack: {
|
||||
height: 9,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
zoneFill: {
|
||||
height: '100%',
|
||||
backgroundColor: colors.action,
|
||||
},
|
||||
zoneValue: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
protocolPanel: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
paddingVertical: spacing.md,
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
},
|
||||
protocolIcon: {
|
||||
width: 34,
|
||||
alignItems: 'center',
|
||||
paddingTop: 2,
|
||||
},
|
||||
protocolCopy: {
|
||||
flex: 1,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
protocolTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 18,
|
||||
lineHeight: 22,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.7,
|
||||
},
|
||||
insightStack: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
insightNote: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.line,
|
||||
paddingTop: spacing.md,
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
},
|
||||
noteIndex: {
|
||||
color: colors.action,
|
||||
fontSize: 18,
|
||||
lineHeight: 24,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.5,
|
||||
width: 34,
|
||||
},
|
||||
noteBody: {
|
||||
flex: 1,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
insightTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 22,
|
||||
lineHeight: 25,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -1,
|
||||
},
|
||||
noteText: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
noteAction: {
|
||||
color: colors.text,
|
||||
fontSize: 14,
|
||||
lineHeight: 19,
|
||||
fontWeight: '800',
|
||||
},
|
||||
confidence: {
|
||||
...typography.label,
|
||||
color: colors.textDim,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
waitingPanel: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.line,
|
||||
paddingTop: spacing.md,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
lastSessionStrip: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
lastSessionText: {
|
||||
color: colors.text,
|
||||
fontSize: 17,
|
||||
lineHeight: 23,
|
||||
fontWeight: '800',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import {
|
||||
Activity,
|
||||
Battery,
|
||||
Bluetooth,
|
||||
RadioReceiver,
|
||||
ShieldCheck,
|
||||
Signal,
|
||||
} from 'lucide-react-native';
|
||||
import { type HeartRateMonitorState } from '../ble/useHeartRateMonitor';
|
||||
import { formatRssi } from '../ble/rssi';
|
||||
import { ActionButton } from '../components/ActionButton';
|
||||
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'];
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={styles.sensorConsole}>
|
||||
<View style={styles.consoleTopline}>
|
||||
<View>
|
||||
<Text style={styles.eyebrow}>SENSOR REAL</Text>
|
||||
<Text style={styles.title}>
|
||||
{friendlyDeviceTitle(device?.name)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.phaseBadge}>
|
||||
<Bluetooth color={colors.warning} size={16} strokeWidth={2.4} />
|
||||
<Text style={styles.phaseText}>{phaseLabel(monitor.phase)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.signalGrid}>
|
||||
<SignalDatum
|
||||
icon="signal"
|
||||
label="RSSI"
|
||||
value={formatRssi(device?.rssi)}
|
||||
/>
|
||||
<SignalDatum
|
||||
icon="activity"
|
||||
label="Leituras"
|
||||
value={`${monitor.samples.length}`}
|
||||
/>
|
||||
<SignalDatum
|
||||
icon="battery"
|
||||
label="Bateria"
|
||||
value={formatBattery(device?.batteryPercent)}
|
||||
/>
|
||||
<SignalDatum
|
||||
icon="shield"
|
||||
label="Fundo"
|
||||
value={monitor.batteryOptimizationIgnored ? 'liberado' : 'restrito'}
|
||||
/>
|
||||
</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}>
|
||||
<Activity color={colors.success} size={15} strokeWidth={2.4} />
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
eyebrow: {
|
||||
...typography.label,
|
||||
color: colors.action,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 34,
|
||||
lineHeight: 36,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -2,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
phaseBadge: {
|
||||
paddingVertical: spacing.xs,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
height: 36,
|
||||
},
|
||||
phaseText: {
|
||||
...typography.label,
|
||||
color: colors.text,
|
||||
},
|
||||
signalGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
signalDatum: {
|
||||
width: '48.5%',
|
||||
minHeight: 76,
|
||||
borderWidth: 1,
|
||||
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: {
|
||||
...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: spacing.xs,
|
||||
},
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export const radius = {
|
||||
xs: 10,
|
||||
sm: 16,
|
||||
md: 24,
|
||||
lg: 36,
|
||||
xl: 48,
|
||||
} as const;
|
||||
@@ -0,0 +1,9 @@
|
||||
export const spacing = {
|
||||
xxs: 4,
|
||||
xs: 8,
|
||||
sm: 12,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
xxl: 48,
|
||||
} as const;
|
||||
+16
-8
@@ -1,3 +1,15 @@
|
||||
import type { TextStyle } from 'react-native';
|
||||
|
||||
const numeric: TextStyle = {
|
||||
fontFamily: 'JetBrainsMono-Bold',
|
||||
fontVariant: ['tabular-nums'] as TextStyle['fontVariant'],
|
||||
};
|
||||
|
||||
const display: TextStyle = {
|
||||
fontFamily: 'JetBrainsMono-ExtraBold',
|
||||
fontVariant: ['tabular-nums'] as TextStyle['fontVariant'],
|
||||
};
|
||||
|
||||
export const typography = {
|
||||
display: {
|
||||
fontSize: 82,
|
||||
@@ -12,32 +24,28 @@ export const typography = {
|
||||
letterSpacing: -3,
|
||||
},
|
||||
displayNumeric: {
|
||||
fontFamily: 'JetBrainsMono-ExtraBold',
|
||||
fontVariant: ['tabular-nums'] as const,
|
||||
...display,
|
||||
fontSize: 110,
|
||||
lineHeight: 100,
|
||||
fontWeight: '900' as const,
|
||||
letterSpacing: -7,
|
||||
},
|
||||
displaySmallNumeric: {
|
||||
fontFamily: 'JetBrainsMono-ExtraBold',
|
||||
fontVariant: ['tabular-nums'] as const,
|
||||
...display,
|
||||
fontSize: 56,
|
||||
lineHeight: 54,
|
||||
fontWeight: '900' as const,
|
||||
letterSpacing: -3,
|
||||
},
|
||||
metricNumeric: {
|
||||
fontFamily: 'JetBrainsMono-Bold',
|
||||
fontVariant: ['tabular-nums'] as const,
|
||||
...numeric,
|
||||
fontSize: 28,
|
||||
lineHeight: 30,
|
||||
fontWeight: '800' as const,
|
||||
letterSpacing: -1.2,
|
||||
},
|
||||
detailNumeric: {
|
||||
fontFamily: 'JetBrainsMono-Bold',
|
||||
fontVariant: ['tabular-nums'] as const,
|
||||
...numeric,
|
||||
fontSize: 18,
|
||||
lineHeight: 22,
|
||||
fontWeight: '800' as const,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { type HeartRateSample } from './trainingTypes';
|
||||
|
||||
export type ContinuousUsageBlock = {
|
||||
readonly id: string;
|
||||
readonly startedAt: number;
|
||||
readonly endedAt: number;
|
||||
readonly durationMs: number;
|
||||
readonly sampleCount: number;
|
||||
readonly averageBpm: number;
|
||||
readonly peakBpm: number;
|
||||
readonly minBpm: number;
|
||||
readonly deviceName: string | null;
|
||||
readonly samples: readonly HeartRateSample[];
|
||||
};
|
||||
|
||||
type ContinuousUsageOptions = {
|
||||
readonly gapMs?: number;
|
||||
};
|
||||
|
||||
export type ContinuousSampleRange = {
|
||||
readonly start: number;
|
||||
readonly end: number;
|
||||
};
|
||||
|
||||
const DEFAULT_GAP_MS = 5 * 60 * 1000;
|
||||
|
||||
export function filterSamplesByRange(
|
||||
samples: readonly HeartRateSample[],
|
||||
range: ContinuousSampleRange,
|
||||
): HeartRateSample[] {
|
||||
const start = Math.min(range.start, range.end);
|
||||
const end = Math.max(range.start, range.end);
|
||||
|
||||
return samples
|
||||
.filter(sample => sample.timestamp >= start && sample.timestamp <= end)
|
||||
.slice()
|
||||
.sort((left, right) => left.timestamp - right.timestamp);
|
||||
}
|
||||
|
||||
export function buildContinuousUsageHistory(
|
||||
samples: readonly HeartRateSample[],
|
||||
options: ContinuousUsageOptions = {},
|
||||
): ContinuousUsageBlock[] {
|
||||
const gapMs = options.gapMs ?? DEFAULT_GAP_MS;
|
||||
const sortedSamples = samples
|
||||
.filter(sample => Number.isFinite(sample.timestamp) && sample.bpm > 0)
|
||||
.slice()
|
||||
.sort((left, right) => left.timestamp - right.timestamp);
|
||||
|
||||
if (sortedSamples.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groups: HeartRateSample[][] = [];
|
||||
let currentGroup: HeartRateSample[] = [];
|
||||
|
||||
sortedSamples.forEach(sample => {
|
||||
const previous = currentGroup[currentGroup.length - 1];
|
||||
const shouldStartNewGroup =
|
||||
previous && sample.timestamp - previous.timestamp > gapMs;
|
||||
|
||||
if (shouldStartNewGroup) {
|
||||
groups.push(currentGroup);
|
||||
currentGroup = [];
|
||||
}
|
||||
|
||||
currentGroup.push(sample);
|
||||
});
|
||||
|
||||
if (currentGroup.length > 0) {
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
|
||||
return groups
|
||||
.map(toUsageBlock)
|
||||
.sort((left, right) => right.startedAt - left.startedAt);
|
||||
}
|
||||
|
||||
function toUsageBlock(
|
||||
samples: readonly HeartRateSample[],
|
||||
): ContinuousUsageBlock {
|
||||
const startedAt = samples[0].timestamp;
|
||||
const endedAt = samples[samples.length - 1].timestamp;
|
||||
const bpms = samples.map(sample => sample.bpm);
|
||||
const averageBpm = Math.round(
|
||||
bpms.reduce((sum, bpm) => sum + bpm, 0) / samples.length,
|
||||
);
|
||||
|
||||
return {
|
||||
id: `usage-${startedAt}`,
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: Math.max(0, endedAt - startedAt),
|
||||
sampleCount: samples.length,
|
||||
averageBpm,
|
||||
peakBpm: Math.max(...bpms),
|
||||
minBpm: Math.min(...bpms),
|
||||
deviceName: mostRecentDeviceName(samples),
|
||||
samples,
|
||||
};
|
||||
}
|
||||
|
||||
function mostRecentDeviceName(samples: readonly HeartRateSample[]) {
|
||||
for (let index = samples.length - 1; index >= 0; index -= 1) {
|
||||
const deviceName = samples[index].deviceName;
|
||||
if (deviceName) {
|
||||
return deviceName;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
import {
|
||||
type BpmVariabilitySummary,
|
||||
type CompletedTrainingSession,
|
||||
type HeartRateDriftSummary,
|
||||
type HeartRateSample,
|
||||
type InsightDashboard,
|
||||
type InsightMetricRow,
|
||||
type IntensityZone,
|
||||
type PostSessionRecoverySummary,
|
||||
type RrAvailabilitySummary,
|
||||
type TrainingInsight,
|
||||
} from './trainingTypes';
|
||||
|
||||
const MIN_VALID_BPM = 30;
|
||||
const MAX_VALID_BPM = 240;
|
||||
const DEFAULT_STALE_AFTER_MS = 12_000;
|
||||
|
||||
type LiveMetricOptions = {
|
||||
readonly now?: number;
|
||||
readonly staleAfterMs?: number;
|
||||
};
|
||||
|
||||
export function isReliableHeartRateSample(sample: HeartRateSample): boolean {
|
||||
return (
|
||||
Number.isFinite(sample.bpm) &&
|
||||
sample.bpm >= MIN_VALID_BPM &&
|
||||
sample.bpm <= MAX_VALID_BPM &&
|
||||
sample.contactDetected !== false
|
||||
);
|
||||
}
|
||||
|
||||
export function filterReliableHeartRateSamples(
|
||||
samples: readonly HeartRateSample[],
|
||||
): HeartRateSample[] {
|
||||
return samples.filter(isReliableHeartRateSample);
|
||||
}
|
||||
|
||||
export function isFreshHeartRateSample(
|
||||
sample: HeartRateSample | null | undefined,
|
||||
now = Date.now(),
|
||||
staleAfterMs = DEFAULT_STALE_AFTER_MS,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
sample &&
|
||||
isReliableHeartRateSample(sample) &&
|
||||
now - sample.timestamp <= staleAfterMs,
|
||||
);
|
||||
}
|
||||
|
||||
export function summarizeCompletedSession(
|
||||
samples: readonly HeartRateSample[],
|
||||
startedAt: number,
|
||||
endedAt: number,
|
||||
): CompletedTrainingSession | null {
|
||||
const reliableSamples = filterReliableHeartRateSamples(samples);
|
||||
if (reliableSamples.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bpms = reliableSamples.map(sample => sample.bpm);
|
||||
const total = bpms.reduce((sum, bpm) => sum + bpm, 0);
|
||||
|
||||
return {
|
||||
id: `session-${startedAt}-${endedAt}`,
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: Math.max(0, endedAt - startedAt),
|
||||
sampleCount: reliableSamples.length,
|
||||
averageBpm: Math.round(total / reliableSamples.length),
|
||||
peakBpm: Math.max(...bpms),
|
||||
minBpm: Math.min(...bpms),
|
||||
deviceName: reliableSamples[reliableSamples.length - 1]?.deviceName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateLiveMetrics(
|
||||
samples: readonly HeartRateSample[],
|
||||
options: LiveMetricOptions = {},
|
||||
) {
|
||||
const reliableSamples = filterReliableHeartRateSamples(samples);
|
||||
if (reliableSamples.length === 0) {
|
||||
return {
|
||||
currentBpm: null,
|
||||
averageBpm: null,
|
||||
peakBpm: null,
|
||||
minBpm: null,
|
||||
sampleRatePerMinute: null,
|
||||
zones: emptyZones(),
|
||||
loadScore: 0,
|
||||
highIntensityPercent: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const latestSample = reliableSamples[reliableSamples.length - 1];
|
||||
const isCurrentFresh =
|
||||
options.now === undefined
|
||||
? true
|
||||
: isFreshHeartRateSample(
|
||||
latestSample,
|
||||
options.now,
|
||||
options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS,
|
||||
);
|
||||
const bpms = reliableSamples.map(sample => sample.bpm);
|
||||
const total = bpms.reduce((sum, bpm) => sum + bpm, 0);
|
||||
const zones = calculateZones(reliableSamples);
|
||||
const durationMinutes = Math.max(
|
||||
1 / 60,
|
||||
(reliableSamples[reliableSamples.length - 1].timestamp -
|
||||
reliableSamples[0].timestamp) /
|
||||
60000,
|
||||
);
|
||||
const highIntensityPercent = zones
|
||||
.filter(zone => zone.key !== 'base')
|
||||
.reduce((sum, zone) => sum + zone.percent, 0);
|
||||
|
||||
return {
|
||||
currentBpm: isCurrentFresh ? latestSample.bpm : null,
|
||||
averageBpm: Math.round(total / reliableSamples.length),
|
||||
peakBpm: Math.max(...bpms),
|
||||
minBpm: Math.min(...bpms),
|
||||
sampleRatePerMinute: Math.round(reliableSamples.length / durationMinutes),
|
||||
zones,
|
||||
loadScore: calculateLoadScore(reliableSamples),
|
||||
highIntensityPercent,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateBpmVariability(
|
||||
samples: readonly HeartRateSample[],
|
||||
): BpmVariabilitySummary {
|
||||
const reliableSamples = filterReliableHeartRateSamples(samples);
|
||||
if (reliableSamples.length === 0) {
|
||||
return {
|
||||
status: 'no-data',
|
||||
averageBpm: null,
|
||||
minBpm: null,
|
||||
maxBpm: null,
|
||||
rangeBpm: 0,
|
||||
standardDeviationBpm: 0,
|
||||
coefficientOfVariation: 0,
|
||||
classification: 'sem-dados',
|
||||
};
|
||||
}
|
||||
|
||||
const bpms = reliableSamples.map(sample => sample.bpm);
|
||||
const rawAverage = bpms.reduce((sum, bpm) => sum + bpm, 0) / bpms.length;
|
||||
const variance =
|
||||
bpms.reduce((sum, bpm) => sum + Math.pow(bpm - rawAverage, 2), 0) /
|
||||
bpms.length;
|
||||
const standardDeviation = Math.sqrt(variance);
|
||||
const coefficientOfVariation =
|
||||
rawAverage > 0 ? standardDeviation / rawAverage : 0;
|
||||
const minBpm = Math.min(...bpms);
|
||||
const maxBpm = Math.max(...bpms);
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
averageBpm: Math.round(rawAverage),
|
||||
minBpm,
|
||||
maxBpm,
|
||||
rangeBpm: maxBpm - minBpm,
|
||||
standardDeviationBpm: roundTo(standardDeviation, 1),
|
||||
coefficientOfVariation: roundTo(coefficientOfVariation, 3),
|
||||
classification: classifyBpmVariation(coefficientOfVariation),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateHeartRateDrift(
|
||||
samples: readonly HeartRateSample[],
|
||||
): HeartRateDriftSummary {
|
||||
const reliableSamples = filterReliableHeartRateSamples(samples);
|
||||
if (reliableSamples.length < 3) {
|
||||
return emptyDrift('short-window');
|
||||
}
|
||||
|
||||
const sortedSamples = sortSamples(reliableSamples);
|
||||
const durationMs =
|
||||
sortedSamples[sortedSamples.length - 1].timestamp -
|
||||
sortedSamples[0].timestamp;
|
||||
|
||||
if (durationMs < 10 * 60_000) {
|
||||
return emptyDrift('short-window');
|
||||
}
|
||||
|
||||
const windowSize = Math.max(2, Math.round(sortedSamples.length * 0.2));
|
||||
const initialAverageBpm = averageRaw(sortedSamples.slice(0, windowSize));
|
||||
const finalAverageBpm = averageRaw(sortedSamples.slice(-windowSize));
|
||||
const driftBpm = finalAverageBpm - initialAverageBpm;
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
driftBpm: Math.round(driftBpm),
|
||||
driftPercent:
|
||||
initialAverageBpm > 0
|
||||
? roundTo((driftBpm / initialAverageBpm) * 100, 1)
|
||||
: 0,
|
||||
initialAverageBpm: Math.round(initialAverageBpm),
|
||||
finalAverageBpm: Math.round(finalAverageBpm),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculatePostSessionRecovery(
|
||||
session: CompletedTrainingSession | null,
|
||||
continuousSamples: readonly HeartRateSample[],
|
||||
): PostSessionRecoverySummary {
|
||||
if (!session) {
|
||||
return emptyRecovery('no-data');
|
||||
}
|
||||
|
||||
const postSessionSamples = sortSamples(
|
||||
filterReliableHeartRateSamples(continuousSamples).filter(
|
||||
sample => sample.timestamp >= session.endedAt,
|
||||
),
|
||||
);
|
||||
|
||||
if (postSessionSamples.length === 0) {
|
||||
return emptyRecovery('no-data');
|
||||
}
|
||||
|
||||
const oneMinuteSample = nearestSample(
|
||||
postSessionSamples,
|
||||
session.endedAt + 60_000,
|
||||
20_000,
|
||||
);
|
||||
const twoMinuteSample = nearestSample(
|
||||
postSessionSamples,
|
||||
session.endedAt + 120_000,
|
||||
25_000,
|
||||
);
|
||||
|
||||
if (!oneMinuteSample && !twoMinuteSample) {
|
||||
return emptyRecovery('short-window');
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
oneMinuteDropBpm: oneMinuteSample
|
||||
? Math.max(0, session.peakBpm - oneMinuteSample.bpm)
|
||||
: null,
|
||||
twoMinuteDropBpm: twoMinuteSample
|
||||
? Math.max(0, session.peakBpm - twoMinuteSample.bpm)
|
||||
: null,
|
||||
oneMinuteBpm: oneMinuteSample?.bpm ?? null,
|
||||
twoMinuteBpm: twoMinuteSample?.bpm ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeRrAvailability(
|
||||
samples: readonly HeartRateSample[],
|
||||
): RrAvailabilitySummary {
|
||||
const samplesWithRr = filterReliableHeartRateSamples(samples).filter(
|
||||
sample => sample.rrIntervalsMs.length > 0,
|
||||
);
|
||||
const intervalCount = samplesWithRr.reduce(
|
||||
(sum, sample) => sum + sample.rrIntervalsMs.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
status: intervalCount > 0 ? 'ok' : 'needs-rr',
|
||||
intervalCount,
|
||||
sampleCount: samplesWithRr.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildInsightDashboard({
|
||||
liveSamples,
|
||||
continuousSamples,
|
||||
sessions,
|
||||
}: {
|
||||
readonly liveSamples: readonly HeartRateSample[];
|
||||
readonly continuousSamples?: readonly HeartRateSample[];
|
||||
readonly sessions: readonly CompletedTrainingSession[];
|
||||
}): InsightDashboard {
|
||||
const samples = sortSamples(
|
||||
filterReliableHeartRateSamples(
|
||||
continuousSamples && continuousSamples.length > 0
|
||||
? continuousSamples
|
||||
: liveSamples,
|
||||
),
|
||||
);
|
||||
const latestSession = sessions[0] ?? null;
|
||||
const metrics = calculateLiveMetrics(samples);
|
||||
const variability = calculateBpmVariability(samples);
|
||||
const drift = calculateHeartRateDrift(samples);
|
||||
const recovery = calculatePostSessionRecovery(latestSession, samples);
|
||||
const rrAvailability = summarizeRrAvailability(samples);
|
||||
const startedAt = samples[0]?.timestamp ?? null;
|
||||
const endedAt = samples[samples.length - 1]?.timestamp ?? null;
|
||||
const durationMs =
|
||||
startedAt !== null && endedAt !== null ? endedAt - startedAt : 0;
|
||||
|
||||
const rows: InsightMetricRow[] = [
|
||||
{
|
||||
key: 'current-bpm',
|
||||
label: 'FC atual',
|
||||
value: formatMetricValue(metrics.currentBpm),
|
||||
unit: 'bpm',
|
||||
status: metrics.currentBpm === null ? 'no-data' : 'ok',
|
||||
statusLabel: metrics.currentBpm === null ? 'sem dados' : 'ao vivo',
|
||||
summary: 'Última amostra real recebida do sensor.',
|
||||
detail: `${samples.length} amostras no fluxo analisado.`,
|
||||
tone:
|
||||
metrics.currentBpm !== null && metrics.currentBpm >= 170
|
||||
? 'danger'
|
||||
: 'neutral',
|
||||
},
|
||||
{
|
||||
key: 'average-bpm',
|
||||
label: 'FC média',
|
||||
value: formatMetricValue(metrics.averageBpm),
|
||||
unit: 'bpm',
|
||||
status: metrics.averageBpm === null ? 'no-data' : 'ok',
|
||||
statusLabel: 'BPM-only',
|
||||
summary: 'Média aritmética das amostras reais.',
|
||||
detail: `Duração observada ${formatDuration(durationMs)} · ${
|
||||
samples.length
|
||||
} amostras.`,
|
||||
tone: 'neutral',
|
||||
},
|
||||
{
|
||||
key: 'bpm-range',
|
||||
label: 'Amplitude',
|
||||
value: variability.status === 'ok' ? String(variability.rangeBpm) : '--',
|
||||
unit: 'bpm',
|
||||
status: variability.status,
|
||||
statusLabel: variability.status === 'ok' ? 'min↔máx' : 'sem dados',
|
||||
summary:
|
||||
variability.status === 'ok'
|
||||
? `${variability.minBpm}–${variability.maxBpm} bpm registrados.`
|
||||
: 'Aguardando amostras reais.',
|
||||
detail: 'Mostra o espaço entre mínima e pico, sem estimar FCmáx.',
|
||||
tone: variability.rangeBpm >= 45 ? 'warning' : 'neutral',
|
||||
},
|
||||
{
|
||||
key: 'bpm-variability',
|
||||
label: 'Oscilação BPM',
|
||||
value:
|
||||
variability.status === 'ok'
|
||||
? `${variability.standardDeviationBpm}`
|
||||
: '--',
|
||||
unit: 'DP',
|
||||
status: variability.status,
|
||||
statusLabel: variability.classification,
|
||||
summary:
|
||||
variability.status === 'ok'
|
||||
? `CV ${(variability.coefficientOfVariation * 100).toFixed(1)}% · ${
|
||||
variability.classification
|
||||
}.`
|
||||
: 'Sem base para variação.',
|
||||
detail: 'Não é VFC/HRV; mede dispersão de BPM agregado.',
|
||||
tone: variability.classification === 'oscilante' ? 'warning' : 'good',
|
||||
},
|
||||
{
|
||||
key: 'zones',
|
||||
label: 'Alta intensidade',
|
||||
value: `${metrics.highIntensityPercent}%`,
|
||||
unit: 'Z2+Z3',
|
||||
status: samples.length < 2 ? 'no-data' : 'ok',
|
||||
statusLabel: 'zonas fixas',
|
||||
summary: 'Percentual em pressão/redline pela zona operacional atual.',
|
||||
detail: metrics.zones
|
||||
.map(zone => `${zone.label}: ${zone.percent}%`)
|
||||
.join(' · '),
|
||||
tone: metrics.highIntensityPercent >= 50 ? 'danger' : 'neutral',
|
||||
},
|
||||
{
|
||||
key: 'load',
|
||||
label: 'Carga observada',
|
||||
value: metrics.loadScore > 0 ? String(metrics.loadScore) : '--',
|
||||
unit: 'u.a.',
|
||||
status: metrics.loadScore > 0 ? 'ok' : 'no-data',
|
||||
statusLabel: 'BPM+duração',
|
||||
summary: 'Carga interna aproximada com BPM real e tempo em amostra.',
|
||||
detail: 'TRIMP científico completo requer FCmáx e FCrepouso do perfil.',
|
||||
tone: metrics.loadScore >= 80 ? 'warning' : 'neutral',
|
||||
},
|
||||
{
|
||||
key: 'drift',
|
||||
label: 'Tendência FC',
|
||||
value: drift.status === 'ok' ? signedNumber(drift.driftBpm) : '--',
|
||||
unit: 'bpm',
|
||||
status: drift.status,
|
||||
statusLabel: drift.status === 'ok' ? 'deriva' : 'janela curta',
|
||||
summary:
|
||||
drift.status === 'ok'
|
||||
? `${signedNumber(drift.driftPercent)}% do início ao fim.`
|
||||
: 'Requer pelo menos 10 min de amostras contínuas.',
|
||||
detail:
|
||||
drift.status === 'ok'
|
||||
? `Início ${drift.initialAverageBpm} bpm · fim ${drift.finalAverageBpm} bpm.`
|
||||
: 'Evita chamar oscilação curta de deriva cardiovascular.',
|
||||
tone:
|
||||
drift.status === 'ok' && (drift.driftBpm ?? 0) > 8
|
||||
? 'warning'
|
||||
: 'neutral',
|
||||
},
|
||||
{
|
||||
key: 'recovery',
|
||||
label: 'Recuperação',
|
||||
value:
|
||||
recovery.oneMinuteDropBpm !== null
|
||||
? String(recovery.oneMinuteDropBpm)
|
||||
: '--',
|
||||
unit: 'bpm/1min',
|
||||
status: recovery.status,
|
||||
statusLabel: recovery.status === 'ok' ? 'pós-janela' : 'aguardando',
|
||||
summary:
|
||||
recovery.status === 'ok'
|
||||
? `Queda em 2min: ${recovery.twoMinuteDropBpm ?? '--'} bpm.`
|
||||
: 'Feche uma janela e mantenha o monitor ligado por 1–2 min.',
|
||||
detail: 'Leitura operacional baseada no pico da última janela salva.',
|
||||
tone:
|
||||
recovery.oneMinuteDropBpm !== null && recovery.oneMinuteDropBpm >= 18
|
||||
? 'good'
|
||||
: 'neutral',
|
||||
},
|
||||
{
|
||||
key: 'profile',
|
||||
label: 'FCmáx / HRR',
|
||||
value: 'perfil',
|
||||
unit: undefined,
|
||||
status: 'needs-profile',
|
||||
statusLabel: 'necessário',
|
||||
summary: 'Zonas por %FCmáx, Karvonen e TRIMP completo exigem perfil.',
|
||||
detail: 'Sem perfil, o app não inventa FCmáx ou FCrepouso.',
|
||||
tone: 'warning',
|
||||
},
|
||||
{
|
||||
key: 'hrv',
|
||||
label: 'VFC / HRV',
|
||||
value:
|
||||
rrAvailability.intervalCount > 0
|
||||
? String(rrAvailability.intervalCount)
|
||||
: 'RR',
|
||||
unit: rrAvailability.intervalCount > 0 ? 'intervalos' : undefined,
|
||||
status: rrAvailability.status,
|
||||
statusLabel: rrAvailability.status === 'ok' ? 'RR detectado' : 'sem RR',
|
||||
summary:
|
||||
rrAvailability.status === 'ok'
|
||||
? `${rrAvailability.intervalCount} intervalos RR em ${rrAvailability.sampleCount} amostras.`
|
||||
: 'RMSSD/SDNN/pNN50 só aparecem com RR intervals reais.',
|
||||
detail: 'VFC não é derivada de média de BPM.',
|
||||
tone: rrAvailability.status === 'ok' ? 'good' : 'warning',
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
sampleCount: samples.length,
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs,
|
||||
rows,
|
||||
zones: metrics.zones,
|
||||
narrative: buildTrainingInsights(samples, sessions),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateZones(
|
||||
samples: readonly HeartRateSample[],
|
||||
): readonly IntensityZone[] {
|
||||
const reliableSamples = filterReliableHeartRateSamples(samples);
|
||||
if (reliableSamples.length < 2) {
|
||||
return emptyZones();
|
||||
}
|
||||
|
||||
const secondsByZone: Record<IntensityZone['key'], number> = {
|
||||
base: 0,
|
||||
pressure: 0,
|
||||
redline: 0,
|
||||
};
|
||||
|
||||
for (let index = 1; index < reliableSamples.length; index += 1) {
|
||||
const previous = reliableSamples[index - 1];
|
||||
const current = reliableSamples[index];
|
||||
const seconds = Math.min(
|
||||
10,
|
||||
Math.max(0, (current.timestamp - previous.timestamp) / 1000),
|
||||
);
|
||||
secondsByZone[toZoneKey(current.bpm)] += seconds;
|
||||
}
|
||||
|
||||
const totalSeconds = Object.values(secondsByZone).reduce(
|
||||
(sum, seconds) => sum + seconds,
|
||||
0,
|
||||
);
|
||||
|
||||
return zoneDefinitions.map(zone => ({
|
||||
...zone,
|
||||
seconds: Math.round(secondsByZone[zone.key]),
|
||||
percent: totalSeconds
|
||||
? Math.round((secondsByZone[zone.key] / totalSeconds) * 100)
|
||||
: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildTrainingInsights(
|
||||
samples: readonly HeartRateSample[],
|
||||
sessions: readonly CompletedTrainingSession[],
|
||||
): readonly TrainingInsight[] {
|
||||
const reliableSamples = filterReliableHeartRateSamples(samples);
|
||||
const insights: TrainingInsight[] = [];
|
||||
const metrics = calculateLiveMetrics(reliableSamples);
|
||||
|
||||
if (
|
||||
reliableSamples.length >= 6 &&
|
||||
metrics.averageBpm !== null &&
|
||||
metrics.peakBpm !== null
|
||||
) {
|
||||
const firstWindow = average(
|
||||
reliableSamples.slice(0, Math.min(8, reliableSamples.length)),
|
||||
);
|
||||
const lastWindow = average(
|
||||
reliableSamples.slice(-Math.min(8, reliableSamples.length)),
|
||||
);
|
||||
const drift = Math.round(lastWindow - firstWindow);
|
||||
const peakGap = metrics.peakBpm - metrics.averageBpm;
|
||||
|
||||
insights.push({
|
||||
title:
|
||||
drift > 8
|
||||
? 'Carga subindo rápido'
|
||||
: drift < -8
|
||||
? 'Recuperação aparecendo'
|
||||
: 'Ritmo estável',
|
||||
why:
|
||||
drift > 8
|
||||
? `A média recente está ${drift} BPM acima do início desta captura.`
|
||||
: drift < -8
|
||||
? `A média recente caiu ${Math.abs(drift)} BPM frente ao início.`
|
||||
: 'A janela recente está próxima da primeira janela de leitura.',
|
||||
action:
|
||||
drift > 8
|
||||
? 'Use como alerta de controle: se a técnica cair, reduza o ritmo ou aumente a pausa.'
|
||||
: drift < -8
|
||||
? 'Bom sinal para blocos intervalados: registre quanto tempo levou para baixar.'
|
||||
: 'Mantenha o bloco atual e compare com a próxima sessão salva.',
|
||||
confidence: `${reliableSamples.length} amostras reais · taxa ${
|
||||
metrics.sampleRatePerMinute ?? '--'
|
||||
}/min`,
|
||||
});
|
||||
|
||||
insights.push({
|
||||
title:
|
||||
peakGap >= 28
|
||||
? 'Picos explosivos detectados'
|
||||
: 'Picos dentro da média do bloco',
|
||||
why:
|
||||
peakGap >= 28
|
||||
? `O pico ficou ${peakGap} BPM acima da média, típico de troca forte ou scramble.`
|
||||
: `Pico e média ficaram próximos (${peakGap} BPM de diferença).`,
|
||||
action:
|
||||
peakGap >= 28
|
||||
? 'Marque mentalmente o round/posição que gerou o pico e compare no histórico depois.'
|
||||
: 'Se o treino era explosivo, talvez falte intensidade ou o sensor esteja mal posicionado.',
|
||||
confidence:
|
||||
'Baseado somente na captura atual, sem estimar FC máxima pessoal.',
|
||||
});
|
||||
}
|
||||
|
||||
if (sessions.length >= 2) {
|
||||
const [latest, previous] = sessions;
|
||||
const averageDelta = latest.averageBpm - previous.averageBpm;
|
||||
insights.push({
|
||||
title:
|
||||
averageDelta >= 6
|
||||
? 'Sessão mais pesada que a anterior'
|
||||
: averageDelta <= -6
|
||||
? 'Sessão menos exigente'
|
||||
: 'Sessões parecidas',
|
||||
why: `Última média ${latest.averageBpm} BPM contra ${previous.averageBpm} BPM da anterior.`,
|
||||
action:
|
||||
'Use a comparação junto do RPE: frequência parecida com sensação maior pode indicar fadiga.',
|
||||
confidence: `${
|
||||
latest.sampleCount + previous.sampleCount
|
||||
} amostras reais em 2 sessões salvas.`,
|
||||
});
|
||||
}
|
||||
|
||||
return insights;
|
||||
}
|
||||
|
||||
export function formatDuration(durationMs: number) {
|
||||
const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function roundTo(value: number, precision: number) {
|
||||
const factor = Math.pow(10, precision);
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function averageRaw(samples: readonly HeartRateSample[]) {
|
||||
if (samples.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return samples.reduce((sum, sample) => sum + sample.bpm, 0) / samples.length;
|
||||
}
|
||||
|
||||
function classifyBpmVariation(
|
||||
coefficientOfVariation: number,
|
||||
): BpmVariabilitySummary['classification'] {
|
||||
if (coefficientOfVariation <= 0) {
|
||||
return 'sem-dados';
|
||||
}
|
||||
if (coefficientOfVariation < 0.06) {
|
||||
return 'estável';
|
||||
}
|
||||
if (coefficientOfVariation <= 0.12) {
|
||||
return 'progressivo';
|
||||
}
|
||||
return 'oscilante';
|
||||
}
|
||||
|
||||
function sortSamples(samples: readonly HeartRateSample[]) {
|
||||
return [...samples].sort((left, right) => left.timestamp - right.timestamp);
|
||||
}
|
||||
|
||||
function nearestSample(
|
||||
samples: readonly HeartRateSample[],
|
||||
targetTimestamp: number,
|
||||
toleranceMs: number,
|
||||
) {
|
||||
return samples.reduce<HeartRateSample | null>((nearest, sample) => {
|
||||
const distance = Math.abs(sample.timestamp - targetTimestamp);
|
||||
if (distance > toleranceMs) {
|
||||
return nearest;
|
||||
}
|
||||
if (!nearest) {
|
||||
return sample;
|
||||
}
|
||||
const nearestDistance = Math.abs(nearest.timestamp - targetTimestamp);
|
||||
return distance < nearestDistance ? sample : nearest;
|
||||
}, null);
|
||||
}
|
||||
|
||||
function emptyDrift(
|
||||
status: HeartRateDriftSummary['status'],
|
||||
): HeartRateDriftSummary {
|
||||
return {
|
||||
status,
|
||||
driftBpm: null,
|
||||
driftPercent: null,
|
||||
initialAverageBpm: null,
|
||||
finalAverageBpm: null,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyRecovery(
|
||||
status: PostSessionRecoverySummary['status'],
|
||||
): PostSessionRecoverySummary {
|
||||
return {
|
||||
status,
|
||||
oneMinuteDropBpm: null,
|
||||
twoMinuteDropBpm: null,
|
||||
oneMinuteBpm: null,
|
||||
twoMinuteBpm: null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatMetricValue(value: number | null) {
|
||||
return value === null ? '--' : String(value);
|
||||
}
|
||||
|
||||
function signedNumber(value: number | null) {
|
||||
if (value === null) {
|
||||
return '--';
|
||||
}
|
||||
return value > 0 ? `+${value}` : String(value);
|
||||
}
|
||||
|
||||
function calculateLoadScore(samples: readonly HeartRateSample[]) {
|
||||
if (samples.length < 2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let load = 0;
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
const previous = samples[index - 1];
|
||||
const current = samples[index];
|
||||
const minutes = Math.min(
|
||||
10 / 60,
|
||||
Math.max(0, (current.timestamp - previous.timestamp) / 60000),
|
||||
);
|
||||
const intensity = current.bpm >= 170 ? 3 : current.bpm >= 150 ? 2 : 1;
|
||||
load += minutes * intensity * Math.max(0, current.bpm - 80);
|
||||
}
|
||||
|
||||
return Math.round(load / 10);
|
||||
}
|
||||
|
||||
function average(samples: readonly HeartRateSample[]) {
|
||||
if (samples.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round(
|
||||
samples.reduce((sum, sample) => sum + sample.bpm, 0) / samples.length,
|
||||
);
|
||||
}
|
||||
|
||||
function toZoneKey(bpm: number): IntensityZone['key'] {
|
||||
if (bpm >= 170) {
|
||||
return 'redline';
|
||||
}
|
||||
if (bpm >= 150) {
|
||||
return 'pressure';
|
||||
}
|
||||
return 'base';
|
||||
}
|
||||
|
||||
function emptyZones(): readonly IntensityZone[] {
|
||||
return zoneDefinitions.map(zone => ({ ...zone, seconds: 0, percent: 0 }));
|
||||
}
|
||||
|
||||
const zoneDefinitions = [
|
||||
{ key: 'base', label: 'BASE <150' },
|
||||
{ key: 'pressure', label: 'PRESSÃO 150–169' },
|
||||
{ key: 'redline', label: 'REDLINE 170+' },
|
||||
] as const satisfies readonly Omit<IntensityZone, 'seconds' | 'percent'>[];
|
||||
@@ -0,0 +1,105 @@
|
||||
export type HeartRateSample = {
|
||||
readonly id?: number;
|
||||
readonly sessionId?: number;
|
||||
readonly bpm: number;
|
||||
readonly timestamp: number;
|
||||
readonly contactDetected: boolean | null;
|
||||
readonly rrIntervalsMs: readonly number[];
|
||||
readonly deviceName?: string | null;
|
||||
};
|
||||
|
||||
export type CompletedTrainingSession = {
|
||||
readonly id: string;
|
||||
readonly startedAt: number;
|
||||
readonly endedAt: number;
|
||||
readonly durationMs: number;
|
||||
readonly sampleCount: number;
|
||||
readonly averageBpm: number;
|
||||
readonly peakBpm: number;
|
||||
readonly minBpm: number;
|
||||
readonly deviceName?: string | null;
|
||||
};
|
||||
|
||||
export type IntensityZoneKey = 'base' | 'pressure' | 'redline';
|
||||
|
||||
export type IntensityZone = {
|
||||
readonly key: IntensityZoneKey;
|
||||
readonly label: string;
|
||||
readonly seconds: number;
|
||||
readonly percent: number;
|
||||
};
|
||||
|
||||
export type TrainingInsight = {
|
||||
readonly title: string;
|
||||
readonly why: string;
|
||||
readonly action: string;
|
||||
readonly confidence: string;
|
||||
};
|
||||
|
||||
export type InsightMetricStatus =
|
||||
| 'ok'
|
||||
| 'needs-profile'
|
||||
| 'needs-rr'
|
||||
| 'short-window'
|
||||
| 'no-data';
|
||||
|
||||
export type InsightMetricTone = 'neutral' | 'good' | 'warning' | 'danger';
|
||||
|
||||
export type InsightMetricRow = {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
readonly unit?: string;
|
||||
readonly status: InsightMetricStatus;
|
||||
readonly statusLabel: string;
|
||||
readonly summary: string;
|
||||
readonly detail: string;
|
||||
readonly tone: InsightMetricTone;
|
||||
};
|
||||
|
||||
export type InsightDashboard = {
|
||||
readonly sampleCount: number;
|
||||
readonly startedAt: number | null;
|
||||
readonly endedAt: number | null;
|
||||
readonly durationMs: number;
|
||||
readonly rows: readonly InsightMetricRow[];
|
||||
readonly zones: readonly IntensityZone[];
|
||||
readonly narrative: readonly TrainingInsight[];
|
||||
};
|
||||
|
||||
export type BpmVariabilitySummary = {
|
||||
readonly status: InsightMetricStatus;
|
||||
readonly averageBpm: number | null;
|
||||
readonly minBpm: number | null;
|
||||
readonly maxBpm: number | null;
|
||||
readonly rangeBpm: number;
|
||||
readonly standardDeviationBpm: number;
|
||||
readonly coefficientOfVariation: number;
|
||||
readonly classification:
|
||||
| 'estável'
|
||||
| 'progressivo'
|
||||
| 'oscilante'
|
||||
| 'sem-dados';
|
||||
};
|
||||
|
||||
export type HeartRateDriftSummary = {
|
||||
readonly status: InsightMetricStatus;
|
||||
readonly driftBpm: number | null;
|
||||
readonly driftPercent: number | null;
|
||||
readonly initialAverageBpm: number | null;
|
||||
readonly finalAverageBpm: number | null;
|
||||
};
|
||||
|
||||
export type PostSessionRecoverySummary = {
|
||||
readonly status: InsightMetricStatus;
|
||||
readonly oneMinuteDropBpm: number | null;
|
||||
readonly twoMinuteDropBpm: number | null;
|
||||
readonly oneMinuteBpm: number | null;
|
||||
readonly twoMinuteBpm: number | null;
|
||||
};
|
||||
|
||||
export type RrAvailabilitySummary = {
|
||||
readonly status: InsightMetricStatus;
|
||||
readonly intervalCount: number;
|
||||
readonly sampleCount: number;
|
||||
};
|
||||
Reference in new issue
Block a user