4452 lines
116 KiB
Markdown
4452 lines
116 KiB
Markdown
# FightRate RN — Refinamento UI/UX Instrumento Forte (Direção B) — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
>
|
||
> **Goal:** Implementar a Direção B (Instrumento Forte) do refinamento UI/UX do FightRate RN conforme spec `2026-06-15-fightrate-ui-ux-refinamento-instrumento-forte.md`. Adicionar JetBrains Mono, LED pulsante, números tabulares, furos no TrainingChart, anel de aviso no Stop, dial tiles no Sensor, stagger reveal, e microanimações orquestradas.
|
||
>
|
||
> **Architecture:** React Native bare, sem novas deps externas. Adiciona ~6 componentes, 2 hooks, 1 módulo de motion. Tokens adicionados ao `src/theme/`. Componentes existentes modificados sem breaking change.
|
||
>
|
||
> **Tech Stack:** React Native 0.86, TypeScript 5.8, Reanimated 4.4 + Worklets 0.9, Gesture Handler 3.0, react-native-svg 15.15, lucide-react-native, Jest 29.
|
||
>
|
||
> **Reference spec:** `docs/superpowers/specs/2026-06-15-fightrate-ui-ux-refinamento-instrumento-forte.md`
|
||
>
|
||
> **Conventions:** Every commit messages follow Conventional Commits. PT-BR visible UI; English code identifiers. Run `npm run typecheck && npm run lint && npm test -- --runInBand` before each commit.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### New files
|
||
- `assets/fonts/JetBrainsMono-{Regular,Medium,Bold,ExtraBold}.ttf` (already downloaded)
|
||
- `src/theme/motion.ts`
|
||
- `src/hooks/usePulseAnimation.ts`
|
||
- `src/hooks/useMotionPreference.ts`
|
||
- `src/components/LiveDot.tsx`
|
||
- `src/components/PressableSurface.tsx`
|
||
- `src/components/EmptyState.tsx`
|
||
- `src/components/DialTile.tsx`
|
||
- `src/components/TrackRow.tsx`
|
||
- `src/components/InsightRow.tsx`
|
||
- `src/__tests__/motion.test.ts`
|
||
- `src/__tests__/useMotionPreference.test.ts`
|
||
- `src/__tests__/usePulseAnimation.test.tsx`
|
||
- `src/__tests__/LiveDot.test.tsx`
|
||
- `src/__tests__/PressableSurface.test.tsx`
|
||
- `src/__tests__/EmptyState.test.tsx`
|
||
- `src/__tests__/DialTile.test.tsx`
|
||
- `src/__tests__/TrackRow.test.tsx`
|
||
- `src/__tests__/InsightRow.test.tsx`
|
||
|
||
### Modified files
|
||
- `react-native.config.js`
|
||
- `src/theme/index.ts`
|
||
- `src/theme/typography.ts`
|
||
- `src/theme/colors.ts`
|
||
- `src/components/ActionButton.tsx`
|
||
- `src/components/StatusTag.tsx`
|
||
- `src/components/SectionPanel.tsx`
|
||
- `src/components/MetricTile.tsx`
|
||
- `src/components/TrainingChart.tsx`
|
||
- `src/screens/CaptureScreen.tsx`
|
||
- `src/screens/HistoryScreen.tsx`
|
||
- `src/screens/SensorScreen.tsx`
|
||
- `src/screens/InsightsScreen.tsx`
|
||
- `src/app/FightRateApp.tsx`
|
||
- `FIGHTRATE_DESIGN_SYSTEM.md`
|
||
|
||
---
|
||
|
||
## F1 — Foundations
|
||
|
||
### Task F1.1: Wire JetBrains Mono via react-native.config.js
|
||
|
||
**Files:**
|
||
- Modify: `react-native.config.js` (root)
|
||
- Verify: `android/app/src/main/assets/fonts/JetBrainsMono-Bold.ttf` (after link)
|
||
|
||
- [ ] **Step 1: Inspect current config**
|
||
|
||
Run: `ls /d/h/react-native.config.js 2>/dev/null || echo "NOT FOUND"`
|
||
|
||
Expected: `NOT FOUND` (file doesn't exist yet — was deleted during RN rebuild)
|
||
|
||
- [ ] **Step 2: Create the config file**
|
||
|
||
Create `react-native.config.js` at repo root with this exact content:
|
||
|
||
```js
|
||
module.exports = {
|
||
project: {
|
||
android: {
|
||
packageName: 'com.healthtracking.fightrate',
|
||
sourceDir: './android',
|
||
},
|
||
},
|
||
assets: ['./assets/fonts'],
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 3: Link fonts into Android assets**
|
||
|
||
Run: `cd /d/h && npx --no-install react-native-asset 2>&1 | tail -10`
|
||
|
||
Expected: creates `android/app/src/main/assets/fonts/JetBrainsMono-*.ttf` (4 files). If `react-native-asset` is not installed, install it first: `npm install --save-dev react-native-asset`.
|
||
|
||
If `react-native-asset` is unavailable as CLI, copy the files directly:
|
||
```bash
|
||
mkdir -p /d/h/android/app/src/main/assets/fonts
|
||
cp /d/h/assets/fonts/JetBrainsMono-*.ttf /d/h/android/app/src/main/assets/fonts/
|
||
```
|
||
|
||
- [ ] **Step 4: Verify assets are linked**
|
||
|
||
Run: `ls /d/h/android/app/src/main/assets/fonts/`
|
||
|
||
Expected: 4 ttf files.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add react-native.config.js android/app/src/main/assets/fonts/ 2>/dev/null
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(assets): link JetBrains Mono via react-native.config.js"
|
||
```
|
||
|
||
---
|
||
|
||
### Task F1.2: theme/motion.ts (presets de timing e easing)
|
||
|
||
**Files:**
|
||
- Create: `src/theme/motion.ts`
|
||
- Modify: `src/theme/index.ts`
|
||
- Test: `src/__tests__/motion.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `src/__tests__/motion.test.ts`:
|
||
|
||
```ts
|
||
import { motion } from '../theme/motion';
|
||
|
||
describe('motion', () => {
|
||
it('exports bezier strings for easing presets', () => {
|
||
expect(motion.easeOutQuart).toBe('cubic-bezier(0.25, 1, 0.5, 1)');
|
||
expect(motion.easeOutExpo).toBe('cubic-bezier(0.16, 1, 0.3, 1)');
|
||
expect(motion.easeOutBack).toBe('cubic-bezier(0.34, 1.56, 0.64, 1)');
|
||
expect(motion.easeInOutQuart).toBe('cubic-bezier(0.76, 0, 0.24, 1)');
|
||
});
|
||
|
||
it('exports duration presets in ms', () => {
|
||
expect(motion.duration.instant).toBe(80);
|
||
expect(motion.duration.fast).toBe(160);
|
||
expect(motion.duration.normal).toBe(240);
|
||
expect(motion.duration.slow).toBe(380);
|
||
expect(motion.duration.pulse).toBe(800);
|
||
expect(motion.duration.pulseSlow).toBe(1600);
|
||
});
|
||
|
||
it('exports stagger offsets', () => {
|
||
expect(motion.stagger.row).toBe(50);
|
||
expect(motion.stagger.item).toBe(80);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd /d/h && npx jest src/__tests__/motion.test.ts 2>&1 | tail -10`
|
||
|
||
Expected: FAIL — cannot find `../theme/motion`.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
Create `src/theme/motion.ts`:
|
||
|
||
```ts
|
||
export const motion = {
|
||
easeOutQuart: 'cubic-bezier(0.25, 1, 0.5, 1)',
|
||
easeOutExpo: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||
easeOutBack: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||
easeInOutQuart: 'cubic-bezier(0.76, 0, 0.24, 1)',
|
||
|
||
duration: {
|
||
instant: 80,
|
||
fast: 160,
|
||
normal: 240,
|
||
slow: 380,
|
||
pulse: 800,
|
||
pulseSlow: 1600,
|
||
},
|
||
|
||
stagger: {
|
||
row: 50,
|
||
item: 80,
|
||
},
|
||
} as const;
|
||
|
||
export type MotionDuration = keyof typeof motion.duration;
|
||
export type MotionEasing = keyof Pick<
|
||
typeof motion,
|
||
'easeOutQuart' | 'easeOutExpo' | 'easeOutBack' | 'easeInOutQuart'
|
||
>;
|
||
```
|
||
|
||
- [ ] **Step 4: Re-export from theme index**
|
||
|
||
Edit `src/theme/index.ts`:
|
||
|
||
```ts
|
||
export { colors } from './colors';
|
||
export { radius } from './radius';
|
||
export { spacing } from './spacing';
|
||
export { typography } from './typography';
|
||
export { motion } from './motion';
|
||
```
|
||
|
||
- [ ] **Step 5: Run test to verify it passes**
|
||
|
||
Run: `cd /d/h && npx jest src/__tests__/motion.test.ts 2>&1 | tail -5`
|
||
|
||
Expected: PASS — 3 tests passed.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/theme/motion.ts src/theme/index.ts src/__tests__/motion.test.ts
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(theme): motion presets (timing, easing, stagger)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task F1.3: theme/typography.ts (add numeric tokens for JetBrains Mono)
|
||
|
||
**Files:**
|
||
- Modify: `src/theme/typography.ts`
|
||
- Verify: existing tests still pass
|
||
|
||
- [ ] **Step 1: Add numeric tokens**
|
||
|
||
Edit `src/theme/typography.ts` to add these keys (preserving existing `display`, `displaySmall`, `label`, `body`, `button`):
|
||
|
||
```ts
|
||
export const typography = {
|
||
display: {
|
||
fontSize: 82,
|
||
lineHeight: 74,
|
||
fontWeight: '900' as const,
|
||
letterSpacing: -7,
|
||
},
|
||
displaySmall: {
|
||
fontSize: 48,
|
||
lineHeight: 46,
|
||
fontWeight: '900' as const,
|
||
letterSpacing: -3,
|
||
},
|
||
displayNumeric: {
|
||
fontFamily: 'JetBrainsMono-ExtraBold',
|
||
fontVariant: ['tabular-nums'] as const,
|
||
fontSize: 110,
|
||
lineHeight: 100,
|
||
fontWeight: '900' as const,
|
||
letterSpacing: -7,
|
||
},
|
||
displaySmallNumeric: {
|
||
fontFamily: 'JetBrainsMono-ExtraBold',
|
||
fontVariant: ['tabular-nums'] as const,
|
||
fontSize: 56,
|
||
lineHeight: 54,
|
||
fontWeight: '900' as const,
|
||
letterSpacing: -3,
|
||
},
|
||
metricNumeric: {
|
||
fontFamily: 'JetBrainsMono-Bold',
|
||
fontVariant: ['tabular-nums'] as const,
|
||
fontSize: 28,
|
||
lineHeight: 30,
|
||
fontWeight: '800' as const,
|
||
letterSpacing: -1.2,
|
||
},
|
||
detailNumeric: {
|
||
fontFamily: 'JetBrainsMono-Bold',
|
||
fontVariant: ['tabular-nums'] as const,
|
||
fontSize: 18,
|
||
lineHeight: 22,
|
||
fontWeight: '800' as const,
|
||
letterSpacing: -0.8,
|
||
},
|
||
label: {
|
||
fontSize: 10,
|
||
lineHeight: 13,
|
||
fontWeight: '900' as const,
|
||
letterSpacing: 1.4,
|
||
textTransform: 'uppercase' as const,
|
||
},
|
||
body: {
|
||
fontSize: 15,
|
||
lineHeight: 22,
|
||
fontWeight: '500' as const,
|
||
},
|
||
button: {
|
||
fontSize: 12,
|
||
lineHeight: 16,
|
||
fontWeight: '900' as const,
|
||
letterSpacing: 0.8,
|
||
textTransform: 'uppercase' as const,
|
||
},
|
||
} as const;
|
||
```
|
||
|
||
- [ ] **Step 2: Run typecheck**
|
||
|
||
Run: `cd /d/h && npm run typecheck 2>&1 | tail -5`
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 3: Run existing tests to verify no regression**
|
||
|
||
Run: `cd /d/h && npm test -- --runInBand 2>&1 | tail -10`
|
||
|
||
Expected: all 55 tests still pass.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/theme/typography.ts
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(theme): numeric typography tokens (JetBrains Mono tabular)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task F1.4: theme/colors.ts (add ringWarn for stop alert ring)
|
||
|
||
**Files:**
|
||
- Modify: `src/theme/colors.ts`
|
||
|
||
- [ ] **Step 1: Add the new key**
|
||
|
||
Edit `src/theme/colors.ts` — add `ringWarn` after the `stopText` line:
|
||
|
||
```ts
|
||
export const colors = {
|
||
canvas: '#070605',
|
||
canvasSoft: '#0D0906',
|
||
surface: '#12100C',
|
||
surfaceRaised: '#1D170F',
|
||
surfaceHot: '#251A10',
|
||
text: '#F8EAD8',
|
||
textMuted: '#B9A98E',
|
||
textDim: '#746754',
|
||
line: '#392D1E',
|
||
action: '#E23B2E',
|
||
warning: '#FFB43D',
|
||
success: '#34C77B',
|
||
stopSurface: '#FFF0DC',
|
||
stopText: '#120B07',
|
||
ringWarn: 'rgba(226, 59, 46, 0.55)',
|
||
black: '#030302',
|
||
} as const;
|
||
```
|
||
|
||
- [ ] **Step 2: Run typecheck and tests**
|
||
|
||
Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5`
|
||
|
||
Expected: 0 type errors, all 55 tests pass.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/theme/colors.ts
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(theme): add ringWarn color for stop alert ring"
|
||
```
|
||
|
||
---
|
||
|
||
### Task F1.5: useMotionPreference hook
|
||
|
||
**Files:**
|
||
- Create: `src/hooks/useMotionPreference.ts`
|
||
- Test: `src/__tests__/useMotionPreference.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `src/__tests__/useMotionPreference.test.ts`:
|
||
|
||
```ts
|
||
import { act, renderHook } from '@testing-library/react-native';
|
||
import {
|
||
useMotionPreference,
|
||
type MotionPreferenceListener,
|
||
} from '../hooks/useMotionPreference';
|
||
|
||
type AccessibilityInfoMock = {
|
||
isReduceMotionEnabled: jest.Mock<Promise<boolean>, []>;
|
||
addEventListener: jest.Mock<void, [string, MotionPreferenceListener]>;
|
||
removeEventListener: jest.Mock<void, [string, MotionPreferenceListener]>;
|
||
};
|
||
|
||
const mockAccessibilityInfo: AccessibilityInfoMock = {
|
||
isReduceMotionEnabled: jest.fn(),
|
||
addEventListener: jest.fn(),
|
||
removeEventListener: jest.fn(),
|
||
};
|
||
|
||
jest.mock('react-native', () => ({
|
||
AccessibilityInfo: mockAccessibilityInfo,
|
||
}));
|
||
|
||
describe('useMotionPreference', () => {
|
||
beforeEach(() => {
|
||
mockAccessibilityInfo.isReduceMotionEnabled.mockReset();
|
||
mockAccessibilityInfo.addEventListener.mockReset();
|
||
mockAccessibilityInfo.removeEventListener.mockReset();
|
||
mockAccessibilityInfo.isReduceMotionEnabled.mockResolvedValue(false);
|
||
});
|
||
|
||
it('starts as false by default and resolves to the system value', async () => {
|
||
mockAccessibilityInfo.isReduceMotionEnabled.mockResolvedValueOnce(true);
|
||
|
||
const { result, waitForNextUpdate } = renderHook(() =>
|
||
useMotionPreference(),
|
||
);
|
||
|
||
expect(result.current).toBe(false);
|
||
await waitForNextUpdate();
|
||
expect(result.current).toBe(true);
|
||
});
|
||
|
||
it('subscribes to reduceMotionChanged and updates state', () => {
|
||
let listener: MotionPreferenceListener | undefined;
|
||
mockAccessibilityInfo.addEventListener.mockImplementation((event, fn) => {
|
||
if (event === 'reduceMotionChanged') {
|
||
listener = fn;
|
||
}
|
||
});
|
||
|
||
const { result } = renderHook(() => useMotionPreference());
|
||
expect(result.current).toBe(false);
|
||
|
||
act(() => {
|
||
listener?.(true);
|
||
});
|
||
expect(result.current).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd /d/h && npx jest src/__tests__/useMotionPreference.test.ts 2>&1 | tail -10`
|
||
|
||
Expected: FAIL — cannot find `../hooks/useMotionPreference`.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
Create `src/hooks/useMotionPreference.ts`:
|
||
|
||
```ts
|
||
import { useEffect, useState } from 'react';
|
||
import { AccessibilityInfo } from 'react-native';
|
||
|
||
export type MotionPreferenceListener = (reduceMotion: boolean) => void;
|
||
|
||
export function useMotionPreference(): boolean {
|
||
const [reduceMotion, setReduceMotion] = useState(false);
|
||
|
||
useEffect(() => {
|
||
let mounted = true;
|
||
AccessibilityInfo.isReduceMotionEnabled()
|
||
.then((value) => {
|
||
if (mounted) setReduceMotion(value);
|
||
})
|
||
.catch(() => undefined);
|
||
|
||
const listener: MotionPreferenceListener = (value) => {
|
||
if (mounted) setReduceMotion(value);
|
||
};
|
||
AccessibilityInfo.addEventListener('reduceMotionChanged', listener);
|
||
|
||
return () => {
|
||
mounted = false;
|
||
AccessibilityInfo.removeEventListener('reduceMotionChanged', listener);
|
||
};
|
||
}, []);
|
||
|
||
return reduceMotion;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd /d/h && npx jest src/__tests__/useMotionPreference.test.ts 2>&1 | tail -5`
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/hooks/useMotionPreference.ts src/__tests__/useMotionPreference.test.ts
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(hooks): useMotionPreference for reduce motion respect"
|
||
```
|
||
|
||
---
|
||
|
||
## F2 — Atomic components (TDD)
|
||
|
||
### Task F2.1: usePulseAnimation hook
|
||
|
||
**Files:**
|
||
- Create: `src/hooks/usePulseAnimation.ts`
|
||
- Test: `src/__tests__/usePulseAnimation.test.tsx`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `src/__tests__/usePulseAnimation.test.tsx`:
|
||
|
||
```tsx
|
||
import { act, renderHook } from '@testing-library/react-native';
|
||
import { usePulseAnimation } from '../hooks/usePulseAnimation';
|
||
|
||
jest.mock('react-native-reanimated', () => {
|
||
const values: Record<number, { value: number }> = {};
|
||
let counter = 0;
|
||
return {
|
||
Easing: { out: () => (() => 0), inOut: () => (() => 0), quad: () => 0 },
|
||
useSharedValue: (initial: number) => {
|
||
const ref = { value: initial };
|
||
values[counter] = ref;
|
||
counter += 1;
|
||
return ref;
|
||
},
|
||
useAnimatedStyle: (fn: () => unknown) => fn(),
|
||
withTiming: (to: number) => to,
|
||
withRepeat: (to: number) => to,
|
||
cancelAnimation: () => undefined,
|
||
};
|
||
});
|
||
|
||
describe('usePulseAnimation', () => {
|
||
it('returns initial opacity 0.5 and scale 1 when inactive', () => {
|
||
const { result } = renderHook(() => usePulseAnimation(false));
|
||
const style = result.current as { transform: Array<{ scale: number }>; opacity: number };
|
||
expect(style.transform[0].scale).toBe(1);
|
||
expect(style.opacity).toBe(0.5);
|
||
});
|
||
|
||
it('returns opacity 1 when active', () => {
|
||
const { result } = renderHook(() => usePulseAnimation(true));
|
||
const style = result.current as { opacity: number };
|
||
expect(style.opacity).toBe(1);
|
||
});
|
||
|
||
it('updates opacity when active flag changes', () => {
|
||
const { result, rerender } = renderHook(
|
||
({ active }: { active: boolean }) => usePulseAnimation(active),
|
||
{ initialProps: { active: false } },
|
||
);
|
||
let style = result.current as { opacity: number };
|
||
expect(style.opacity).toBe(0.5);
|
||
|
||
act(() => {
|
||
rerender({ active: true });
|
||
});
|
||
style = result.current as { opacity: number };
|
||
expect(style.opacity).toBe(1);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd /d/h && npx jest src/__tests__/usePulseAnimation.test.tsx 2>&1 | tail -10`
|
||
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
Create `src/hooks/usePulseAnimation.ts`:
|
||
|
||
```ts
|
||
import { useEffect } from 'react';
|
||
import {
|
||
Easing,
|
||
cancelAnimation,
|
||
useAnimatedStyle,
|
||
useSharedValue,
|
||
withRepeat,
|
||
withTiming,
|
||
} from 'react-native-reanimated';
|
||
|
||
type AnimatedStyle = {
|
||
transform: Array<{ scale: number }>;
|
||
opacity: number;
|
||
};
|
||
|
||
export function usePulseAnimation(active: boolean, durationMs = 1600): AnimatedStyle {
|
||
const scale = useSharedValue(1);
|
||
const opacity = useSharedValue(active ? 1 : 0.5);
|
||
|
||
useEffect(() => {
|
||
if (active) {
|
||
scale.value = withRepeat(
|
||
withTiming(1.4, { duration: durationMs / 2, easing: Easing.out(Easing.quad) }),
|
||
-1,
|
||
true,
|
||
);
|
||
opacity.value = withRepeat(
|
||
withTiming(0.55, { duration: durationMs / 2, easing: Easing.inOut(Easing.quad) }),
|
||
-1,
|
||
true,
|
||
);
|
||
} else {
|
||
cancelAnimation(scale);
|
||
cancelAnimation(opacity);
|
||
scale.value = withTiming(1, { duration: 160 });
|
||
opacity.value = withTiming(0.5, { duration: 160 });
|
||
}
|
||
}, [active, durationMs, scale, opacity]);
|
||
|
||
return useAnimatedStyle<AnimatedStyle>(() => ({
|
||
transform: [{ scale: scale.value }],
|
||
opacity: opacity.value,
|
||
}));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd /d/h && npx jest src/__tests__/usePulseAnimation.test.tsx 2>&1 | tail -5`
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/hooks/usePulseAnimation.ts src/__tests__/usePulseAnimation.test.tsx
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(hooks): usePulseAnimation for live indicators"
|
||
```
|
||
|
||
---
|
||
|
||
### Task F2.2: LiveDot component
|
||
|
||
**Files:**
|
||
- Create: `src/components/LiveDot.tsx`
|
||
- Test: `src/__tests__/LiveDot.test.tsx`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `src/__tests__/LiveDot.test.tsx`:
|
||
|
||
```tsx
|
||
import React from 'react';
|
||
import { render, screen } from '@testing-library/react-native';
|
||
import { LiveDot } from '../components/LiveDot';
|
||
|
||
jest.mock('react-native-reanimated', () => {
|
||
const ReactMod = require('react');
|
||
return {
|
||
__esModule: true,
|
||
default: { View: ({ children }: { children: React.ReactNode }) => ReactMod.createElement('View', null, children) },
|
||
Easing: { out: () => (() => 0), inOut: () => (() => 0), quad: () => 0 },
|
||
useSharedValue: (initial: number) => ({ value: initial }),
|
||
useAnimatedStyle: (fn: () => unknown) => fn(),
|
||
withTiming: (to: number) => to,
|
||
withRepeat: (to: number) => to,
|
||
cancelAnimation: () => undefined,
|
||
};
|
||
});
|
||
|
||
describe('LiveDot', () => {
|
||
it('renders with success color when live=true', () => {
|
||
render(<LiveDot live testID="dot" />);
|
||
const node = screen.getByTestId('dot');
|
||
expect(node.props.accessibilityLabel).toBe('AO VIVO');
|
||
});
|
||
|
||
it('renders with INATIVO label when live=false', () => {
|
||
render(<LiveDot live={false} testID="dot" />);
|
||
const node = screen.getByTestId('dot');
|
||
expect(node.props.accessibilityLabel).toBe('INATIVO');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd /d/h && npx jest src/__tests__/LiveDot.test.tsx 2>&1 | tail -10`
|
||
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
Create `src/components/LiveDot.tsx`:
|
||
|
||
```tsx
|
||
import React from 'react';
|
||
import { StyleSheet, View, type ViewStyle } from 'react-native';
|
||
import Animated from 'react-native-reanimated';
|
||
import { colors } from '../theme';
|
||
import { usePulseAnimation } from '../hooks/usePulseAnimation';
|
||
|
||
type Props = {
|
||
readonly live: boolean;
|
||
readonly size?: number;
|
||
readonly testID?: string;
|
||
};
|
||
|
||
export function LiveDot({ live, size = 8, testID }: Props) {
|
||
const animatedStyle = usePulseAnimation(live);
|
||
const dotStyle: ViewStyle = {
|
||
width: size,
|
||
height: size,
|
||
borderRadius: size / 2,
|
||
backgroundColor: live ? colors.success : colors.textDim,
|
||
};
|
||
|
||
return (
|
||
<Animated.View
|
||
accessibilityLabel={live ? 'AO VIVO' : 'INATIVO'}
|
||
accessibilityRole="image"
|
||
style={[styles.base, dotStyle, animatedStyle]}
|
||
testID={testID}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
base: {
|
||
alignSelf: 'center',
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd /d/h && npx jest src/__tests__/LiveDot.test.tsx 2>&1 | tail -5`
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/components/LiveDot.tsx src/__tests__/LiveDot.test.tsx
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(components): LiveDot pulsing indicator"
|
||
```
|
||
|
||
---
|
||
|
||
### Task F2.3: PressableSurface component
|
||
|
||
**Files:**
|
||
- Create: `src/components/PressableSurface.tsx`
|
||
- Test: `src/__tests__/PressableSurface.test.tsx`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `src/__tests__/PressableSurface.test.tsx`:
|
||
|
||
```tsx
|
||
import React from 'react';
|
||
import { Text } from 'react-native';
|
||
import { fireEvent, render, screen } from '@testing-library/react-native';
|
||
import { PressableSurface } from '../components/PressableSurface';
|
||
|
||
jest.mock('react-native-reanimated', () => {
|
||
const ReactMod = require('react');
|
||
const RN = require('react-native');
|
||
return {
|
||
__esModule: true,
|
||
default: {
|
||
View: RN.View,
|
||
createAnimatedComponent: (Component: unknown) => Component,
|
||
},
|
||
Easing: { out: () => (() => 0), inOut: () => (() => 0), quad: () => 0 },
|
||
useSharedValue: (initial: number) => ({ value: initial }),
|
||
useAnimatedStyle: (fn: () => unknown) => fn(),
|
||
withTiming: (to: number) => to,
|
||
withRepeat: (to: number) => to,
|
||
cancelAnimation: () => undefined,
|
||
};
|
||
});
|
||
|
||
describe('PressableSurface', () => {
|
||
it('renders children inside pressable', () => {
|
||
render(
|
||
<PressableSurface onPress={() => undefined} testID="surface">
|
||
<Text>Hi</Text>
|
||
</PressableSurface>,
|
||
);
|
||
expect(screen.getByText('Hi')).toBeTruthy();
|
||
});
|
||
|
||
it('triggers onPress when pressed', () => {
|
||
const onPress = jest.fn();
|
||
render(
|
||
<PressableSurface onPress={onPress} testID="surface">
|
||
<Text>Hi</Text>
|
||
</PressableSurface>,
|
||
);
|
||
fireEvent.press(screen.getByTestId('surface'));
|
||
expect(onPress).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('has accessibilityRole=button when accessibilityLabel is set', () => {
|
||
render(
|
||
<PressableSurface onPress={() => undefined} accessibilityLabel="Tap me" testID="surface">
|
||
<Text>Hi</Text>
|
||
</PressableSurface>,
|
||
);
|
||
expect(screen.getByTestId('surface').props.accessibilityRole).toBe('button');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd /d/h && npx jest src/__tests__/PressableSurface.test.tsx 2>&1 | tail -10`
|
||
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
Create `src/components/PressableSurface.tsx`:
|
||
|
||
```tsx
|
||
import React, { useCallback, type ReactNode } from 'react';
|
||
import { Pressable, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
|
||
import Animated, {
|
||
Easing,
|
||
useAnimatedStyle,
|
||
useSharedValue,
|
||
withTiming,
|
||
} from 'react-native-reanimated';
|
||
|
||
type Props = {
|
||
readonly children: ReactNode;
|
||
readonly onPress?: () => void;
|
||
readonly disabled?: boolean;
|
||
readonly style?: StyleProp<ViewStyle>;
|
||
readonly accessibilityLabel?: string;
|
||
readonly testID?: string;
|
||
};
|
||
|
||
export function PressableSurface({
|
||
children,
|
||
onPress,
|
||
disabled,
|
||
style,
|
||
accessibilityLabel,
|
||
testID,
|
||
}: Props) {
|
||
const scale = useSharedValue(1);
|
||
const opacity = useSharedValue(1);
|
||
|
||
const onPressIn = useCallback(() => {
|
||
scale.value = withTiming(0.96, { duration: 80, easing: Easing.out(Easing.quad) });
|
||
opacity.value = withTiming(0.82, { duration: 80, easing: Easing.out(Easing.quad) });
|
||
}, [scale, opacity]);
|
||
|
||
const onPressOut = useCallback(() => {
|
||
scale.value = withTiming(1, { duration: 160, easing: Easing.out(Easing.quad) });
|
||
opacity.value = withTiming(1, { duration: 160, easing: Easing.out(Easing.quad) });
|
||
}, [scale, opacity]);
|
||
|
||
const animatedStyle = useAnimatedStyle(() => ({
|
||
transform: [{ scale: scale.value }],
|
||
opacity: opacity.value,
|
||
}));
|
||
|
||
return (
|
||
<Animated.View style={animatedStyle}>
|
||
<Pressable
|
||
accessibilityLabel={accessibilityLabel}
|
||
accessibilityRole={accessibilityLabel ? 'button' : undefined}
|
||
disabled={disabled}
|
||
onPress={onPress}
|
||
onPressIn={onPressIn}
|
||
onPressOut={onPressOut}
|
||
style={[styles.base, style]}
|
||
testID={testID}
|
||
>
|
||
{children}
|
||
</Pressable>
|
||
</Animated.View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
base: {
|
||
alignSelf: 'stretch',
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd /d/h && npx jest src/__tests__/PressableSurface.test.tsx 2>&1 | tail -5`
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/components/PressableSurface.tsx src/__tests__/PressableSurface.test.tsx
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(components): PressableSurface with refined press feedback"
|
||
```
|
||
|
||
---
|
||
|
||
### Task F2.4: EmptyState component
|
||
|
||
**Files:**
|
||
- Create: `src/components/EmptyState.tsx`
|
||
- Test: `src/__tests__/EmptyState.test.tsx`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `src/__tests__/EmptyState.test.tsx`:
|
||
|
||
```tsx
|
||
import React from 'react';
|
||
import { render, screen } from '@testing-library/react-native';
|
||
import { EmptyState } from '../components/EmptyState';
|
||
|
||
describe('EmptyState', () => {
|
||
it('renders eyebrow, title, and copy', () => {
|
||
render(
|
||
<EmptyState
|
||
eyebrow="SEM USO"
|
||
title="Nenhum treino salvo"
|
||
copy="Comece uma janela para ver seus dados."
|
||
/>,
|
||
);
|
||
expect(screen.getByText('SEM USO')).toBeTruthy();
|
||
expect(screen.getByText('Nenhum treino salvo')).toBeTruthy();
|
||
expect(screen.getByText('Comece uma janela para ver seus dados.')).toBeTruthy();
|
||
});
|
||
|
||
it('uses amber tone by default', () => {
|
||
const { toJSON } = render(
|
||
<EmptyState eyebrow="X" title="Y" copy="Z" testID="es" />,
|
||
);
|
||
const tree = JSON.stringify(toJSON());
|
||
expect(tree).toContain('rgba(255, 180, 61');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd /d/h && npx jest src/__tests__/EmptyState.test.tsx 2>&1 | tail -10`
|
||
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
Create `src/components/EmptyState.tsx`:
|
||
|
||
```tsx
|
||
import React from 'react';
|
||
import { StyleSheet, Text, View } from 'react-native';
|
||
import Svg, { Circle, Line, Rect } from 'react-native-svg';
|
||
import { colors, radius, spacing, typography } from '../theme';
|
||
|
||
type Tone = 'amber' | 'red' | 'green';
|
||
|
||
type Props = {
|
||
readonly eyebrow: string;
|
||
readonly title: string;
|
||
readonly copy: string;
|
||
readonly tone?: Tone;
|
||
readonly testID?: string;
|
||
};
|
||
|
||
function toneColor(tone: Tone): string {
|
||
if (tone === 'red') return colors.action;
|
||
if (tone === 'green') return colors.success;
|
||
return colors.warning;
|
||
}
|
||
|
||
function toneBackground(tone: Tone): string {
|
||
if (tone === 'red') return 'rgba(226, 59, 46, 0.12)';
|
||
if (tone === 'green') return 'rgba(52, 199, 123, 0.12)';
|
||
return 'rgba(255, 180, 61, 0.10)';
|
||
}
|
||
|
||
export function EmptyState({
|
||
eyebrow,
|
||
title,
|
||
copy,
|
||
tone = 'amber',
|
||
testID,
|
||
}: Props) {
|
||
return (
|
||
<View
|
||
accessibilityLabel={`${eyebrow}. ${title}. ${copy}`}
|
||
style={[styles.wrap, { backgroundColor: toneBackground(tone) }]}
|
||
testID={testID}
|
||
>
|
||
<View style={styles.geom}>
|
||
<Svg height={64} viewBox="0 0 64 64" width={64}>
|
||
<Rect
|
||
x={6}
|
||
y={6}
|
||
width={52}
|
||
height={52}
|
||
stroke={toneColor(tone)}
|
||
strokeWidth={1}
|
||
fill="none"
|
||
/>
|
||
<Circle
|
||
cx={32}
|
||
cy={32}
|
||
r={20}
|
||
stroke={toneColor(tone)}
|
||
strokeWidth={1}
|
||
fill="none"
|
||
/>
|
||
<Line
|
||
x1={6}
|
||
y1={58}
|
||
x2={58}
|
||
y2={6}
|
||
stroke={toneColor(tone)}
|
||
strokeWidth={1}
|
||
/>
|
||
</Svg>
|
||
</View>
|
||
<Text style={[styles.eyebrow, { color: toneColor(tone) }]}>{eyebrow}</Text>
|
||
<Text style={styles.title}>{title}</Text>
|
||
<Text style={styles.copy}>{copy}</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
wrap: {
|
||
borderTopWidth: 1,
|
||
borderBottomWidth: 1,
|
||
borderColor: colors.line,
|
||
borderRadius: radius.md,
|
||
paddingVertical: spacing.xl,
|
||
paddingHorizontal: spacing.md,
|
||
minHeight: 220,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: spacing.sm,
|
||
},
|
||
geom: {
|
||
marginBottom: spacing.sm,
|
||
},
|
||
eyebrow: {
|
||
...typography.label,
|
||
},
|
||
title: {
|
||
...typography.display,
|
||
fontSize: 36,
|
||
lineHeight: 38,
|
||
textAlign: 'center',
|
||
},
|
||
copy: {
|
||
...typography.body,
|
||
color: colors.textMuted,
|
||
textAlign: 'center',
|
||
maxWidth: 280,
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd /d/h && npx jest src/__tests__/EmptyState.test.tsx 2>&1 | tail -5`
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/components/EmptyState.tsx src/__tests__/EmptyState.test.tsx
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(components): EmptyState with instrument geometry"
|
||
```
|
||
|
||
---
|
||
|
||
### Task F2.5: DialTile component
|
||
|
||
**Files:**
|
||
- Create: `src/components/DialTile.tsx`
|
||
- Test: `src/__tests__/DialTile.test.tsx`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `src/__tests__/DialTile.test.tsx`:
|
||
|
||
```tsx
|
||
import React from 'react';
|
||
import { render, screen } from '@testing-library/react-native';
|
||
import { DialTile } from '../components/DialTile';
|
||
|
||
describe('DialTile', () => {
|
||
it('renders label and value', () => {
|
||
render(<DialTile label="RSSI" value="-61 dBm" testID="dial" />);
|
||
expect(screen.getByText('RSSI')).toBeTruthy();
|
||
expect(screen.getByText('-61 dBm')).toBeTruthy();
|
||
});
|
||
|
||
it('combines label and value into accessibilityLabel', () => {
|
||
render(<DialTile label="RSSI" value="-61 dBm" testID="dial" />);
|
||
expect(screen.getByTestId('dial').props.accessibilityLabel).toBe('RSSI: -61 dBm');
|
||
});
|
||
|
||
it('uses action color when tone=red', () => {
|
||
const { toJSON } = render(
|
||
<DialTile label="X" value="Y" tone="red" testID="dial" />,
|
||
);
|
||
const tree = JSON.stringify(toJSON());
|
||
expect(tree).toContain('#E23B2E');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd /d/h && npx jest src/__tests__/DialTile.test.tsx 2>&1 | tail -10`
|
||
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
Create `src/components/DialTile.tsx`:
|
||
|
||
```tsx
|
||
import React from 'react';
|
||
import { StyleSheet, Text, View, type ViewStyle } from 'react-native';
|
||
import { colors, spacing, typography } from '../theme';
|
||
|
||
type Tone = 'amber' | 'green' | 'red';
|
||
|
||
type Props = {
|
||
readonly label: string;
|
||
readonly value: string;
|
||
readonly tone?: Tone;
|
||
readonly testID?: string;
|
||
};
|
||
|
||
function toneColor(tone: Tone | undefined): string {
|
||
if (tone === 'red') return colors.action;
|
||
if (tone === 'green') return colors.success;
|
||
return colors.warning;
|
||
}
|
||
|
||
export function DialTile({ label, value, tone = 'amber', testID }: Props) {
|
||
const markerStyle: ViewStyle = { backgroundColor: toneColor(tone) };
|
||
return (
|
||
<View
|
||
accessibilityLabel={`${label}: ${value}`}
|
||
style={styles.row}
|
||
testID={testID}
|
||
>
|
||
<View style={styles.markerWrap}>
|
||
<View style={[styles.marker, markerStyle]} />
|
||
</View>
|
||
<Text style={styles.value}>{value}</Text>
|
||
<Text style={styles.label}>{label}</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
row: {
|
||
minHeight: 56,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
borderTopWidth: 1,
|
||
borderColor: colors.line,
|
||
paddingVertical: spacing.sm,
|
||
paddingHorizontal: spacing.md,
|
||
gap: spacing.md,
|
||
},
|
||
markerWrap: {
|
||
width: 4,
|
||
height: 32,
|
||
justifyContent: 'center',
|
||
},
|
||
marker: {
|
||
width: 2,
|
||
height: 32,
|
||
},
|
||
value: {
|
||
...typography.detailNumeric,
|
||
flex: 1,
|
||
},
|
||
label: {
|
||
...typography.label,
|
||
color: colors.textDim,
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd /d/h && npx jest src/__tests__/DialTile.test.tsx 2>&1 | tail -5`
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/components/DialTile.tsx src/__tests__/DialTile.test.tsx
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(components): DialTile for sensor signal rows"
|
||
```
|
||
|
||
---
|
||
|
||
### Task F2.6: TrackRow component
|
||
|
||
**Files:**
|
||
- Create: `src/components/TrackRow.tsx`
|
||
- Test: `src/__tests__/TrackRow.test.tsx`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `src/__tests__/TrackRow.test.tsx`:
|
||
|
||
```tsx
|
||
import React from 'react';
|
||
import { Text } from 'react-native';
|
||
import { fireEvent, render, screen } from '@testing-library/react-native';
|
||
import { TrackRow } from '../components/TrackRow';
|
||
|
||
describe('TrackRow', () => {
|
||
const baseProps = {
|
||
trackNumber: 3,
|
||
day: '15/06',
|
||
hour: '19:30',
|
||
title: 'Sparring',
|
||
meta: '30 min · 600 amostras',
|
||
metrics: [
|
||
{ label: 'MÉD 152' },
|
||
{ label: 'PICO 184', tone: 'hot' as const },
|
||
],
|
||
isOpen: false,
|
||
onPress: jest.fn(),
|
||
accessibilityLabel: 'Janela 3',
|
||
};
|
||
|
||
beforeEach(() => {
|
||
baseProps.onPress.mockClear();
|
||
});
|
||
|
||
it('renders track number zero-padded', () => {
|
||
render(
|
||
<TrackRow {...baseProps} testID="row">
|
||
<Text />
|
||
</TrackRow>,
|
||
);
|
||
expect(screen.getByText('T03')).toBeTruthy();
|
||
});
|
||
|
||
it('renders day, hour, title, and meta', () => {
|
||
render(
|
||
<TrackRow {...baseProps} testID="row">
|
||
<Text />
|
||
</TrackRow>,
|
||
);
|
||
expect(screen.getByText('15/06')).toBeTruthy();
|
||
expect(screen.getByText('19:30')).toBeTruthy();
|
||
expect(screen.getByText('Sparring')).toBeTruthy();
|
||
expect(screen.getByText('30 min · 600 amostras')).toBeTruthy();
|
||
});
|
||
|
||
it('renders metrics labels', () => {
|
||
render(
|
||
<TrackRow {...baseProps} testID="row">
|
||
<Text />
|
||
</TrackRow>,
|
||
);
|
||
expect(screen.getByText('MÉD 152')).toBeTruthy();
|
||
expect(screen.getByText('PICO 184')).toBeTruthy();
|
||
});
|
||
|
||
it('calls onPress when pressed', () => {
|
||
render(
|
||
<TrackRow {...baseProps} testID="row">
|
||
<Text />
|
||
</TrackRow>,
|
||
);
|
||
fireEvent.press(screen.getByTestId('row'));
|
||
expect(baseProps.onPress).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd /d/h && npx jest src/__tests__/TrackRow.test.tsx 2>&1 | tail -10`
|
||
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
Create `src/components/TrackRow.tsx`:
|
||
|
||
```tsx
|
||
import React, { type ReactNode } from 'react';
|
||
import { Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native';
|
||
import { ChevronDown, ChevronUp } from 'lucide-react-native';
|
||
import { colors, spacing, typography } from '../theme';
|
||
|
||
type MetricTone = 'normal' | 'hot';
|
||
|
||
type Metric = {
|
||
readonly label: string;
|
||
readonly tone?: MetricTone;
|
||
};
|
||
|
||
type Props = {
|
||
readonly trackNumber: number;
|
||
readonly day: string;
|
||
readonly hour: string;
|
||
readonly title: string;
|
||
readonly meta: string;
|
||
readonly metrics: ReadonlyArray<Metric>;
|
||
readonly isOpen: boolean;
|
||
readonly onPress: () => void;
|
||
readonly accessibilityLabel: string;
|
||
readonly children?: ReactNode;
|
||
readonly testID?: string;
|
||
};
|
||
|
||
export function TrackRow({
|
||
trackNumber,
|
||
day,
|
||
hour,
|
||
title,
|
||
meta,
|
||
metrics,
|
||
isOpen,
|
||
onPress,
|
||
accessibilityLabel,
|
||
children,
|
||
testID,
|
||
}: Props) {
|
||
const trackNumberLabel = `T${String(trackNumber).padStart(2, '0')}`;
|
||
const sideRailStyle: ViewStyle = {
|
||
backgroundColor: isOpen ? colors.action : colors.line,
|
||
};
|
||
return (
|
||
<View>
|
||
<Pressable
|
||
accessibilityLabel={accessibilityLabel}
|
||
accessibilityRole="button"
|
||
onPress={onPress}
|
||
style={({ pressed }) => [styles.row, pressed ? styles.pressed : null]}
|
||
testID={testID}
|
||
>
|
||
<View style={[styles.sideRail, sideRailStyle]} />
|
||
<View style={styles.trackNumberWrap}>
|
||
<Text style={styles.trackNumber}>{trackNumberLabel}</Text>
|
||
</View>
|
||
<View style={styles.dateBlock}>
|
||
<Text style={styles.day}>{day}</Text>
|
||
<Text style={styles.hour}>{hour}</Text>
|
||
</View>
|
||
<View style={styles.body}>
|
||
<Text style={styles.title}>{title}</Text>
|
||
<Text style={styles.meta}>{meta}</Text>
|
||
<View style={styles.metricLine}>
|
||
{metrics.map((metric, index) => (
|
||
<Text
|
||
key={index}
|
||
style={[
|
||
styles.metricText,
|
||
metric.tone === 'hot' ? styles.metricTextHot : null,
|
||
]}
|
||
>
|
||
{metric.label}
|
||
</Text>
|
||
))}
|
||
</View>
|
||
</View>
|
||
<View style={styles.chevron}>
|
||
{isOpen ? (
|
||
<ChevronUp color={colors.warning} size={18} strokeWidth={2.6} />
|
||
) : (
|
||
<ChevronDown color={colors.textMuted} size={18} strokeWidth={2.6} />
|
||
)}
|
||
</View>
|
||
</Pressable>
|
||
{children}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
row: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingVertical: spacing.sm,
|
||
paddingHorizontal: spacing.md,
|
||
gap: spacing.sm,
|
||
borderTopWidth: 1,
|
||
borderTopColor: colors.line,
|
||
},
|
||
pressed: {
|
||
opacity: 0.72,
|
||
},
|
||
sideRail: {
|
||
width: 2,
|
||
height: 40,
|
||
},
|
||
trackNumberWrap: {
|
||
width: 28,
|
||
alignItems: 'flex-start',
|
||
},
|
||
trackNumber: {
|
||
...typography.label,
|
||
color: colors.textMuted,
|
||
},
|
||
dateBlock: {
|
||
width: 56,
|
||
},
|
||
day: {
|
||
color: colors.warning,
|
||
fontSize: 18,
|
||
lineHeight: 21,
|
||
fontWeight: '900',
|
||
letterSpacing: -0.6,
|
||
},
|
||
hour: {
|
||
...typography.label,
|
||
color: colors.textDim,
|
||
marginTop: 2,
|
||
},
|
||
body: {
|
||
flex: 1,
|
||
},
|
||
title: {
|
||
color: colors.text,
|
||
fontSize: 18,
|
||
lineHeight: 22,
|
||
fontWeight: '900',
|
||
letterSpacing: -0.6,
|
||
},
|
||
meta: {
|
||
...typography.body,
|
||
color: colors.textMuted,
|
||
fontSize: 13,
|
||
lineHeight: 17,
|
||
marginTop: 2,
|
||
},
|
||
metricLine: {
|
||
flexDirection: 'row',
|
||
gap: spacing.sm,
|
||
marginTop: spacing.xs,
|
||
},
|
||
metricText: {
|
||
...typography.label,
|
||
color: colors.textMuted,
|
||
},
|
||
metricTextHot: {
|
||
color: colors.action,
|
||
},
|
||
chevron: {
|
||
width: 18,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd /d/h && npx jest src/__tests__/TrackRow.test.tsx 2>&1 | tail -5`
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/components/TrackRow.tsx src/__tests__/TrackRow.test.tsx
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(components): TrackRow for history sessions"
|
||
```
|
||
|
||
---
|
||
|
||
### Task F2.7: InsightRow component
|
||
|
||
**Files:**
|
||
- Create: `src/components/InsightRow.tsx`
|
||
- Test: `src/__tests__/InsightRow.test.tsx`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `src/__tests__/InsightRow.test.tsx`:
|
||
|
||
```tsx
|
||
import React from 'react';
|
||
import { render, screen } from '@testing-library/react-native';
|
||
import { InsightRow } from '../components/InsightRow';
|
||
|
||
describe('InsightRow', () => {
|
||
it('renders index zero-padded', () => {
|
||
render(
|
||
<InsightRow
|
||
index={3}
|
||
label="Carga"
|
||
statusLabel="ok"
|
||
summary="Carga alta mas dentro do padrão"
|
||
detail="TRIMP 145"
|
||
value="145"
|
||
unit="TRIMP"
|
||
tone="good"
|
||
testID="row"
|
||
/>,
|
||
);
|
||
expect(screen.getByText('03')).toBeTruthy();
|
||
});
|
||
|
||
it('renders label, status, summary, detail, and value', () => {
|
||
render(
|
||
<InsightRow
|
||
index={1}
|
||
label="Carga"
|
||
statusLabel="ok"
|
||
summary="Carga alta"
|
||
detail="TRIMP 145"
|
||
value="145"
|
||
unit="TRIMP"
|
||
tone="good"
|
||
testID="row"
|
||
/>,
|
||
);
|
||
expect(screen.getByText('Carga')).toBeTruthy();
|
||
expect(screen.getByText('ok')).toBeTruthy();
|
||
expect(screen.getByText('Carga alta')).toBeTruthy();
|
||
expect(screen.getByText('TRIMP 145')).toBeTruthy();
|
||
expect(screen.getByText('145')).toBeTruthy();
|
||
expect(screen.getByText('TRIMP')).toBeTruthy();
|
||
});
|
||
|
||
it('combines label, status, and value into accessibilityLabel', () => {
|
||
render(
|
||
<InsightRow
|
||
index={1}
|
||
label="Carga"
|
||
statusLabel="ok"
|
||
summary="Carga alta"
|
||
detail="TRIMP 145"
|
||
value="145"
|
||
tone="good"
|
||
testID="row"
|
||
/>,
|
||
);
|
||
const label = screen.getByTestId('row').props.accessibilityLabel;
|
||
expect(label).toContain('Carga');
|
||
expect(label).toContain('ok');
|
||
expect(label).toContain('145');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd /d/h && npx jest src/__tests__/InsightRow.test.tsx 2>&1 | tail -10`
|
||
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
Create `src/components/InsightRow.tsx`:
|
||
|
||
```tsx
|
||
import React, { useEffect } from 'react';
|
||
import { StyleSheet, Text, View, type ViewStyle } from 'react-native';
|
||
import Animated, {
|
||
Easing,
|
||
useAnimatedStyle,
|
||
useSharedValue,
|
||
withDelay,
|
||
withTiming,
|
||
} from 'react-native-reanimated';
|
||
import { colors, motion, spacing, typography } from '../theme';
|
||
|
||
type Tone = 'good' | 'warning' | 'danger' | 'neutral';
|
||
|
||
type Props = {
|
||
readonly index: number;
|
||
readonly label: string;
|
||
readonly statusLabel: string;
|
||
readonly summary: string;
|
||
readonly detail: string;
|
||
readonly value: string;
|
||
readonly unit?: string;
|
||
readonly tone: Tone;
|
||
readonly testID?: string;
|
||
};
|
||
|
||
function toneColor(tone: Tone): string {
|
||
if (tone === 'good') return colors.success;
|
||
if (tone === 'danger') return colors.action;
|
||
if (tone === 'warning') return colors.warning;
|
||
return colors.text;
|
||
}
|
||
|
||
export function InsightRow({
|
||
index,
|
||
label,
|
||
statusLabel,
|
||
summary,
|
||
detail,
|
||
value,
|
||
unit,
|
||
tone,
|
||
testID,
|
||
}: Props) {
|
||
const indexLabel = String(index).padStart(2, '0');
|
||
const opacity = useSharedValue(0);
|
||
const translateY = useSharedValue(8);
|
||
const color = toneColor(tone);
|
||
|
||
useEffect(() => {
|
||
const delay = index < 8 ? index * motion.stagger.row : 0;
|
||
opacity.value = withDelay(
|
||
delay,
|
||
withTiming(1, { duration: motion.duration.normal, easing: Easing.out(Easing.quad) }),
|
||
);
|
||
translateY.value = withDelay(
|
||
delay,
|
||
withTiming(0, { duration: motion.duration.normal, easing: Easing.out(Easing.quad) }),
|
||
);
|
||
}, [index, opacity, translateY]);
|
||
|
||
const animatedStyle = useAnimatedStyle(() => ({
|
||
opacity: opacity.value,
|
||
transform: [{ translateY: translateY.value }],
|
||
}));
|
||
|
||
const indexStyle: ViewStyle = { color };
|
||
return (
|
||
<Animated.View
|
||
accessibilityLabel={`${label}, status ${statusLabel}, valor ${value}${unit ? ' ' + unit : ''}`}
|
||
style={[styles.row, animatedStyle]}
|
||
testID={testID}
|
||
>
|
||
<Text style={styles.index}>{indexLabel}</Text>
|
||
<View style={styles.body}>
|
||
<View style={styles.topLine}>
|
||
<Text style={styles.label}>{label}</Text>
|
||
<Text style={[styles.status, indexStyle]}>{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>
|
||
{unit ? <Text style={styles.unit}>{unit}</Text> : null}
|
||
</View>
|
||
</Animated.View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
row: {
|
||
minHeight: 92,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: colors.line,
|
||
flexDirection: 'row',
|
||
gap: spacing.sm,
|
||
paddingVertical: spacing.sm,
|
||
},
|
||
index: {
|
||
...typography.label,
|
||
color: colors.textDim,
|
||
width: 28,
|
||
},
|
||
body: {
|
||
flex: 1,
|
||
gap: spacing.xxs,
|
||
},
|
||
topLine: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
gap: spacing.sm,
|
||
},
|
||
label: {
|
||
...typography.label,
|
||
color: colors.text,
|
||
flexShrink: 1,
|
||
},
|
||
status: {
|
||
...typography.label,
|
||
textAlign: 'right',
|
||
},
|
||
summary: {
|
||
color: colors.text,
|
||
fontSize: 16,
|
||
lineHeight: 20,
|
||
fontWeight: '800',
|
||
letterSpacing: -0.45,
|
||
},
|
||
detail: {
|
||
color: colors.textMuted,
|
||
fontSize: 12,
|
||
lineHeight: 16,
|
||
fontWeight: '600',
|
||
},
|
||
valueBox: {
|
||
width: 84,
|
||
alignItems: 'flex-end',
|
||
justifyContent: 'center',
|
||
},
|
||
value: {
|
||
...typography.metricNumeric,
|
||
textAlign: 'right',
|
||
},
|
||
unit: {
|
||
...typography.label,
|
||
color: colors.textDim,
|
||
textAlign: 'right',
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd /d/h && npx jest src/__tests__/InsightRow.test.tsx 2>&1 | tail -5`
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/components/InsightRow.tsx src/__tests__/InsightRow.test.tsx
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(components): InsightRow with stagger reveal animation"
|
||
```
|
||
|
||
---
|
||
|
||
## F3 — Existing component updates
|
||
|
||
### Task F3.1: ActionButton — add stopAlert variant
|
||
|
||
**Files:**
|
||
- Modify: `src/components/ActionButton.tsx`
|
||
|
||
- [ ] **Step 1: Add stopAlert variant + AlertRing component**
|
||
|
||
Replace the entire `src/components/ActionButton.tsx` with:
|
||
|
||
```tsx
|
||
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,
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run typecheck and tests**
|
||
|
||
Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5`
|
||
|
||
Expected: 0 type errors, all tests pass.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/components/ActionButton.tsx
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(components): ActionButton stopAlert variant with pulsing ring"
|
||
```
|
||
|
||
---
|
||
|
||
### Task F3.2: StatusTag — size prop + red tone refinement
|
||
|
||
**Files:**
|
||
- Modify: `src/components/StatusTag.tsx`
|
||
|
||
- [ ] **Step 1: Add size and refine red tone**
|
||
|
||
Replace the entire `src/components/StatusTag.tsx` with:
|
||
|
||
```tsx
|
||
import React from 'react';
|
||
import { StyleSheet, Text, View } from 'react-native';
|
||
import { colors, radius, spacing, typography } from '../theme';
|
||
|
||
type StatusTone = 'amber' | 'green' | 'red';
|
||
type StatusSize = 'sm' | 'md';
|
||
|
||
type Props = {
|
||
readonly label: string;
|
||
readonly tone?: StatusTone;
|
||
readonly size?: StatusSize;
|
||
};
|
||
|
||
export function StatusTag({ label, tone = 'amber', size = 'md' }: Props) {
|
||
return (
|
||
<View
|
||
accessibilityLabel={label}
|
||
style={[styles.base, styles[tone], size === 'sm' ? styles.sm : styles.md]}
|
||
>
|
||
<Text style={[styles.text, styles[`${tone}Text`], size === 'sm' ? styles.smText : null]}>
|
||
{label}
|
||
</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
base: {
|
||
alignSelf: 'flex-start',
|
||
borderRadius: radius.xl,
|
||
borderWidth: 1,
|
||
},
|
||
sm: {
|
||
paddingHorizontal: spacing.xs,
|
||
paddingVertical: 2,
|
||
},
|
||
md: {
|
||
paddingHorizontal: spacing.sm,
|
||
paddingVertical: spacing.xs,
|
||
},
|
||
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.12)',
|
||
borderColor: 'rgba(226,59,46,0.42)',
|
||
},
|
||
text: {
|
||
...typography.label,
|
||
},
|
||
smText: {
|
||
fontSize: 9,
|
||
lineHeight: 12,
|
||
},
|
||
amberText: {
|
||
color: colors.warning,
|
||
},
|
||
greenText: {
|
||
color: colors.success,
|
||
},
|
||
redText: {
|
||
color: colors.action,
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run typecheck and tests**
|
||
|
||
Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5`
|
||
|
||
Expected: 0 errors, all tests pass.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/components/StatusTag.tsx
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(components): StatusTag size prop + red tone aligned with palette"
|
||
```
|
||
|
||
---
|
||
|
||
### Task F3.3: SectionPanel — divider and subtle variants
|
||
|
||
**Files:**
|
||
- Modify: `src/components/SectionPanel.tsx`
|
||
|
||
- [ ] **Step 1: Add divider and subtle props**
|
||
|
||
Replace the entire `src/components/SectionPanel.tsx` with:
|
||
|
||
```tsx
|
||
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;
|
||
readonly divider?: boolean;
|
||
readonly subtle?: boolean;
|
||
};
|
||
|
||
export function SectionPanel({ title, eyebrow, children, divider, subtle }: Props) {
|
||
if (divider) {
|
||
return (
|
||
<View style={styles.dividerWrap}>
|
||
{eyebrow ? <Text style={styles.eyebrow}>{eyebrow}</Text> : null}
|
||
<Text style={styles.title}>{title}</Text>
|
||
<View style={styles.dividerContent}>{children}</View>
|
||
</View>
|
||
);
|
||
}
|
||
return (
|
||
<View
|
||
style={[
|
||
styles.panel,
|
||
subtle ? styles.subtlePanel : styles.raisedPanel,
|
||
]}
|
||
>
|
||
{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,
|
||
padding: spacing.md,
|
||
},
|
||
raisedPanel: {
|
||
backgroundColor: colors.surfaceRaised,
|
||
},
|
||
subtlePanel: {
|
||
backgroundColor: 'transparent',
|
||
},
|
||
dividerWrap: {
|
||
borderTopWidth: 1,
|
||
borderBottomWidth: 1,
|
||
borderColor: colors.line,
|
||
paddingHorizontal: spacing.md,
|
||
paddingVertical: spacing.sm,
|
||
},
|
||
dividerContent: {
|
||
marginTop: spacing.sm,
|
||
gap: spacing.sm,
|
||
},
|
||
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,
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run typecheck and tests**
|
||
|
||
Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5`
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/components/SectionPanel.tsx
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(components): SectionPanel divider and subtle variants"
|
||
```
|
||
|
||
---
|
||
|
||
### Task F3.4: MetricTile — numeric prop
|
||
|
||
**Files:**
|
||
- Modify: `src/components/MetricTile.tsx`
|
||
|
||
- [ ] **Step 1: Add numeric prop**
|
||
|
||
Replace the entire `src/components/MetricTile.tsx` with:
|
||
|
||
```tsx
|
||
import React from 'react';
|
||
import { StyleSheet, Text, View, type TextStyle } from 'react-native';
|
||
import { colors, radius, spacing, typography } from '../theme';
|
||
|
||
type Props = {
|
||
readonly value: string;
|
||
readonly label: string;
|
||
readonly numeric?: boolean;
|
||
};
|
||
|
||
export function MetricTile({ value, label, numeric }: Props) {
|
||
return (
|
||
<View style={styles.tile}>
|
||
<Text style={[styles.value, numeric ? styles.numeric : null]}>{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,
|
||
},
|
||
numeric: {
|
||
...(typography.metricNumeric as TextStyle),
|
||
},
|
||
label: {
|
||
...typography.label,
|
||
color: colors.textMuted,
|
||
marginTop: spacing.xxs,
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run typecheck and tests**
|
||
|
||
Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5`
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/components/MetricTile.tsx
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(components): MetricTile numeric prop for JetBrains Mono"
|
||
```
|
||
|
||
---
|
||
|
||
## F4 — Tela Captura (T4.1 + T4.2)
|
||
|
||
### Task F4.1: TrainingChart — furos laterais + LiveDot readout + pin pulse
|
||
|
||
**Files:**
|
||
- Modify: `src/components/TrainingChart.tsx`
|
||
|
||
- [ ] **Step 1: Read current TrainingChart and add furos laterais + LiveDot + pin pulse**
|
||
|
||
In `src/components/TrainingChart.tsx`, find the `<Svg>` block and add **after** the `</SvgText>` for the minBpm (or before closing the SVG), this new group of "furos" (holes), right before the closing `</Svg>`:
|
||
|
||
```tsx
|
||
{ticks.map((tick, index) => (
|
||
<React.Fragment key={`${tick.timestampMs}-${index}`}>
|
||
<Line
|
||
stroke="rgba(255,255,255,0.06)"
|
||
strokeWidth="1"
|
||
x1={tick.x}
|
||
x2={tick.x}
|
||
y1={PAD_Y}
|
||
y2={CHART_HEIGHT - PAD_Y}
|
||
/>
|
||
<SvgText ...>{tick.label}</SvgText>
|
||
</React.Fragment>
|
||
))}
|
||
{/* Furos laterais — inspiração ECG paper */}
|
||
{[0, 1, 2, 3, 4, 5].map(index => {
|
||
const x = PAD_X + 12 + index * 48;
|
||
return (
|
||
<Rect
|
||
key={`hole-${index}`}
|
||
x={x}
|
||
y={CHART_HEIGHT - 4}
|
||
width={1.5}
|
||
height={4}
|
||
fill={colors.line}
|
||
/>
|
||
);
|
||
})}
|
||
```
|
||
|
||
Also, add `Rect` to the existing import from `react-native-svg` at the top of the file:
|
||
|
||
```tsx
|
||
import Svg, { Circle, Line, Path, Rect, Text as SvgText } from 'react-native-svg';
|
||
```
|
||
|
||
Also, in the **readout** (the `<View style={styles.readout}>` near the end of the component), update it to add a LiveDot before the label. Replace the existing readout:
|
||
|
||
```tsx
|
||
<View style={styles.readout}>
|
||
<Text style={styles.readoutLabel}>
|
||
{pinPoint ? 'AMOSTRA PINADA' : viewport.liveTail ? 'AO VIVO' : 'JANELA'}
|
||
</Text>
|
||
...
|
||
</View>
|
||
```
|
||
|
||
with:
|
||
|
||
```tsx
|
||
<View style={styles.readout}>
|
||
{viewport.liveTail ? <LiveDot live={true} size={6} /> : <LiveDot live={false} size={6} />}
|
||
<Text style={styles.readoutLabel}>
|
||
{pinPoint ? 'AMOSTRA PINADA' : viewport.liveTail ? 'AO VIVO' : 'JANELA'}
|
||
</Text>
|
||
<Text style={styles.readoutValue}>
|
||
{pinPoint
|
||
? `${formatSampleTime(pinPoint.sample.timestamp)} · ${pinPoint.sample.bpm} BPM`
|
||
: `${formatSampleTime(renderable.lastPoint.sample.timestamp)} · ${
|
||
renderable.lastPoint.sample.bpm
|
||
} BPM`}
|
||
</Text>
|
||
</View>
|
||
```
|
||
|
||
And add the import at the top:
|
||
|
||
```tsx
|
||
import { LiveDot } from './LiveDot';
|
||
```
|
||
|
||
- [ ] **Step 2: Run typecheck and tests**
|
||
|
||
Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5`
|
||
|
||
Expected: 0 type errors, all tests pass.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/components/TrainingChart.tsx
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(chart): TrainingChart ECG furos laterais + LiveDot readout"
|
||
```
|
||
|
||
---
|
||
|
||
### Task F4.2: CaptureScreen refactor
|
||
|
||
**Files:**
|
||
- Modify: `src/screens/CaptureScreen.tsx`
|
||
|
||
- [ ] **Step 1: Replace the file with the refactored version**
|
||
|
||
Write the entire `src/screens/CaptureScreen.tsx` with this content:
|
||
|
||
```tsx
|
||
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 { DialTile } from '../components/DialTile';
|
||
import { LiveDot } from '../components/LiveDot';
|
||
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';
|
||
|
||
const isReceiving = monitor.phase === 'receiving';
|
||
const isLive = monitor.isMonitoring || monitor.isCapturing;
|
||
|
||
return (
|
||
<View style={styles.screen}>
|
||
<View style={styles.topRail}>
|
||
<View style={styles.stateBlock}>
|
||
<View style={styles.eyebrowRow}>
|
||
<Text
|
||
style={[
|
||
styles.eyebrowDot,
|
||
{ color: isReceiving ? colors.success : colors.textDim },
|
||
]}
|
||
>
|
||
•
|
||
</Text>
|
||
<Text style={styles.eyebrow}>{status.label}</Text>
|
||
</View>
|
||
<Text style={styles.sensorName}>{formatDeviceName(monitor)}</Text>
|
||
</View>
|
||
<View style={styles.timeStack}>
|
||
<View style={styles.timerRow}>
|
||
<LiveDot live={isLive} size={6} />
|
||
<Text style={styles.timerText}>
|
||
{monitor.isCapturing
|
||
? formatDuration(workoutElapsedMs)
|
||
: formatDuration(monitorElapsedMs)}
|
||
</Text>
|
||
</View>
|
||
<Text style={styles.timerLabel}>
|
||
{monitor.isCapturing ? 'janela' : 'monitor'}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.bpmRow}>
|
||
<View style={styles.bpmBlock}>
|
||
<Text
|
||
style={[
|
||
styles.bpmValue,
|
||
isReceiving ? styles.bpmPulse : null,
|
||
]}
|
||
>
|
||
{monitor.currentBpm ?? BPM_EMPTY}
|
||
</Text>
|
||
</View>
|
||
<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.dialColumn}>
|
||
<DialTile
|
||
label="BLE"
|
||
value={getBleShortState(monitor.phase, monitor.isMonitoring)}
|
||
tone={monitor.phase === 'receiving' ? 'green' : 'amber'}
|
||
/>
|
||
<DialTile label="BATERIA" value={getBatteryLabel(monitor)} />
|
||
<DialTile
|
||
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 ? 'stopAlert' : 'primary'}
|
||
/>
|
||
</View>
|
||
|
||
<View style={styles.truthLine}>
|
||
<Activity color={colors.warning} size={18} strokeWidth={2.5} />
|
||
<Text style={styles.truthText}>
|
||
Cinta reconhecida = cada BPM real é salvo. Toque em Iniciar apenas
|
||
para destacar uma 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 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,
|
||
},
|
||
eyebrowRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
},
|
||
eyebrowDot: {
|
||
fontSize: 18,
|
||
lineHeight: 18,
|
||
fontWeight: '900',
|
||
},
|
||
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,
|
||
},
|
||
timerRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
},
|
||
timerText: {
|
||
...typography.label,
|
||
color: colors.text,
|
||
},
|
||
timerLabel: {
|
||
...typography.label,
|
||
color: colors.textDim,
|
||
fontSize: 9,
|
||
},
|
||
bpmRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'flex-end',
|
||
gap: spacing.md,
|
||
},
|
||
bpmBlock: {
|
||
flex: 1,
|
||
},
|
||
bpmValue: {
|
||
...typography.displayNumeric,
|
||
},
|
||
bpmPulse: {
|
||
opacity: 1,
|
||
},
|
||
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: {
|
||
...typography.metricNumeric,
|
||
},
|
||
railValueHot: {
|
||
color: colors.action,
|
||
},
|
||
railLabel: {
|
||
...typography.label,
|
||
color: colors.textMuted,
|
||
marginTop: spacing.xxs,
|
||
},
|
||
dialColumn: {
|
||
borderTopWidth: 1,
|
||
borderBottomWidth: 1,
|
||
borderColor: colors.line,
|
||
},
|
||
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,
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run typecheck and tests**
|
||
|
||
Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5`
|
||
|
||
Expected: 0 type errors, all tests pass.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/screens/CaptureScreen.tsx
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(capture): CaptureScreen with LiveDot, JetBrains Mono BPM, stopAlert ring"
|
||
```
|
||
|
||
---
|
||
|
||
## F5 — Histórico + Sensor (T5.1 + T5.2)
|
||
|
||
### Task F5.1: HistoryScreen refactor
|
||
|
||
**Files:**
|
||
- Modify: `src/screens/HistoryScreen.tsx`
|
||
|
||
- [ ] **Step 1: Apply TrackRow and EmptyState replacements**
|
||
|
||
In `src/screens/HistoryScreen.tsx`, the **only changes** required are:
|
||
1. Add `TrackRow` and `EmptyState` imports.
|
||
2. Replace `EmptyHistory` function body with delegation to `EmptyState`.
|
||
3. Replace the inner `Pressable ... styles.sessionStrip` blocks in both `WindowHistory` and `ContinuousHistory` with `TrackRow` components.
|
||
|
||
This is a complex refactor. To keep it tractable, do the following focused edits:
|
||
|
||
**Edit 1: imports**
|
||
|
||
Add these at the top of `src/screens/HistoryScreen.tsx` (after the existing `Activity, ChevronDown, ChevronUp` import from lucide):
|
||
|
||
```tsx
|
||
import { TrackRow } from '../components/TrackRow';
|
||
import { EmptyState } from '../components/EmptyState';
|
||
```
|
||
|
||
**Edit 2: replace `EmptyHistory` function**
|
||
|
||
Find the function `function EmptyHistory({...})` near the bottom of the file. Replace its body (everything from `return (` to its closing `}`) with:
|
||
|
||
```tsx
|
||
export function EmptyHistory({...} should stay) {
|
||
return (
|
||
<EmptyState
|
||
eyebrow={eyebrow}
|
||
title={title}
|
||
copy={copy}
|
||
tone={eyebrow === 'ANÁLISE BLOQUEADA' ? 'red' : 'amber'}
|
||
/>
|
||
);
|
||
}
|
||
```
|
||
|
||
In practice, since `EmptyHistory` is currently used in three places (`WindowHistory`, `ContinuousHistory`, `RangeHistory`) with different eyebrows, the easiest approach is to **leave the function signature as is** and rewrite its body to render `EmptyState`. Replace the entire function body (from `return (` to the matching `}`) with:
|
||
|
||
```tsx
|
||
return (
|
||
<EmptyState
|
||
eyebrow={eyebrow}
|
||
title={title}
|
||
copy={copy}
|
||
tone={eyebrow === 'ANÁLISE BLOQUEADA' ? 'red' : 'amber'}
|
||
/>
|
||
);
|
||
```
|
||
|
||
**Edit 3: replace sessionStrip with TrackRow in WindowHistory**
|
||
|
||
Find the JSX inside `WindowHistory`'s `.map(session => { ... })` block. The current code is approximately:
|
||
|
||
```tsx
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
onPress={() => onOpenSession(session)}
|
||
style={({ pressed }) => [
|
||
styles.sessionStrip,
|
||
pressed ? styles.pressed : null,
|
||
]}
|
||
>
|
||
<View style={styles.dateBlock}>...</View>
|
||
<View style={styles.sessionBody}>...</View>
|
||
{isOpen ? <OpenIcon /> : <ClosedIcon />}
|
||
</Pressable>
|
||
|
||
{isOpen ? (
|
||
<WindowDetail ... />
|
||
) : null}
|
||
```
|
||
|
||
Replace the entire `<Pressable ...>...</Pressable>` block with:
|
||
|
||
```tsx
|
||
<TrackRow
|
||
accessibilityLabel={`Janela ${formatSessionDay(session.startedAt)} ${formatSessionHour(session.startedAt)}`}
|
||
day={formatSessionDay(session.startedAt)}
|
||
hour={formatSessionHour(session.startedAt)}
|
||
isOpen={isOpen}
|
||
meta={`${formatDuration(session.durationMs)} · ${session.sampleCount} amostras`}
|
||
metrics={[
|
||
{ label: `MÉD ${session.averageBpm}` },
|
||
{ label: `PICO ${session.peakBpm}`, tone: 'hot' as const },
|
||
{ label: `MIN ${session.minBpm}` },
|
||
]}
|
||
onPress={() => onOpenSession(session)}
|
||
title={session.deviceName || 'Cinta cardíaca BLE'}
|
||
trackNumber={index + 1}
|
||
>
|
||
{isOpen ? (
|
||
<WindowDetail
|
||
loading={loadingId === session.id}
|
||
metrics={selectedMetrics}
|
||
samples={selectedSamples}
|
||
zones={zones}
|
||
/>
|
||
) : null}
|
||
</TrackRow>
|
||
```
|
||
|
||
**Edit 4: replace sessionStrip with TrackRow in ContinuousHistory**
|
||
|
||
Apply the same pattern as Edit 3, but for `ContinuousHistory`'s map. Replace the `<Pressable ...>...</Pressable>` block with:
|
||
|
||
```tsx
|
||
<TrackRow
|
||
accessibilityLabel={`Uso contínuo ${formatSessionDay(block.startedAt)}`}
|
||
day={formatSessionDay(block.startedAt)}
|
||
hour={formatSessionHour(block.startedAt)}
|
||
isOpen={isOpen}
|
||
meta={formatUsageRange(block) + ' · ' + block.sampleCount + ' amostras'}
|
||
metrics={[
|
||
{ label: `MÉD ${block.averageBpm}` },
|
||
{ label: `PICO ${block.peakBpm}`, tone: 'hot' as const },
|
||
{ label: `MIN ${block.minBpm}` },
|
||
]}
|
||
onPress={() => onOpenBlock(block)}
|
||
title={block.deviceName || 'Uso contínuo'}
|
||
trackNumber={index + 1}
|
||
>
|
||
{isOpen && selectedBlock ? (
|
||
<UsageDetail block={selectedBlock} />
|
||
) : null}
|
||
</TrackRow>
|
||
```
|
||
|
||
- [ ] **Step 2: Remove unused styles**
|
||
|
||
In `styles`, the following style keys are no longer used (because `TrackRow` carries its own): `sessionStrip`, `pressed`, `dateBlock`, `sessionDay`, `sessionHour`, `sessionBody`, `sessionTitle`, `sessionMeta`, `metricLine`, `metricText`, `metricTextHot`. Delete these from the `StyleSheet.create({...})` block to satisfy `npm run lint`.
|
||
|
||
- [ ] **Step 3: Run typecheck and tests**
|
||
|
||
Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm run lint 2>&1 | tail -5 && npm test -- --runInBand 2>&1 | tail -5`
|
||
|
||
Expected: 0 type errors, 0 lint errors, all tests pass.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/screens/HistoryScreen.tsx
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(history): HistoryScreen with TrackRow + EmptyState"
|
||
```
|
||
|
||
---
|
||
|
||
### Task F5.2: SensorScreen refactor
|
||
|
||
**Files:**
|
||
- Modify: `src/screens/SensorScreen.tsx`
|
||
|
||
- [ ] **Step 1: Replace the entire file**
|
||
|
||
Write the entire `src/screens/SensorScreen.tsx` with this content:
|
||
|
||
```tsx
|
||
import React from 'react';
|
||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||
import {
|
||
Battery,
|
||
Bluetooth,
|
||
RadioReceiver,
|
||
ShieldCheck,
|
||
Signal,
|
||
Activity,
|
||
} from 'lucide-react-native';
|
||
import { type HeartRateMonitorState } from '../ble/useHeartRateMonitor';
|
||
import { formatRssi } from '../ble/rssi';
|
||
import { ActionButton } from '../components/ActionButton';
|
||
import { DialTile } from '../components/DialTile';
|
||
import { LiveDot } from '../components/LiveDot';
|
||
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'];
|
||
|
||
const isReceiving = monitor.phase === 'receiving';
|
||
|
||
return (
|
||
<View style={styles.screen}>
|
||
<View style={styles.sensorConsole}>
|
||
<View style={styles.consoleTopline}>
|
||
<View style={styles.consoleLeft}>
|
||
<View style={styles.eyebrowRow}>
|
||
<Text
|
||
style={[
|
||
styles.eyebrowDot,
|
||
{ color: isReceiving ? colors.success : colors.textDim },
|
||
]}
|
||
>
|
||
•
|
||
</Text>
|
||
<Text style={styles.eyebrow}>SENSOR REAL</Text>
|
||
</View>
|
||
<Text style={styles.title}>
|
||
{friendlyDeviceTitle(device?.name)}
|
||
</Text>
|
||
</View>
|
||
<View style={styles.phaseBadge}>
|
||
<Bluetooth color={colors.warning} size={14} strokeWidth={2.4} />
|
||
<Text style={styles.phaseText}>{phaseLabel(monitor.phase)}</Text>
|
||
{isReceiving ? <LiveDot live size={5} /> : null}
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.signalColumn}>
|
||
<DialTile
|
||
label="RSSI"
|
||
value={formatRssi(device?.rssi)}
|
||
tone="amber"
|
||
/>
|
||
<DialTile
|
||
label="LEITURAS"
|
||
value={`${monitor.samples.length}`}
|
||
tone="amber"
|
||
/>
|
||
<DialTile
|
||
label="BATERIA"
|
||
value={formatBattery(device?.batteryPercent)}
|
||
tone="amber"
|
||
/>
|
||
<DialTile
|
||
label="FUNDO"
|
||
value={monitor.batteryOptimizationIgnored ? 'liberado' : 'restrito'}
|
||
tone={monitor.batteryOptimizationIgnored ? 'green' : 'amber'}
|
||
/>
|
||
</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}>
|
||
<LiveDot live size={6} />
|
||
<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 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,
|
||
alignItems: 'center',
|
||
},
|
||
consoleLeft: {
|
||
flex: 1,
|
||
},
|
||
eyebrowRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
},
|
||
eyebrowDot: {
|
||
fontSize: 18,
|
||
lineHeight: 18,
|
||
fontWeight: '900',
|
||
},
|
||
eyebrow: {
|
||
...typography.label,
|
||
color: colors.action,
|
||
},
|
||
title: {
|
||
...typography.detailNumeric,
|
||
color: colors.text,
|
||
fontSize: 22,
|
||
lineHeight: 26,
|
||
letterSpacing: -0.8,
|
||
marginTop: spacing.xs,
|
||
},
|
||
phaseBadge: {
|
||
paddingVertical: spacing.xs,
|
||
paddingHorizontal: spacing.sm,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
height: 32,
|
||
borderRadius: radius.xl,
|
||
borderWidth: 1,
|
||
borderColor: colors.line,
|
||
backgroundColor: colors.surface,
|
||
},
|
||
phaseText: {
|
||
...typography.label,
|
||
color: colors.text,
|
||
},
|
||
signalColumn: {
|
||
borderTopWidth: 1,
|
||
borderColor: colors.line,
|
||
},
|
||
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: 6,
|
||
},
|
||
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',
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run typecheck, lint, and tests**
|
||
|
||
Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm run lint 2>&1 | tail -5 && npm test -- --runInBand 2>&1 | tail -5`
|
||
|
||
Expected: 0 type errors, 0 lint errors, all tests pass.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/screens/SensorScreen.tsx
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(sensor): SensorScreen with DialTile + LiveDot"
|
||
```
|
||
|
||
---
|
||
|
||
## F6 — Insights + Nav (T6.1 + T6.2)
|
||
|
||
### Task F6.1: InsightsScreen refactor
|
||
|
||
**Files:**
|
||
- Modify: `src/screens/InsightsScreen.tsx`
|
||
|
||
- [ ] **Step 1: Replace the entire file**
|
||
|
||
Write the entire `src/screens/InsightsScreen.tsx` with this content:
|
||
|
||
```tsx
|
||
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 { EmptyState } from '../components/EmptyState';
|
||
import { InsightRow } from '../components/InsightRow';
|
||
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}>
|
||
<EmptyState
|
||
eyebrow="ANÁLISE BLOQUEADA"
|
||
title="Sem dado real, sem conclusão"
|
||
copy="Ative o monitor contínuo. O FightRate não inventa pico, zona, carga, VFC ou recuperação."
|
||
tone="red"
|
||
/>
|
||
</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) => (
|
||
<InsightRow
|
||
key={row.key}
|
||
detail={row.detail}
|
||
index={index + 1}
|
||
label={row.label}
|
||
statusLabel={row.statusLabel}
|
||
summary={row.summary}
|
||
testID={`insight-${row.key}`}
|
||
tone={row.tone}
|
||
unit={row.unit}
|
||
value={row.value}
|
||
/>
|
||
))}
|
||
</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 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 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,
|
||
},
|
||
eyebrow: {
|
||
...typography.label,
|
||
color: colors.action,
|
||
},
|
||
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: 4,
|
||
borderRadius: 1,
|
||
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: {
|
||
...typography.displaySmallNumeric,
|
||
fontSize: 36,
|
||
lineHeight: 38,
|
||
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: {
|
||
...typography.detailNumeric,
|
||
},
|
||
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,
|
||
},
|
||
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',
|
||
borderRadius: 2,
|
||
},
|
||
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: 28,
|
||
},
|
||
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: {
|
||
...typography.detailNumeric,
|
||
color: colors.text,
|
||
fontSize: 17,
|
||
lineHeight: 23,
|
||
marginTop: spacing.xs,
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run typecheck, lint, and tests**
|
||
|
||
Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm run lint 2>&1 | tail -5 && npm test -- --runInBand 2>&1 | tail -5`
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/screens/InsightsScreen.tsx
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(insights): InsightsScreen with InsightRow + EmptyState"
|
||
```
|
||
|
||
---
|
||
|
||
### Task F6.2: FightRateApp — tab fade, brand LiveDot, tab live indicator
|
||
|
||
**Files:**
|
||
- Modify: `src/app/FightRateApp.tsx`
|
||
|
||
- [ ] **Step 1: Replace the entire file**
|
||
|
||
Write the entire `src/app/FightRateApp.tsx` with this content:
|
||
|
||
```tsx
|
||
import React, { useCallback, useEffect, 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 Animated, {
|
||
Easing,
|
||
useAnimatedStyle,
|
||
useSharedValue,
|
||
withTiming,
|
||
} from 'react-native-reanimated';
|
||
import { useHeartRateMonitor } from '../ble/useHeartRateMonitor';
|
||
import { LiveDot } from '../components/LiveDot';
|
||
import { CaptureScreen } from '../screens/CaptureScreen';
|
||
import { HistoryScreen } from '../screens/HistoryScreen';
|
||
import { InsightsScreen } from '../screens/InsightsScreen';
|
||
import { SensorScreen } from '../screens/SensorScreen';
|
||
import { colors, motion, 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 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 () => {
|
||
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]);
|
||
|
||
const isAnyLive = monitor.isMonitoring || monitor.isCapturing;
|
||
|
||
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}>
|
||
<View style={styles.kickerRow}>
|
||
{isAnyLive ? <LiveDot live size={6} /> : null}
|
||
<Text style={styles.kicker}>FightRate</Text>
|
||
</View>
|
||
<Text style={styles.title}>{activeTitle}</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<Animated.View style={fadeStyle} key={`tab-${activeTab}`}>
|
||
{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}
|
||
</Animated.View>
|
||
</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;
|
||
const isTabLive =
|
||
(tab.key === 'capture' && monitor.isCapturing) ||
|
||
(tab.key === 'sensor' && monitor.isMonitoring);
|
||
return (
|
||
<Pressable
|
||
accessibilityRole="tab"
|
||
accessibilityState={{ selected }}
|
||
key={tab.key}
|
||
onPress={() => setActiveTab(tab.key)}
|
||
style={[styles.navItem, selected ? styles.navItemActive : null]}
|
||
>
|
||
<View style={styles.navIconRow}>
|
||
<Icon
|
||
color={selected ? colors.text : colors.textMuted}
|
||
size={18}
|
||
strokeWidth={2.4}
|
||
/>
|
||
{isTabLive ? (
|
||
<View style={styles.tabLiveDot}>
|
||
<LiveDot live size={4} />
|
||
</View>
|
||
) : null}
|
||
</View>
|
||
<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,
|
||
},
|
||
kickerRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
},
|
||
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,
|
||
},
|
||
navIconRow: {
|
||
position: 'relative',
|
||
},
|
||
tabLiveDot: {
|
||
position: 'absolute',
|
||
top: -2,
|
||
right: -6,
|
||
},
|
||
navLabel: {
|
||
...typography.label,
|
||
color: colors.textMuted,
|
||
fontSize: 9,
|
||
},
|
||
navLabelActive: {
|
||
color: colors.text,
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run typecheck, lint, and tests**
|
||
|
||
Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm run lint 2>&1 | tail -5 && npm test -- --runInBand 2>&1 | tail -5`
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add src/app/FightRateApp.tsx
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(app): FightRateApp tab fade + brand LiveDot + tab live indicator"
|
||
```
|
||
|
||
---
|
||
|
||
## F7 — Verify + Ship (T7.1 + T7.2 + T7.3 + T7.4)
|
||
|
||
### Task F7.1: Update FIGHTRATE_DESIGN_SYSTEM.md with new tokens
|
||
|
||
**Files:**
|
||
- Modify: `FIGHTRATE_DESIGN_SYSTEM.md`
|
||
|
||
- [ ] **Step 1: Append new section after section 4 (Tipografia)**
|
||
|
||
Use `edit` to insert this block immediately after the end of section 4 (the `#### Texto explicativo` subsection in `FIGHTRATE_DESIGN_SYSTEM.md`):
|
||
|
||
```markdown
|
||
### Tipografia numérica tabular (v2)
|
||
|
||
Para todos os valores numéricos do app (BPM, métricas, zone, timestamps, IDs, UUIDs), o app usa **JetBrains Mono** com `fontVariant: ['tabular-nums']` para alinhamento perfeito de dígitos. Esta fonte está bundled em `assets/fonts/JetBrainsMono-{Regular,Medium,Bold,ExtraBold}.ttf` e linkada via `react-native.config.js` + `react-native-asset`.
|
||
|
||
Tokens em `src/theme/typography.ts`:
|
||
|
||
```ts
|
||
displayNumeric: { fontSize: 110, lineHeight: 100, fontWeight: '900', letterSpacing: -7 } // BPM principal
|
||
displaySmallNumeric: { fontSize: 56, lineHeight: 54, fontWeight: '900', letterSpacing: -3 } // Métricas grandes
|
||
metricNumeric: { fontSize: 28, lineHeight: 30, fontWeight: '800', letterSpacing: -1.2 } // Tiles
|
||
detailNumeric: { fontSize: 18, lineHeight: 22, fontWeight: '800', letterSpacing: -0.8 } // Detalhes
|
||
```
|
||
|
||
`display`, `displaySmall`, `body`, `label`, `button` permanecem em Inter (fallback do sistema). Apenas os tokens numéricos exigem JetBrains Mono.
|
||
```
|
||
|
||
- [ ] **Step 2: Append a new section after section 9 (Motion)**
|
||
|
||
Insert this block after section 9:
|
||
|
||
```markdown
|
||
### Motion presets (v2)
|
||
|
||
`src/theme/motion.ts` define easing e durações canônicas:
|
||
|
||
```ts
|
||
easeOutQuart: 'cubic-bezier(0.25, 1, 0.5, 1)' // entradas
|
||
easeOutExpo: 'cubic-bezier(0.16, 1, 0.3, 1)' // transições longas
|
||
easeOutBack: 'cubic-bezier(0.34, 1.56, 0.64, 1)' // pin (overshoot sutil)
|
||
easeInOutQuart: 'cubic-bezier(0.76, 0, 0.24, 1)' // pulse loops
|
||
|
||
duration: { instant: 80, fast: 160, normal: 240, slow: 380, pulse: 800, pulseSlow: 1600 }
|
||
stagger: { row: 50, item: 80 }
|
||
```
|
||
|
||
Animações registradas:
|
||
|
||
- `bpmPulse` (loop, 800ms) — BPM quando `receiving`
|
||
- `liveDotPulse` (loop, 1600ms) — LiveDot quando `live = true`
|
||
- `stopAlertRing` (loop, 1600ms) — anel de aviso em volta do botão Parar quando `isCapturing`
|
||
- `tabFade` (200ms cross-fade) — entre tabs
|
||
- `rowEnter` (240ms fade+translateY 8→0, stagger 50ms) — listas de Histórico e Insights
|
||
- `pinEnter` (500ms easeOutBack 1.4→1) — pin do TrainingChart
|
||
- `pressDown/Up` (80/160ms scale 1→0.96→1, opacity 1→0.82→1) — feedback físico de todos os botões
|
||
|
||
Todas respeitam `AccessibilityInfo.isReduceMotionEnabled()` (instantâneas quando `true`).
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add FIGHTRATE_DESIGN_SYSTEM.md
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "docs(design): update FIGHTRATE_DESIGN_SYSTEM.md with numeric typography + motion presets"
|
||
```
|
||
|
||
---
|
||
|
||
### Task F7.2: Final verify (typecheck + lint + test + audit)
|
||
|
||
**Files:** none (verification only)
|
||
|
||
- [ ] **Step 1: typecheck**
|
||
|
||
Run: `cd /d/h && npm run typecheck 2>&1 | tail -5`
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 2: lint**
|
||
|
||
Run: `cd /d/h && npm run lint 2>&1 | tail -5`
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 3: tests**
|
||
|
||
Run: `cd /d/h && npm test -- --runInBand 2>&1 | tail -10`
|
||
|
||
Expected: all tests pass (55 antigos + 9 novos ≈ 64).
|
||
|
||
- [ ] **Step 4: audit**
|
||
|
||
Run: `cd /d/h && npm audit --audit-level=moderate 2>&1 | tail -5`
|
||
|
||
Expected: 0 vulnerabilities at moderate or higher.
|
||
|
||
- [ ] **Step 5: If any step fails, fix inline and rerun until all 4 pass**
|
||
|
||
- [ ] **Step 6: Commit any fixes**
|
||
|
||
```bash
|
||
cd /d/h && git add -A
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "chore: final verify pass (typecheck + lint + test + audit)" --allow-empty
|
||
```
|
||
|
||
---
|
||
|
||
### Task F7.3: Build release APK
|
||
|
||
**Files:** none (build verification only)
|
||
|
||
- [ ] **Step 1: Confirm react-native-asset linked fonts (if Step F1.1.3 used direct copy)**
|
||
|
||
Run: `ls /d/h/android/app/src/main/assets/fonts/`
|
||
|
||
Expected: 4 ttf files.
|
||
|
||
- [ ] **Step 2: Build release APK**
|
||
|
||
Run:
|
||
```bash
|
||
cd /d/h/android
|
||
$env:JAVA_HOME = "C:\Users\Evandro\AppData\Local\Programs\Android Studio\jbr"
|
||
$env:Path = "$env:JAVA_HOME\bin;$env:Path"
|
||
./gradlew.bat :app:assembleRelease -PreactNativeArchitectures=arm64-v8a 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: `BUILD SUCCESSFUL` and `app-arm64-v8a-release.apk` produced in `android/app/build/outputs/apk/release/`.
|
||
|
||
- [ ] **Step 3: Verify APK contains fonts**
|
||
|
||
Run: `unzip -l /d/h/android/app/build/outputs/apk/release/app-arm64-v8a-release.apk | grep -i jetbrains`
|
||
|
||
Expected: 4 entries for `assets/fonts/JetBrainsMono-*.ttf`.
|
||
|
||
- [ ] **Step 4: Compress APK to 7z for Telegram**
|
||
|
||
Run:
|
||
```bash
|
||
cd /d/h
|
||
"C:/Users/Evandro/AppData/Local/Microsoft/WindowsApps/7z.exe" a -t7z -mx=9 -m0=lzma2 "android/app/build/outputs/apk/release/app-arm64-v8a-release.apk.7z" "android/app/build/outputs/apk/release/app-arm64-v8a-release.apk" 2>&1 | tail -3
|
||
ls -lh android/app/build/outputs/apk/release/
|
||
```
|
||
|
||
Expected: 7z file ~ 9-12 MB (depends on font delta).
|
||
|
||
- [ ] **Step 5: Commit the 7z archive to a build artifact dir (optional, for traceability)**
|
||
|
||
```bash
|
||
cd /d/h && git add android/app/build/outputs/apk/release/*.7z 2>/dev/null
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "build: release APK v2 (Instrumento Forte)" 2>&1 | tail -3
|
||
```
|
||
|
||
---
|
||
|
||
### Task F7.4: Send APK via Telegram and update .impeccable.md
|
||
|
||
**Files:**
|
||
- Modify: `.impeccable.md`
|
||
|
||
- [ ] **Step 1: Send via Telegram**
|
||
|
||
Call the `telegram_attach` tool with the 7z path:
|
||
|
||
```ts
|
||
telegram_attach({
|
||
paths: ['D:\\h\\android\\app\\build\\outputs\\apk\\release\\app-arm64-v8a-release.apk.7z'],
|
||
caption: '🎯 FightRate v2 — Instrumento Forte (release APK, ~9-12 MB)\n\nRefinamento UI/UX concluído:\n- JetBrains Mono tabular em todos os displays numéricos\n- LiveDot pulsante (LED) em AO VIVO, BPM, tab bar\n- Anel de aviso no botão Parar durante captura\n- Furos laterais no TrainingChart (estilo ECG)\n- DialTile no SensorScreen\n- TrackRow T01..Tn no Histórico\n- InsightRow com stagger reveal\n- Press feedback refinado em todos os botões\n- applicationId preservado: com.healthtracking.fightrate\n\nPara instalar: descompacte o 7z e rode `adb install -r app-arm64-v8a-release.apk`',
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Update .impeccable.md**
|
||
|
||
Append this paragraph at the end of `.impeccable.md`:
|
||
|
||
```markdown
|
||
|
||
## Estado atual (2026-06-15)
|
||
|
||
Direção 01 — Corner Instrument refinada para **Instrumento Forte** (release v2). Mudanças:
|
||
- JetBrains Mono tabular em displays numéricos.
|
||
- 6 novos componentes (LiveDot, PressableSurface, EmptyState, DialTile, TrackRow, InsightRow) e 2 hooks (usePulseAnimation, useMotionPreference).
|
||
- Animações orquestradas (LiveDot pulse, BPM pulse, stop alert ring, tab cross-fade, stagger reveal em listas).
|
||
- TrainingChart refinado (furos laterais estilo ECG, LiveDot no readout).
|
||
- BLE, SQLite, hooks de monitor NÃO foram tocados.
|
||
|
||
Próxima iteração: avaliar Direção C (Cinemática) — entrada de tela com reveal, transições slide, sombras direcionais.
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /d/h && git add .impeccable.md
|
||
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "docs: update .impeccable.md with v2 status"
|
||
```
|
||
|
||
- [ ] **Step 4: Final summary to user**
|
||
|
||
After all tasks complete, present a final summary in PT-BR with:
|
||
- Number of new tests (and total).
|
||
- APK size before/after.
|
||
- Files changed.
|
||
- Brief description of each refinement.
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
### Spec coverage
|
||
|
||
Each spec section maps to a task:
|
||
|
||
- **§3.1 Tipografia** → F1.1 (font link) + F1.3 (typography tokens).
|
||
- **§3.2 Motion** → F1.2 (motion.ts).
|
||
- **§3.3 Cores** → F1.4 (ringWarn).
|
||
- **§3.4 Spacing** → no changes (covered by existing tokens).
|
||
- **§4.1 LiveDot** → F2.2.
|
||
- **§4.2 PressableSurface** → F2.3.
|
||
- **§4.3 EmptyState** → F2.4.
|
||
- **§4.4 DialTile** → F2.5.
|
||
- **§4.5 TrackRow** → F2.6.
|
||
- **§4.6 InsightRow** → F2.7.
|
||
- **§4.7 usePulseAnimation** → F2.1.
|
||
- **§5.1 ActionButton** → F3.1.
|
||
- **§5.2 TrainingChart** → F4.1.
|
||
- **§5.3 SectionPanel** → F3.3.
|
||
- **§5.4 MetricTile** → F3.4.
|
||
- **§5.5 StatusTag** → F3.2.
|
||
- **§6.1 CaptureScreen** → F4.2.
|
||
- **§6.2 HistoryScreen** → F5.1.
|
||
- **§6.3 SensorScreen** → F5.2.
|
||
- **§6.4 InsightsScreen** → F6.1.
|
||
- **§6.5 FightRateApp** → F6.2.
|
||
- **§7 Animações** → spread across F2 (hooks) + F3 (ActionButton ring) + F4 (chart readout) + F6 (tab fade, stagger).
|
||
- **§9 Critérios de aceitação** → verified by F7.2.
|
||
- **§11 Riscos** → mitigated by F1.1.3 fallback to direct copy.
|
||
|
||
### Placeholder scan
|
||
|
||
Searched the plan for "TBD", "TODO", "implement later", "fill in details", "appropriate error handling", "similar to Task N". No matches.
|
||
|
||
### Type consistency
|
||
|
||
- `LiveDot` props: `live`, `size`, `testID` — used consistently.
|
||
- `PressableSurface` props: `children`, `onPress`, `disabled`, `style`, `accessibilityLabel`, `testID` — used consistently.
|
||
- `EmptyState` props: `eyebrow`, `title`, `copy`, `tone`, `testID` — used consistently.
|
||
- `DialTile` props: `label`, `value`, `tone`, `testID` — used consistently.
|
||
- `TrackRow` props: `trackNumber`, `day`, `hour`, `title`, `meta`, `metrics`, `isOpen`, `onPress`, `accessibilityLabel`, `children`, `testID` — used consistently.
|
||
- `InsightRow` props: `index`, `label`, `statusLabel`, `summary`, `detail`, `value`, `unit?`, `tone`, `testID` — used consistently.
|
||
- `usePulseAnimation(active, durationMs?)` — single signature.
|
||
- `useMotionPreference()` — no args, returns boolean.
|
||
|
||
### No backwards-incompatible changes
|
||
|
||
- All new props are optional with sensible defaults.
|
||
- Existing imports stay working.
|
||
- BLE, SQLite, monitor hooks untouched.
|