feat(app): FightRateApp tab fade + brand LiveDot + tab live indicator
This commit is contained in:
1 parent
55fb37a34a
commit
1cb658f899
1 file changed
+92
-94
+92
-94
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Image,
|
Image,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -16,12 +16,19 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react-native';
|
} from 'lucide-react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
import { useHeartRateMonitor } from '../ble/useHeartRateMonitor';
|
import { useHeartRateMonitor } from '../ble/useHeartRateMonitor';
|
||||||
|
import { LiveDot } from '../components/LiveDot';
|
||||||
import { CaptureScreen } from '../screens/CaptureScreen';
|
import { CaptureScreen } from '../screens/CaptureScreen';
|
||||||
import { HistoryScreen } from '../screens/HistoryScreen';
|
import { HistoryScreen } from '../screens/HistoryScreen';
|
||||||
import { InsightsScreen } from '../screens/InsightsScreen';
|
import { InsightsScreen } from '../screens/InsightsScreen';
|
||||||
import { SensorScreen } from '../screens/SensorScreen';
|
import { SensorScreen } from '../screens/SensorScreen';
|
||||||
import { colors, radius, spacing, typography } from '../theme';
|
import { colors, motion, radius, spacing, typography } from '../theme';
|
||||||
|
|
||||||
type TabKey = 'capture' | 'history' | 'sensor' | 'insights';
|
type TabKey = 'capture' | 'history' | 'sensor' | 'insights';
|
||||||
|
|
||||||
@@ -33,7 +40,7 @@ type Tab = {
|
|||||||
|
|
||||||
const tabs: readonly Tab[] = [
|
const tabs: readonly Tab[] = [
|
||||||
{ key: 'capture', label: 'Captura', icon: Radio },
|
{ key: 'capture', label: 'Captura', icon: Radio },
|
||||||
{ key: 'history', label: 'Histórico', icon: History },
|
{ key: 'history', label: 'Hist\órico', icon: History },
|
||||||
{ key: 'sensor', label: 'Sensor', icon: Bluetooth },
|
{ key: 'sensor', label: 'Sensor', icon: Bluetooth },
|
||||||
{ key: 'insights', label: 'Insights', icon: ChartNoAxesCombined },
|
{ key: 'insights', label: 'Insights', icon: ChartNoAxesCombined },
|
||||||
];
|
];
|
||||||
@@ -42,13 +49,23 @@ export function FightRateApp() {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const monitor = useHeartRateMonitor();
|
const monitor = useHeartRateMonitor();
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>('capture');
|
const [activeTab, setActiveTab] = useState<TabKey>('capture');
|
||||||
|
const opacity = useSharedValue(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
opacity.value = 0;
|
||||||
|
opacity.value = withTiming(1, {
|
||||||
|
duration: motion.duration.fast,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
});
|
||||||
|
}, [activeTab, opacity]);
|
||||||
|
|
||||||
|
const fadeStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
|
||||||
|
|
||||||
const onCaptureAction = useCallback(async () => {
|
const onCaptureAction = useCallback(async () => {
|
||||||
if (monitor.isCapturing) {
|
if (monitor.isCapturing) {
|
||||||
await monitor.stopCapture();
|
await monitor.stopCapture();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await monitor.startCapture();
|
await monitor.startCapture();
|
||||||
}, [monitor]);
|
}, [monitor]);
|
||||||
|
|
||||||
@@ -57,31 +74,22 @@ export function FightRateApp() {
|
|||||||
await monitor.stopMonitoring();
|
await monitor.stopMonitoring();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await monitor.startMonitoring();
|
await monitor.startMonitoring();
|
||||||
}, [monitor]);
|
}, [monitor]);
|
||||||
|
|
||||||
const activeTitle = useMemo(() => {
|
const activeTitle = useMemo(() => {
|
||||||
if (activeTab === 'capture') {
|
if (activeTab === 'capture') {
|
||||||
if (monitor.isCapturing) {
|
if (monitor.isCapturing) return 'Janela de treino ativa';
|
||||||
return 'Janela de treino ativa';
|
if (monitor.isMonitoring) return 'Monitor cont\ínuo';
|
||||||
}
|
|
||||||
if (monitor.isMonitoring) {
|
|
||||||
return 'Monitor contínuo';
|
|
||||||
}
|
|
||||||
return 'Ativar sensor';
|
return 'Ativar sensor';
|
||||||
}
|
}
|
||||||
|
if (activeTab === 'history') return 'Hist\órico real';
|
||||||
if (activeTab === 'history') {
|
if (activeTab === 'sensor') return 'Sensor BLE';
|
||||||
return 'Histórico real';
|
return 'An\álise real';
|
||||||
}
|
|
||||||
|
|
||||||
if (activeTab === 'sensor') {
|
|
||||||
return 'Sensor BLE';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Análise real';
|
|
||||||
}, [activeTab, monitor.isCapturing, monitor.isMonitoring]);
|
}, [activeTab, monitor.isCapturing, monitor.isMonitoring]);
|
||||||
|
|
||||||
|
const isAnyLive = monitor.isMonitoring || monitor.isCapturing;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.root}>
|
<View style={styles.root}>
|
||||||
<StatusBar barStyle="light-content" backgroundColor={colors.canvas} />
|
<StatusBar barStyle="light-content" backgroundColor={colors.canvas} />
|
||||||
@@ -101,43 +109,54 @@ export function FightRateApp() {
|
|||||||
style={styles.logo}
|
style={styles.logo}
|
||||||
/>
|
/>
|
||||||
<View style={styles.titleColumn}>
|
<View style={styles.titleColumn}>
|
||||||
<Text style={styles.kicker}>FightRate</Text>
|
<View style={styles.kickerRow}>
|
||||||
|
{isAnyLive ? <LiveDot live size={6} /> : null}
|
||||||
|
<Text style={styles.kicker}>FightRate</Text>
|
||||||
|
</View>
|
||||||
<Text style={styles.title}>{activeTitle}</Text>
|
<Text style={styles.title}>{activeTitle}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{activeTab === 'capture' ? (
|
<Animated.View style={fadeStyle} key={`tab-${activeTab}`}>
|
||||||
<CaptureScreen
|
{activeTab === 'capture' ? (
|
||||||
monitor={monitor}
|
<CaptureScreen
|
||||||
onCaptureAction={onCaptureAction}
|
monitor={monitor}
|
||||||
onMonitorAction={onMonitorAction}
|
onCaptureAction={onCaptureAction}
|
||||||
/>
|
onMonitorAction={onMonitorAction}
|
||||||
) : null}
|
/>
|
||||||
{activeTab === 'history' ? (
|
) : null}
|
||||||
<HistoryScreen
|
{activeTab === 'history' ? (
|
||||||
sessions={monitor.sessions}
|
<HistoryScreen
|
||||||
getSessionSamples={monitor.getSessionSamples}
|
sessions={monitor.sessions}
|
||||||
getContinuousSamples={monitor.getContinuousSamples}
|
getSessionSamples={monitor.getSessionSamples}
|
||||||
liveSamples={monitor.samples}
|
getContinuousSamples={monitor.getContinuousSamples}
|
||||||
/>
|
liveSamples={monitor.samples}
|
||||||
) : null}
|
/>
|
||||||
{activeTab === 'sensor' ? (
|
) : null}
|
||||||
<SensorScreen monitor={monitor} onMonitorAction={onMonitorAction} />
|
{activeTab === 'sensor' ? (
|
||||||
) : null}
|
<SensorScreen
|
||||||
{activeTab === 'insights' ? (
|
monitor={monitor}
|
||||||
<InsightsScreen
|
onMonitorAction={onMonitorAction}
|
||||||
liveSamples={monitor.samples}
|
/>
|
||||||
sessions={monitor.sessions}
|
) : null}
|
||||||
getContinuousSamples={monitor.getContinuousSamples}
|
{activeTab === 'insights' ? (
|
||||||
/>
|
<InsightsScreen
|
||||||
) : null}
|
liveSamples={monitor.samples}
|
||||||
|
sessions={monitor.sessions}
|
||||||
|
getContinuousSamples={monitor.getContinuousSamples}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Animated.View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<View style={[styles.navWrap, { bottom: insets.bottom + spacing.sm }]}>
|
<View style={[styles.navWrap, { bottom: insets.bottom + spacing.sm }]}>
|
||||||
<View style={styles.navRail}>
|
<View style={styles.navRail}>
|
||||||
{tabs.map(tab => {
|
{tabs.map((tab) => {
|
||||||
const selected = tab.key === activeTab;
|
const selected = tab.key === activeTab;
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
|
const isTabLive =
|
||||||
|
(tab.key === 'capture' && monitor.isCapturing) ||
|
||||||
|
(tab.key === 'sensor' && monitor.isMonitoring);
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="tab"
|
accessibilityRole="tab"
|
||||||
@@ -146,11 +165,18 @@ export function FightRateApp() {
|
|||||||
onPress={() => setActiveTab(tab.key)}
|
onPress={() => setActiveTab(tab.key)}
|
||||||
style={[styles.navItem, selected ? styles.navItemActive : null]}
|
style={[styles.navItem, selected ? styles.navItemActive : null]}
|
||||||
>
|
>
|
||||||
<Icon
|
<View style={styles.navIconRow}>
|
||||||
color={selected ? colors.text : colors.textMuted}
|
<Icon
|
||||||
size={18}
|
color={selected ? colors.text : colors.textMuted}
|
||||||
strokeWidth={2.4}
|
size={18}
|
||||||
/>
|
strokeWidth={2.4}
|
||||||
|
/>
|
||||||
|
{isTabLive ? (
|
||||||
|
<View style={styles.tabLiveDot}>
|
||||||
|
<LiveDot live size={4} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.navLabel,
|
styles.navLabel,
|
||||||
@@ -169,31 +195,13 @@ export function FightRateApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
root: {
|
root: { flex: 1, backgroundColor: colors.canvas },
|
||||||
flex: 1,
|
content: { paddingHorizontal: spacing.md, gap: spacing.lg },
|
||||||
backgroundColor: colors.canvas,
|
brandRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
|
||||||
},
|
logo: { width: 54, height: 54, borderRadius: radius.sm },
|
||||||
content: {
|
titleColumn: { flex: 1 },
|
||||||
paddingHorizontal: spacing.md,
|
kickerRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||||
gap: spacing.lg,
|
kicker: { ...typography.label, color: colors.action },
|
||||||
},
|
|
||||||
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: {
|
title: {
|
||||||
marginTop: spacing.xs,
|
marginTop: spacing.xs,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
@@ -202,11 +210,7 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '900',
|
fontWeight: '900',
|
||||||
letterSpacing: -2.1,
|
letterSpacing: -2.1,
|
||||||
},
|
},
|
||||||
navWrap: {
|
navWrap: { position: 'absolute', left: spacing.md, right: spacing.md },
|
||||||
position: 'absolute',
|
|
||||||
left: spacing.md,
|
|
||||||
right: spacing.md,
|
|
||||||
},
|
|
||||||
navRail: {
|
navRail: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.line,
|
borderColor: colors.line,
|
||||||
@@ -228,15 +232,9 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: spacing.xxs,
|
gap: spacing.xxs,
|
||||||
},
|
},
|
||||||
navItemActive: {
|
navItemActive: { backgroundColor: colors.action },
|
||||||
backgroundColor: colors.action,
|
navIconRow: { position: 'relative' },
|
||||||
},
|
tabLiveDot: { position: 'absolute', top: -2, right: -6 },
|
||||||
navLabel: {
|
navLabel: { ...typography.label, color: colors.textMuted, fontSize: 9 },
|
||||||
...typography.label,
|
navLabelActive: { color: colors.text },
|
||||||
color: colors.textMuted,
|
|
||||||
fontSize: 9,
|
|
||||||
},
|
|
||||||
navLabelActive: {
|
|
||||||
color: colors.text,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user