feat(components): ActionButton stopAlert + typography type fixes
No files matched your search
@@ -0,0 +1,26 @@
|
||||
# FightRate Design Context
|
||||
|
||||
## Design Context
|
||||
|
||||
### Users
|
||||
FightRate is for combat-sport athletes and coaches during boxing, Muay Thai, jiu-jitsu, MMA, conditioning, pad work, sparring, and heavy bag sessions. The athlete starts the app before training, leaves the phone aside, and expects heart-rate monitoring to keep running while the screen is locked. During training they cannot babysit the interface.
|
||||
|
||||
### Use Cases
|
||||
- Monitor heart rate continuously all day/session-long from a BLE chest strap such as Magene H003.
|
||||
- Mark a training window by pressing start and stop; that window is highlighted later in history while the underlying heart-rate stream continues to be stored.
|
||||
- Open saved history items and inspect the exact training window with real timestamped BPM data.
|
||||
- Extract as much useful training information as possible from captured data: max/min/avg BPM, zones, load, effort spikes, recovery patterns, density, and session notes.
|
||||
- Diagnose sensor state clearly without fake statuses or fake data.
|
||||
|
||||
### Brand Personality
|
||||
Industrial, precise, uncompromising. The product should feel like a physical corner instrument used by a fight team: durable, technical, serious, and immediately legible under pressure.
|
||||
|
||||
### Aesthetic Direction
|
||||
Corner Instrument. Dark, physical, rugged surfaces; high-contrast numbers; technical strips; timeline/window metaphors; no generic SaaS cards, no nested cards, no decorative fake analytics, no soft consumer wellness look. The interface should look like training equipment, not a dashboard template.
|
||||
|
||||
### Design Principles
|
||||
1. Continuous capture is the core product. A workout is a marked window inside the continuous stream, not the whole lifetime of monitoring.
|
||||
2. Every metric must come from real timestamped samples. Empty states must explain what data is missing.
|
||||
3. Replace generic cards with instrument surfaces, rails, strips, separators, and timeline details.
|
||||
4. History must be inspectable: saved windows open into detailed, timestamped training evidence.
|
||||
5. UI copy stays PT-BR; code identifiers stay English.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Deprecated — FightRate Combat Lab Design System
|
||||
|
||||
Este design system foi rejeitado e não deve mais ser usado.
|
||||
|
||||
Fonte de verdade atual:
|
||||
|
||||
```text
|
||||
FIGHTRATE_DESIGN_SYSTEM.md
|
||||
```
|
||||
|
||||
Direção oficial escolhida:
|
||||
|
||||
```text
|
||||
01 — Corner Instrument
|
||||
```
|
||||
|
||||
Regra:
|
||||
|
||||
- Não usar Combat Lab como base visual.
|
||||
- Não reaproveitar os padrões antigos de card/dashboard.
|
||||
- Não tratar round manual como fluxo principal.
|
||||
- Para a reconstrução React Native, seguir somente `FIGHTRATE_DESIGN_SYSTEM.md`.
|
||||
@@ -0,0 +1,513 @@
|
||||
# FightRate Design System — Corner Instrument
|
||||
|
||||
Status: **oficial para a reconstrução em React Native**
|
||||
Direção escolhida: **01 — Corner Instrument**
|
||||
Substitui: qualquer direção antiga baseada em Combat Lab, cards genéricos ou dashboard visualmente pesado.
|
||||
|
||||
---
|
||||
|
||||
## 1. Essência da marca
|
||||
|
||||
FightRate é uma ferramenta de treino para luta.
|
||||
|
||||
A interface deve parecer:
|
||||
|
||||
- equipamento de córner;
|
||||
- instrumento físico de captura;
|
||||
- confiável sob pressão;
|
||||
- legível em um celular largado perto do treino;
|
||||
- forte, mas não decorativa.
|
||||
|
||||
A interface **não** deve parecer:
|
||||
|
||||
- dashboard SaaS;
|
||||
- app fitness genérico;
|
||||
- coleção de cards;
|
||||
- UI médica fria;
|
||||
- layout de template;
|
||||
- tela cheia de controles que o atleta precisa operar no meio da luta.
|
||||
|
||||
Frase-guia:
|
||||
|
||||
> Treino ligado. Celular esquecido. Dados confiáveis depois.
|
||||
|
||||
---
|
||||
|
||||
## 2. Modelo de produto
|
||||
|
||||
O fluxo principal é captura contínua do treino inteiro.
|
||||
|
||||
1. Atleta coloca a cinta.
|
||||
2. Abre o app.
|
||||
3. Toca em **Iniciar treino**.
|
||||
4. Larga o celular.
|
||||
5. Treina.
|
||||
6. Volta e toca em **Parar treino**.
|
||||
7. Analisa histórico, relatório, sensor e insights.
|
||||
|
||||
Rounds são secundários. Se existirem, devem ser pré-configurados ou detectados/segmentados depois. Não devem dominar a tela principal.
|
||||
|
||||
---
|
||||
|
||||
## 3. Paleta 60 / 30 / 10
|
||||
|
||||
### 60% — Preto lona
|
||||
|
||||
```text
|
||||
#070605
|
||||
```
|
||||
|
||||
Uso:
|
||||
|
||||
- fundo da captura ativa;
|
||||
- superfícies profundas;
|
||||
- atmosfera de treino real.
|
||||
|
||||
### 30% — Couro queimado
|
||||
|
||||
```text
|
||||
#1D170F
|
||||
#12100C
|
||||
#19130C
|
||||
```
|
||||
|
||||
Uso:
|
||||
|
||||
- painéis;
|
||||
- blocos de métrica;
|
||||
- trilhos de gráfico;
|
||||
- superfícies secundárias.
|
||||
|
||||
### 10% — Vermelho golpe
|
||||
|
||||
```text
|
||||
#E23B2E
|
||||
```
|
||||
|
||||
Uso:
|
||||
|
||||
- ação primária;
|
||||
- pico;
|
||||
- risco;
|
||||
- estado intenso;
|
||||
- pontos de atenção.
|
||||
|
||||
Não usar vermelho como decoração espalhada. Se tudo é vermelho, nada é urgente.
|
||||
|
||||
### Auxiliares
|
||||
|
||||
```text
|
||||
#FFB43D âmbar / leitura auxiliar
|
||||
#34C77B verde / sucesso ou estabilidade
|
||||
#FFF0DC creme / ação crítica clara, como Parar treino
|
||||
#B9A98E texto secundário
|
||||
#392D1E borda técnica quente
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Tipografia
|
||||
|
||||
### Regra
|
||||
|
||||
Número vem primeiro.
|
||||
|
||||
Durante captura, o usuário precisa entender o estado em menos de 1 segundo.
|
||||
|
||||
### Hierarquia
|
||||
|
||||
#### Display numérico
|
||||
|
||||
Uso:
|
||||
|
||||
- BPM atual;
|
||||
- duração;
|
||||
- pico;
|
||||
- TRIMP/carga;
|
||||
- recuperação.
|
||||
|
||||
Estilo:
|
||||
|
||||
```text
|
||||
font-weight: 900–1000
|
||||
letter-spacing: -0.08em a -0.10em
|
||||
line-height: 0.82–0.90
|
||||
```
|
||||
|
||||
Tamanhos de referência mobile:
|
||||
|
||||
```text
|
||||
BPM principal: 72–88px
|
||||
Métrica secundária grande: 42–56px
|
||||
Tile numérico: 22–28px
|
||||
```
|
||||
|
||||
#### Labels técnicos
|
||||
|
||||
Uso:
|
||||
|
||||
- CAPTURA ATIVA;
|
||||
- SENSOR OK;
|
||||
- MÉDIA;
|
||||
- PICO;
|
||||
- Z4/Z5;
|
||||
- RSSI.
|
||||
|
||||
Estilo:
|
||||
|
||||
```text
|
||||
uppercase
|
||||
font-weight: 850–950
|
||||
letter-spacing: 0.12em–0.18em
|
||||
font-size: 10–12px
|
||||
```
|
||||
|
||||
#### Texto explicativo
|
||||
|
||||
Uso só quando ajuda decisão:
|
||||
|
||||
- pré-treino;
|
||||
- pós-treino;
|
||||
- insights;
|
||||
- diagnóstico sensor.
|
||||
|
||||
Evitar parágrafos longos durante captura ativa.
|
||||
|
||||
---
|
||||
|
||||
## 5. Forma, borda e raio
|
||||
|
||||
### Raios
|
||||
|
||||
```text
|
||||
r1: 10px elementos pequenos técnicos
|
||||
r2: 16px inputs e linhas técnicas
|
||||
r3: 24px tiles e módulos
|
||||
r4: 36px painéis principais
|
||||
r5: 48px carcaça / phone shell / hero surfaces
|
||||
```
|
||||
|
||||
### Bordas
|
||||
|
||||
Cor base:
|
||||
|
||||
```text
|
||||
#392D1E
|
||||
```
|
||||
|
||||
Bordas são parte da metáfora de instrumento físico. Devem parecer metal/couro queimado, nunca cinza genérico.
|
||||
|
||||
### Sombra
|
||||
|
||||
Usar sombra profunda apenas em:
|
||||
|
||||
- carcaça de tela;
|
||||
- painéis principais;
|
||||
- overlays críticos.
|
||||
|
||||
Evitar sombra em cada tile.
|
||||
|
||||
---
|
||||
|
||||
## 6. Componentes base
|
||||
|
||||
### 6.1 Capture Shell
|
||||
|
||||
Função: conter a experiência de treino ativo.
|
||||
|
||||
Características:
|
||||
|
||||
- fundo escuro;
|
||||
- BPM dominante;
|
||||
- status no topo;
|
||||
- gráfico compacto;
|
||||
- métricas essenciais;
|
||||
- ação de parar muito clara.
|
||||
|
||||
Conteúdo máximo:
|
||||
|
||||
```text
|
||||
CAPTURA ATIVA 42:18
|
||||
154 bpm
|
||||
[linha temporal]
|
||||
MÉDIA 146 | PICO 183 | Z4/Z5 41%
|
||||
[Evento] [Parar treino]
|
||||
```
|
||||
|
||||
### 6.2 Primary Action
|
||||
|
||||
Uso:
|
||||
|
||||
- Iniciar treino;
|
||||
- abrir relatório;
|
||||
- ação principal do estado atual.
|
||||
|
||||
Estilo:
|
||||
|
||||
- pill;
|
||||
- vermelho golpe;
|
||||
- texto curto;
|
||||
- opcional com ícone circular interno.
|
||||
|
||||
### 6.3 Stop Action
|
||||
|
||||
Uso:
|
||||
|
||||
- Parar treino.
|
||||
|
||||
Estilo:
|
||||
|
||||
```text
|
||||
background: #FFF0DC
|
||||
text: #120B07
|
||||
border: none
|
||||
```
|
||||
|
||||
Motivo: precisa ser imediatamente reconhecível, sem parecer alerta destrutivo escondido.
|
||||
|
||||
### 6.4 Metric Tile
|
||||
|
||||
Uso:
|
||||
|
||||
- média;
|
||||
- pico;
|
||||
- zona;
|
||||
- carga;
|
||||
- recuperação;
|
||||
- RSSI;
|
||||
- bateria.
|
||||
|
||||
Estrutura:
|
||||
|
||||
```text
|
||||
valor grande
|
||||
label técnico pequeno
|
||||
```
|
||||
|
||||
Não transformar cada métrica em card pesado.
|
||||
|
||||
### 6.5 Sensor Row
|
||||
|
||||
Uso:
|
||||
|
||||
- UUID;
|
||||
- propriedade BLE;
|
||||
- estado;
|
||||
- leitura.
|
||||
|
||||
Exemplo:
|
||||
|
||||
```text
|
||||
2A37 · notify
|
||||
2A19 · read
|
||||
RSSI · -61 dBm
|
||||
```
|
||||
|
||||
Usar monospace pontual apenas para UUIDs, logs e identificadores BLE.
|
||||
|
||||
### 6.6 Insight Note
|
||||
|
||||
Estrutura:
|
||||
|
||||
```text
|
||||
Conclusão
|
||||
Por que importa
|
||||
O que fazer agora
|
||||
Base/confiança dos dados
|
||||
```
|
||||
|
||||
Insight bom é curto, explicável e acionável.
|
||||
|
||||
---
|
||||
|
||||
## 7. Gráficos
|
||||
|
||||
### Princípios
|
||||
|
||||
- Nunca mostrar gráfico fake.
|
||||
- Nunca mostrar eixo vazio decorativo.
|
||||
- Só renderizar linha com dados reais suficientes.
|
||||
- O viewport inicial deve mostrar a janela selecionada inteira.
|
||||
- Zoom é ação do usuário, não estado inicial.
|
||||
|
||||
### Visual
|
||||
|
||||
- fundo escuro técnico;
|
||||
- grade sutil;
|
||||
- linha vermelha principal;
|
||||
- labels mínimos;
|
||||
- destaque de pico e média fora do gráfico, não poluindo a linha.
|
||||
|
||||
### Densidade temporal
|
||||
|
||||
Labels devem se adaptar:
|
||||
|
||||
```text
|
||||
10 min: HH:mm
|
||||
1h: HH:mm limpo
|
||||
Hoje: horas principais
|
||||
Zoom profundo: granularidade progressiva
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Telas
|
||||
|
||||
### Captura pronta
|
||||
|
||||
Objetivo: começar rápido.
|
||||
|
||||
Deve mostrar:
|
||||
|
||||
- status do sensor;
|
||||
- bateria;
|
||||
- RSSI;
|
||||
- ação Iniciar treino;
|
||||
- check-in opcional.
|
||||
|
||||
Não deve mostrar:
|
||||
|
||||
- gráfico vazio;
|
||||
- round timer dominante;
|
||||
- histórico;
|
||||
- insights longos.
|
||||
|
||||
### Captura ativa
|
||||
|
||||
Objetivo: monitorar sem interação.
|
||||
|
||||
Deve mostrar:
|
||||
|
||||
- BPM grande;
|
||||
- duração;
|
||||
- média;
|
||||
- pico;
|
||||
- tempo em zona/carga;
|
||||
- sensor OK;
|
||||
- parar treino.
|
||||
|
||||
### Pós-treino
|
||||
|
||||
Objetivo: transformar captura em interpretação.
|
||||
|
||||
Deve mostrar:
|
||||
|
||||
- duração;
|
||||
- carga;
|
||||
- pico;
|
||||
- blocos do treino;
|
||||
- recuperação;
|
||||
- exportação;
|
||||
- relatório completo.
|
||||
|
||||
### Histórico
|
||||
|
||||
Objetivo: análise densa, organizada e útil.
|
||||
|
||||
Deve mostrar:
|
||||
|
||||
- semanas;
|
||||
- sessões;
|
||||
- tendência;
|
||||
- melhores trechos;
|
||||
- comparação;
|
||||
- carga total;
|
||||
- consistência.
|
||||
|
||||
### Sensor
|
||||
|
||||
Objetivo: confiança técnica.
|
||||
|
||||
Deve mostrar:
|
||||
|
||||
- dispositivo conectado;
|
||||
- RSSI;
|
||||
- bateria;
|
||||
- serviços BLE;
|
||||
- características;
|
||||
- propriedades;
|
||||
- quedas de sinal;
|
||||
- última amostra;
|
||||
- taxa aproximada de amostragem.
|
||||
|
||||
---
|
||||
|
||||
## 9. Motion
|
||||
|
||||
Motion deve ser funcional.
|
||||
|
||||
Permitido:
|
||||
|
||||
- transição suave de BPM;
|
||||
- pulso discreto em status ativo;
|
||||
- entrada curta de linhas no histórico;
|
||||
- expansão de detalhes do sensor.
|
||||
|
||||
Evitar:
|
||||
|
||||
- animações decorativas grandes;
|
||||
- blur pesado;
|
||||
- gradiente animado;
|
||||
- movimento durante treino que distraia.
|
||||
|
||||
---
|
||||
|
||||
## 10. React Native tokens sugeridos
|
||||
|
||||
```ts
|
||||
export const colors = {
|
||||
canvas: '#070605',
|
||||
surface: '#12100C',
|
||||
surfaceRaised: '#1D170F',
|
||||
text: '#F8EAD8',
|
||||
textMuted: '#B9A98E',
|
||||
line: '#392D1E',
|
||||
action: '#E23B2E',
|
||||
warning: '#FFB43D',
|
||||
success: '#34C77B',
|
||||
stopSurface: '#FFF0DC',
|
||||
stopText: '#120B07',
|
||||
}
|
||||
|
||||
export const radius = {
|
||||
xs: 10,
|
||||
sm: 16,
|
||||
md: 24,
|
||||
lg: 36,
|
||||
xl: 48,
|
||||
}
|
||||
|
||||
export const spacing = {
|
||||
xxs: 4,
|
||||
xs: 8,
|
||||
sm: 12,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
xxl: 48,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Proibição explícita
|
||||
|
||||
Não usar mais a direção visual antiga do app nativo.
|
||||
|
||||
Não usar como base:
|
||||
|
||||
- Combat Lab antigo;
|
||||
- cards genéricos;
|
||||
- Material dashboard padrão;
|
||||
- tela cheia de tiles sem hierarquia;
|
||||
- round timer manual como produto principal.
|
||||
|
||||
---
|
||||
|
||||
## 12. Artefato visual de referência
|
||||
|
||||
HTML aprovado como base de exploração:
|
||||
|
||||
```text
|
||||
docs/design-directions/react-native-redesign-2026-06-10/v2/01-corner-instrument-v2.html
|
||||
```
|
||||
@@ -1,97 +1,84 @@
|
||||
This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli).
|
||||
# FightRateRN
|
||||
|
||||
# Getting Started
|
||||
React Native Community CLI rebuild of FightRate. This app is **bare React Native only** and does not use Expo.
|
||||
|
||||
> **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding.
|
||||
## Visual source of truth
|
||||
|
||||
## Step 1: Start Metro
|
||||
The app follows the selected FightRate direction:
|
||||
|
||||
First, you will need to run **Metro**, the JavaScript build tool for React Native.
|
||||
- `../FIGHTRATE_DESIGN_SYSTEM.md`
|
||||
- Direction: `01 — Corner Instrument`
|
||||
|
||||
To start the Metro dev server, run the following command from the root of your React Native project:
|
||||
Do not reuse the deprecated Combat Lab/card-heavy direction.
|
||||
|
||||
```sh
|
||||
# Using npm
|
||||
npm start
|
||||
## Current slice
|
||||
|
||||
# OR using Yarn
|
||||
yarn start
|
||||
Implemented in this first slice:
|
||||
|
||||
- Corner Instrument app shell.
|
||||
- Continuous capture-first home screen.
|
||||
- History, Sensor, and Insights screens.
|
||||
- Centralized design tokens in `src/theme`.
|
||||
- Reusable UI components in `src/components`.
|
||||
- No Expo dependencies.
|
||||
- Android `applicationId` aligned to `com.healthtracking.fightrate`.
|
||||
|
||||
Not implemented yet:
|
||||
|
||||
- Real BLE Magene H003 integration.
|
||||
- SQLite/local persistence.
|
||||
- Real timestamped workout chart data.
|
||||
- Navigation library.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm test -- --runInBand
|
||||
```
|
||||
|
||||
## Step 2: Build and run your app
|
||||
Run Metro:
|
||||
|
||||
With Metro running, open a new terminal window/pane from the root of your React Native project, and use one of the following commands to build and run your Android or iOS app:
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
|
||||
### Android
|
||||
Run Android on a device/emulator:
|
||||
|
||||
```sh
|
||||
# Using npm
|
||||
```bash
|
||||
npm run android
|
||||
|
||||
# OR using Yarn
|
||||
yarn android
|
||||
```
|
||||
|
||||
### iOS
|
||||
Build Android debug APK:
|
||||
|
||||
For iOS, remember to install CocoaPods dependencies (this only needs to be run on first clone or after updating native deps).
|
||||
|
||||
The first time you create a new project, run the Ruby bundler to install CocoaPods itself:
|
||||
|
||||
```sh
|
||||
bundle install
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
Then, and every time you update your native dependencies, run:
|
||||
If Gradle reports `No Java compiler found`, point `JAVA_HOME` to Android Studio JBR:
|
||||
|
||||
```sh
|
||||
bundle exec pod install
|
||||
```bash
|
||||
export JAVA_HOME="/c/Users/Evandro/AppData/Local/Programs/Android Studio/jbr"
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
cd android
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html).
|
||||
If React Native autolinking keeps a stale package after renaming Android package paths,
|
||||
clean generated Gradle caches before rebuilding:
|
||||
|
||||
```sh
|
||||
# Using npm
|
||||
npm run ios
|
||||
|
||||
# OR using Yarn
|
||||
yarn ios
|
||||
```bash
|
||||
cd ..
|
||||
rm -rf android/.gradle android/app/build android/build .gradle
|
||||
cd android
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device.
|
||||
Debug APK output:
|
||||
|
||||
This is one way to run your app — you can also build it directly from Android Studio or Xcode.
|
||||
|
||||
## Step 3: Modify your app
|
||||
|
||||
Now that you have successfully run the app, let's make changes!
|
||||
|
||||
Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh).
|
||||
|
||||
When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload:
|
||||
|
||||
- **Android**: Press the <kbd>R</kbd> key twice or select **"Reload"** from the **Dev Menu**, accessed via <kbd>Ctrl</kbd> + <kbd>M</kbd> (Windows/Linux) or <kbd>Cmd ⌘</kbd> + <kbd>M</kbd> (macOS).
|
||||
- **iOS**: Press <kbd>R</kbd> in iOS Simulator.
|
||||
|
||||
## Congratulations! :tada:
|
||||
|
||||
You've successfully run and modified your React Native App. :partying_face:
|
||||
|
||||
### Now what?
|
||||
|
||||
- If you want to add this new React Native code to an existing application, check out the [Integration guide](https://reactnative.dev/docs/integration-with-existing-apps).
|
||||
- If you're curious to learn more about React Native, check out the [docs](https://reactnative.dev/docs/getting-started).
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page.
|
||||
|
||||
# Learn More
|
||||
|
||||
To learn more about React Native, take a look at the following resources:
|
||||
|
||||
- [React Native Website](https://reactnative.dev) - learn more about React Native.
|
||||
- [Getting Started](https://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how setup your environment.
|
||||
- [Learn the Basics](https://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**.
|
||||
- [Blog](https://reactnative.dev/blog) - read the latest official React Native **Blog** posts.
|
||||
- [`@facebook/react-native`](https://github.com/facebook/react-native) - the Open Source; GitHub **repository** for React Native.
|
||||
```text
|
||||
android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
@@ -0,0 +1,78 @@
|
||||
"use strict";
|
||||
|
||||
Object.defineProperty(exports, "__esModule", {
|
||||
value: true
|
||||
});
|
||||
Object.defineProperty(exports, "adb", {
|
||||
enumerable: true,
|
||||
get: function () {
|
||||
return _runAndroid.adb;
|
||||
}
|
||||
});
|
||||
Object.defineProperty(exports, "commands", {
|
||||
enumerable: true,
|
||||
get: function () {
|
||||
return _commands.default;
|
||||
}
|
||||
});
|
||||
Object.defineProperty(exports, "dependencyConfig", {
|
||||
enumerable: true,
|
||||
get: function () {
|
||||
return _cliConfigAndroid().dependencyConfig;
|
||||
}
|
||||
});
|
||||
Object.defineProperty(exports, "getAdbPath", {
|
||||
enumerable: true,
|
||||
get: function () {
|
||||
return _runAndroid.getAdbPath;
|
||||
}
|
||||
});
|
||||
Object.defineProperty(exports, "getAndroidProject", {
|
||||
enumerable: true,
|
||||
get: function () {
|
||||
return _cliConfigAndroid().getAndroidProject;
|
||||
}
|
||||
});
|
||||
Object.defineProperty(exports, "getPackageName", {
|
||||
enumerable: true,
|
||||
get: function () {
|
||||
return _cliConfigAndroid().getPackageName;
|
||||
}
|
||||
});
|
||||
Object.defineProperty(exports, "isProjectUsingKotlin", {
|
||||
enumerable: true,
|
||||
get: function () {
|
||||
return _cliConfigAndroid().isProjectUsingKotlin;
|
||||
}
|
||||
});
|
||||
Object.defineProperty(exports, "listAndroidDevices", {
|
||||
enumerable: true,
|
||||
get: function () {
|
||||
return _runAndroid.listAndroidDevices;
|
||||
}
|
||||
});
|
||||
Object.defineProperty(exports, "projectConfig", {
|
||||
enumerable: true,
|
||||
get: function () {
|
||||
return _cliConfigAndroid().projectConfig;
|
||||
}
|
||||
});
|
||||
Object.defineProperty(exports, "tryRunAdbReverse", {
|
||||
enumerable: true,
|
||||
get: function () {
|
||||
return _runAndroid.tryRunAdbReverse;
|
||||
}
|
||||
});
|
||||
var _commands = _interopRequireDefault(require("./commands"));
|
||||
var _runAndroid = require("./commands/runAndroid");
|
||||
function _cliConfigAndroid() {
|
||||
const data = require("@react-native-community/cli-config-android");
|
||||
console.log("CC loaded, keys:", Object.keys(data));
|
||||
_cliConfigAndroid = function () {
|
||||
return data;
|
||||
};
|
||||
return data;
|
||||
}
|
||||
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
|
||||
|
||||
//# sourceMappingURL=index.js.map
|
||||
@@ -45,7 +45,7 @@ react {
|
||||
|
||||
/* Hermes Commands */
|
||||
// The hermes compiler command to run. By default it is 'hermesc'
|
||||
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
||||
hermesCommand = "D:/h/node_modules/react-native/sdks/hermesc/win64-bin/hermesc.exe"
|
||||
//
|
||||
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
|
||||
// hermesFlags = ["-O", "-output-source-map"]
|
||||
@@ -77,14 +77,23 @@ android {
|
||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
|
||||
namespace "com.fightratern"
|
||||
namespace "com.healthtracking.fightrate"
|
||||
defaultConfig {
|
||||
applicationId "com.fightratern"
|
||||
applicationId "com.healthtracking.fightrate"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
}
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
reset()
|
||||
include 'arm64-v8a'
|
||||
universalApk false
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
debug {
|
||||
storeFile file('debug.keystore')
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
@@ -23,5 +34,10 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".capture.FightRateCaptureService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.fightratern
|
||||
package com.healthtracking.fightrate
|
||||
|
||||
import com.facebook.react.ReactActivity
|
||||
import com.facebook.react.ReactActivityDelegate
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.fightratern
|
||||
package com.healthtracking.fightrate
|
||||
|
||||
import android.app.Application
|
||||
import com.facebook.react.PackageList
|
||||
@@ -6,6 +6,7 @@ import com.facebook.react.ReactApplication
|
||||
import com.facebook.react.ReactHost
|
||||
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
|
||||
import com.healthtracking.fightrate.capture.FightRateNativePackage
|
||||
|
||||
class MainApplication : Application(), ReactApplication {
|
||||
|
||||
@@ -14,9 +15,8 @@ class MainApplication : Application(), ReactApplication {
|
||||
context = applicationContext,
|
||||
packageList =
|
||||
PackageList(this).packages.apply {
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
// add(MyReactNativePackage())
|
||||
},
|
||||
add(FightRateNativePackage())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
package com.healthtracking.fightrate.capture
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.WritableArray
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
|
||||
class FightRateCaptureModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
private val store by lazy { FightRateCaptureStore(reactContext.applicationContext) }
|
||||
private var receiverRegistered = false
|
||||
|
||||
private val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
FightRateCaptureService.ACTION_SAMPLE -> emit(EVENT_SAMPLE, sampleFromIntent(intent))
|
||||
FightRateCaptureService.ACTION_STATUS -> emit(EVENT_STATUS, statusFromIntent(intent))
|
||||
FightRateCaptureService.ACTION_DEVICES -> emit(EVENT_DEVICES, devicesFromIntent(intent))
|
||||
FightRateCaptureService.ACTION_METADATA -> emit(EVENT_METADATA, metadataFromIntent(intent))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getName(): String = NAME
|
||||
|
||||
override fun initialize() {
|
||||
super.initialize()
|
||||
registerReceiverIfNeeded()
|
||||
restoreMonitoringIfNeeded()
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
unregisterReceiverIfNeeded()
|
||||
super.invalidate()
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun addListener(eventName: String) {
|
||||
registerReceiverIfNeeded()
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun removeListeners(count: Int) = Unit
|
||||
|
||||
@ReactMethod
|
||||
fun startMonitoring(deviceId: String?, deviceName: String?, promise: Promise) {
|
||||
runCatching {
|
||||
FightRateCaptureService.startMonitoring(reactContext.applicationContext, deviceId, deviceName)
|
||||
promise.resolve(snapshotMap())
|
||||
}.onFailure { promise.reject("FIGHTRATE_MONITOR_START_FAILED", it) }
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun startCapture(deviceId: String?, deviceName: String?, promise: Promise) {
|
||||
startWorkout(deviceId, deviceName, promise)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun startWorkout(deviceId: String?, deviceName: String?, promise: Promise) {
|
||||
runCatching {
|
||||
FightRateCaptureService.startWorkout(reactContext.applicationContext, deviceId, deviceName)
|
||||
promise.resolve(snapshotMap())
|
||||
}.onFailure { promise.reject("FIGHTRATE_START_FAILED", it) }
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun connectToDevice(deviceId: String, deviceName: String?, promise: Promise) {
|
||||
runCatching {
|
||||
FightRateCaptureService.connect(reactContext.applicationContext, deviceId, deviceName)
|
||||
promise.resolve(snapshotMap())
|
||||
}.onFailure { promise.reject("FIGHTRATE_CONNECT_FAILED", it) }
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun stopCapture(promise: Promise) {
|
||||
stopWorkout(promise)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun stopWorkout(promise: Promise) {
|
||||
runCatching {
|
||||
FightRateCaptureService.stopWorkout(reactContext.applicationContext)
|
||||
promise.resolve(snapshotMap())
|
||||
}.onFailure { promise.reject("FIGHTRATE_STOP_FAILED", it) }
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun stopMonitoring(promise: Promise) {
|
||||
runCatching {
|
||||
FightRateCaptureService.stop(reactContext.applicationContext)
|
||||
promise.resolve(snapshotMap(forceIdle = true))
|
||||
}.onFailure { promise.reject("FIGHTRATE_MONITOR_STOP_FAILED", it) }
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getSnapshot(promise: Promise) {
|
||||
runCatching { promise.resolve(snapshotMap()) }
|
||||
.onFailure { promise.reject("FIGHTRATE_SNAPSHOT_FAILED", it) }
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getSessionSamples(sessionId: String, promise: Promise) {
|
||||
runCatching {
|
||||
val id = sessionId.toLongOrNull() ?: return@runCatching Arguments.createArray()
|
||||
samplesToWritableArray(store.samplesForSession(id, MAX_SESSION_DETAIL_SAMPLES))
|
||||
}.onSuccess { promise.resolve(it) }
|
||||
.onFailure { promise.reject("FIGHTRATE_SESSION_SAMPLES_FAILED", it) }
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getContinuousSamples(limit: Double, promise: Promise) {
|
||||
runCatching {
|
||||
val requestedLimit = limit.toInt()
|
||||
val samples = if (requestedLimit <= 0) {
|
||||
store.allContinuousSamples()
|
||||
} else {
|
||||
store.recentSamples(requestedLimit.coerceIn(1, MAX_CONTINUOUS_HISTORY_SAMPLES))
|
||||
}
|
||||
samplesToWritableArray(samples)
|
||||
}.onSuccess { promise.resolve(it) }
|
||||
.onFailure { promise.reject("FIGHTRATE_CONTINUOUS_SAMPLES_FAILED", it) }
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun isIgnoringBatteryOptimizations(promise: Promise) {
|
||||
promise.resolve(isBatteryOptimizationIgnored())
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun openBatteryOptimizationSettings(promise: Promise) {
|
||||
runCatching {
|
||||
val packageName = reactContext.packageName
|
||||
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !isBatteryOptimizationIgnored()) {
|
||||
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:$packageName")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
} else {
|
||||
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
reactContext.startActivity(intent)
|
||||
promise.resolve(true)
|
||||
}.onFailure { promise.reject("FIGHTRATE_BATTERY_SETTINGS_FAILED", it) }
|
||||
}
|
||||
|
||||
private fun registerReceiverIfNeeded() {
|
||||
if (receiverRegistered) return
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(FightRateCaptureService.ACTION_SAMPLE)
|
||||
addAction(FightRateCaptureService.ACTION_STATUS)
|
||||
addAction(FightRateCaptureService.ACTION_DEVICES)
|
||||
addAction(FightRateCaptureService.ACTION_METADATA)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
reactContext.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
reactContext.registerReceiver(receiver, filter)
|
||||
}
|
||||
receiverRegistered = true
|
||||
}
|
||||
|
||||
private fun unregisterReceiverIfNeeded() {
|
||||
if (!receiverRegistered) return
|
||||
runCatching { reactContext.unregisterReceiver(receiver) }
|
||||
receiverRegistered = false
|
||||
}
|
||||
|
||||
private fun restoreMonitoringIfNeeded() {
|
||||
val context = reactContext.applicationContext
|
||||
if (FightRateCaptureService.isMonitoringEnabled(context)) {
|
||||
FightRateCaptureService.startMonitoring(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapshotMap(forceIdle: Boolean = false): WritableMap {
|
||||
val active = if (forceIdle) null else store.activeSession()
|
||||
val context = reactContext.applicationContext
|
||||
val monitoring = !forceIdle && FightRateCaptureService.isMonitoringEnabled(context)
|
||||
val sessions = store.finishedSessionSummaries(MAX_SESSION_SUMMARIES)
|
||||
val samples = store.recentSamples(MAX_SNAPSHOT_SAMPLES)
|
||||
val latestSample = samples.lastOrNull()
|
||||
val hasFreshSample = latestSample?.let { FightRateCaptureService.isFreshTimestamp(it.timestampMillis) } == true
|
||||
val connectedDevice = FightRateCaptureService.lastKnownDevice(context)?.let { nativeDeviceMap(it) }
|
||||
?: active?.let { sessionDeviceMap(it) }
|
||||
val map = Arguments.createMap()
|
||||
val phase = when {
|
||||
forceIdle -> FightRateCaptureService.PHASE_IDLE
|
||||
!monitoring -> FightRateCaptureService.PHASE_IDLE
|
||||
samples.isEmpty() -> FightRateCaptureService.PHASE_SCANNING
|
||||
hasFreshSample -> FightRateCaptureService.PHASE_RECEIVING
|
||||
else -> FightRateCaptureService.PHASE_CONNECTED
|
||||
}
|
||||
map.putString("phase", phase)
|
||||
map.putBoolean("isMonitoring", monitoring)
|
||||
map.putBoolean("isCapturing", active != null && !forceIdle)
|
||||
map.putDouble("startedAt", (active?.startedAtMillis ?: 0L).toDouble())
|
||||
map.putString("statusMessage", when {
|
||||
forceIdle || !monitoring -> "Monitor parado."
|
||||
active != null && hasFreshSample -> "Janela de treino ativa. Monitor contínuo gravando."
|
||||
active != null -> "Janela ativa, mas sem BPM válido recente. Aguardando sensor."
|
||||
hasFreshSample -> "Monitor contínuo ativo. Recebendo BPM real."
|
||||
else -> "Monitor contínuo ativo. Sem batimento válido recente."
|
||||
})
|
||||
map.putMap("connectedDevice", connectedDevice)
|
||||
map.putArray("discoveredDevices", Arguments.createArray())
|
||||
map.putArray("samples", samplesToWritableArray(samples))
|
||||
map.putArray("sessions", sessionsToWritableArray(sessions))
|
||||
map.putArray("services", Arguments.createArray())
|
||||
map.putArray("characteristics", Arguments.createArray())
|
||||
map.putBoolean("batteryOptimizationIgnored", isBatteryOptimizationIgnored())
|
||||
return map
|
||||
}
|
||||
|
||||
private fun sessionDeviceMap(session: StoredSession): WritableMap? {
|
||||
if (session.deviceId.isNullOrBlank() && session.deviceName.isNullOrBlank()) return null
|
||||
return Arguments.createMap().apply {
|
||||
putString("id", session.deviceId ?: "")
|
||||
putString("name", session.deviceName ?: "Cinta cardíaca BLE")
|
||||
putInt("rssi", Int.MIN_VALUE)
|
||||
putInt("batteryPercent", -1)
|
||||
putString("bodySensorLocation", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun nativeDeviceMap(device: NativeDiscoveredDevice): WritableMap = Arguments.createMap().apply {
|
||||
putString("id", device.id)
|
||||
putString("name", device.name)
|
||||
putInt("rssi", device.rssi)
|
||||
putInt("batteryPercent", -1)
|
||||
putString("bodySensorLocation", null)
|
||||
}
|
||||
|
||||
private fun sampleFromIntent(intent: Intent): WritableMap = Arguments.createMap().apply {
|
||||
putDouble("id", intent.getLongExtra(FightRateCaptureService.EXTRA_SAMPLE_ID, -1L).toDouble())
|
||||
val sessionId = intent.getLongExtra(FightRateCaptureService.EXTRA_SESSION_ID, -1L)
|
||||
if (sessionId >= 0L) putDouble("sessionId", sessionId.toDouble()) else putNull("sessionId")
|
||||
putDouble("timestamp", intent.getLongExtra(FightRateCaptureService.EXTRA_TIMESTAMP, 0L).toDouble())
|
||||
putInt("bpm", intent.getIntExtra(FightRateCaptureService.EXTRA_BPM, 0))
|
||||
if (intent.hasExtra(FightRateCaptureService.EXTRA_CONTACT_DETECTED)) {
|
||||
putBoolean("contactDetected", intent.getBooleanExtra(FightRateCaptureService.EXTRA_CONTACT_DETECTED, false))
|
||||
} else {
|
||||
putNull("contactDetected")
|
||||
}
|
||||
putArray("rrIntervalsMs", intArrayListToWritable(intent.getIntegerArrayListExtra(FightRateCaptureService.EXTRA_RR_INTERVALS)))
|
||||
putString("deviceName", intent.getStringExtra(FightRateCaptureService.EXTRA_DEVICE_NAME))
|
||||
}
|
||||
|
||||
private fun statusFromIntent(intent: Intent): WritableMap = Arguments.createMap().apply {
|
||||
putString("phase", intent.getStringExtra(FightRateCaptureService.EXTRA_PHASE) ?: FightRateCaptureService.PHASE_IDLE)
|
||||
putString("statusMessage", intent.getStringExtra(FightRateCaptureService.EXTRA_STATUS_MESSAGE))
|
||||
val sessionId = intent.getLongExtra(FightRateCaptureService.EXTRA_SESSION_ID, -1L)
|
||||
if (sessionId >= 0L) putDouble("sessionId", sessionId.toDouble()) else putNull("sessionId")
|
||||
putMap("connectedDevice", Arguments.createMap().apply {
|
||||
putString("id", intent.getStringExtra(FightRateCaptureService.EXTRA_DEVICE_ID) ?: "")
|
||||
putString("name", intent.getStringExtra(FightRateCaptureService.EXTRA_DEVICE_NAME) ?: "Cinta cardíaca BLE")
|
||||
putInt("rssi", intent.getIntExtra(FightRateCaptureService.EXTRA_RSSI, Int.MIN_VALUE))
|
||||
putInt("batteryPercent", intent.getIntExtra(FightRateCaptureService.EXTRA_BATTERY_PERCENT, -1))
|
||||
putString("bodySensorLocation", intent.getStringExtra(FightRateCaptureService.EXTRA_BODY_SENSOR_LOCATION))
|
||||
})
|
||||
}
|
||||
|
||||
private fun devicesFromIntent(intent: Intent): WritableMap = Arguments.createMap().apply {
|
||||
val array = Arguments.createArray()
|
||||
@Suppress("DEPRECATION")
|
||||
val devices = intent.getParcelableArrayListExtra<Bundle>(FightRateCaptureService.EXTRA_DEVICES).orEmpty()
|
||||
devices.forEach { bundle ->
|
||||
array.pushMap(Arguments.createMap().apply {
|
||||
putString("id", bundle.getString("id") ?: "")
|
||||
putString("name", bundle.getString("name") ?: "Sensor BLE sem nome")
|
||||
putInt("rssi", bundle.getInt("rssi", Int.MIN_VALUE))
|
||||
putArray("serviceUuids", stringArrayListToWritable(bundle.getStringArrayList("serviceUuids")))
|
||||
})
|
||||
}
|
||||
putArray("devices", array)
|
||||
}
|
||||
|
||||
private fun metadataFromIntent(intent: Intent): WritableMap = Arguments.createMap().apply {
|
||||
putArray("services", stringArrayListToWritable(intent.getStringArrayListExtra(FightRateCaptureService.EXTRA_SERVICES)))
|
||||
putArray("characteristics", stringArrayListToWritable(intent.getStringArrayListExtra(FightRateCaptureService.EXTRA_CHARACTERISTICS)))
|
||||
}
|
||||
|
||||
private fun samplesToWritableArray(samples: List<StoredSample>): WritableArray = Arguments.createArray().also { array ->
|
||||
samples.forEach { sample ->
|
||||
array.pushMap(Arguments.createMap().apply {
|
||||
putDouble("id", sample.id.toDouble())
|
||||
sample.sessionId?.let { putDouble("sessionId", it.toDouble()) } ?: putNull("sessionId")
|
||||
putDouble("timestamp", sample.timestampMillis.toDouble())
|
||||
putInt("bpm", sample.bpm)
|
||||
if (sample.contactDetected == null) putNull("contactDetected") else putBoolean("contactDetected", sample.contactDetected)
|
||||
putArray("rrIntervalsMs", intArrayListToWritable(ArrayList(sample.rrIntervalsMs)))
|
||||
putString("deviceName", sample.deviceName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun sessionsToWritableArray(sessions: List<StoredSessionSummary>): WritableArray = Arguments.createArray().also { array ->
|
||||
sessions.forEach { session ->
|
||||
array.pushMap(Arguments.createMap().apply {
|
||||
putString("id", session.id.toString())
|
||||
putDouble("startedAt", session.startedAtMillis.toDouble())
|
||||
putDouble("endedAt", session.endedAtMillis.toDouble())
|
||||
putDouble("durationMs", session.durationMillis.toDouble())
|
||||
putString("deviceName", session.deviceName)
|
||||
putInt("sampleCount", session.sampleCount)
|
||||
putInt("averageBpm", session.averageBpm)
|
||||
putInt("peakBpm", session.peakBpm)
|
||||
putInt("minBpm", session.minBpm)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun intArrayListToWritable(values: ArrayList<Int>?): WritableArray = Arguments.createArray().also { array ->
|
||||
values.orEmpty().forEach { array.pushInt(it) }
|
||||
}
|
||||
|
||||
private fun stringArrayListToWritable(values: ArrayList<String>?): WritableArray = Arguments.createArray().also { array ->
|
||||
values.orEmpty().forEach { array.pushString(it) }
|
||||
}
|
||||
|
||||
private fun emit(eventName: String, payload: WritableMap) {
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit(eventName, payload)
|
||||
}
|
||||
|
||||
private fun isBatteryOptimizationIgnored(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
|
||||
val powerManager = reactContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
return powerManager.isIgnoringBatteryOptimizations(reactContext.packageName)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NAME = "FightRateCapture"
|
||||
private const val EVENT_SAMPLE = "FightRateSample"
|
||||
private const val EVENT_STATUS = "FightRateStatus"
|
||||
private const val EVENT_DEVICES = "FightRateDevices"
|
||||
private const val EVENT_METADATA = "FightRateMetadata"
|
||||
private const val MAX_SNAPSHOT_SAMPLES = 1800
|
||||
private const val MAX_SESSION_SUMMARIES = 60
|
||||
private const val MAX_SESSION_DETAIL_SAMPLES = 7200
|
||||
private const val MAX_CONTINUOUS_HISTORY_SAMPLES = 20_000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,892 @@
|
||||
package com.healthtracking.fightrate.capture
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothStatusCodes
|
||||
import android.bluetooth.BluetoothGattService
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.healthtracking.fightrate.MainActivity
|
||||
import com.healthtracking.fightrate.R
|
||||
import java.util.UUID
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class FightRateCaptureService : Service() {
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private lateinit var store: FightRateCaptureStore
|
||||
private val bluetoothManager by lazy { getSystemService(BluetoothManager::class.java) }
|
||||
private val bluetoothAdapter by lazy { bluetoothManager?.adapter }
|
||||
private val bluetoothScanner get() = bluetoothAdapter?.bluetoothLeScanner
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var activeSessionId: Long? = null
|
||||
private var manualStop = true
|
||||
private var currentDeviceId: String? = null
|
||||
private var currentDeviceName: String? = null
|
||||
private var currentRssi: Int? = null
|
||||
private var batteryPercent: Int? = null
|
||||
private var bodySensorLocation: String? = null
|
||||
private var reconnectAttempts = 0
|
||||
private val discoveredDevices = linkedMapOf<String, NativeDiscoveredDevice>()
|
||||
private var lastNotificationUpdateMillis = 0L
|
||||
private var lastRssiReadMillis = 0L
|
||||
private var lastNotificationBpm: Int? = null
|
||||
private var recentBpm = ArrayDeque<Pair<Long, Int>>()
|
||||
private var lastValidMeasurementMillis = 0L
|
||||
private var lastInvalidMeasurementMillis = 0L
|
||||
private var notifyRetryCount = 0
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
store = FightRateCaptureStore(applicationContext)
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START_MONITORING -> startMonitoring(intent.getStringExtra(EXTRA_DEVICE_ID), intent.getStringExtra(EXTRA_DEVICE_NAME))
|
||||
ACTION_START -> startWorkoutWindow(intent.getStringExtra(EXTRA_DEVICE_ID), intent.getStringExtra(EXTRA_DEVICE_NAME))
|
||||
ACTION_CONNECT_DEVICE -> connectKnownDevice(intent.getStringExtra(EXTRA_DEVICE_ID), intent.getStringExtra(EXTRA_DEVICE_NAME))
|
||||
ACTION_STOP_WINDOW -> stopWorkoutWindow(manual = true)
|
||||
ACTION_STOP -> stopMonitoring(manual = true)
|
||||
else -> restoreCaptureIfNeeded()
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
stopScan()
|
||||
closeGatt()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startMonitoring(deviceId: String?, deviceName: String?) {
|
||||
manualStop = false
|
||||
currentDeviceId = deviceId ?: currentDeviceId ?: prefs().getString(KEY_LAST_DEVICE_ID, null)
|
||||
currentDeviceName = deviceName ?: currentDeviceName ?: prefs().getString(KEY_LAST_DEVICE_NAME, null)
|
||||
currentRssi = currentRssi ?: lastStoredRssi()
|
||||
persistState(manual = false)
|
||||
startForeground(NOTIFICATION_ID, buildNotification("Monitor cardíaco contínuo ativo..."))
|
||||
emitStatus(PHASE_SCANNING, "Monitor contínuo ativo. Procurando a cinta cardíaca BLE.")
|
||||
|
||||
if (gatt != null) {
|
||||
emitStatus(PHASE_RECEIVING, "Monitor contínuo recebendo BPM real.")
|
||||
return
|
||||
}
|
||||
|
||||
val knownDeviceId = currentDeviceId
|
||||
if (!knownDeviceId.isNullOrBlank()) {
|
||||
connectKnownDevice(knownDeviceId, currentDeviceName)
|
||||
} else {
|
||||
startScan()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startWorkoutWindow(deviceId: String?, deviceName: String?) {
|
||||
startMonitoring(deviceId, deviceName)
|
||||
val session = store.startSession(System.currentTimeMillis(), currentDeviceId, currentDeviceName)
|
||||
activeSessionId = session.id
|
||||
persistState(manual = false)
|
||||
emitStatus(PHASE_RECEIVING, "Janela de treino aberta. O monitor segue gravando continuamente.")
|
||||
updateNotification("Treino em andamento · monitor contínuo ativo")
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun connectKnownDevice(deviceId: String?, deviceName: String?) {
|
||||
if (deviceId.isNullOrBlank()) {
|
||||
startScan()
|
||||
return
|
||||
}
|
||||
if (!hasConnectPermission()) {
|
||||
emitStatus(PHASE_PERMISSION_DENIED, "Permissão de conexão Bluetooth não concedida.")
|
||||
return
|
||||
}
|
||||
val adapter = bluetoothAdapter
|
||||
if (adapter?.isEnabled != true) {
|
||||
emitStatus(PHASE_BLUETOOTH_OFF, "Bluetooth desligado.")
|
||||
return
|
||||
}
|
||||
|
||||
currentDeviceId = deviceId
|
||||
currentDeviceName = deviceName ?: currentDeviceName ?: "Cinta cardíaca BLE"
|
||||
activeSessionId?.let { store.updateSessionDevice(it, currentDeviceId, currentDeviceName) }
|
||||
persistState(manual = false)
|
||||
stopScan()
|
||||
closeGatt()
|
||||
emitStatus(PHASE_CONNECTING, "Conectando em ${friendlyDeviceName(currentDeviceName)}...")
|
||||
updateNotification("Conectando em ${friendlyDeviceName(currentDeviceName)}...")
|
||||
|
||||
runCatching {
|
||||
val device = adapter.getRemoteDevice(deviceId)
|
||||
gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
device.connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||
} else {
|
||||
device.connectGatt(this, false, gattCallback)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
emitStatus(PHASE_ERROR, error.message ?: "Falha ao abrir conexão BLE.")
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startScan() {
|
||||
if (!hasScanPermission()) {
|
||||
emitStatus(PHASE_PERMISSION_DENIED, "Permissão de busca Bluetooth não concedida.")
|
||||
return
|
||||
}
|
||||
val adapter = bluetoothAdapter
|
||||
if (adapter?.isEnabled != true) {
|
||||
emitStatus(PHASE_BLUETOOTH_OFF, "Bluetooth desligado.")
|
||||
return
|
||||
}
|
||||
|
||||
stopScan()
|
||||
discoveredDevices.clear()
|
||||
emitDevices()
|
||||
emitStatus(PHASE_SCANNING, "Buscando sensores cardíacos BLE próximos.")
|
||||
updateNotification("Buscando cinta cardíaca BLE...")
|
||||
|
||||
val settings = ScanSettings.Builder()
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.build()
|
||||
bluetoothScanner?.startScan(null, settings, scanCallback)
|
||||
mainHandler.removeCallbacks(scanTimeoutRunnable)
|
||||
mainHandler.postDelayed(scanTimeoutRunnable, SCAN_WINDOW_MILLIS)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun stopScan() {
|
||||
runCatching { bluetoothScanner?.stopScan(scanCallback) }
|
||||
mainHandler.removeCallbacks(scanTimeoutRunnable)
|
||||
}
|
||||
|
||||
private val scanTimeoutRunnable = Runnable {
|
||||
if (manualStop || gatt != null) return@Runnable
|
||||
emitStatus(PHASE_SENSOR_NOT_FOUND, "Nenhuma cinta cardíaca encontrada. Vou tentar de novo em 15s.")
|
||||
updateNotification("Sensor não encontrado. Nova busca em 15s...")
|
||||
mainHandler.postDelayed({ if (!manualStop && gatt == null) startScan() }, SCAN_RETRY_MILLIS)
|
||||
}
|
||||
|
||||
private val scanCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
handleScanResult(result)
|
||||
}
|
||||
|
||||
override fun onBatchScanResults(results: MutableList<ScanResult>) {
|
||||
results.forEach(::handleScanResult)
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
emitStatus(PHASE_ERROR, "Falha no scan BLE: $errorCode")
|
||||
mainHandler.postDelayed({ if (!manualStop) startScan() }, SCAN_RETRY_MILLIS)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun handleScanResult(result: ScanResult) {
|
||||
val device = result.device ?: return
|
||||
val name = result.scanRecord?.deviceName ?: device.name ?: "Sensor BLE sem nome"
|
||||
val serviceUuids = result.scanRecord?.serviceUuids?.map { it.uuid.toString() }.orEmpty()
|
||||
val candidate = NativeDiscoveredDevice(device.address, name, result.rssi, serviceUuids)
|
||||
discoveredDevices[device.address] = candidate
|
||||
emitDevices()
|
||||
|
||||
if (!isHeartRateCandidate(candidate)) return
|
||||
currentRssi = result.rssi
|
||||
persistState(manual = false)
|
||||
connectKnownDevice(device.address, name)
|
||||
}
|
||||
|
||||
private val gattCallback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
if (manualStop) return
|
||||
when {
|
||||
status != BluetoothGatt.GATT_SUCCESS -> {
|
||||
emitStatus(PHASE_ERROR, "Falha GATT: $status. Tentando reconectar.")
|
||||
closeGatt(gatt)
|
||||
scheduleReconnect()
|
||||
}
|
||||
newState == BluetoothProfile.STATE_CONNECTED -> {
|
||||
reconnectAttempts = 0
|
||||
currentDeviceId = gatt.device.address
|
||||
currentDeviceName = safeDeviceName(gatt.device)
|
||||
activeSessionId?.let { store.updateSessionDevice(it, currentDeviceId, currentDeviceName) }
|
||||
persistState(manual = false)
|
||||
emitStatus(PHASE_CONNECTED, "Sensor conectado. Descobrindo serviços.")
|
||||
readRssiNow(gatt)
|
||||
updateNotification("Sensor conectado. Preparando leitura...")
|
||||
if (!gatt.discoverServices()) {
|
||||
emitStatus(PHASE_ERROR, "Não foi possível descobrir serviços BLE.")
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
newState == BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
closeGatt(gatt)
|
||||
emitStatus(PHASE_ERROR, "Sensor desconectado. Tentando reconectar.")
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||
if (manualStop) return
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
emitStatus(PHASE_ERROR, "Falha ao descobrir serviços BLE: $status")
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
emitMetadata(gatt.services)
|
||||
val service = gatt.getService(HEART_RATE_SERVICE_UUID)
|
||||
if (service == null) {
|
||||
emitStatus(PHASE_ERROR, "Serviço Heart Rate 180D não encontrado no sensor.")
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
subscribeToHeartRate(gatt, service)
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated by Android framework")
|
||||
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
||||
handleMeasurement(characteristic.value)
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
|
||||
handleMeasurement(value)
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
||||
if (manualStop || descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
|
||||
val characteristic = descriptor.characteristic
|
||||
if (characteristic?.uuid != HEART_RATE_MEASUREMENT_UUID) return
|
||||
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
notifyRetryCount = 0
|
||||
emitStatus(PHASE_RECEIVING, "2A37 confirmado. Aguardando o primeiro BPM real.")
|
||||
updateNotification("2A37 ativo. Aguardando batimentos reais...")
|
||||
readRssiNow(gatt)
|
||||
readBodyLocationOrBattery(gatt)
|
||||
scheduleMeasurementWatchdog(gatt)
|
||||
} else {
|
||||
emitStatus(PHASE_ERROR, "Falha ao ativar notify 2A37: $status. Tentando reconectar.")
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
currentRssi = rssi
|
||||
persistState(manual = false)
|
||||
emitStatus(PHASE_RECEIVING, null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) return
|
||||
when (characteristic.uuid) {
|
||||
BATTERY_LEVEL_UUID -> {
|
||||
batteryPercent = characteristic.value?.firstOrNull()?.toInt()?.and(0xFF)?.coerceIn(0, 100)
|
||||
emitStatus(PHASE_RECEIVING, null)
|
||||
}
|
||||
BODY_SENSOR_LOCATION_UUID -> {
|
||||
bodySensorLocation = bodyLocationName(characteristic.value?.firstOrNull()?.toInt()?.and(0xFF))
|
||||
readBattery(gatt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun subscribeToHeartRate(gatt: BluetoothGatt, service: BluetoothGattService) {
|
||||
val characteristic = service.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
|
||||
if (characteristic == null) {
|
||||
emitStatus(PHASE_ERROR, "Característica Heart Rate Measurement 2A37 não encontrada.")
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
val supportsNotify = characteristic.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0
|
||||
val supportsIndicate = characteristic.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0
|
||||
if (!supportsNotify && !supportsIndicate) {
|
||||
emitStatus(PHASE_ERROR, "2A37 existe, mas não expõe notify/indicate.")
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
if (!gatt.setCharacteristicNotification(characteristic, true)) {
|
||||
emitStatus(PHASE_ERROR, "Android recusou setCharacteristicNotification em 2A37.")
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
val descriptor = characteristic.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID)
|
||||
if (descriptor == null) {
|
||||
emitStatus(PHASE_ERROR, "Descriptor CCCD 2902 não encontrado para 2A37.")
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
val descriptorValue = if (supportsNotify) {
|
||||
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
} else {
|
||||
BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
|
||||
}
|
||||
val writeStarted = writeDescriptorCompat(gatt, descriptor, descriptorValue)
|
||||
if (!writeStarted) {
|
||||
emitStatus(PHASE_ERROR, "Não foi possível escrever CCCD 2902 para 2A37.")
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
emitStatus(PHASE_CONNECTED, "Ativando notify 2A37. Dados serão salvos assim que chegarem.")
|
||||
updateNotification("Ativando leitura 2A37...")
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun writeDescriptorCompat(
|
||||
gatt: BluetoothGatt,
|
||||
descriptor: BluetoothGattDescriptor,
|
||||
value: ByteArray,
|
||||
): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
gatt.writeDescriptor(descriptor, value) == BluetoothStatusCodes.SUCCESS
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
descriptor.value = value
|
||||
@Suppress("DEPRECATION")
|
||||
gatt.writeDescriptor(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleMeasurementWatchdog(gatt: BluetoothGatt) {
|
||||
val watchedGatt = gatt
|
||||
val armedAt = System.currentTimeMillis()
|
||||
mainHandler.postDelayed({
|
||||
if (manualStop || this.gatt !== watchedGatt) return@postDelayed
|
||||
val hasReadingAfterArm = lastValidMeasurementMillis >= armedAt
|
||||
if (hasReadingAfterArm) return@postDelayed
|
||||
val hasInvalidAfterArm = lastInvalidMeasurementMillis >= armedAt
|
||||
if (hasInvalidAfterArm) {
|
||||
emitStatus(PHASE_CONNECTED, "Sensor conectado, mas sem batimento válido. Aguardando contato/sinal real.")
|
||||
updateNotification("Sem BPM válido · ajuste a cinta/sensor")
|
||||
scheduleSignalStaleWatchdog()
|
||||
return@postDelayed
|
||||
}
|
||||
|
||||
notifyRetryCount += 1
|
||||
if (notifyRetryCount <= 2) {
|
||||
emitStatus(PHASE_CONNECTED, "Sensor reconhecido, mas ainda sem BPM. Reativando notify 2A37.")
|
||||
watchedGatt.getService(HEART_RATE_SERVICE_UUID)?.let { subscribeToHeartRate(watchedGatt, it) }
|
||||
} else {
|
||||
emitStatus(PHASE_ERROR, "Sensor reconhecido sem enviar BPM. Reconectando para reabrir 2A37.")
|
||||
scheduleReconnect()
|
||||
}
|
||||
}, FIRST_MEASUREMENT_TIMEOUT_MILLIS)
|
||||
}
|
||||
|
||||
private fun handleMeasurement(value: ByteArray?) {
|
||||
if (manualStop || value == null || value.isEmpty()) return
|
||||
val parsed = runCatching { HeartRateMeasurementParser.parse(value) }.getOrNull() ?: return
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
if (!isValidMeasurement(parsed)) {
|
||||
handleInvalidMeasurement(parsed, now)
|
||||
return
|
||||
}
|
||||
|
||||
lastValidMeasurementMillis = now
|
||||
val sessionId = activeSessionId ?: store.activeSession()?.id
|
||||
val sample = store.insertSample(
|
||||
activeSessionId = sessionId,
|
||||
timestampMillis = now,
|
||||
bpm = parsed.bpm,
|
||||
contactDetected = parsed.contactDetected,
|
||||
rrIntervalsMs = parsed.rrIntervalsMs,
|
||||
deviceName = currentDeviceName,
|
||||
)
|
||||
reconnectAttempts = 0
|
||||
addRecentBpm(now, parsed.bpm)
|
||||
emitSample(sample)
|
||||
emitStatus(PHASE_RECEIVING, "Recebendo BPM real de ${friendlyDeviceName(currentDeviceName)}.")
|
||||
updateNotificationIfNeeded(now, parsed.bpm)
|
||||
readRssiEventually()
|
||||
scheduleSignalStaleWatchdog()
|
||||
}
|
||||
|
||||
private fun handleInvalidMeasurement(parsed: ParsedHeartRateMeasurement, timestamp: Long) {
|
||||
lastInvalidMeasurementMillis = timestamp
|
||||
recentBpm.clear()
|
||||
lastNotificationBpm = null
|
||||
val reason = invalidMeasurementReason(parsed)
|
||||
emitStatus(PHASE_CONNECTED, "Sensor conectado, mas $reason. Aguardando batimento real.")
|
||||
updateNotification("Sem BPM válido · $reason")
|
||||
scheduleSignalStaleWatchdog()
|
||||
}
|
||||
|
||||
private fun isValidMeasurement(parsed: ParsedHeartRateMeasurement): Boolean {
|
||||
return parsed.bpm in MIN_VALID_BPM..MAX_VALID_BPM && parsed.contactDetected != false
|
||||
}
|
||||
|
||||
private fun invalidMeasurementReason(parsed: ParsedHeartRateMeasurement): String {
|
||||
return when {
|
||||
parsed.contactDetected == false -> "sem contato detectado"
|
||||
parsed.bpm <= 0 -> "o sensor reportou 0 bpm"
|
||||
parsed.bpm < MIN_VALID_BPM -> "BPM abaixo do plausível"
|
||||
parsed.bpm > MAX_VALID_BPM -> "BPM acima do plausível"
|
||||
else -> "sem sinal válido"
|
||||
}
|
||||
}
|
||||
|
||||
private val signalStaleRunnable = Runnable {
|
||||
if (manualStop || gatt == null) return@Runnable
|
||||
val now = System.currentTimeMillis()
|
||||
val elapsed = now - lastValidMeasurementMillis
|
||||
if (lastValidMeasurementMillis > 0L && elapsed >= VALID_SAMPLE_STALE_MILLIS) {
|
||||
recentBpm.clear()
|
||||
lastNotificationBpm = null
|
||||
emitStatus(PHASE_CONNECTED, "Sem batimento válido há ${elapsed / 1000}s. Aguardando novos dados reais.")
|
||||
updateNotification("Sem BPM válido · aguardando sensor")
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleSignalStaleWatchdog() {
|
||||
mainHandler.removeCallbacks(signalStaleRunnable)
|
||||
mainHandler.postDelayed(signalStaleRunnable, VALID_SAMPLE_STALE_MILLIS)
|
||||
}
|
||||
|
||||
private fun currentSignalPhase(): String {
|
||||
if (gatt == null) return PHASE_SCANNING
|
||||
return if (FightRateCaptureService.isFreshTimestamp(lastValidMeasurementMillis)) {
|
||||
PHASE_RECEIVING
|
||||
} else {
|
||||
PHASE_CONNECTED
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun readRssiEventually() {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastRssiReadMillis < RSSI_READ_INTERVAL_MILLIS) return
|
||||
lastRssiReadMillis = now
|
||||
readRssiNow(gatt)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun readRssiNow(target: BluetoothGatt?) {
|
||||
if (!hasConnectPermission()) return
|
||||
runCatching { target?.readRemoteRssi() }
|
||||
}
|
||||
|
||||
private fun stopWorkoutWindow(manual: Boolean) {
|
||||
store.finishActiveSession(System.currentTimeMillis())
|
||||
activeSessionId = null
|
||||
val message = if (manual) "Treino salvo. Monitor cardíaco continua ativo." else "Janela de treino encerrada."
|
||||
emitStatus(currentSignalPhase(), message)
|
||||
updateNotification(if (currentSignalPhase() == PHASE_RECEIVING) "Monitor contínuo ativo · sem janela de treino" else "Monitor ativo · aguardando BPM válido")
|
||||
}
|
||||
|
||||
private fun stopMonitoring(manual: Boolean) {
|
||||
manualStop = manual
|
||||
persistState(manual = manual)
|
||||
stopScan()
|
||||
closeGatt()
|
||||
mainHandler.removeCallbacks(signalStaleRunnable)
|
||||
store.finishActiveSession(System.currentTimeMillis())
|
||||
activeSessionId = null
|
||||
recentBpm.clear()
|
||||
emitStatus(PHASE_IDLE, if (manual) "Monitor parado manualmente." else "Monitor encerrado.")
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun restoreCaptureIfNeeded() {
|
||||
manualStop = prefs().getBoolean(KEY_MANUAL_STOP, true)
|
||||
if (manualStop) return
|
||||
activeSessionId = store.activeSession()?.id
|
||||
currentDeviceId = prefs().getString(KEY_LAST_DEVICE_ID, null)
|
||||
currentDeviceName = prefs().getString(KEY_LAST_DEVICE_NAME, null)
|
||||
startForeground(NOTIFICATION_ID, buildNotification("Retomando monitor contínuo..."))
|
||||
if (!currentDeviceId.isNullOrBlank()) connectKnownDevice(currentDeviceId, currentDeviceName) else startScan()
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (manualStop) return
|
||||
val delayMillis = when (reconnectAttempts++) {
|
||||
0 -> 2_000L
|
||||
1 -> 5_000L
|
||||
2 -> 10_000L
|
||||
else -> 30_000L
|
||||
}
|
||||
updateNotification("Reconectando em ${delayMillis / 1000}s...")
|
||||
mainHandler.postDelayed({
|
||||
if (manualStop) return@postDelayed
|
||||
val deviceId = currentDeviceId
|
||||
if (!deviceId.isNullOrBlank()) connectKnownDevice(deviceId, currentDeviceName) else startScan()
|
||||
}, delayMillis)
|
||||
}
|
||||
|
||||
private fun closeGatt() {
|
||||
runCatching { gatt?.disconnect() }
|
||||
runCatching { gatt?.close() }
|
||||
gatt = null
|
||||
mainHandler.removeCallbacks(signalStaleRunnable)
|
||||
}
|
||||
|
||||
private fun closeGatt(target: BluetoothGatt) {
|
||||
if (gatt === target) gatt = null
|
||||
runCatching { target.close() }
|
||||
}
|
||||
|
||||
private fun addRecentBpm(timestamp: Long, bpm: Int) {
|
||||
recentBpm.addLast(timestamp to bpm)
|
||||
val cutoff = timestamp - 60_000L
|
||||
while (recentBpm.isNotEmpty() && recentBpm.first().first < cutoff) {
|
||||
recentBpm.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateNotificationIfNeeded(timestamp: Long, bpm: Int) {
|
||||
val rollingAverage = if (recentBpm.isEmpty()) bpm else recentBpm.map { it.second }.average().roundToInt()
|
||||
val changedEnough = lastNotificationBpm?.let { kotlin.math.abs(rollingAverage - it) >= 3 } ?: true
|
||||
if (timestamp - lastNotificationUpdateMillis < NOTIFICATION_THROTTLE_MILLIS && !changedEnough) return
|
||||
lastNotificationUpdateMillis = timestamp
|
||||
lastNotificationBpm = rollingAverage
|
||||
updateNotification("${rollingAverage} BPM média 60s · ${friendlyDeviceName(currentDeviceName)}")
|
||||
}
|
||||
|
||||
private fun buildNotification(text: String): Notification {
|
||||
val openIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val stopIntent = PendingIntent.getService(
|
||||
this,
|
||||
1,
|
||||
Intent(this, FightRateCaptureService::class.java).setAction(ACTION_STOP),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("FightRate capturando")
|
||||
.setContentText(text)
|
||||
.setContentIntent(openIntent)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.addAction(R.mipmap.ic_launcher, "Parar", stopIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun updateNotification(text: String) {
|
||||
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, buildNotification(text))
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val channel = NotificationChannel(CHANNEL_ID, "Captura contínua", NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = "Mantém a cinta cardíaca BLE ativa com a tela bloqueada."
|
||||
}
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun emitSample(sample: StoredSample) {
|
||||
val intent = Intent(ACTION_SAMPLE).setPackage(packageName)
|
||||
.putExtra(EXTRA_SAMPLE_ID, sample.id)
|
||||
.putExtra(EXTRA_TIMESTAMP, sample.timestampMillis)
|
||||
.putExtra(EXTRA_BPM, sample.bpm)
|
||||
.putIntegerArrayListExtra(EXTRA_RR_INTERVALS, ArrayList(sample.rrIntervalsMs))
|
||||
.putExtra(EXTRA_DEVICE_NAME, sample.deviceName)
|
||||
sample.sessionId?.let { intent.putExtra(EXTRA_SESSION_ID, it) }
|
||||
sample.contactDetected?.let { intent.putExtra(EXTRA_CONTACT_DETECTED, it) }
|
||||
sendBroadcast(intent)
|
||||
}
|
||||
|
||||
private fun emitDevices() {
|
||||
sendBroadcast(
|
||||
Intent(ACTION_DEVICES).setPackage(packageName)
|
||||
.putParcelableArrayListExtra(EXTRA_DEVICES, ArrayList(discoveredDevices.values.map { it.toBundle() })),
|
||||
)
|
||||
}
|
||||
|
||||
private fun emitMetadata(services: List<BluetoothGattService>) {
|
||||
val serviceRows = services.map { service ->
|
||||
"${knownServiceName(service.uuid)} · ${shortUuid(service.uuid)} · ${service.characteristics.size} dados"
|
||||
}
|
||||
val characteristicRows = services.flatMap { service ->
|
||||
service.characteristics.map { characteristic ->
|
||||
"${knownCharacteristicName(characteristic.uuid)} · ${shortUuid(characteristic.uuid)} · ${propertySummary(characteristic.properties)}"
|
||||
}
|
||||
}
|
||||
sendBroadcast(
|
||||
Intent(ACTION_METADATA).setPackage(packageName)
|
||||
.putStringArrayListExtra(EXTRA_SERVICES, ArrayList(serviceRows))
|
||||
.putStringArrayListExtra(EXTRA_CHARACTERISTICS, ArrayList(characteristicRows)),
|
||||
)
|
||||
}
|
||||
|
||||
private fun emitStatus(phase: String, message: String?) {
|
||||
sendBroadcast(
|
||||
Intent(ACTION_STATUS).setPackage(packageName)
|
||||
.putExtra(EXTRA_PHASE, phase)
|
||||
.putExtra(EXTRA_STATUS_MESSAGE, message)
|
||||
.putExtra(EXTRA_SESSION_ID, activeSessionId ?: -1L)
|
||||
.putExtra(EXTRA_DEVICE_ID, currentDeviceId)
|
||||
.putExtra(EXTRA_DEVICE_NAME, currentDeviceName)
|
||||
.putExtra(EXTRA_RSSI, currentRssi ?: Int.MIN_VALUE)
|
||||
.putExtra(EXTRA_BATTERY_PERCENT, batteryPercent ?: -1)
|
||||
.putExtra(EXTRA_BODY_SENSOR_LOCATION, bodySensorLocation),
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun readBodyLocationOrBattery(gatt: BluetoothGatt) {
|
||||
val bodyStarted = gatt.getService(HEART_RATE_SERVICE_UUID)
|
||||
?.getCharacteristic(BODY_SENSOR_LOCATION_UUID)
|
||||
?.let { gatt.readCharacteristic(it) } == true
|
||||
if (!bodyStarted) readBattery(gatt)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun readBattery(gatt: BluetoothGatt) {
|
||||
gatt.getService(BATTERY_SERVICE_UUID)
|
||||
?.getCharacteristic(BATTERY_LEVEL_UUID)
|
||||
?.let { gatt.readCharacteristic(it) }
|
||||
}
|
||||
|
||||
private fun isHeartRateCandidate(device: NativeDiscoveredDevice): Boolean {
|
||||
val name = device.name.lowercase()
|
||||
val uuids = device.serviceUuids.map { it.lowercase() }
|
||||
return uuids.any { it == HEART_RATE_SERVICE_UUID.toString() || it.contains("180d") } ||
|
||||
name.contains("magene") ||
|
||||
name.contains("h003") ||
|
||||
name.contains("heart") ||
|
||||
name.contains("hrm")
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun safeDeviceName(device: BluetoothDevice): String = runCatching {
|
||||
device.name ?: currentDeviceName ?: "Cinta cardíaca BLE"
|
||||
}.getOrDefault(currentDeviceName ?: "Cinta cardíaca BLE")
|
||||
|
||||
private fun friendlyDeviceName(name: String?): String = when {
|
||||
name.isNullOrBlank() -> "cinta cardíaca BLE"
|
||||
name.contains("h003", ignoreCase = true) || name.contains("magene", ignoreCase = true) -> "Magene H003"
|
||||
name.contains("heart", ignoreCase = true) || name.contains("hrm", ignoreCase = true) -> "cinta cardíaca BLE"
|
||||
else -> name
|
||||
}
|
||||
|
||||
private fun hasScanPermission(): Boolean {
|
||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun hasConnectPermission(): Boolean {
|
||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun prefs() = getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
|
||||
|
||||
private fun lastStoredRssi(): Int? {
|
||||
val value = prefs().getInt(KEY_LAST_RSSI, Int.MIN_VALUE)
|
||||
return if (value == Int.MIN_VALUE) null else value
|
||||
}
|
||||
|
||||
private fun persistState(manual: Boolean) {
|
||||
val editor = prefs().edit()
|
||||
.putBoolean(KEY_MANUAL_STOP, manual)
|
||||
.putString(KEY_LAST_DEVICE_ID, currentDeviceId)
|
||||
.putString(KEY_LAST_DEVICE_NAME, currentDeviceName)
|
||||
|
||||
currentRssi?.let { editor.putInt(KEY_LAST_RSSI, it) }
|
||||
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
private fun shortUuid(uuid: UUID): String {
|
||||
val text = uuid.toString()
|
||||
return if (text.endsWith("-0000-1000-8000-00805f9b34fb")) "0x${text.substring(4, 8).uppercase()}" else text
|
||||
}
|
||||
|
||||
private fun knownServiceName(uuid: UUID): String = when (uuid) {
|
||||
HEART_RATE_SERVICE_UUID -> "Heart Rate Service"
|
||||
BATTERY_SERVICE_UUID -> "Battery Service"
|
||||
DEVICE_INFORMATION_SERVICE_UUID -> "Device Information"
|
||||
else -> "Serviço BLE"
|
||||
}
|
||||
|
||||
private fun knownCharacteristicName(uuid: UUID): String = when (uuid) {
|
||||
HEART_RATE_MEASUREMENT_UUID -> "Heart Rate Measurement"
|
||||
BODY_SENSOR_LOCATION_UUID -> "Body Sensor Location"
|
||||
BATTERY_LEVEL_UUID -> "Battery Level"
|
||||
else -> "Característica BLE"
|
||||
}
|
||||
|
||||
private fun propertySummary(properties: Int): String = buildList {
|
||||
if (properties and BluetoothGattCharacteristic.PROPERTY_READ != 0) add("read")
|
||||
if (properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) add("notify")
|
||||
if (properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) add("indicate")
|
||||
if (properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) add("write")
|
||||
}.ifEmpty { listOf("sem propriedade") }.joinToString("/")
|
||||
|
||||
private fun bodyLocationName(value: Int?): String? = when (value) {
|
||||
0 -> "Outro"
|
||||
1 -> "Peito"
|
||||
2 -> "Pulso"
|
||||
3 -> "Dedo"
|
||||
4 -> "Mão"
|
||||
5 -> "Orelha"
|
||||
6 -> "Pé"
|
||||
else -> null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = "com.healthtracking.fightrate.rn.START_CAPTURE"
|
||||
const val ACTION_START_MONITORING = "com.healthtracking.fightrate.rn.START_MONITORING"
|
||||
const val ACTION_CONNECT_DEVICE = "com.healthtracking.fightrate.rn.CONNECT_DEVICE"
|
||||
const val ACTION_STOP = "com.healthtracking.fightrate.rn.STOP_CAPTURE"
|
||||
const val ACTION_STOP_WINDOW = "com.healthtracking.fightrate.rn.STOP_WORKOUT_WINDOW"
|
||||
const val ACTION_SAMPLE = "com.healthtracking.fightrate.rn.SAMPLE"
|
||||
const val ACTION_STATUS = "com.healthtracking.fightrate.rn.STATUS"
|
||||
const val ACTION_DEVICES = "com.healthtracking.fightrate.rn.DEVICES"
|
||||
const val ACTION_METADATA = "com.healthtracking.fightrate.rn.METADATA"
|
||||
|
||||
const val EXTRA_DEVICE_ID = "device_id"
|
||||
const val EXTRA_DEVICE_NAME = "device_name"
|
||||
const val EXTRA_SAMPLE_ID = "sample_id"
|
||||
const val EXTRA_SESSION_ID = "session_id"
|
||||
const val EXTRA_TIMESTAMP = "timestamp"
|
||||
const val EXTRA_BPM = "bpm"
|
||||
const val EXTRA_CONTACT_DETECTED = "contact_detected"
|
||||
const val EXTRA_RR_INTERVALS = "rr_intervals"
|
||||
const val EXTRA_PHASE = "phase"
|
||||
const val EXTRA_STATUS_MESSAGE = "status_message"
|
||||
const val EXTRA_RSSI = "rssi"
|
||||
const val EXTRA_BATTERY_PERCENT = "battery_percent"
|
||||
const val EXTRA_BODY_SENSOR_LOCATION = "body_sensor_location"
|
||||
const val EXTRA_DEVICES = "devices"
|
||||
const val EXTRA_SERVICES = "services"
|
||||
const val EXTRA_CHARACTERISTICS = "characteristics"
|
||||
|
||||
const val PHASE_IDLE = "idle"
|
||||
const val PHASE_SCANNING = "scanning"
|
||||
const val PHASE_CONNECTING = "connecting"
|
||||
const val PHASE_CONNECTED = "connected"
|
||||
const val PHASE_RECEIVING = "receiving"
|
||||
const val PHASE_PERMISSION_DENIED = "permission-denied"
|
||||
const val PHASE_BLUETOOTH_OFF = "bluetooth-off"
|
||||
const val PHASE_SENSOR_NOT_FOUND = "sensor-not-found"
|
||||
const val PHASE_ERROR = "error"
|
||||
|
||||
val HEART_RATE_SERVICE_UUID: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
|
||||
val HEART_RATE_MEASUREMENT_UUID: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
|
||||
val BODY_SENSOR_LOCATION_UUID: UUID = UUID.fromString("00002a38-0000-1000-8000-00805f9b34fb")
|
||||
val BATTERY_SERVICE_UUID: UUID = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
|
||||
val BATTERY_LEVEL_UUID: UUID = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
|
||||
private val CLIENT_CHARACTERISTIC_CONFIG_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
private val DEVICE_INFORMATION_SERVICE_UUID: UUID = UUID.fromString("0000180a-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
private const val PREFS_NAME = "fightrate_rn_capture"
|
||||
private const val KEY_MANUAL_STOP = "manual_stop"
|
||||
private const val KEY_LAST_DEVICE_ID = "last_device_id"
|
||||
private const val KEY_LAST_DEVICE_NAME = "last_device_name"
|
||||
private const val KEY_LAST_RSSI = "last_rssi"
|
||||
private const val CHANNEL_ID = "fightrate_continuous_capture"
|
||||
private const val NOTIFICATION_ID = 18037
|
||||
private const val SCAN_WINDOW_MILLIS = 20_000L
|
||||
private const val SCAN_RETRY_MILLIS = 15_000L
|
||||
private const val NOTIFICATION_THROTTLE_MILLIS = 15_000L
|
||||
private const val RSSI_READ_INTERVAL_MILLIS = 5_000L
|
||||
private const val FIRST_MEASUREMENT_TIMEOUT_MILLIS = 12_000L
|
||||
private const val VALID_SAMPLE_STALE_MILLIS = 12_000L
|
||||
private const val MIN_VALID_BPM = 30
|
||||
private const val MAX_VALID_BPM = 240
|
||||
|
||||
fun startMonitoring(context: Context, deviceId: String? = null, deviceName: String? = null) {
|
||||
val intent = Intent(context, FightRateCaptureService::class.java)
|
||||
.setAction(ACTION_START_MONITORING)
|
||||
.putExtra(EXTRA_DEVICE_ID, deviceId)
|
||||
.putExtra(EXTRA_DEVICE_NAME, deviceName)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
|
||||
fun startWorkout(context: Context, deviceId: String? = null, deviceName: String? = null) {
|
||||
start(context, deviceId, deviceName)
|
||||
}
|
||||
fun start(context: Context, deviceId: String? = null, deviceName: String? = null) {
|
||||
val intent = Intent(context, FightRateCaptureService::class.java)
|
||||
.setAction(ACTION_START)
|
||||
.putExtra(EXTRA_DEVICE_ID, deviceId)
|
||||
.putExtra(EXTRA_DEVICE_NAME, deviceName)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
|
||||
fun connect(context: Context, deviceId: String, deviceName: String?) {
|
||||
startMonitoring(context, deviceId, deviceName)
|
||||
}
|
||||
|
||||
fun stopWorkout(context: Context) {
|
||||
context.startService(Intent(context, FightRateCaptureService::class.java).setAction(ACTION_STOP_WINDOW))
|
||||
}
|
||||
|
||||
fun isMonitoringEnabled(context: Context): Boolean =
|
||||
!context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).getBoolean(KEY_MANUAL_STOP, true)
|
||||
|
||||
fun isFreshTimestamp(timestampMillis: Long, nowMillis: Long = System.currentTimeMillis()): Boolean =
|
||||
timestampMillis > 0L && nowMillis - timestampMillis <= VALID_SAMPLE_STALE_MILLIS
|
||||
|
||||
fun lastKnownDevice(context: Context): NativeDiscoveredDevice? {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val id = prefs.getString(KEY_LAST_DEVICE_ID, null)
|
||||
val name = prefs.getString(KEY_LAST_DEVICE_NAME, null)
|
||||
val rssi = prefs.getInt(KEY_LAST_RSSI, Int.MIN_VALUE)
|
||||
if (id.isNullOrBlank()) return null
|
||||
return NativeDiscoveredDevice(id, name ?: "Cinta cardíaca BLE", rssi, emptyList())
|
||||
}
|
||||
fun stop(context: Context) {
|
||||
context.startService(Intent(context, FightRateCaptureService::class.java).setAction(ACTION_STOP))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class NativeDiscoveredDevice(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val rssi: Int,
|
||||
val serviceUuids: List<String>,
|
||||
) {
|
||||
fun toBundle(): android.os.Bundle = android.os.Bundle().apply {
|
||||
putString("id", id)
|
||||
putString("name", name)
|
||||
putInt("rssi", rssi)
|
||||
putStringArrayList("serviceUuids", ArrayList(serviceUuids))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package com.healthtracking.fightrate.capture
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
|
||||
class FightRateCaptureStore(context: Context) : SQLiteOpenHelper(
|
||||
context,
|
||||
DATABASE_NAME,
|
||||
null,
|
||||
DATABASE_VERSION,
|
||||
) {
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
createWorkoutSessionsTable(db)
|
||||
createLegacySamplesTable(db)
|
||||
createContinuousSamplesTable(db)
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
if (oldVersion < 2) {
|
||||
createContinuousSamplesTable(db)
|
||||
db.execSQL(
|
||||
"""
|
||||
INSERT INTO $CONTINUOUS_SAMPLE_TABLE (timestamp_millis, bpm, contact_detected, rr_intervals_ms, device_name)
|
||||
SELECT timestamp_millis, bpm, contact_detected, rr_intervals_ms, device_name
|
||||
FROM $LEGACY_SAMPLE_TABLE
|
||||
WHERE NOT EXISTS (SELECT 1 FROM $CONTINUOUS_SAMPLE_TABLE LIMIT 1)
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun startSession(nowMillis: Long, deviceId: String?, deviceName: String?): StoredSession {
|
||||
activeSession()?.let { active ->
|
||||
updateSessionDevice(active.id, deviceId, deviceName)
|
||||
return sessionById(active.id) ?: active
|
||||
}
|
||||
|
||||
val values = ContentValues().apply {
|
||||
put("started_at_millis", nowMillis)
|
||||
putNull("ended_at_millis")
|
||||
put("status", STATUS_ACTIVE)
|
||||
put("device_id", deviceId)
|
||||
put("device_name", deviceName)
|
||||
}
|
||||
val id = writableDatabase.insert(SESSION_TABLE, null, values)
|
||||
return sessionById(id) ?: StoredSession(id, nowMillis, null, STATUS_ACTIVE, deviceId, deviceName)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun finishActiveSession(nowMillis: Long): StoredSession? {
|
||||
val active = activeSession() ?: return null
|
||||
val values = ContentValues().apply {
|
||||
put("status", STATUS_FINISHED)
|
||||
put("ended_at_millis", nowMillis)
|
||||
}
|
||||
writableDatabase.update(SESSION_TABLE, values, "id = ?", arrayOf(active.id.toString()))
|
||||
return sessionById(active.id)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun insertSample(
|
||||
activeSessionId: Long?,
|
||||
timestampMillis: Long,
|
||||
bpm: Int,
|
||||
contactDetected: Boolean?,
|
||||
rrIntervalsMs: List<Int>,
|
||||
deviceName: String?,
|
||||
): StoredSample {
|
||||
val values = ContentValues().apply {
|
||||
put("timestamp_millis", timestampMillis)
|
||||
put("bpm", bpm)
|
||||
if (contactDetected == null) putNull("contact_detected") else put("contact_detected", if (contactDetected) 1 else 0)
|
||||
put("rr_intervals_ms", rrIntervalsMs.joinToString(","))
|
||||
put("device_name", deviceName)
|
||||
}
|
||||
val id = writableDatabase.insert(CONTINUOUS_SAMPLE_TABLE, null, values)
|
||||
return StoredSample(id, activeSessionId, timestampMillis, bpm, contactDetected, rrIntervalsMs, deviceName)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun updateSessionDevice(sessionId: Long, deviceId: String?, deviceName: String?) {
|
||||
val values = ContentValues().apply {
|
||||
put("device_id", deviceId)
|
||||
put("device_name", deviceName)
|
||||
}
|
||||
writableDatabase.update(SESSION_TABLE, values, "id = ?", arrayOf(sessionId.toString()))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun activeSession(): StoredSession? {
|
||||
val cursor = readableDatabase.query(
|
||||
SESSION_TABLE,
|
||||
SESSION_COLUMNS,
|
||||
"status = ?",
|
||||
arrayOf(STATUS_ACTIVE),
|
||||
null,
|
||||
null,
|
||||
"started_at_millis DESC",
|
||||
"1",
|
||||
)
|
||||
return cursor.use { if (it.moveToFirst()) it.toSession() else null }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun sessionById(id: Long): StoredSession? {
|
||||
val cursor = readableDatabase.query(
|
||||
SESSION_TABLE,
|
||||
SESSION_COLUMNS,
|
||||
"id = ?",
|
||||
arrayOf(id.toString()),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"1",
|
||||
)
|
||||
return cursor.use { if (it.moveToFirst()) it.toSession() else null }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun recentSamples(limit: Int): List<StoredSample> {
|
||||
val cursor = readableDatabase.query(
|
||||
CONTINUOUS_SAMPLE_TABLE,
|
||||
CONTINUOUS_SAMPLE_COLUMNS,
|
||||
VALID_SAMPLE_SELECTION,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"timestamp_millis DESC",
|
||||
limit.toString(),
|
||||
)
|
||||
return cursor.use {
|
||||
buildList {
|
||||
while (it.moveToNext()) add(it.toContinuousSample(null))
|
||||
}.asReversed()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun allContinuousSamples(): List<StoredSample> {
|
||||
val cursor = readableDatabase.query(
|
||||
CONTINUOUS_SAMPLE_TABLE,
|
||||
CONTINUOUS_SAMPLE_COLUMNS,
|
||||
VALID_SAMPLE_SELECTION,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"timestamp_millis ASC",
|
||||
null,
|
||||
)
|
||||
return cursor.use {
|
||||
buildList {
|
||||
while (it.moveToNext()) add(it.toContinuousSample(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
@Synchronized
|
||||
fun samplesForSession(sessionId: Long, limit: Int): List<StoredSample> {
|
||||
val session = sessionById(sessionId) ?: return emptyList()
|
||||
val endedAt = session.endedAtMillis ?: System.currentTimeMillis()
|
||||
val cursor = readableDatabase.query(
|
||||
CONTINUOUS_SAMPLE_TABLE,
|
||||
CONTINUOUS_SAMPLE_COLUMNS,
|
||||
"$VALID_SAMPLE_SELECTION AND timestamp_millis BETWEEN ? AND ?",
|
||||
arrayOf(session.startedAtMillis.toString(), endedAt.toString()),
|
||||
null,
|
||||
null,
|
||||
"timestamp_millis ASC",
|
||||
limit.toString(),
|
||||
)
|
||||
return cursor.use {
|
||||
buildList {
|
||||
while (it.moveToNext()) add(it.toContinuousSample(sessionId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun finishedSessionSummaries(limit: Int): List<StoredSessionSummary> {
|
||||
val sql =
|
||||
"""
|
||||
SELECT s.id, s.started_at_millis, s.ended_at_millis, s.device_name,
|
||||
COUNT(hr.id) AS sample_count,
|
||||
ROUND(AVG(hr.bpm)) AS average_bpm,
|
||||
MAX(hr.bpm) AS peak_bpm,
|
||||
MIN(hr.bpm) AS min_bpm
|
||||
FROM $SESSION_TABLE s
|
||||
LEFT JOIN $CONTINUOUS_SAMPLE_TABLE hr
|
||||
ON hr.timestamp_millis BETWEEN s.started_at_millis AND COALESCE(s.ended_at_millis, strftime('%s','now') * 1000)
|
||||
AND hr.bpm BETWEEN $MIN_VALID_BPM AND $MAX_VALID_BPM
|
||||
AND (hr.contact_detected IS NULL OR hr.contact_detected = 1)
|
||||
WHERE s.status = ?
|
||||
GROUP BY s.id
|
||||
HAVING sample_count > 0
|
||||
ORDER BY s.started_at_millis DESC
|
||||
LIMIT ?
|
||||
""".trimIndent()
|
||||
val cursor = readableDatabase.rawQuery(sql, arrayOf(STATUS_FINISHED, limit.toString()))
|
||||
return cursor.use {
|
||||
buildList {
|
||||
while (it.moveToNext()) {
|
||||
val startedAt = it.getLong(1)
|
||||
val endedAt = if (it.isNull(2)) startedAt else it.getLong(2)
|
||||
add(
|
||||
StoredSessionSummary(
|
||||
id = it.getLong(0),
|
||||
startedAtMillis = startedAt,
|
||||
endedAtMillis = endedAt,
|
||||
durationMillis = (endedAt - startedAt).coerceAtLeast(0),
|
||||
deviceName = it.getString(3),
|
||||
sampleCount = it.getInt(4),
|
||||
averageBpm = it.getInt(5),
|
||||
peakBpm = it.getInt(6),
|
||||
minBpm = it.getInt(7),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createWorkoutSessionsTable(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS $SESSION_TABLE (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
started_at_millis INTEGER NOT NULL,
|
||||
ended_at_millis INTEGER,
|
||||
status TEXT NOT NULL,
|
||||
device_id TEXT,
|
||||
device_name TEXT
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS idx_sessions_status_start ON $SESSION_TABLE(status, started_at_millis)")
|
||||
}
|
||||
|
||||
private fun createLegacySamplesTable(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS $LEGACY_SAMPLE_TABLE (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
timestamp_millis INTEGER NOT NULL,
|
||||
bpm INTEGER NOT NULL,
|
||||
contact_detected INTEGER,
|
||||
rr_intervals_ms TEXT NOT NULL DEFAULT '',
|
||||
device_name TEXT,
|
||||
FOREIGN KEY(session_id) REFERENCES $SESSION_TABLE(id)
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS idx_samples_session_time ON $LEGACY_SAMPLE_TABLE(session_id, timestamp_millis)")
|
||||
}
|
||||
|
||||
private fun createContinuousSamplesTable(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS $CONTINUOUS_SAMPLE_TABLE (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp_millis INTEGER NOT NULL,
|
||||
bpm INTEGER NOT NULL,
|
||||
contact_detected INTEGER,
|
||||
rr_intervals_ms TEXT NOT NULL DEFAULT '',
|
||||
device_name TEXT
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS idx_continuous_samples_time ON $CONTINUOUS_SAMPLE_TABLE(timestamp_millis)")
|
||||
}
|
||||
|
||||
private fun android.database.Cursor.toSession(): StoredSession = StoredSession(
|
||||
id = getLong(0),
|
||||
startedAtMillis = getLong(1),
|
||||
endedAtMillis = if (isNull(2)) null else getLong(2),
|
||||
status = getString(3),
|
||||
deviceId = getString(4),
|
||||
deviceName = getString(5),
|
||||
)
|
||||
|
||||
private fun android.database.Cursor.toContinuousSample(sessionId: Long?): StoredSample = StoredSample(
|
||||
id = getLong(0),
|
||||
sessionId = sessionId,
|
||||
timestampMillis = getLong(1),
|
||||
bpm = getInt(2),
|
||||
contactDetected = if (isNull(3)) null else getInt(3) == 1,
|
||||
rrIntervalsMs = getString(4).orEmpty().split(',').mapNotNull { it.toIntOrNull() },
|
||||
deviceName = getString(5),
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val DATABASE_NAME = "fightrate_rn_capture.db"
|
||||
private const val DATABASE_VERSION = 2
|
||||
private const val SESSION_TABLE = "workout_sessions"
|
||||
private const val LEGACY_SAMPLE_TABLE = "heart_rate_samples"
|
||||
private const val CONTINUOUS_SAMPLE_TABLE = "continuous_heart_rate_samples"
|
||||
const val STATUS_ACTIVE = "active"
|
||||
const val STATUS_FINISHED = "finished"
|
||||
private val SESSION_COLUMNS = arrayOf("id", "started_at_millis", "ended_at_millis", "status", "device_id", "device_name")
|
||||
private val CONTINUOUS_SAMPLE_COLUMNS = arrayOf("id", "timestamp_millis", "bpm", "contact_detected", "rr_intervals_ms", "device_name")
|
||||
private const val MIN_VALID_BPM = 30
|
||||
private const val MAX_VALID_BPM = 240
|
||||
private const val VALID_SAMPLE_SELECTION = "bpm BETWEEN $MIN_VALID_BPM AND $MAX_VALID_BPM AND (contact_detected IS NULL OR contact_detected = 1)"
|
||||
}
|
||||
}
|
||||
|
||||
data class StoredSession(
|
||||
val id: Long,
|
||||
val startedAtMillis: Long,
|
||||
val endedAtMillis: Long?,
|
||||
val status: String,
|
||||
val deviceId: String?,
|
||||
val deviceName: String?,
|
||||
)
|
||||
|
||||
data class StoredSample(
|
||||
val id: Long,
|
||||
val sessionId: Long?,
|
||||
val timestampMillis: Long,
|
||||
val bpm: Int,
|
||||
val contactDetected: Boolean?,
|
||||
val rrIntervalsMs: List<Int>,
|
||||
val deviceName: String?,
|
||||
)
|
||||
|
||||
data class StoredSessionSummary(
|
||||
val id: Long,
|
||||
val startedAtMillis: Long,
|
||||
val endedAtMillis: Long,
|
||||
val durationMillis: Long,
|
||||
val deviceName: String?,
|
||||
val sampleCount: Int,
|
||||
val averageBpm: Int,
|
||||
val peakBpm: Int,
|
||||
val minBpm: Int,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.healthtracking.fightrate.capture
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class FightRateNativePackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
||||
listOf(FightRateCaptureModule(reactContext))
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<in Nothing, in Nothing>> =
|
||||
emptyList()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.healthtracking.fightrate.capture
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
data class ParsedHeartRateMeasurement(
|
||||
val bpm: Int,
|
||||
val contactDetected: Boolean?,
|
||||
val rrIntervalsMs: List<Int>,
|
||||
)
|
||||
|
||||
object HeartRateMeasurementParser {
|
||||
fun parse(value: ByteArray): ParsedHeartRateMeasurement {
|
||||
require(value.size >= 2) { "Pacote de batimento incompleto" }
|
||||
|
||||
val flags = value[0].toInt() and 0xFF
|
||||
val isUInt16 = flags and 0x01 != 0
|
||||
val contactSupported = flags and 0x04 != 0
|
||||
val contactDetected = if (contactSupported) flags and 0x02 != 0 else null
|
||||
val hasRrIntervals = flags and 0x10 != 0
|
||||
val hasEnergyExpended = flags and 0x08 != 0
|
||||
|
||||
var offset = 1
|
||||
val bpm = if (isUInt16) {
|
||||
require(value.size >= 3) { "Pacote de batimento 16-bit incompleto" }
|
||||
val parsed = ByteBuffer.wrap(value, offset, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() and 0xFFFF
|
||||
offset += 2
|
||||
parsed
|
||||
} else {
|
||||
val parsed = value[offset].toInt() and 0xFF
|
||||
offset += 1
|
||||
parsed
|
||||
}
|
||||
|
||||
if (hasEnergyExpended) {
|
||||
require(value.size >= offset + 2) { "Pacote de energia expended incompleto" }
|
||||
offset += 2
|
||||
}
|
||||
|
||||
val rrIntervalsMs = mutableListOf<Int>()
|
||||
if (hasRrIntervals) {
|
||||
while (offset + 1 < value.size) {
|
||||
val rrRaw = ByteBuffer.wrap(value, offset, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() and 0xFFFF
|
||||
rrIntervalsMs.add(((rrRaw * 1000.0) / 1024.0).toInt())
|
||||
offset += 2
|
||||
}
|
||||
}
|
||||
|
||||
return ParsedHeartRateMeasurement(
|
||||
bpm = bpm,
|
||||
contactDetected = contactDetected,
|
||||
rrIntervalsMs = rrIntervalsMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 1.3 MiB |
@@ -1,3 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">FightRateRN</string>
|
||||
<string name="app_name">FightRate</string>
|
||||
</resources>
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"name": "FightRateRN",
|
||||
"displayName": "FightRateRN"
|
||||
"displayName": "FightRate"
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
> Task :gradle-plugin:settings-plugin:checkKotlinGradlePluginConfigurationErrors SKIPPED
|
||||
> Task :gradle-plugin:shared:checkKotlinGradlePluginConfigurationErrors SKIPPED
|
||||
> Task :gradle-plugin:shared:compileKotlin UP-TO-DATE
|
||||
> Task :gradle-plugin:shared:compileJava NO-SOURCE
|
||||
> Task :gradle-plugin:shared:processResources NO-SOURCE
|
||||
> Task :gradle-plugin:shared:classes UP-TO-DATE
|
||||
> Task :gradle-plugin:shared:jar UP-TO-DATE
|
||||
> Task :gradle-plugin:settings-plugin:compileKotlin UP-TO-DATE
|
||||
> Task :gradle-plugin:settings-plugin:compileJava NO-SOURCE
|
||||
> Task :gradle-plugin:settings-plugin:pluginDescriptors UP-TO-DATE
|
||||
> Task :gradle-plugin:settings-plugin:processResources UP-TO-DATE
|
||||
> Task :gradle-plugin:settings-plugin:classes UP-TO-DATE
|
||||
> Task :gradle-plugin:settings-plugin:jar UP-TO-DATE
|
||||
> Task :gradle-plugin:react-native-gradle-plugin:checkKotlinGradlePluginConfigurationErrors SKIPPED
|
||||
> Task :gradle-plugin:react-native-gradle-plugin:compileKotlin UP-TO-DATE
|
||||
> Task :gradle-plugin:react-native-gradle-plugin:compileJava NO-SOURCE
|
||||
> Task :gradle-plugin:react-native-gradle-plugin:pluginDescriptors UP-TO-DATE
|
||||
> Task :gradle-plugin:react-native-gradle-plugin:processResources UP-TO-DATE
|
||||
> Task :gradle-plugin:react-native-gradle-plugin:classes UP-TO-DATE
|
||||
> Task :gradle-plugin:react-native-gradle-plugin:jar UP-TO-DATE
|
||||
> Task :app:generateAutolinkingNewArchitectureFiles UP-TO-DATE
|
||||
> Task :app:generateAutolinkingPackageList UP-TO-DATE
|
||||
> Task :app:generateCodegenSchemaFromJavaScript SKIPPED
|
||||
> Task :app:generateCodegenArtifactsFromSchema SKIPPED
|
||||
> Task :app:generateReactNativeEntryPoint UP-TO-DATE
|
||||
> Task :react-native-gesture-handler:generateCodegenSchemaFromJavaScript UP-TO-DATE
|
||||
> Task :react-native-gesture-handler:generateCodegenArtifactsFromSchema UP-TO-DATE
|
||||
> Task :react-native-gesture-handler:preBuild UP-TO-DATE
|
||||
> Task :react-native-reanimated:assertMinimalReactNativeVersionTask
|
||||
> Task :react-native-reanimated:assertWorkletsVersionTask
|
||||
> Task :react-native-reanimated:generateCodegenSchemaFromJavaScript UP-TO-DATE
|
||||
> Task :react-native-reanimated:generateCodegenArtifactsFromSchema UP-TO-DATE
|
||||
> Task :react-native-reanimated:prepareReanimatedHeadersForPrefabs UP-TO-DATE
|
||||
> Task :react-native-reanimated:preBuild
|
||||
> Task :react-native-safe-area-context:generateCodegenSchemaFromJavaScript UP-TO-DATE
|
||||
> Task :react-native-safe-area-context:generateCodegenArtifactsFromSchema UP-TO-DATE
|
||||
> Task :react-native-safe-area-context:preBuild UP-TO-DATE
|
||||
> Task :react-native-svg:generateCodegenSchemaFromJavaScript UP-TO-DATE
|
||||
> Task :react-native-svg:generateCodegenArtifactsFromSchema UP-TO-DATE
|
||||
> Task :react-native-svg:preBuild UP-TO-DATE
|
||||
> Task :react-native-worklets:assertMinimalReactNativeVersionTask
|
||||
> Task :react-native-worklets:generateCodegenSchemaFromJavaScript UP-TO-DATE
|
||||
> Task :react-native-worklets:generateCodegenArtifactsFromSchema UP-TO-DATE
|
||||
> Task :react-native-worklets:prepareWorkletsHeadersForPrefabs UP-TO-DATE
|
||||
> Task :react-native-worklets:preBuild
|
||||
> Task :app:preBuild
|
||||
> Task :app:preReleaseBuild
|
||||
> Task :react-native-reanimated:preReleaseBuild
|
||||
> Task :react-native-worklets:preReleaseBuild
|
||||
> Task :react-native-worklets:configureCMakeRelWithDebInfo[arm64-v8a]
|
||||
|
||||
> Task :react-native-worklets:buildCMakeRelWithDebInfo[arm64-v8a][worklets]
|
||||
C/C++: Hard link from 'C:\Users\Evandro\.gradle\caches\9.3.1\transforms\2454197a7a7bdd22c9814cde61f3ed7f\workspace\transformed\react-android-0.86.0-release\prefab\modules\reactnative\libs\android.arm64-v8a\libreactnative.so' to 'D:\h\node_modules\react-native-worklets\android\build\intermediates\cxx\RelWithDebInfo\c4c5v6u1\obj\arm64-v8a\libreactnative.so' failed. Doing a slower copy instead.
|
||||
C/C++: Hard link from 'C:\Users\Evandro\.gradle\caches\9.3.1\transforms\2454197a7a7bdd22c9814cde61f3ed7f\workspace\transformed\react-android-0.86.0-release\prefab\modules\reactnative\libs\android.arm64-v8a\libreactnative.so' to 'D:\h\node_modules\react-native-worklets\android\build\intermediates\cxx\RelWithDebInfo\c4c5v6u1\obj\arm64-v8a\libreactnative.so' failed. Doing a slower copy instead.
|
||||
|
||||
> Task :react-native-worklets:externalNativeBuildRelease
|
||||
> Task :react-native-worklets:generateJsonModelRelease
|
||||
> Task :react-native-worklets:prefabReleaseConfigurePackage
|
||||
> Task :react-native-reanimated:configureCMakeRelWithDebInfo[arm64-v8a]
|
||||
> Task :react-native-reanimated:generateJsonModelRelease
|
||||
> Task :react-native-reanimated:prefabReleaseConfigurePackage UP-TO-DATE
|
||||
> Task :app:configureCMakeRelWithDebInfo[arm64-v8a]
|
||||
> Task :react-native-worklets:prefabReleasePackage UP-TO-DATE
|
||||
|
||||
> Task :react-native-reanimated:buildCMakeRelWithDebInfo[arm64-v8a][reanimated]
|
||||
C/C++: Hard link from 'C:\Users\Evandro\.gradle\caches\9.3.1\transforms\2454197a7a7bdd22c9814cde61f3ed7f\workspace\transformed\react-android-0.86.0-release\prefab\modules\reactnative\libs\android.arm64-v8a\libreactnative.so' to 'D:\h\node_modules\react-native-reanimated\android\build\intermediates\cxx\RelWithDebInfo\5b3or1s1\obj\arm64-v8a\libreactnative.so' failed. Doing a slower copy instead.
|
||||
C/C++: Hard link from 'C:\Users\Evandro\.gradle\caches\9.3.1\transforms\2454197a7a7bdd22c9814cde61f3ed7f\workspace\transformed\react-android-0.86.0-release\prefab\modules\reactnative\libs\android.arm64-v8a\libreactnative.so' to 'D:\h\node_modules\react-native-reanimated\android\build\intermediates\cxx\RelWithDebInfo\5b3or1s1\obj\arm64-v8a\libreactnative.so' failed. Doing a slower copy instead.
|
||||
|
||||
> Task :react-native-worklets:mergeReleaseJniLibFolders UP-TO-DATE
|
||||
> Task :react-native-worklets:mergeReleaseNativeLibs UP-TO-DATE
|
||||
> Task :react-native-worklets:copyReleaseJniLibsProjectOnly UP-TO-DATE
|
||||
> Task :react-native-reanimated:externalNativeBuildRelease
|
||||
> Task :react-native-reanimated:prefabReleasePackage UP-TO-DATE
|
||||
|
||||
> Task :app:buildCMakeRelWithDebInfo[arm64-v8a]
|
||||
C/C++: Hard link from 'C:\Users\Evandro\.gradle\caches\9.3.1\transforms\2454197a7a7bdd22c9814cde61f3ed7f\workspace\transformed\react-android-0.86.0-release\prefab\modules\reactnative\libs\android.arm64-v8a\libreactnative.so' to 'D:\h\android\app\build\intermediates\cxx\RelWithDebInfo\64556v59\obj\arm64-v8a\libreactnative.so' failed. Doing a slower copy instead.
|
||||
C/C++: Hard link from 'C:\Users\Evandro\.gradle\caches\9.3.1\transforms\2454197a7a7bdd22c9814cde61f3ed7f\workspace\transformed\react-android-0.86.0-release\prefab\modules\reactnative\libs\android.arm64-v8a\libreactnative.so' to 'D:\h\android\app\build\intermediates\cxx\RelWithDebInfo\64556v59\obj\arm64-v8a\libreactnative.so' failed. Doing a slower copy instead.
|
||||
|
||||
> Task :app:mergeReleaseJniLibFolders UP-TO-DATE
|
||||
> Task :react-native-gesture-handler:preReleaseBuild UP-TO-DATE
|
||||
> Task :react-native-gesture-handler:configureCMakeRelWithDebInfo[arm64-v8a]
|
||||
> Task :react-native-gesture-handler:buildCMakeRelWithDebInfo[arm64-v8a]
|
||||
> Task :react-native-gesture-handler:mergeReleaseJniLibFolders UP-TO-DATE
|
||||
> Task :react-native-gesture-handler:mergeReleaseNativeLibs UP-TO-DATE
|
||||
> Task :react-native-gesture-handler:copyReleaseJniLibsProjectOnly UP-TO-DATE
|
||||
> Task :react-native-reanimated:mergeReleaseJniLibFolders UP-TO-DATE
|
||||
> Task :react-native-reanimated:mergeReleaseNativeLibs UP-TO-DATE
|
||||
> Task :react-native-reanimated:copyReleaseJniLibsProjectOnly UP-TO-DATE
|
||||
> Task :react-native-safe-area-context:preReleaseBuild UP-TO-DATE
|
||||
> Task :react-native-safe-area-context:mergeReleaseJniLibFolders UP-TO-DATE
|
||||
> Task :react-native-safe-area-context:mergeReleaseNativeLibs NO-SOURCE
|
||||
> Task :react-native-safe-area-context:copyReleaseJniLibsProjectOnly UP-TO-DATE
|
||||
> Task :react-native-svg:preReleaseBuild UP-TO-DATE
|
||||
> Task :react-native-svg:mergeReleaseJniLibFolders UP-TO-DATE
|
||||
> Task :react-native-svg:mergeReleaseNativeLibs NO-SOURCE
|
||||
> Task :react-native-svg:copyReleaseJniLibsProjectOnly UP-TO-DATE
|
||||
> Task :app:mergeReleaseNativeLibs UP-TO-DATE
|
||||
> Task :app:stripReleaseDebugSymbols UP-TO-DATE
|
||||
> Task :app:extractReleaseNativeSymbolTables UP-TO-DATE
|
||||
> Task :app:mergeReleaseNativeDebugMetadata UP-TO-DATE
|
||||
> Task :react-native-gesture-handler:checkKotlinGradlePluginConfigurationErrors SKIPPED
|
||||
> Task :react-native-gesture-handler:generateReleaseBuildConfig UP-TO-DATE
|
||||
> Task :react-native-gesture-handler:generateReleaseResValues UP-TO-DATE
|
||||
> Task :react-native-gesture-handler:generateReleaseResources UP-TO-DATE
|
||||
> Task :react-native-gesture-handler:packageReleaseResources UP-TO-DATE
|
||||
> Task :react-native-gesture-handler:parseReleaseLocalResources UP-TO-DATE
|
||||
> Task :react-native-gesture-handler:generateReleaseRFile UP-TO-DATE
|
||||
> Task :react-native-reanimated:checkKotlinGradlePluginConfigurationErrors SKIPPED
|
||||
> Task :react-native-reanimated:generateReleaseBuildConfig UP-TO-DATE
|
||||
> Task :react-native-reanimated:generateReleaseResValues UP-TO-DATE
|
||||
> Task :react-native-reanimated:generateReleaseResources UP-TO-DATE
|
||||
> Task :react-native-reanimated:packageReleaseResources UP-TO-DATE
|
||||
> Task :react-native-reanimated:parseReleaseLocalResources UP-TO-DATE
|
||||
> Task :react-native-reanimated:generateReleaseRFile UP-TO-DATE
|
||||
> Task :react-native-worklets:checkKotlinGradlePluginConfigurationErrors SKIPPED
|
||||
> Task :react-native-worklets:generateReleaseBuildConfig UP-TO-DATE
|
||||
> Task :react-native-worklets:generateReleaseResValues UP-TO-DATE
|
||||
> Task :react-native-worklets:generateReleaseResources UP-TO-DATE
|
||||
> Task :react-native-worklets:packageReleaseResources UP-TO-DATE
|
||||
> Task :react-native-worklets:parseReleaseLocalResources UP-TO-DATE
|
||||
> Task :react-native-worklets:generateReleaseRFile UP-TO-DATE
|
||||
> Task :react-native-worklets:compileReleaseKotlin UP-TO-DATE
|
||||
> Task :react-native-worklets:javaPreCompileRelease UP-TO-DATE
|
||||
> Task :react-native-worklets:compileReleaseJavaWithJavac UP-TO-DATE
|
||||
> Task :react-native-worklets:bundleLibCompileToJarRelease UP-TO-DATE
|
||||
> Task :react-native-reanimated:compileReleaseKotlin UP-TO-DATE
|
||||
> Task :react-native-reanimated:javaPreCompileRelease UP-TO-DATE
|
||||
> Task :react-native-reanimated:compileReleaseJavaWithJavac UP-TO-DATE
|
||||
> Task :react-native-reanimated:bundleLibCompileToJarRelease UP-TO-DATE
|
||||
> Task :react-native-svg:generateReleaseBuildConfig UP-TO-DATE
|
||||
> Task :react-native-svg:generateReleaseResValues UP-TO-DATE
|
||||
> Task :react-native-svg:generateReleaseResources UP-TO-DATE
|
||||
> Task :react-native-svg:packageReleaseResources UP-TO-DATE
|
||||
> Task :react-native-svg:parseReleaseLocalResources UP-TO-DATE
|
||||
> Task :react-native-svg:generateReleaseRFile UP-TO-DATE
|
||||
> Task :react-native-svg:javaPreCompileRelease UP-TO-DATE
|
||||
> Task :react-native-svg:compileReleaseJavaWithJavac UP-TO-DATE
|
||||
> Task :react-native-svg:bundleLibCompileToJarRelease UP-TO-DATE
|
||||
> Task :react-native-gesture-handler:compileReleaseKotlin UP-TO-DATE
|
||||
> Task :react-native-gesture-handler:javaPreCompileRelease UP-TO-DATE
|
||||
> Task :react-native-gesture-handler:compileReleaseJavaWithJavac
|
||||
> Task :react-native-reanimated:bundleLibRuntimeToDirRelease UP-TO-DATE
|
||||
> Task :react-native-safe-area-context:checkKotlinGradlePluginConfigurationErrors SKIPPED
|
||||
> Task :react-native-safe-area-context:generateReleaseBuildConfig UP-TO-DATE
|
||||
> Task :react-native-safe-area-context:generateReleaseResValues UP-TO-DATE
|
||||
> Task :react-native-safe-area-context:generateReleaseResources UP-TO-DATE
|
||||
> Task :react-native-safe-area-context:packageReleaseResources UP-TO-DATE
|
||||
> Task :react-native-safe-area-context:parseReleaseLocalResources UP-TO-DATE
|
||||
> Task :react-native-safe-area-context:generateReleaseRFile UP-TO-DATE
|
||||
> Task :react-native-safe-area-context:compileReleaseKotlin UP-TO-DATE
|
||||
> Task :react-native-safe-area-context:javaPreCompileRelease UP-TO-DATE
|
||||
> Task :react-native-safe-area-context:compileReleaseJavaWithJavac UP-TO-DATE
|
||||
> Task :react-native-safe-area-context:bundleLibRuntimeToDirRelease UP-TO-DATE
|
||||
> Task :react-native-worklets:bundleLibRuntimeToDirRelease UP-TO-DATE
|
||||
> Task :react-native-svg:bundleLibRuntimeToDirRelease UP-TO-DATE
|
||||
> Task :app:checkReleaseDuplicateClasses UP-TO-DATE
|
||||
> Task :app:buildKotlinToolingMetadata UP-TO-DATE
|
||||
> Task :app:checkKotlinGradlePluginConfigurationErrors SKIPPED
|
||||
> Task :app:generateReleaseBuildConfig UP-TO-DATE
|
||||
> Task :react-native-gesture-handler:writeReleaseAarMetadata UP-TO-DATE
|
||||
> Task :react-native-reanimated:writeReleaseAarMetadata UP-TO-DATE
|
||||
> Task :react-native-safe-area-context:writeReleaseAarMetadata UP-TO-DATE
|
||||
> Task :react-native-svg:writeReleaseAarMetadata UP-TO-DATE
|
||||
> Task :react-native-worklets:writeReleaseAarMetadata UP-TO-DATE
|
||||
> Task :app:checkReleaseAarMetadata UP-TO-DATE
|
||||
> Task :react-native-gesture-handler:bundleLibRuntimeToDirRelease
|
||||
|
||||
> Task :app:createBundleReleaseJsAndAssets
|
||||
|
||||
▒▒▓▓▓▓▒▒
|
||||
▒▓▓▓▒▒░░▒▒▓▓▓▒
|
||||
▒▓▓▓▓░░░▒▒▒▒░░░▓▓▓▓▒
|
||||
▓▓▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▓▓
|
||||
▓▓░░░░░▒▓▓▓▓▓▓▒░░░░░▓▓
|
||||
▓▓░░▓▓▒░░░▒▒░░░▒▓▒░░▓▓
|
||||
▓▓░░▓▓▓▓▓▒▒▒▒▓▓▓▓▒░░▓▓
|
||||
▓▓░░▓▓▓▓▓▓▓▓▓▓▓▓▓▒░░▓▓
|
||||
▓▓▒░░▒▒▓▓▓▓▓▓▓▓▒░░░▒▓▓
|
||||
▒▓▓▓▒░░░▒▓▓▒░░░▒▓▓▓▒
|
||||
▒▓▓▓▒░░░░▒▓▓▓▒
|
||||
▒▒▓▓▓▓▒▒
|
||||
|
||||
|
||||
WARN the transform cache was reset.
|
||||
Welcome to Metro v0.84.4
|
||||
Fast - Scalable - Integrated
|
||||
|
||||
|
||||
LOG:Writing bundle output to: android\app\build\generated\assets\react\release\index.android.bundle
|
||||
LOG:Writing sourcemap output to: android\app\build\intermediates\sourcemaps\react\release\index.android.bundle.packager.map
|
||||
LOG:Done writing bundle output
|
||||
LOG:Done writing sourcemap output
|
||||
Copying 1 asset files
|
||||
Done copying assets
|
||||
|
||||
> Task :app:generateReleaseResValues
|
||||
> Task :app:mapReleaseSourceSetPaths
|
||||
> Task :app:generateReleaseResources
|
||||
> Task :app:packageReleaseResources
|
||||
> Task :app:createReleaseCompatibleScreenManifests
|
||||
> Task :app:extractDeepLinksRelease
|
||||
> Task :react-native-gesture-handler:extractDeepLinksRelease
|
||||
> Task :react-native-reanimated:extractDeepLinksRelease
|
||||
> Task :app:parseReleaseLocalResources
|
||||
> Task :react-native-safe-area-context:extractDeepLinksRelease
|
||||
> Task :react-native-svg:extractDeepLinksRelease
|
||||
> Task :react-native-worklets:extractDeepLinksRelease
|
||||
> Task :react-native-gesture-handler:compileReleaseLibraryResources
|
||||
> Task :react-native-reanimated:compileReleaseLibraryResources
|
||||
> Task :react-native-safe-area-context:compileReleaseLibraryResources
|
||||
> Task :react-native-svg:compileReleaseLibraryResources
|
||||
> Task :react-native-gesture-handler:processReleaseManifest
|
||||
> Task :react-native-worklets:processReleaseManifest
|
||||
|
||||
> Task :react-native-safe-area-context:processReleaseManifest
|
||||
package="com.th3rdwave.safeareacontext" found in source AndroidManifest.xml: D:\h\node_modules\react-native-safe-area-context\android\src\main\AndroidManifest.xml.
|
||||
Setting the namespace via the package attribute in the source AndroidManifest.xml is no longer supported, and the value is ignored.
|
||||
Recommendation: remove package="com.th3rdwave.safeareacontext" from the source AndroidManifest.xml: D:\h\node_modules\react-native-safe-area-context\android\src\main\AndroidManifest.xml.
|
||||
|
||||
> Task :react-native-svg:processReleaseManifest
|
||||
> Task :react-native-reanimated:processReleaseManifest
|
||||
> Task :react-native-worklets:compileReleaseLibraryResources
|
||||
> Task :app:processReleaseMainManifest
|
||||
> Task :app:processReleaseManifest
|
||||
> Task :app:javaPreCompileRelease
|
||||
> Task :app:desugarReleaseFileDependencies
|
||||
> Task :app:mergeReleaseStartupProfile
|
||||
> Task :react-native-safe-area-context:bundleLibCompileToJarRelease
|
||||
> Task :react-native-gesture-handler:bundleLibCompileToJarRelease
|
||||
> Task :react-native-gesture-handler:prepareReleaseArtProfile
|
||||
> Task :react-native-reanimated:prepareReleaseArtProfile
|
||||
> Task :react-native-safe-area-context:prepareReleaseArtProfile
|
||||
> Task :react-native-svg:prepareReleaseArtProfile
|
||||
> Task :react-native-worklets:prepareReleaseArtProfile
|
||||
> Task :app:mergeReleaseArtProfile
|
||||
> Task :app:processReleaseManifestForPackage
|
||||
> Task :react-native-safe-area-context:bundleLibRuntimeToJarRelease
|
||||
> Task :react-native-reanimated:bundleLibRuntimeToJarRelease
|
||||
> Task :react-native-gesture-handler:bundleLibRuntimeToJarRelease
|
||||
> Task :app:mergeReleaseShaders
|
||||
> Task :app:compileReleaseShaders NO-SOURCE
|
||||
> Task :app:generateReleaseAssets UP-TO-DATE
|
||||
> Task :react-native-gesture-handler:mergeReleaseShaders
|
||||
> Task :react-native-gesture-handler:compileReleaseShaders NO-SOURCE
|
||||
> Task :react-native-gesture-handler:generateReleaseAssets UP-TO-DATE
|
||||
> Task :react-native-gesture-handler:mergeReleaseAssets
|
||||
> Task :react-native-reanimated:mergeReleaseShaders
|
||||
> Task :react-native-reanimated:compileReleaseShaders NO-SOURCE
|
||||
> Task :react-native-reanimated:generateReleaseAssets UP-TO-DATE
|
||||
> Task :react-native-reanimated:mergeReleaseAssets
|
||||
> Task :react-native-safe-area-context:mergeReleaseShaders
|
||||
> Task :react-native-safe-area-context:compileReleaseShaders NO-SOURCE
|
||||
> Task :react-native-safe-area-context:generateReleaseAssets UP-TO-DATE
|
||||
> Task :react-native-safe-area-context:mergeReleaseAssets
|
||||
> Task :react-native-svg:mergeReleaseShaders
|
||||
> Task :react-native-svg:compileReleaseShaders NO-SOURCE
|
||||
> Task :react-native-svg:generateReleaseAssets UP-TO-DATE
|
||||
> Task :react-native-worklets:bundleLibRuntimeToJarRelease
|
||||
> Task :react-native-svg:mergeReleaseAssets
|
||||
> Task :react-native-worklets:mergeReleaseShaders
|
||||
> Task :react-native-worklets:compileReleaseShaders NO-SOURCE
|
||||
> Task :react-native-worklets:generateReleaseAssets UP-TO-DATE
|
||||
> Task :react-native-worklets:mergeReleaseAssets
|
||||
> Task :react-native-svg:bundleLibRuntimeToJarRelease
|
||||
> Task :app:mergeReleaseAssets
|
||||
> Task :app:compressReleaseAssets
|
||||
> Task :app:extractReleaseVersionControlInfo
|
||||
> Task :app:extractProguardFiles
|
||||
> Task :react-native-gesture-handler:processReleaseJavaRes
|
||||
> Task :react-native-gesture-handler:createFullJarRelease
|
||||
> Task :react-native-gesture-handler:extractProguardFiles
|
||||
> Task :react-native-reanimated:processReleaseJavaRes
|
||||
> Task :react-native-reanimated:createFullJarRelease
|
||||
> Task :react-native-reanimated:extractProguardFiles
|
||||
> Task :react-native-worklets:processReleaseJavaRes
|
||||
> Task :react-native-worklets:createFullJarRelease
|
||||
> Task :react-native-worklets:extractProguardFiles
|
||||
> Task :react-native-worklets:generateReleaseLintModel
|
||||
> Task :react-native-worklets:prepareLintJarForPublish
|
||||
> Task :react-native-reanimated:prepareLintJarForPublish
|
||||
> Task :react-native-reanimated:generateReleaseLintModel
|
||||
> Task :react-native-svg:processReleaseJavaRes NO-SOURCE
|
||||
> Task :react-native-svg:createFullJarRelease
|
||||
> Task :react-native-svg:extractProguardFiles
|
||||
> Task :react-native-svg:generateReleaseLintModel
|
||||
> Task :react-native-svg:prepareLintJarForPublish
|
||||
> Task :react-native-gesture-handler:prepareLintJarForPublish
|
||||
> Task :react-native-gesture-handler:generateReleaseLintModel
|
||||
> Task :react-native-safe-area-context:processReleaseJavaRes
|
||||
> Task :react-native-safe-area-context:createFullJarRelease
|
||||
> Task :react-native-safe-area-context:extractProguardFiles
|
||||
> Task :react-native-safe-area-context:generateReleaseLintModel
|
||||
> Task :app:mergeExtDexRelease
|
||||
> Task :react-native-safe-area-context:prepareLintJarForPublish
|
||||
> Task :react-native-gesture-handler:extractDeepLinksForAarRelease
|
||||
> Task :react-native-gesture-handler:extractReleaseAnnotations
|
||||
> Task :react-native-gesture-handler:mergeReleaseGeneratedProguardFiles
|
||||
> Task :react-native-gesture-handler:mergeReleaseConsumerProguardFiles
|
||||
> Task :react-native-gesture-handler:stripReleaseDebugSymbols
|
||||
> Task :react-native-reanimated:extractDeepLinksForAarRelease
|
||||
> Task :react-native-gesture-handler:copyReleaseJniLibsProjectAndLocalJars
|
||||
> Task :react-native-reanimated:extractReleaseAnnotations
|
||||
> Task :react-native-reanimated:mergeReleaseGeneratedProguardFiles
|
||||
> Task :react-native-reanimated:mergeReleaseConsumerProguardFiles
|
||||
> Task :react-native-safe-area-context:stripReleaseDebugSymbols NO-SOURCE
|
||||
> Task :react-native-safe-area-context:copyReleaseJniLibsProjectAndLocalJars
|
||||
> Task :react-native-safe-area-context:extractDeepLinksForAarRelease
|
||||
> Task :react-native-safe-area-context:extractReleaseAnnotations
|
||||
> Task :react-native-reanimated:mergeReleaseJavaResource
|
||||
> Task :react-native-gesture-handler:mergeReleaseJavaResource
|
||||
> Task :react-native-reanimated:syncReleaseLibJars
|
||||
> Task :react-native-reanimated:stripReleaseDebugSymbols
|
||||
> Task :react-native-gesture-handler:syncReleaseLibJars
|
||||
> Task :react-native-gesture-handler:bundleReleaseLocalLintAar
|
||||
> Task :react-native-safe-area-context:mergeReleaseGeneratedProguardFiles
|
||||
> Task :react-native-safe-area-context:mergeReleaseConsumerProguardFiles
|
||||
> Task :react-native-reanimated:copyReleaseJniLibsProjectAndLocalJars
|
||||
> Task :react-native-worklets:extractDeepLinksForAarRelease
|
||||
> Task :react-native-safe-area-context:mergeReleaseJavaResource
|
||||
> Task :react-native-worklets:stripReleaseDebugSymbols
|
||||
> Task :react-native-reanimated:bundleReleaseLocalLintAar
|
||||
> Task :react-native-safe-area-context:syncReleaseLibJars
|
||||
> Task :react-native-safe-area-context:bundleReleaseLocalLintAar
|
||||
> Task :react-native-worklets:extractReleaseAnnotations
|
||||
> Task :react-native-worklets:copyReleaseJniLibsProjectAndLocalJars
|
||||
> Task :react-native-worklets:mergeReleaseGeneratedProguardFiles
|
||||
> Task :react-native-worklets:mergeReleaseConsumerProguardFiles
|
||||
> Task :react-native-svg:stripReleaseDebugSymbols NO-SOURCE
|
||||
> Task :react-native-svg:copyReleaseJniLibsProjectAndLocalJars
|
||||
> Task :react-native-worklets:mergeReleaseJavaResource
|
||||
> Task :react-native-svg:extractDeepLinksForAarRelease
|
||||
> Task :react-native-worklets:syncReleaseLibJars
|
||||
> Task :react-native-worklets:bundleReleaseLocalLintAar
|
||||
> Task :react-native-svg:extractReleaseAnnotations
|
||||
> Task :react-native-svg:mergeReleaseGeneratedProguardFiles
|
||||
> Task :react-native-svg:mergeReleaseConsumerProguardFiles
|
||||
> Task :react-native-svg:mergeReleaseJavaResource
|
||||
> Task :react-native-reanimated:writeReleaseLintModelMetadata
|
||||
> Task :react-native-svg:syncReleaseLibJars
|
||||
> Task :react-native-svg:bundleReleaseLocalLintAar
|
||||
> Task :react-native-svg:writeReleaseLintModelMetadata
|
||||
> Task :react-native-worklets:writeReleaseLintModelMetadata
|
||||
> Task :react-native-gesture-handler:writeReleaseLintModelMetadata
|
||||
> Task :react-native-safe-area-context:writeReleaseLintModelMetadata
|
||||
> Task :react-native-worklets:lintVitalAnalyzeRelease SKIPPED
|
||||
> Task :react-native-gesture-handler:generateReleaseLintVitalModel
|
||||
> Task :react-native-reanimated:generateReleaseLintVitalModel
|
||||
> Task :react-native-safe-area-context:generateReleaseLintVitalModel
|
||||
> Task :react-native-svg:generateReleaseLintVitalModel
|
||||
> Task :react-native-worklets:generateReleaseLintVitalModel
|
||||
> Task :app:collectReleaseDependencies
|
||||
> Task :app:sdkReleaseDependencyData
|
||||
> Task :app:validateSigningRelease
|
||||
> Task :app:writeReleaseAppMetadata
|
||||
> Task :app:writeReleaseSigningConfigVersions
|
||||
> Task :app:mergeReleaseResources
|
||||
> Task :app:processReleaseResources
|
||||
> Task :app:optimizeReleaseResources
|
||||
|
||||
> Task :app:compileReleaseKotlin
|
||||
w: file:///D:/h/android/app/src/main/java/com/healthtracking/fightrate/capture/FightRateCaptureService.kt:276:40 'var value: ByteArray!' is deprecated. Deprecated in Java.
|
||||
w: file:///D:/h/android/app/src/main/java/com/healthtracking/fightrate/capture/FightRateCaptureService.kt:313:43 'var value: ByteArray!' is deprecated. Deprecated in Java.
|
||||
w: file:///D:/h/android/app/src/main/java/com/healthtracking/fightrate/capture/FightRateCaptureService.kt:317:64 'var value: ByteArray!' is deprecated. Deprecated in Java.
|
||||
w: file:///D:/h/android/app/src/main/java/com/healthtracking/fightrate/capture/FightRateNativePackage.kt:9:16 This declaration overrides a deprecated member but is not marked as deprecated itself. Add the '@Deprecated' annotation or suppress the diagnostic.
|
||||
|
||||
> Task :react-native-svg:lintVitalAnalyzeRelease
|
||||
> Task :app:compileReleaseJavaWithJavac
|
||||
> Task :app:dexBuilderRelease
|
||||
> Task :app:mergeReleaseGlobalSynthetics
|
||||
> Task :app:generateReleaseLintVitalReportModel
|
||||
> Task :react-native-reanimated:lintVitalAnalyzeRelease SKIPPED
|
||||
> Task :app:processReleaseJavaRes
|
||||
> Task :react-native-safe-area-context:lintVitalAnalyzeRelease
|
||||
> Task :app:mergeReleaseJavaResource
|
||||
The Daemon will expire after the build after running out of JVM Metaspace.
|
||||
The project memory settings are likely not configured or are configured to an insufficient value.
|
||||
The daemon will restart for the next build, which may increase subsequent build times.
|
||||
These settings can be adjusted by setting 'org.gradle.jvmargs' in 'gradle.properties'.
|
||||
The currently configured max heap space is '2 GiB' and the configured max metaspace is '512 MiB'.
|
||||
For more information on how to set these values, please refer to https://docs.gradle.org/9.3.1/userguide/build_environment.html#sec:configuring_jvm_memory in the Gradle documentation.
|
||||
To disable this warning, set 'org.gradle.daemon.performance.disable-logging=true'.
|
||||
Daemon will be stopped at the end of the build after running out of JVM Metaspace
|
||||
> Task :app:mergeDexRelease
|
||||
> Task :app:compileReleaseArtProfile
|
||||
> Task :react-native-gesture-handler:lintVitalAnalyzeRelease
|
||||
> Task :app:lintVitalAnalyzeRelease
|
||||
> Task :app:lintVitalReportRelease
|
||||
> Task :app:lintVitalRelease
|
||||
> Task :app:packageRelease
|
||||
> Task :app:createReleaseApkListingFileRedirect
|
||||
> Task :app:assembleRelease
|
||||
|
||||
[Incubating] Problems report is available at: file:///D:/h/android/build/reports/problems/problems-report.html
|
||||
|
||||
Deprecated Gradle features were used in this build, making it incompatible with Gradle 10.
|
||||
|
||||
You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.
|
||||
|
||||
For more on this, please refer to https://docs.gradle.org/9.3.1/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.
|
||||
|
||||
BUILD SUCCESSFUL in 1m 54s
|
||||
281 actionable tasks: 176 executed, 105 up-to-date
|
||||
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.3.1/userguide/configuration_cache_enabling.html
|
||||
EXIT=0
|
||||
|
After Width: | Height: | Size: 1011 KiB |
@@ -0,0 +1,28 @@
|
||||
const path = require('path');
|
||||
|
||||
// Patch _cliConfigAndroid to log
|
||||
const Module = require('module');
|
||||
const cliPlat = require.resolve('@react-native-community/cli-platform-android');
|
||||
const platContent = require('fs').readFileSync(cliPlat, 'utf8');
|
||||
// Find _cliConfigAndroid function and patch
|
||||
const patched = platContent.replace(
|
||||
'function _cliConfigAndroid() {\n const data = require("@react-native-community/cli-config-android");',
|
||||
'function _cliConfigAndroid() {\n const data = require("@react-native-community/cli-config-android");\n console.log("CC loaded, keys:", Object.keys(data));',
|
||||
);
|
||||
// Write to a temp file and require it
|
||||
const tmp = './_patched_cli.js';
|
||||
require('fs').writeFileSync(tmp, patched);
|
||||
delete require.cache[cliPlat];
|
||||
const plat = require(tmp);
|
||||
console.log('projectConfig type:', typeof plat.projectConfig);
|
||||
|
||||
// Try loadConfig
|
||||
try {
|
||||
const cli = require('@react-native-community/cli');
|
||||
const cfg = cli.loadConfig({ projectRoot: './' });
|
||||
console.log('SUCCESS:', JSON.stringify(cfg.project));
|
||||
} catch (e) {
|
||||
console.log('FAIL:', e.message);
|
||||
}
|
||||
|
||||
require('fs').unlinkSync(tmp);
|
||||
@@ -0,0 +1,276 @@
|
||||
# FightRate Development Workflow
|
||||
|
||||
## Objetivo
|
||||
|
||||
Este documento define o padrão de desenvolvimento do FightRate com apoio de IA. O objetivo é evoluir o app com qualidade, reduzir retrabalho, evitar código morto e reaproveitar o que já existe antes de criar novas estruturas.
|
||||
|
||||
## Princípio central
|
||||
|
||||
> Antes de criar algo novo, procure e avalie o que já existe.
|
||||
|
||||
A IA deve atuar como engenheira disciplinada: entender o problema, auditar o código existente, propor uma solução pequena, implementar em fatias, verificar e revisar.
|
||||
|
||||
## Níveis de mudança
|
||||
|
||||
### Nível 1 — Ajuste pequeno
|
||||
|
||||
Use para copy, espaçamento, correção simples ou bug isolado.
|
||||
|
||||
Fluxo:
|
||||
|
||||
1. Mini-spec de 2 a 5 linhas.
|
||||
2. Editar o menor trecho possível.
|
||||
3. Compilar ou rodar teste relacionado.
|
||||
4. Responder com evidência.
|
||||
|
||||
### Nível 2 — Feature média
|
||||
|
||||
Use para melhorias em uma tela, novo comportamento de domínio, persistência pequena ou ajustes de BLE sem mudança arquitetural.
|
||||
|
||||
Fluxo:
|
||||
|
||||
1. Spec curta.
|
||||
2. Auditoria do existente.
|
||||
3. Plano em fatias.
|
||||
4. Teste de domínio quando houver regra de negócio.
|
||||
5. Implementação incremental.
|
||||
6. Build/test final.
|
||||
7. Revisão de código e UI.
|
||||
|
||||
### Nível 3 — Feature grande
|
||||
|
||||
Use para novos fluxos de treino, histórico avançado, dashboards, mudanças de banco importantes, BLE/background tracking ou nova tela grande.
|
||||
|
||||
Fluxo:
|
||||
|
||||
1. Spec completa.
|
||||
2. Aprovação humana.
|
||||
3. Plano técnico.
|
||||
4. Tarefas pequenas com acceptance/verify.
|
||||
5. Implementação uma tarefa por vez.
|
||||
6. Revisão final e atualização do roadmap.
|
||||
|
||||
## Intake obrigatório
|
||||
|
||||
Antes de implementar, responder:
|
||||
|
||||
```text
|
||||
O que vamos melhorar?
|
||||
Qual problema real isso resolve?
|
||||
Em qual tela/fluxo?
|
||||
Como saberemos que ficou bom?
|
||||
```
|
||||
|
||||
Quando o pedido for vago, converter para critérios testáveis.
|
||||
|
||||
Exemplo:
|
||||
|
||||
```text
|
||||
Pedido: melhorar alertas.
|
||||
Critérios:
|
||||
- usuário configura limite cardíaco;
|
||||
- alerta não dispara sem controle;
|
||||
- vibração/som são opcionais;
|
||||
- UI em PT-BR;
|
||||
- build final passa.
|
||||
```
|
||||
|
||||
## Auditoria do existente
|
||||
|
||||
Antes de criar arquivo, classe, componente ou dependência, verificar:
|
||||
|
||||
```text
|
||||
Já existe domínio parecido?
|
||||
Já existe state no ViewModel?
|
||||
Já existe componente visual reutilizável?
|
||||
Já existe persistência parecida?
|
||||
Já existe teste relacionado?
|
||||
```
|
||||
|
||||
Pontos comuns de reaproveitamento:
|
||||
|
||||
```text
|
||||
HeartRateSampleStore
|
||||
LiveHeartRateViewModel
|
||||
LiveHeartRateUiState
|
||||
HeartRateZones
|
||||
HeartRateForegroundService
|
||||
HomeScreen
|
||||
FightCard
|
||||
MetricTile
|
||||
LiveMetricPill
|
||||
```
|
||||
|
||||
Regra:
|
||||
|
||||
> Se a IA não consegue explicar por que um arquivo novo precisa existir, ela não deve criar esse arquivo.
|
||||
|
||||
## Arquitetura esperada
|
||||
|
||||
### Domínio
|
||||
|
||||
Use `domain/` para regra pura e testável sem Android.
|
||||
|
||||
Exemplos:
|
||||
|
||||
```text
|
||||
RoundTimer.kt
|
||||
HeartRateZones.kt
|
||||
WorkoutSession.kt
|
||||
HeartRateAlertSettings.kt
|
||||
```
|
||||
|
||||
### Dados
|
||||
|
||||
Use `data/` para persistência, exportação e acesso local.
|
||||
|
||||
```text
|
||||
HeartRateSampleStore.kt
|
||||
HeartRateExporter.kt
|
||||
```
|
||||
|
||||
SQLite local-first continua sendo a fonte de verdade.
|
||||
|
||||
### BLE / Android
|
||||
|
||||
Use `ble/`, `service/` e `system/` para integração Android.
|
||||
|
||||
Regra importante:
|
||||
|
||||
> A conexão BLE pertence ao `HeartRateForegroundService`, não ao ViewModel.
|
||||
|
||||
### UI
|
||||
|
||||
Use `ui/screens/`, `ui/live/` e `ui/theme/`.
|
||||
|
||||
A UI deve consumir estado pronto e não concentrar regra de negócio pesada.
|
||||
|
||||
## Padrão de fatias
|
||||
|
||||
Implementar em fatias pequenas:
|
||||
|
||||
```text
|
||||
Fatia 1 — domínio puro
|
||||
Fatia 2 — persistência
|
||||
Fatia 3 — ViewModel/state
|
||||
Fatia 4 — UI
|
||||
Fatia 5 — integração Android/BLE
|
||||
Fatia 6 — revisão
|
||||
```
|
||||
|
||||
Cada fatia deve declarar:
|
||||
|
||||
```text
|
||||
Acceptance: o que precisa funcionar.
|
||||
Verify: comando ou checagem que comprova.
|
||||
```
|
||||
|
||||
## TDD obrigatório quando houver regra
|
||||
|
||||
Escrever teste antes ou junto para:
|
||||
|
||||
```text
|
||||
parser BLE
|
||||
zonas cardíacas
|
||||
timer de rounds
|
||||
resumo de treino
|
||||
alertas
|
||||
janelas temporais
|
||||
estatísticas
|
||||
migração/persistência crítica
|
||||
```
|
||||
|
||||
UI simples não exige TDD pesado, mas exige build e revisão visual/UX.
|
||||
|
||||
## Quality gates
|
||||
|
||||
Antes de concluir qualquer mudança média/grande:
|
||||
|
||||
```bash
|
||||
./gradlew :app:testDebugUnitTest :app:assembleDebug
|
||||
```
|
||||
|
||||
Para domínio específico:
|
||||
|
||||
```bash
|
||||
./gradlew :app:testDebugUnitTest --tests "*NomeDoTeste"
|
||||
```
|
||||
|
||||
Também verificar:
|
||||
|
||||
```text
|
||||
- UI visível em PT-BR;
|
||||
- identificadores de código em inglês;
|
||||
- sem código morto óbvio;
|
||||
- sem componente duplicado desnecessário;
|
||||
- sem Canvas custom frágil para gráfico/gauge;
|
||||
- sem dependência nova sem justificativa;
|
||||
- sem quebrar background BLE;
|
||||
- sem alterar minSdk 26 sem aprovação.
|
||||
```
|
||||
|
||||
## Sempre fazer
|
||||
|
||||
- Reaproveitar componentes existentes.
|
||||
- Preservar SQLite como fonte local.
|
||||
- Escrever domínio puro para regra de negócio.
|
||||
- Testar domínio antes da UI quando aplicável.
|
||||
- Manter textos visíveis em PT-BR.
|
||||
- Manter código em inglês.
|
||||
- Rodar build final.
|
||||
- Atualizar roadmap/Obsidian quando mudar escopo relevante.
|
||||
|
||||
## Perguntar antes
|
||||
|
||||
- Adicionar dependência nova.
|
||||
- Fazer mudança destrutiva no schema SQLite.
|
||||
- Criar tela grande nova.
|
||||
- Trocar biblioteca de gráfico/UI.
|
||||
- Alterar BLE service/background tracking.
|
||||
- Mudar minSdk, targetSdk, AGP, Kotlin ou Gradle.
|
||||
|
||||
## Nunca fazer
|
||||
|
||||
- Criar gráfico/gauge em Canvas custom sem necessidade clara.
|
||||
- Remover teste falhando só para passar build.
|
||||
- Criar componente novo se um existente resolve.
|
||||
- Deixar código morto “para usar depois”.
|
||||
- Implementar feature sem critério de sucesso.
|
||||
- Alterar texto do app para inglês sem pedido.
|
||||
- Misturar refactor grande com feature.
|
||||
|
||||
## Checklist antes de implementar
|
||||
|
||||
```text
|
||||
[ ] Existe spec ou mini-spec?
|
||||
[ ] Critérios de sucesso são testáveis?
|
||||
[ ] Arquivos existentes foram auditados?
|
||||
[ ] Componentes reaproveitados foram listados?
|
||||
[ ] Novos arquivos têm responsabilidade clara?
|
||||
[ ] Fatia atual toca no máximo 3–5 arquivos principais?
|
||||
[ ] Há teste para regra de domínio?
|
||||
[ ] Há comando de verificação definido?
|
||||
```
|
||||
|
||||
## Checklist antes de responder “concluído”
|
||||
|
||||
```text
|
||||
[ ] Testes/build rodaram.
|
||||
[ ] Resultado foi registrado.
|
||||
[ ] APK foi localizado quando aplicável.
|
||||
[ ] Roadmap/Obsidian foi atualizado quando aplicável.
|
||||
[ ] Limitações foram declaradas.
|
||||
```
|
||||
|
||||
## Dependências aprovadas (histórico)
|
||||
|
||||
Adicionar uma dependência nova continua exigindo aprovação explícita. Esta seção registra as que já passaram pelo gate, com a justificativa de produto:
|
||||
|
||||
- `react-native-gesture-handler` (aprovado em 2026-06-14) — necessário para gestos nativos de pinch e pan no `TrainingChart` dinâmico (viewport com zoom, pan e tap). O `PanResponder` builtin não tem pinch de 2 dedos e perde precisão fora do viewBox.
|
||||
- `react-native-reanimated` (aprovado em 2026-06-14) — worklets para gestures performáticos em tempo real (atualização do viewport durante o pinch sem travar a thread JS).
|
||||
- `react-native-worklets` (aprovado em 2026-06-14) — peer dep de `react-native-reanimated` 4.x; runtime paralelo a JS.
|
||||
|
||||
Todas as três exigem a New Architecture habilitada (`newArchEnabled=true` no `android/gradle.properties`) e setup específico:
|
||||
- `react-native-gesture-handler` importado no topo de `index.js` antes de qualquer outro React Native.
|
||||
- App envolto em `GestureHandlerRootView`.
|
||||
- Babel com `react-native-worklets/plugin`.
|
||||
@@ -0,0 +1,551 @@
|
||||
# FightRate — Full Project Context for AI Successor
|
||||
|
||||
## Author: Evandro
|
||||
## Stack: Android Native (Kotlin, Jetpack Compose, Material3)
|
||||
|
||||
---
|
||||
|
||||
## 1. DEV ENVIRONMENT
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Project root | `D:/Documents/Github/HealthTracking` |
|
||||
| Package | `com.healthtracking.fightrate` |
|
||||
| Gradle | Wrapper 8.11.1 |
|
||||
| AGP | 8.9.1 |
|
||||
| Kotlin | 2.3.21 |
|
||||
| Compose BOM | 2024.12.01 |
|
||||
| compileSdk | 36 |
|
||||
| targetSdk | 36 |
|
||||
| minSdk | 26 |
|
||||
| Android SDK | `C:\Users\Evandro\AppData\Local\Android\Sdk` |
|
||||
| Java | JDK 17 (jvmToolchain) |
|
||||
| App name | **FightRate** |
|
||||
| APK naming | `FightRate-YYYY-MM-DD-HH-mm-ss-debug.apk` |
|
||||
| No git repo | Project directory is NOT a git repository |
|
||||
|
||||
### Key dependencies
|
||||
- `navigation-compose:2.8.5`
|
||||
- `compose.material3`
|
||||
- `icons-lucide-android:2.2.1` (via `com.composables:icons-lucide-android`)
|
||||
- **IMPORTANT**: Non-transitive R; access Lucide icons via `import com.composables.icons.lucide.R as LucideR`
|
||||
- `vico:compose:3.2.2` + `vico:compose-m3:3.2.2` (charting)
|
||||
- `coil-compose:3.0.4`
|
||||
- `lottie-compose:6.6.2`
|
||||
- `fastexcel:0.18.4` (XLSX export)
|
||||
- `junit:4.13.2` (testing)
|
||||
|
||||
### Build commands
|
||||
```bash
|
||||
# Full build + test
|
||||
./gradlew :app:testDebugUnitTest :app:assembleDebug
|
||||
|
||||
# Quick compile
|
||||
./gradlew :app:compileDebugKotlin
|
||||
|
||||
# Unit test focused
|
||||
./gradlew :app:testDebugUnitTest --tests "*AthleteInsightsTest*"
|
||||
|
||||
# Install on device
|
||||
./gradlew :app:installDebug
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. PROJECT VISION
|
||||
|
||||
**FightRate** is a native Android app for tracking heart rate during combat/fighting sports using a **Magene H003** chest strap (BLE Heart Rate Service 0x180D).
|
||||
|
||||
### Core pillars
|
||||
1. **Real-time BLE heart rate tracking** — even with screen off, even in background
|
||||
2. **Workout sessions** — start/pause/resume/finish with round timer
|
||||
3. **Athlete profile & cardiac zones** — personalized by age, sex, resting HR, measured max HR
|
||||
4. **Data insights** — readiness, effort, hydration, recovery, fight zone time
|
||||
5. **Rich history & charts** — time-based Vico charts with compaction, export (CSV/JSON/XLSX)
|
||||
6. **Scientific cardiac analytics** — statistical and clinical-grade metrics from raw HR data
|
||||
|
||||
### UI conventions
|
||||
- All visible text in **PT-BR** (Portuguese Brazilian)
|
||||
- All code identifiers in **English**
|
||||
- Dark theme with signature orange accent (`FightOrange = #FF5733`)
|
||||
- Bottom navigation tabs: Ao vivo | Sensor | Histórico | Insights | Perfil
|
||||
- Cards use `FightCard` composable (dark panels with rounded corners)
|
||||
|
||||
---
|
||||
|
||||
## 3. FILE STRUCTURE
|
||||
|
||||
```
|
||||
app/src/main/java/com/healthtracking/fightrate/
|
||||
├── MainActivity.kt # Entry point, permission requests
|
||||
├── ble/
|
||||
│ ├── AndroidHeartRateBleClient.kt # Concrete BLE GATT client implementation
|
||||
│ ├── AndroidHeartRateBleScanner.kt # Concrete BLE scanner implementation
|
||||
│ ├── HeartRateBleClient.kt # BLE client interface
|
||||
│ ├── HeartRateBleConstants.kt # BLE UUIDs (0x180D, 0x2A37, 0x2A38, 0x180F, 0x2A19)
|
||||
│ ├── HeartRateBleScanner.kt # BLE scanner interface
|
||||
│ ├── HeartRateBleStatus.kt # Status sealed class (Connecting/Connected/etc.)
|
||||
│ ├── HeartRateDevice.kt # Device data class
|
||||
│ ├── HeartRateParser.kt # BLE heart rate measurement parser
|
||||
│ └── HeartRateSensorMetadata.kt # Battery level + body sensor location
|
||||
├── data/
|
||||
│ ├── HeartRateSampleStore.kt # SQLite DB (heart_rate_samples + workout_sessions)
|
||||
│ └── HeartRateExporter.kt # CSV/JSON/XLSX export via FileProvider
|
||||
├── domain/
|
||||
│ ├── AthleteInsights.kt # BMI, Karvonen, readiness, effort, hydration, fight zone, alerts
|
||||
│ ├── HeartRateAlertSettings.kt # Alert config + evaluator
|
||||
│ ├── HeartRateStats.kt # Stats (avg/min/max/stdDev)
|
||||
│ ├── HeartRateWindows.kt # Rolling time window filter
|
||||
│ ├── HeartRateZones.kt # Zone calculation (6 zones by % max HR)
|
||||
│ ├── RoundTimer.kt # Round/rest timer logic
|
||||
│ ├── WorkoutSession.kt # Session + summary models + calculations
|
||||
│ └── model/
|
||||
│ ├── AthleteProfile.kt # Age, sex, height, weight, maxHR, restingHR, sport, class, goal
|
||||
│ ├── FightSport.kt # Boxing, MuayThai, MMA, BJJ, Wrestling, FreeTraining
|
||||
│ ├── HeartRateMeasurement.kt # Measurement model
|
||||
│ └── HeartRateSample.kt # Core sample (timestampMillis, bpm, sport, source, deviceName, sessionId)
|
||||
├── service/
|
||||
│ └── HeartRateForegroundService.kt # Foreground BLE service with auto-reconnect + backoff
|
||||
├── system/
|
||||
│ ├── BackgroundRunPermission.kt # Battery optimization settings opener
|
||||
│ └── HeartRateAlertNotifier.kt # Vibration + sound alert dispatcher
|
||||
└── ui/
|
||||
├── live/
|
||||
│ ├── BleConnectionStatus.kt # Connection status enum
|
||||
│ ├── HistoryPeriod.kt # Time window enum (Last10min/30min/1h/3h/6h/Today)
|
||||
│ ├── LiveHeartRateUiState.kt # Single source of truth state data class
|
||||
│ └── LiveHeartRateViewModel.kt # Central AndroidViewModel (~729 lines)
|
||||
├── screens/
|
||||
│ └── HomeScreen.kt # ALL UI in one file (~1700+ lines of Compose)
|
||||
└── theme/
|
||||
├── Color.kt # Dark theme color palette
|
||||
└── FightRateTheme.kt # Material3 theme configuration
|
||||
|
||||
Tests:
|
||||
app/src/test/java/com/healthtracking/fightrate/
|
||||
├── ble/HeartRateParserTest.kt
|
||||
├── domain/
|
||||
│ ├── AthleteInsightsTest.kt
|
||||
│ ├── HeartRateAlertEvaluatorTest.kt
|
||||
│ ├── HeartRateStatsCalculatorTest.kt
|
||||
│ ├── HeartRateWindowsTest.kt
|
||||
│ ├── HeartRateZonesTest.kt
|
||||
│ ├── RoundTimerTest.kt
|
||||
│ └── WorkoutSessionSummaryTest.kt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. DATABASE SCHEMA
|
||||
|
||||
### Table: heart_rate_samples (version 4)
|
||||
```sql
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||
timestamp_millis INTEGER NOT NULL
|
||||
day_key TEXT NOT NULL
|
||||
bpm INTEGER NOT NULL
|
||||
sport TEXT NOT NULL
|
||||
source TEXT NOT NULL
|
||||
device_name TEXT
|
||||
session_id INTEGER
|
||||
```
|
||||
Indexed on `day_key`, `timestamp_millis`, `(session_id, timestamp_millis)`.
|
||||
|
||||
### Table: workout_sessions (created at v3, expanded at v4)
|
||||
```sql
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||
started_at_millis INTEGER NOT NULL
|
||||
ended_at_millis INTEGER
|
||||
status TEXT NOT NULL DEFAULT 'Active'
|
||||
sport TEXT NOT NULL DEFAULT 'Treino livre'
|
||||
round_duration_seconds INTEGER NOT NULL DEFAULT 180
|
||||
rest_duration_seconds INTEGER NOT NULL DEFAULT 60
|
||||
rounds INTEGER NOT NULL DEFAULT 3
|
||||
accumulated_active_seconds INTEGER NOT NULL DEFAULT 0
|
||||
last_resumed_at_millis INTEGER
|
||||
-- v4 columns:
|
||||
pre_workout_energy INTEGER
|
||||
pre_workout_fatigue INTEGER
|
||||
muscle_soreness INTEGER
|
||||
sleep_hours REAL
|
||||
sleep_quality INTEGER
|
||||
pre_workout_weight_kg REAL
|
||||
water_intake_ml INTEGER
|
||||
symptoms_note TEXT NOT NULL DEFAULT ''
|
||||
post_workout_rpe INTEGER
|
||||
post_workout_fatigue INTEGER
|
||||
post_workout_soreness INTEGER
|
||||
post_workout_weight_kg REAL
|
||||
post_workout_note TEXT NOT NULL DEFAULT ''
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. DATA FLOW
|
||||
|
||||
### BLE → Service → ViewModel → UI
|
||||
```
|
||||
Magene H003 (BLE)
|
||||
↓ Heart Rate Service 0x180D / Measurement 0x2A37
|
||||
AndroidHeartRateBleClient (GATT)
|
||||
↓ Flow<HeartRateMeasurement>
|
||||
HeartRateForegroundService
|
||||
↓ BroadcastReceiver (ActionSample, ActionStatus, ActionMetadata)
|
||||
LiveHeartRateViewModel.handleServiceSample() / handleServiceStatus() / handleSensorMetadata()
|
||||
↓ StateFlow<LiveHeartRateUiState>
|
||||
HomeScreen.kt (@Composable)
|
||||
```
|
||||
|
||||
### Session lifecycle
|
||||
1. User fills **Check-in** (mandatory dialog) → `startWorkoutSessionWithCheckIn()`
|
||||
2. SQLite `workout_sessions` inserted, sample `session_id` auto-assigned
|
||||
3. Round timer runs in ViewModel ticker
|
||||
4. User can Pause / Resume
|
||||
5. User taps Finish → **Post-workout** dialog → `finishWorkoutSessionWithPostWorkout()`
|
||||
6. Session status = Finished, `ended_at_millis` set
|
||||
|
||||
---
|
||||
|
||||
## 6. CARDIAC ZONES SYSTEM
|
||||
|
||||
### Zone thresholds based on % of max heart rate
|
||||
| Zone | % Max HR | Label (PT-BR) | Usage |
|
||||
|------|----------|----------------|-------|
|
||||
| Recovery | 1–59% | Recuperação | Recovery/active rest |
|
||||
| Base | 60–69% | Base | Aerobic base |
|
||||
| Vigorous | 70–85% | Vigorosa | Moderate-high intensity |
|
||||
| Fight | 86–89% | Luta intensa | Fight zone specific |
|
||||
| RedLine | 90–95% | Muito intensa | Very high intensity |
|
||||
| Limit | 96–200% | Limite | Maximum effort |
|
||||
|
||||
### Max HR formula
|
||||
- Estimated: `208 - (0.7 × age)` (Tanaka formula)
|
||||
- Overrideable with `measuredMaxHeartRate` (campo: "limite cardíaco real")
|
||||
|
||||
### Karvonen zones (reserve-based)
|
||||
When resting HR is available, zones shift upward using:
|
||||
`TargetHR = RestingHR + Reserve × %Intensity`
|
||||
where `Reserve = MaxHR - RestingHR`
|
||||
|
||||
---
|
||||
|
||||
## 7. CURRENT METRICS / CALCULATIONS
|
||||
|
||||
### Already implemented
|
||||
1. **BMI** — from weight + height, with category (abaixo do peso / peso adequado / sobrepeso / obesidade)
|
||||
2. **Heart Rate Reserve** (Karvonen) — MaxHR − RestingHR
|
||||
3. **Zones by Karvonen** — personalized zone ranges
|
||||
4. **Readiness Score** (0–100) — based on sleep hours, sleep quality, energy, fatigue, soreness
|
||||
5. **Workout Effort Score** (0–100) — based on avg%BPM, peak%BPM, duration, RPE, with session load (RPE×min)
|
||||
6. **Hydration Estimate** — pre/post weight difference + water intake, with fluid loss warning
|
||||
7. **Fight Zone Time** — seconds and % of session spent in Fight/RedLine/Limit zones
|
||||
8. **Athlete Insights Alerts** — combined warnings from readiness, hydration, effort, symptoms
|
||||
9. **Heart Rate Stats** — avg, min, max, standard deviation
|
||||
10. **Zone Breakdown** — % of session in each zone
|
||||
11. **Rolling 60s average** — main display; instant BPM shown as "Agora"
|
||||
|
||||
---
|
||||
|
||||
## 8. BLE RESILIENCE SYSTEM
|
||||
|
||||
### Reconnection strategy
|
||||
- Service runs as foreground with `foregroundServiceType="connectedDevice"`
|
||||
- `START_STICKY` — Android restarts if killed
|
||||
- On unexpected disconnect (distance/signal loss), auto-reconnect with backoff:
|
||||
- 1st: 2s → 2nd: 5s → 3rd: 10s → 4th+: 30s
|
||||
- Manual disconnect sets `manualDisconnect = true` flag in SharedPreferences
|
||||
- When `manualDisconnect = true`, no auto-reconnect
|
||||
- Reconnection state persisted: last device ID, device name, manual flag
|
||||
- On service restart (START_STICKY), reads persisted state and auto-reconnects if not manual
|
||||
- No aggressive continuous scanning; reconnects by known device ID first
|
||||
|
||||
### Battery optimization
|
||||
- Single foreground service during session (no persistent background service when idle)
|
||||
- Notification updates throttled: max every 15s OR >3 BPM delta
|
||||
- No SQLite polling on every beat from service
|
||||
- ViewModel polls DB at 5s intervals only when connected
|
||||
- Rolling 60s average computed in-memory in service
|
||||
- Guide user to battery optimization settings via `BackgroundRunPermission`
|
||||
|
||||
---
|
||||
|
||||
## 9. CURRENT UI STRUCTURE (HomeScreen.kt)
|
||||
|
||||
Single massive file with all composable functions (~1700+ lines):
|
||||
|
||||
### Screens (tabs)
|
||||
- **LiveScreen** — rolling 10-min chart, instant BPM, 60s avg, zone, Muay Thai comparison
|
||||
- **SensorScreen** — scan button, device list, battery chip, body sensor location, connection status, background toggle
|
||||
- **HistoryScreen** — period selector, stats cards, zone breakdown, Vico chart (time-based) with empty state
|
||||
- **InsightsScreen** — BMI, reserve, readiness, effort, hydration, fight zone, alerts
|
||||
- **ProfileScreen** — expandable sections (básico, cardiovascular, luta), choice chips for sex/sport, save button
|
||||
|
||||
### Key composables
|
||||
- `FightCard` — dark panel wrapper
|
||||
- `MetricTile` — stat label+value pair
|
||||
- `LiveMetricPill` — compact live stat pill
|
||||
- `HeartRateLineChart` — Vico chart with time-based x-axis
|
||||
- `EmptyTimeWindowChart` — placeholder when no data
|
||||
- `RoundSessionCard` — session controls + round timer
|
||||
- `ExpandedSessionCard` — session metrics
|
||||
- `AlertSettingsCard` — alert configuration
|
||||
|
||||
---
|
||||
|
||||
## 10. WHAT DOCTORS CONSIDER FROM HEART RATE DATA (Scientific Foundation)
|
||||
|
||||
Below is a structured compilation of medical-grade metrics that can be calculated from the data types FightRate already collects (raw HR timestamps, profile data, session data, sleep, subjective scales). These are well-established in sports cardiology, exercise physiology, and clinical practice.
|
||||
|
||||
### 10.1 Resting Heart Rate (RHR)
|
||||
- **Clinical significance**: Lower RHR = better cardiovascular fitness. Resting tachycardia (>100 bpm) flags medical concern.
|
||||
- **Can trend**: Weekly RHR average from morning readings. Spike of +5-7 bpm signals possible illness, overtraining, or poor recovery.
|
||||
- **Calculation**: Mean of lowest 5 readings during rest periods (early morning before rising is gold standard).
|
||||
|
||||
### 10.2 Heart Rate Recovery (HRR)
|
||||
- **Clinical gold standard**: Drop in BPM 1-minute and 2-minutes post-exercise cessation.
|
||||
- **Normal**: >22 bpm drop at 1 min (adults). Drop <12 bpm = elevated cardiovascular mortality risk (Cole et al., NEJM 1999).
|
||||
- **Athlete context**: Faster recovery = better conditioning. Slowing recovery trend = functional overreaching or detraining.
|
||||
- **Can calculate from existing data**: After session ends, look at first 60s and 120s of BPM decline.
|
||||
|
||||
### 10.3 Heart Rate Variability (HRV)
|
||||
- **Key clinical parameters**:
|
||||
- **RMSSD** (Root Mean Square of Successive Differences): Parasympathetic activity marker. Gold standard for daily monitoring.
|
||||
- **SDNN** (Standard Deviation of NN intervals): Overall HRV. Clinical risk predictor.
|
||||
- **LF/HF Ratio**: Sympathetic/parasympathetic balance.
|
||||
- **From our data**: HRV requires beat-to-beat R-R intervals (ms). **BLE HR Service 0x2A37 can provide RR intervals** if the sensor sends them (Magene H003 does). We already have `HeartRateMeasurement` with potential RR data.
|
||||
- **Clinical use**: Low HRV = stress, illness risk, overtraining. High HRV = recovery, readiness.
|
||||
- **Training application**: Morning HRV guides daily training intensity (Daneshvar, 2024).
|
||||
|
||||
### 10.4 Cardiac Drift (Cardiovascular Drift)
|
||||
- **Definition**: Progressive rise in HR during steady-state exercise despite constant workload.
|
||||
- **Causes**: Dehydration, electrolyte loss, thermoregulatory strain, blood volume shift.
|
||||
- **Clinical marker**: Drift >10% of starting HR = significant heat/dehydration stress.
|
||||
- **Can calculate**: Linear regression slope of HR over session duration at constant intensity.
|
||||
|
||||
### 10.5 Chronotropic Response / Chronotropic Incompetence
|
||||
- **Definition**: Inability of HR to rise appropriately with exercise intensity.
|
||||
- **Calculation**: `%HeartRateReserve achieved / %MetabolicReserve achieved`
|
||||
- **Clinical threshold**: CI = failure to reach 85% of age-predicted max HR at peak exercise.
|
||||
- **Detection from data**: Slope of HR increase in first 3 minutes of session.
|
||||
|
||||
### 10.6 Heart Rate Deflection Point (HRDP / Conconi Test)
|
||||
- **Definition**: The point at which HR stops increasing linearly with running speed/workload.
|
||||
- **Use**: Non-invasive anaerobic threshold estimation.
|
||||
- **Calculation**: Piecewise linear regression of HR × workload curve.
|
||||
|
||||
### 10.7 TRIMP (Training Impulse)
|
||||
- **Banister's TRIMP**: `duration × (ΔHR/ΔHRmax) × 0.64e^(1.92×ΔHR/ΔHRmax)`
|
||||
- **MICT TRIMP**: `duration × %MaxHR`
|
||||
- **Edwards TRIMP (our approach)**: Sum of minutes in each zone × zone coefficient.
|
||||
- **Clinical use**: Quantifies internal training load. Correlates with adaptation and injury risk.
|
||||
|
||||
### 10.8 Training Monotony & Strain (Foster, 1998)
|
||||
- **Monotony**: `WeeklyMeanLoad / WeeklySDLoad`. High monotony (>2.0) + high strain = overtraining risk.
|
||||
- **Strain**: `WeeklySumLoad × Monotony`.
|
||||
- **Data requirement**: Daily session load over 7+ days.
|
||||
|
||||
### 10.9 Excess Post-Exercise Oxygen Consumption (EPOC) Estimate
|
||||
- **Correlation**: Higher HR during session → higher EPOC → more post-workout calorie burn.
|
||||
- **Proxy metric**: Time above 70% max HR correlates with EPOC duration.
|
||||
|
||||
### 10.10 Aerobic Efficiency / Cardiac Economy
|
||||
- **Metric**: HR at a given perceived exertion level over time. If HR at same RPE decreases, athlete is fitter.
|
||||
- **Calculation**: Match sessions with same RPE → compare average HR.
|
||||
|
||||
### 10.11 Relative VO2max Estimation
|
||||
- **Astrand-Ryhming nomogram**: Based on HR at submaximal workload with age/sex correction.
|
||||
- **Uth-Sørensen-Overgaard formula**: `VO2max = 15.3 × (MaxHR / RestingHR)` (approximate).
|
||||
|
||||
### 10.12 Heart Rate Turbulence (HRT)
|
||||
- **Clinical**: Post-premature-ventricular-contraction HR response. Not feasible without ECG.
|
||||
|
||||
### 10.13 Summary of actionable clinical-grade metrics from our data
|
||||
|
||||
| Metric | Data needed | Clinical value | Feasibility |
|
||||
|--------|-----------|----------------|-------------|
|
||||
| Resting HR trend | Morning readings | Overtraining, illness | ✅ Already have |
|
||||
| HR Recovery (1min, 2min) | Post-session BPM | CV risk, conditioning | ✅ Easy add |
|
||||
| Cardiac Drift | Session BPM trend | Hydration, heat stress | ✅ Easy add |
|
||||
| Chronotropic Response | Session onset BPM | CV fitness, beta-blocker effect | ✅ Easy add |
|
||||
| TRIMP (Edwards) | Zone time | Training load | ✅ Already have zone breakdown |
|
||||
| TRIMP (Banister) | Duration, avgHR, maxHR | Training load | ⚡ Medium effort |
|
||||
| Weekly Monotony & Strain | 7d load history | Overtraining risk | ⚡ Need 7d history UI |
|
||||
| VO2max estimation | MaxHR, RestingHR, sex | Aerobic fitness estimate | ✅ Ready (formula exists) |
|
||||
| HRV RMSSD/SDNN | RR intervals | Recovery, readiness | ⚡ Need RR parser |
|
||||
| HR Deflection Point | Incremental test BPM | Anaerobic threshold | ❌ Requires protocol |
|
||||
| Fatigue slope | HR per minute over session | Pacing, fatigue onset | ✅ Easy add |
|
||||
|
||||
---
|
||||
|
||||
## 11. WHAT USER WANTS NOW (Pre-approved)
|
||||
|
||||
### A. Charts improvement
|
||||
Make charts more informative and interactive:
|
||||
- Better tooltips/markers
|
||||
- Zone-colored areas
|
||||
- Callout for min/max/avg points
|
||||
- Session overlay with round/rest phases
|
||||
- Smoother pan/zoom
|
||||
- Multiple time ranges
|
||||
|
||||
### B. Session-based detailed report
|
||||
A system that:
|
||||
1. User can start/stop a **recording time window**
|
||||
2. All HR samples within that window are collected
|
||||
3. At stop, a **detailed report** is generated with:
|
||||
- Full session statistics
|
||||
- Zone breakdown
|
||||
- HR Recovery
|
||||
- Cardiac drift analysis
|
||||
- Chronotropic response
|
||||
- TRIMP score
|
||||
- Readiness context (from pre-session check-in)
|
||||
- Hydration estimate
|
||||
- Fight zone time
|
||||
- Trend compared to past sessions
|
||||
|
||||
### C. Scientific/clinical analytics
|
||||
Research-backed metrics that doctors consider from HR data (section 10 above).
|
||||
This is now **approved** to implement:
|
||||
- HR Recovery (1min, 2min)
|
||||
- Cardiac Drift
|
||||
- Chronotropic Response
|
||||
- TRIMP (Banister + Edwards)
|
||||
- VO2max estimation
|
||||
- Weekly monotony & strain
|
||||
- Fatigue slope
|
||||
- HRV when RR data available
|
||||
|
||||
---
|
||||
|
||||
## 12. APPROVED DECISIONS (Perfil/Insights P0)
|
||||
|
||||
| Item | Decision |
|
||||
|------|----------|
|
||||
| Check-in | **Mandatory** before starting session |
|
||||
| Insights location | **New tab** in bottom navigation |
|
||||
| Biological sex | **Collected** as Masculino/Feminino |
|
||||
| Scale range | **0–10** for energy, fatigue, soreness, sleep quality |
|
||||
| Advanced fields | **Collapsible** sections (expand/hide) |
|
||||
|
||||
---
|
||||
|
||||
## 13. IMPORTANT CODE PATTERNS & PITFALLS
|
||||
|
||||
### Don'ts (from previous learnings)
|
||||
1. DON'T create custom Canvas charts — use Vico (`com.patrykandpatrick.vico`)
|
||||
2. DON'T move BLE ownership from foreground service to ViewModel
|
||||
3. DON'T add new dependencies without asking
|
||||
4. DON'T use `lineSeries` — Vico API: use `lineModel` instead
|
||||
5. DON'T add fragil custom Canvas gauges
|
||||
6. DON'T mix business rules in Compose — keep domain pure Kotlin
|
||||
7. DON'T make destructive SQLite migrations without approval
|
||||
8. DON'T add unused code or placeholder features
|
||||
|
||||
### Do's
|
||||
1. DO use `LucideR.drawable.lucide_ic_xxx` for Lucide icons
|
||||
2. DO test domain with JUnit before UI
|
||||
3. DO implement in slices: domain → persistence → ViewModel/state → UI → BLE
|
||||
4. DO use `HeartRateForegroundService.ActionConnect` / `ActionDisconnect` for BLE control
|
||||
5. DO preserve PT-BR in UI, English in code
|
||||
6. DO run `./gradlew :app:testDebugUnitTest :app:assembleDebug` before claiming completion
|
||||
7. DO save data locally first (SQLite source of truth)
|
||||
8. DO write `collect` calls with explicit `{ }` braces around the when-body in service
|
||||
9. DO use `ChoiceChipButton`, `ExpandableSectionHeader`, `ProfileNumberField` for consistent UI
|
||||
|
||||
### Known non-blocking warnings
|
||||
- `lineSeries` deprecated → use `lineModel` (low priority)
|
||||
- BLE `writeDescriptor` deprecated in SDK 36 (non-blocking)
|
||||
- No LSP client for Kotlin in this environment; rely on `compileDebugKotlin` output
|
||||
|
||||
---
|
||||
|
||||
## 14. IMMEDIATE NEXT TASK
|
||||
|
||||
### Priority: Implement session-based detailed report + scientific analytics
|
||||
|
||||
1. **Enhance chart**: Session overlay with round phases, zone colors, better tooltips
|
||||
2. **Session report system**:
|
||||
- Start/stop recording independent of session (or same)
|
||||
- Collect all samples in range
|
||||
- Generate report at stop with medical-grade metrics
|
||||
3. **Add new domain calculations**:
|
||||
- HR Recovery (1min, 2min)
|
||||
- Cardiac Drift
|
||||
- Chronotropic Response
|
||||
- TRIMP (Banister + Edwards)
|
||||
- VO2max estimation
|
||||
4. **Report UI**: Dedicated report screen or dialog with full metrics
|
||||
5. **After done**: send APK via Telegram
|
||||
|
||||
### Workflow to follow
|
||||
```
|
||||
→ Search web if needed
|
||||
→ Write/update spec
|
||||
→ Audit existing code
|
||||
→ Implement domain + tests (new calculations)
|
||||
→ Implement persistence if needed
|
||||
→ Implement ViewModel/state
|
||||
→ Implement UI (chart + report)
|
||||
→ Build & test
|
||||
→ Send APK
|
||||
→ Update Obsidian roadmap
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. WORKFLOW DOCUMENT
|
||||
|
||||
Path: `D:/Documents/Github/HealthTracking/docs/FIGHTRATE_DEVELOPMENT_WORKFLOW.md`
|
||||
|
||||
### Project skill: `fightrate-quality-loop`
|
||||
Scope: project:HealthTracking
|
||||
|
||||
### Skill path
|
||||
`C:\Users\Evandro\.pi\agent\projects-memory\HealthTracking\skills\fightrate-quality-loop\SKILL.md`
|
||||
|
||||
### Obsidian vault
|
||||
Path: `D:\Documents\Obsidian Vault`
|
||||
Roadmap file: `Projetos/FightRate - Auditoria e Roadmap.md`
|
||||
|
||||
---
|
||||
|
||||
## 16. COMMANDS QUICK REFERENCE
|
||||
|
||||
```bash
|
||||
# Full verification
|
||||
./gradlew :app:testDebugUnitTest :app:assembleDebug
|
||||
|
||||
# Quick compile
|
||||
./gradlew :app:compileDebugKotlin
|
||||
|
||||
# Domain tests
|
||||
./gradlew :app:testDebugUnitTest --tests "*AthleteInsightsTest*"
|
||||
./gradlew :app:testDebugUnitTest --tests "*WorkoutSessionSummary*"
|
||||
./gradlew :app:testDebugUnitTest --tests "*HeartRateZonesTest*"
|
||||
|
||||
# Install APK
|
||||
./gradlew :app:installDebug
|
||||
|
||||
# Latest APK path
|
||||
app/build/outputs/apk/debug/FightRate-*-debug.apk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 17. TELEGRAM CONTEXT
|
||||
|
||||
- User delivers messages via Telegram bridge
|
||||
- `[telegram]` prefix → deliver replies via Telegram
|
||||
- When sending APK: call `telegram_attach(localPath)`
|
||||
- The user says: "Ao terminar me mande o apk no telegram"
|
||||
|
||||
---
|
||||
|
||||
## 18. FINAL NOTES
|
||||
|
||||
- This document was built by reading all source files, build files, specs, and tests
|
||||
- Everything described here is **already implemented** unless marked as "new" or "next"
|
||||
- The user has **pre-approved** the scientific analytics implementation + session report
|
||||
- The user wants **continuous improvement** — no stop until done
|
||||
- Focus on **charts**, **session-based reporting**, and **scientific-grade metrics**
|
||||
@@ -0,0 +1,112 @@
|
||||
## Resultado consolidado
|
||||
|
||||
A literatura **não fornece uma tabela robusta e universal** de “mínimo, média e máximo” de batimentos **por faixas de 5 anos** para Muay Thai e corrida intensa. O que existe com boa qualidade são:
|
||||
|
||||
1. **Dados populacionais de repouso**, por faixas etárias amplas.
|
||||
2. **Estudos pequenos e específicos** medindo Muay Thai/corrida, geralmente com média ± desvio padrão ou erro padrão.
|
||||
3. **Estimativas por fórmula** para frequência cardíaca máxima e zonas de intensidade, que não substituem teste real.
|
||||
|
||||
Abaixo separei o que é **medido** do que é **estimado**.
|
||||
|
||||
---
|
||||
|
||||
# 1. Batimentos em repouso — dados populacionais
|
||||
|
||||
A tabela abaixo usa dados da Statistics Canada, com média, mediana e percentis. Para não inventar “mínimo” e “máximo”, usei **P5** e **P95** como aproximações populacionais: P5 significa que 5% ficam abaixo; P95 significa que 95% ficam abaixo. Não são extremos absolutos. ([Statistics Canada][1])
|
||||
|
||||
| Faixa etária | P5 “baixo” | Média | Mediana | P95 “alto” |
|
||||
| -----------: | ---------: | -----: | ------: | ---------: |
|
||||
| 12–19 anos | 54 bpm | 74 bpm | 73 bpm | 93 bpm |
|
||||
| 20–39 anos | 52 bpm | 69 bpm | 68 bpm | 85 bpm |
|
||||
| 40–59 anos | 53 bpm | 67 bpm | 66 bpm | 85 bpm |
|
||||
| 60–79 anos | 49 bpm | 66 bpm | 65 bpm | 85 bpm |
|
||||
|
||||
**Leitura prática:** em repouso, pessoas jovens/adultas geralmente ficam na faixa de **~50 a ~90 bpm**, mas atletas podem ter repouso bem mais baixo. A American Heart Association cita 60–100 bpm como faixa normal para a maioria dos adultos e observa que pessoas muito ativas podem chegar a cerca de 40 bpm em repouso. ([www.heart.org][2])
|
||||
|
||||
---
|
||||
|
||||
# 2. Muay Thai — dados medidos em estudos
|
||||
|
||||
| Estudo / contexto | Amostra | Repouso / referência | Durante ou após atividade | Maior valor reportado |
|
||||
| ----------------------------------------------------------- | ---------------------------------: | --------------------------------------------------------------------------------------: | -----------------------------------------------------------------------------------------------------: | --------------------------------------------------------------------------------------: |
|
||||
| Muay Thai simulado — 3 rounds | 10 homens, 23,7 ± 1,5 anos | FC máx em teste incremental: **182,9 ± 1,6 bpm**; limiar anaeróbio: **137,5 ± 4,5 bpm** | Round 1: **159,7 ± 13,7 bpm**; round 2: **165,2 ± 16,4 bpm**; round 3: **174,0 ± 10,9 bpm** | Média do 3º round: **174,0 bpm**; FCmáx laboratorial: **182,9 bpm** ([ResearchGate][3]) |
|
||||
| Muay Thai em competição real | 20 lutadores homens experientes | FC máx em teste: **187,2 bpm**; limiar anaeróbio: **168,4 bpm** | Média durante as lutas: **178,9 ± 0,3 bpm** | Pós-4º round: **182,9 ± 0,6 bpm** ([ResearchGate][4]) |
|
||||
| Sessão de Muay Thai em adolescentes com sobrepeso/obesidade | 27 adolescentes, 12,85 ± 2,08 anos | Basal total: **93 ± 12 bpm** | Imediatamente após: **130 ± 16 bpm**; 10 min: **109 ± 11**; 20 min: **104 ± 12**; 30 min: **102 ± 10** | Pós-sessão: **130 ± 16 bpm** ([ResearchGate][5]) |
|
||||
|
||||
## Interpretação do Muay Thai
|
||||
|
||||
Para adultos jovens treinados, os estudos apontam Muay Thai como atividade **muito intensa**, frequentemente acima do limiar anaeróbio e próxima da FC máxima individual. No estudo de competição real, a média de **178,9 bpm** ficou próxima da FC máxima medida em laboratório, **187,2 bpm**, o que equivale a aproximadamente **95,6% da FCmáx** naquela amostra. ([ResearchGate][4])
|
||||
|
||||
Um ponto importante: em esportes intermitentes como Muay Thai, a frequência cardíaca pode **superestimar ou não representar perfeitamente** o custo metabólico real, porque golpes, pausas, explosões, adrenalina e metabolismo anaeróbio alteram a relação entre esforço e batimento. ([ResearchGate][3])
|
||||
|
||||
---
|
||||
|
||||
# 3. Corrida intensa — dados medidos em estudos
|
||||
|
||||
| Estudo / contexto | Amostra | Valor medido |
|
||||
| ------------------------------------------------------------------------------- | ----------------------------------------------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| Teste incremental em esteira até exaustão, corredores recreacionais de maratona | 180 corredores, idade média **43,2 ± 8,5 anos** | FCmáx total: **178,2 ± 10,2 bpm**; homens: **178,9 ± 10,4 bpm**; mulheres: **174,8 ± 8,8 bpm** ([Frontiers][6]) |
|
||||
| Maratona real com medição contínua | 10 homens, **41,7 ± 7,7 anos** | Após o 5º km, a FC ficou estável entre **88% e 91% da FCmáx**; tabela do estudo reporta média geral de maratona em torno de **157 ± 10 bpm** ([ResearchGate][7]) |
|
||||
|
||||
## Interpretação da corrida intensa
|
||||
|
||||
Corrida em ritmo alto pode ficar facilmente em **80–90%+ da FCmáx**, dependendo de condicionamento, distância, calor, hidratação, inclinação, descanso e estratégia de ritmo. Em maratona real, um estudo mostrou FC sustentada entre **88% e 91% da FCmáx**, mas os autores alertam que usar apenas batimentos para controlar ritmo pode ser problemático, porque a FC pode se manter alta enquanto velocidade e consumo de oxigênio caem. ([ResearchGate][7])
|
||||
|
||||
---
|
||||
|
||||
# 4. Tabela estimada por idade para atividade intensa
|
||||
|
||||
Esta tabela **não é dado medido diretamente por faixa etária**. É uma consolidação matemática usando:
|
||||
|
||||
* Fórmula de Tanaka: **FCmáx = 208 − 0,7 × idade**, derivada de meta-análise com 351 estudos e 18.712 pessoas. ([ScienceDirect][8])
|
||||
* Zona vigorosa geral: **70–85% da FCmáx**, conforme referência da American Heart Association. ([www.heart.org][2])
|
||||
* Zona “muito intensa/competitiva”: **90–95% da FCmáx**, coerente com os estudos de Muay Thai e corrida em que atletas ficaram perto de 89–96% da FCmáx. ([ResearchGate][4])
|
||||
|
||||
| Idade | FCmáx estimada | Atividade vigorosa 70–85% | Muito intenso 90–95% |
|
||||
| -----: | -------------: | ------------------------: | -------------------: |
|
||||
| 15–19* | 196 bpm | 137–167 bpm | 176–186 bpm |
|
||||
| 20–24 | 193 bpm | 135–164 bpm | 173–183 bpm |
|
||||
| 25–29 | 189 bpm | 132–161 bpm | 170–180 bpm |
|
||||
| 30–34 | 186 bpm | 130–158 bpm | 167–176 bpm |
|
||||
| 35–39 | 182 bpm | 127–155 bpm | 164–173 bpm |
|
||||
| 40–44 | 179 bpm | 125–152 bpm | 161–170 bpm |
|
||||
| 45–49 | 175 bpm | 123–149 bpm | 158–166 bpm |
|
||||
| 50–54 | 172 bpm | 120–146 bpm | 154–163 bpm |
|
||||
| 55–59 | 168 bpm | 118–143 bpm | 151–160 bpm |
|
||||
| 60–64 | 165 bpm | 115–140 bpm | 148–156 bpm |
|
||||
| 65–69 | 161 bpm | 113–137 bpm | 145–153 bpm |
|
||||
| 70–74 | 158 bpm | 110–134 bpm | 142–150 bpm |
|
||||
|
||||
* Para adolescentes, essa fórmula deve ser usada com muito cuidado. Uma revisão sistemática sobre crianças e adolescentes concluiu que fórmulas adultas de FCmáx têm erro relevante nessa população e não devem ser tratadas como precisas para menores de 18 anos.
|
||||
|
||||
---
|
||||
|
||||
# 5. Consolidação prática
|
||||
|
||||
| Situação | Valores mais defensáveis pela literatura |
|
||||
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------: |
|
||||
| Repouso, população geral 12–79 anos | Média aproximada: **66–74 bpm**, dependendo da idade; P5–P95: cerca de **49–93 bpm** ([Statistics Canada][1]) |
|
||||
| Repouso em adultos, referência clínica geral | **60–100 bpm** para maioria; atletas podem ficar perto de **40 bpm** ([www.heart.org][2]) |
|
||||
| Muay Thai adulto treinado, simulado | Aproximadamente **160–174 bpm** por round, subindo conforme o round ([ResearchGate][3]) |
|
||||
| Muay Thai adulto treinado, competição real | Média próxima de **179 bpm**, com pico pós-round perto de **183 bpm** ([ResearchGate][4]) |
|
||||
| Muay Thai adolescente, sessão recreativa/terapêutica | Basal **93 ± 12 bpm**; pós-sessão **130 ± 16 bpm** ([ResearchGate][5]) |
|
||||
| Corrida máxima em teste incremental, corredores recreacionais | FCmáx medida em torno de **178 ± 10 bpm** em média de idade ~43 anos ([Frontiers][6]) |
|
||||
| Maratona real / ritmo sustentado alto | Aproximadamente **88–91% da FCmáx** após o 5º km no estudo citado ([ResearchGate][7]) |
|
||||
|
||||
---
|
||||
|
||||
## Conclusão direta
|
||||
|
||||
Para uma pessoa jovem adulta, atividades como **Muay Thai intenso** ou **corrida forte** frequentemente colocam a FC em algo como **160–185 bpm**, dependendo da FCmáx individual. Em atletas de Muay Thai treinados, os estudos mostram médias próximas de **174–179 bpm** e picos perto de **183 bpm**. Para corrida forte ou competição, estudos em corredores recreacionais mostram manutenção de **~88–91% da FCmáx**, o que para muitos adultos fica na faixa de **150–180 bpm**, dependendo da idade e da FCmáx real.
|
||||
|
||||
Não existe base científica suficiente para afirmar, com precisão, “mínimo, média e máximo real” por faixas como 15–20, 20–25, 25–30 para Muay Thai/corrida intensa. A forma correta é usar **dados medidos dos estudos específicos** + **estimativas por FCmáx** + **medição individual com cinta cardíaca ou teste cardiopulmonar**.
|
||||
|
||||
[1]: https://www150.statcan.gc.ca/n1/pub/82-626-x/2012001/t004-eng.htm "Canadian Health Measures Survey: Cycle 2 Data Tables: Table 3 — Average resting heart rate, by age and sex, household
|
||||
population, Canada, 2009 to 2011"
|
||||
[2]: https://www.heart.org/en/healthy-living/exercise-and-physical-activity/fitness-basics/target-heart-rates "Target Heart Rates Chart | American Heart Association"
|
||||
[3]: https://www.researchgate.net/publication/24281067_Physiological_responses_and_energy_cost_during_a_simulation_of_a_Muay_Thai_boxing_match "(PDF) Physiological responses and energy cost during a simulation of a Muay Thai boxing match"
|
||||
[4]: https://www.researchgate.net/publication/263213413_Physiological_responses_and_match_analysis_of_Muay_Thai_fighting "(PDF) Physiological responses and match analysis of Muay Thai fighting"
|
||||
[5]: https://www.researchgate.net/publication/358798865_Acute_Effects_of_Muay_Thai_on_Blood_Pressure_and_Heart_Rate_in_Adolescents_with_OverweightObesity "(PDF) Acute Effects of Muay Thai on Blood Pressure and Heart Rate in Adolescents with Overweight/Obesity"
|
||||
[6]: https://www.frontiersin.org/journals/physiology/articles/10.3389/fphys.2018.00226/full "Frontiers | Age-Predicted Maximal Heart Rate in Recreational Marathon Runners: A Cross-Sectional Study on Fox's and Tanaka's Equations"
|
||||
[7]: https://www.researchgate.net/publication/364033021_Heart_Rate_Does_Not_Reflect_the_VO2max_in_Recreational_Runners_during_the_Marathon "(PDF) Heart Rate Does Not Reflect the %VO2max in Recreational Runners during the Marathon"
|
||||
[8]: https://www.sciencedirect.com/science/article/pii/S0735109700010548 "Age-predicted maximal heart rate revisited - ScienceDirect"
|
||||
@@ -0,0 +1,513 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FightRate — React Native Redesign Directions</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #11110f;
|
||||
--paper: #f5f1e8;
|
||||
--muted: #706c63;
|
||||
--line: #d9d0bf;
|
||||
--red: #d9352a;
|
||||
--amber: #f0a01f;
|
||||
--blue: #1d5cff;
|
||||
--green: #15a06e;
|
||||
--black: #050505;
|
||||
--radius-xl: 32px;
|
||||
--radius-lg: 22px;
|
||||
--radius-md: 14px;
|
||||
--shadow: 0 24px 80px rgba(17, 17, 15, 0.12);
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
Segoe UI,
|
||||
sans-serif;
|
||||
line-height: 1.45;
|
||||
}
|
||||
body:before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 10% 0%,
|
||||
rgba(217, 53, 42, 0.12),
|
||||
transparent 34%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 90% 20%,
|
||||
rgba(29, 92, 255, 0.1),
|
||||
transparent 36%
|
||||
),
|
||||
linear-gradient(rgba(17, 17, 15, 0.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(17, 17, 15, 0.035) 1px, transparent 1px);
|
||||
background-size:
|
||||
auto,
|
||||
auto,
|
||||
42px 42px,
|
||||
42px 42px;
|
||||
}
|
||||
.wrap {
|
||||
width: min(1180px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 48px 0 80px;
|
||||
position: relative;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 800;
|
||||
color: var(--red);
|
||||
}
|
||||
h1 {
|
||||
font-family: Georgia, serif;
|
||||
font-size: clamp(46px, 9vw, 104px);
|
||||
line-height: 0.88;
|
||||
letter-spacing: -0.07em;
|
||||
margin: 18px 0 18px;
|
||||
max-width: 980px;
|
||||
}
|
||||
h2 {
|
||||
font-size: clamp(28px, 5vw, 56px);
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 0.95;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
p {
|
||||
color: var(--muted);
|
||||
font-size: 17px;
|
||||
max-width: 760px;
|
||||
}
|
||||
.hero {
|
||||
padding: 34px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-xl);
|
||||
background: rgba(255, 255, 255, 0.46);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-radius: 999px;
|
||||
padding: 13px 18px;
|
||||
text-decoration: none;
|
||||
font-weight: 900;
|
||||
border: 1px solid var(--ink);
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
}
|
||||
.btn.dark {
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 14px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.card {
|
||||
position: relative;
|
||||
min-height: 420px;
|
||||
border-radius: 28px;
|
||||
border: 1px solid var(--line);
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 45px rgba(17, 17, 15, 0.08);
|
||||
}
|
||||
.card .top {
|
||||
padding: 18px;
|
||||
}
|
||||
.card h3 {
|
||||
font-size: 25px;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 900;
|
||||
color: var(--muted);
|
||||
}
|
||||
.phone {
|
||||
margin: 10px auto 0;
|
||||
width: 150px;
|
||||
height: 282px;
|
||||
border-radius: 30px;
|
||||
background: #0d0d0d;
|
||||
padding: 8px;
|
||||
box-shadow: 0 22px 44px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
.screen {
|
||||
height: 100%;
|
||||
border-radius: 24px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.metric {
|
||||
font-size: 44px;
|
||||
font-weight: 950;
|
||||
letter-spacing: -0.08em;
|
||||
}
|
||||
.small {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-weight: 900;
|
||||
}
|
||||
.line {
|
||||
height: 56px;
|
||||
border-bottom: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
transform: skewX(-12deg);
|
||||
opacity: 0.65;
|
||||
}
|
||||
.rail {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.pill {
|
||||
border-radius: 999px;
|
||||
padding: 7px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
text-align: center;
|
||||
}
|
||||
.corner .screen {
|
||||
background: #0c0b09;
|
||||
color: #f5ead8;
|
||||
}
|
||||
.corner .pill {
|
||||
background: #211e17;
|
||||
color: #ffbd37;
|
||||
}
|
||||
.corner .line {
|
||||
color: #e23b2e;
|
||||
}
|
||||
.lab .screen {
|
||||
background: #f7fbf8;
|
||||
color: #12231d;
|
||||
}
|
||||
.lab .pill {
|
||||
background: #e7f4ed;
|
||||
color: #08724c;
|
||||
}
|
||||
.lab .line {
|
||||
color: #16a06b;
|
||||
}
|
||||
.night .screen {
|
||||
background: #140409;
|
||||
color: #fff2f2;
|
||||
}
|
||||
.night .pill {
|
||||
background: #35111a;
|
||||
color: #ff596c;
|
||||
}
|
||||
.night .line {
|
||||
color: #ff2e48;
|
||||
}
|
||||
.sensor .screen {
|
||||
background: #03070a;
|
||||
color: #d6f7ff;
|
||||
}
|
||||
.sensor .pill {
|
||||
background: #06222c;
|
||||
color: #5be6ff;
|
||||
}
|
||||
.sensor .line {
|
||||
color: #5be6ff;
|
||||
}
|
||||
.minimal .screen {
|
||||
background: #f7f3eb;
|
||||
color: #15130f;
|
||||
}
|
||||
.minimal .pill {
|
||||
background: #eee4d3;
|
||||
color: #15130f;
|
||||
}
|
||||
.minimal .line {
|
||||
color: #15130f;
|
||||
}
|
||||
.matrix {
|
||||
margin-top: 42px;
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr repeat(5, 1fr);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
.matrix div {
|
||||
padding: 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-right: 1px solid var(--line);
|
||||
font-size: 13px;
|
||||
}
|
||||
.matrix div:nth-child(6n) {
|
||||
border-right: 0;
|
||||
}
|
||||
.matrix .head {
|
||||
font-weight: 950;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
}
|
||||
.score {
|
||||
font-weight: 950;
|
||||
}
|
||||
.notes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.note {
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
padding: 18px;
|
||||
}
|
||||
.note b {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
@media (max-width: 1050px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.matrix {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.matrix div {
|
||||
border-right: 0;
|
||||
}
|
||||
.notes {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.hero {
|
||||
padding: 22px;
|
||||
}
|
||||
.phone {
|
||||
width: 170px;
|
||||
height: 320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<section class="hero">
|
||||
<div class="eyebrow">FightRate / React Native Redesign</div>
|
||||
<h1>Antes de reconstruir, escolher a linguagem visual certa.</h1>
|
||||
<p>
|
||||
Este pacote propõe cinco direções de design system para um app de
|
||||
captura contínua de treino, histórico denso, sensor BLE e insights
|
||||
úteis. Nenhuma direção usa o design antigo como base.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a class="btn dark" href="01-corner-command.html"
|
||||
>01 Corner Command</a
|
||||
>
|
||||
<a class="btn" href="02-athlete-lab.html">02 Athlete Lab</a>
|
||||
<a class="btn" href="03-fight-night.html">03 Fight Night</a>
|
||||
<a class="btn" href="04-sensor-instrument.html"
|
||||
>04 Sensor Instrument</a
|
||||
>
|
||||
<a class="btn" href="05-minimal-performance.html"
|
||||
>05 Minimal Performance</a
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid" aria-label="direções visuais">
|
||||
<article class="card corner">
|
||||
<div class="top">
|
||||
<span class="tag">tático premium</span>
|
||||
<h3>Corner Command</h3>
|
||||
<p>Treino real, córner, intensidade, preto/couro/vermelho.</p>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="small">captura ativa</div>
|
||||
<div class="metric">148</div>
|
||||
<div>bpm médio</div>
|
||||
<div class="line"></div>
|
||||
<div class="rail">
|
||||
<span class="pill">Z4 38%</span
|
||||
><span class="pill">Pico 181</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card lab">
|
||||
<div class="top">
|
||||
<span class="tag">ciência esportiva</span>
|
||||
<h3>Athlete Lab</h3>
|
||||
<p>Limpo, confiável, claro, excelente para dados e relatórios.</p>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="small">sessão</div>
|
||||
<div class="metric">72%</div>
|
||||
<div>tempo em carga</div>
|
||||
<div class="line"></div>
|
||||
<div class="rail">
|
||||
<span class="pill">TRIMP 96</span
|
||||
><span class="pill">Rec 18h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card night">
|
||||
<div class="top">
|
||||
<span class="tag">marca esportiva</span>
|
||||
<h3>Fight Night</h3>
|
||||
<p>Editorial, energia de evento, bom para virar marca pública.</p>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="small">rounds auto</div>
|
||||
<div class="metric">6×3</div>
|
||||
<div>alta intensidade</div>
|
||||
<div class="line"></div>
|
||||
<div class="rail">
|
||||
<span class="pill">Explosão</span
|
||||
><span class="pill">Ritmo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card sensor">
|
||||
<div class="top">
|
||||
<span class="tag">instrumentação</span>
|
||||
<h3>Sensor Instrument</h3>
|
||||
<p>BLE, telemetria, logs e confiança técnica máxima.</p>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="small">h003 conectado</div>
|
||||
<div class="metric">-61</div>
|
||||
<div>RSSI estável</div>
|
||||
<div class="line"></div>
|
||||
<div class="rail">
|
||||
<span class="pill">180D</span><span class="pill">2A37</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card minimal">
|
||||
<div class="top">
|
||||
<span class="tag">premium minimal</span>
|
||||
<h3>Minimal Performance</h3>
|
||||
<p>Menos interface, mais leitura. Elegante e rápido.</p>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="small">hoje</div>
|
||||
<div class="metric">41m</div>
|
||||
<div>capturados</div>
|
||||
<div class="line"></div>
|
||||
<div class="rail">
|
||||
<span class="pill">Média 142</span
|
||||
><span class="pill">Z5 08%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="matrix">
|
||||
<div class="head">Critério</div>
|
||||
<div class="head">Corner</div>
|
||||
<div class="head">Lab</div>
|
||||
<div class="head">Night</div>
|
||||
<div class="head">Sensor</div>
|
||||
<div class="head">Minimal</div>
|
||||
<div>Confiança para atleta</div>
|
||||
<div class="score">9</div>
|
||||
<div class="score">9</div>
|
||||
<div class="score">8</div>
|
||||
<div class="score">8</div>
|
||||
<div class="score">9</div>
|
||||
<div>Potencial de marca</div>
|
||||
<div class="score">8</div>
|
||||
<div class="score">7</div>
|
||||
<div class="score">10</div>
|
||||
<div class="score">7</div>
|
||||
<div class="score">8</div>
|
||||
<div>Clareza de dados</div>
|
||||
<div class="score">8</div>
|
||||
<div class="score">10</div>
|
||||
<div class="score">7</div>
|
||||
<div class="score">9</div>
|
||||
<div class="score">9</div>
|
||||
<div>Energia visual</div>
|
||||
<div class="score">9</div>
|
||||
<div class="score">6</div>
|
||||
<div class="score">10</div>
|
||||
<div class="score">7</div>
|
||||
<div class="score">5</div>
|
||||
<div>Viabilidade React Native</div>
|
||||
<div class="score">9</div>
|
||||
<div class="score">10</div>
|
||||
<div class="score">8</div>
|
||||
<div class="score">9</div>
|
||||
<div class="score">10</div>
|
||||
</section>
|
||||
|
||||
<section class="notes">
|
||||
<div class="note">
|
||||
<b>Recomendação forte</b
|
||||
><span
|
||||
>Escolher entre Corner Command e Athlete Lab. Corner vende melhor a
|
||||
ideia de combate; Athlete Lab resolve melhor histórico, relatórios e
|
||||
confiança.</span
|
||||
>
|
||||
</div>
|
||||
<div class="note">
|
||||
<b>Possível híbrido</b
|
||||
><span
|
||||
>Base Athlete Lab com modo treino Corner Command. Assim o app fica
|
||||
sério no relatório e intenso na captura.</span
|
||||
>
|
||||
</div>
|
||||
<div class="note">
|
||||
<b>Próximo passo</b
|
||||
><span
|
||||
>Escolher 1 direção ou 2 para fundir. Depois criar DESIGN.md final +
|
||||
tokens React Native + protótipo Expo.</span
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,732 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FightRate Direction 01 — Corner Command</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #090806;
|
||||
--surface: #12100c;
|
||||
--surface-2: #1b1710;
|
||||
--ink: #fff3dd;
|
||||
--muted: #b7a98e;
|
||||
--line: #3a3022;
|
||||
--accent: #e23b2e;
|
||||
--accent-2: #ffb02e;
|
||||
--ok: #31c47a;
|
||||
--blue: #48a7ff;
|
||||
--r-xs: 8px;
|
||||
--r-sm: 13px;
|
||||
--r-md: 18px;
|
||||
--r-lg: 28px;
|
||||
--r-xl: 38px;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-12: 48px;
|
||||
--fast: 180ms cubic-bezier(0.2, 0.9, 0.2, 1);
|
||||
--slow: 700ms cubic-bezier(0.19, 1, 0.22, 1);
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: Bahnschrift, "Arial Narrow", system-ui, sans-serif;
|
||||
line-height: 1.35;
|
||||
}
|
||||
body:before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 20% -10%,
|
||||
rgba(226, 59, 46, 0.28),
|
||||
transparent 32%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 90% 12%,
|
||||
rgba(255, 176, 46, 0.13),
|
||||
transparent 28%
|
||||
),
|
||||
linear-gradient(
|
||||
120deg,
|
||||
rgba(255, 255, 255, 0.055) 0 1px,
|
||||
transparent 1px 18px
|
||||
);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.wrap {
|
||||
width: min(1240px, calc(100% - 28px));
|
||||
margin: auto;
|
||||
padding: 34px 0 80px;
|
||||
position: relative;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 10px 12px 10px 18px;
|
||||
background: rgba(18, 16, 12, 0.84);
|
||||
backdrop-filter: blur(14px);
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
z-index: 2;
|
||||
}
|
||||
.brand {
|
||||
font-weight: 1000;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.brand span {
|
||||
color: var(--accent);
|
||||
}
|
||||
.nav a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
margin-left: 18px;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.08fr 0.92fr;
|
||||
gap: 24px;
|
||||
margin-top: 34px;
|
||||
}
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-xl);
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(27, 23, 16, 0.96),
|
||||
rgba(10, 9, 7, 0.98)
|
||||
);
|
||||
box-shadow:
|
||||
0 32px 90px rgba(0, 0, 0, 0.46),
|
||||
inset 0 1px rgba(255, 255, 255, 0.06);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.055),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
animation: scan 5s infinite;
|
||||
}
|
||||
@keyframes scan {
|
||||
60%,
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
.pad {
|
||||
padding: 30px;
|
||||
}
|
||||
.eyebrow {
|
||||
color: var(--accent-2);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.25em;
|
||||
font-weight: 900;
|
||||
}
|
||||
.h1 {
|
||||
font-size: clamp(54px, 9vw, 118px);
|
||||
line-height: 0.78;
|
||||
letter-spacing: -0.09em;
|
||||
margin: 18px 0;
|
||||
}
|
||||
.lead {
|
||||
font-size: 19px;
|
||||
color: var(--muted);
|
||||
max-width: 720px;
|
||||
}
|
||||
.cta-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 14px 18px;
|
||||
font-weight: 1000;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
box-shadow: 0 14px 34px rgba(226, 59, 46, 0.28);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.btn.secondary {
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.btn i {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
font-style: normal;
|
||||
}
|
||||
.phone-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18px;
|
||||
margin-top: 26px;
|
||||
}
|
||||
.phone {
|
||||
height: 560px;
|
||||
border-radius: 42px;
|
||||
background: #050403;
|
||||
padding: 10px;
|
||||
border: 1px solid #33291c;
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.screen {
|
||||
height: 100%;
|
||||
border-radius: 34px;
|
||||
background: var(--surface);
|
||||
padding: 18px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.topline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.mega {
|
||||
font-size: 82px;
|
||||
line-height: 0.85;
|
||||
font-weight: 1000;
|
||||
letter-spacing: -0.1em;
|
||||
}
|
||||
.unit {
|
||||
font-size: 18px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.status {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chip {
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
background: #231d14;
|
||||
color: var(--accent-2);
|
||||
font-size: 11px;
|
||||
font-weight: 1000;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.chart {
|
||||
height: 118px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, #211911, #100d09);
|
||||
border: 1px solid var(--line);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chart svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.chart path {
|
||||
fill: none;
|
||||
stroke: var(--accent);
|
||||
stroke-width: 4;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.metric-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.metric {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 12px;
|
||||
background: #16120d;
|
||||
}
|
||||
.metric b {
|
||||
font-size: 22px;
|
||||
}
|
||||
.metric span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.13em;
|
||||
}
|
||||
.dock {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.dock .btn {
|
||||
justify-content: center;
|
||||
padding: 14px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.stop {
|
||||
background: #fff0e4;
|
||||
color: #100b08;
|
||||
}
|
||||
.section {
|
||||
margin-top: 28px;
|
||||
}
|
||||
.section h2 {
|
||||
font-size: 42px;
|
||||
letter-spacing: -0.06em;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.book {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.spec {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 28px;
|
||||
padding: 20px;
|
||||
background: rgba(18, 16, 12, 0.78);
|
||||
}
|
||||
.swatches {
|
||||
display: grid;
|
||||
grid-template-columns: 6fr 3fr 1fr;
|
||||
gap: 8px;
|
||||
height: 96px;
|
||||
}
|
||||
.swatches div {
|
||||
border-radius: 18px;
|
||||
padding: 12px;
|
||||
font-weight: 1000;
|
||||
}
|
||||
.type-sample .a {
|
||||
font-size: 52px;
|
||||
letter-spacing: -0.09em;
|
||||
line-height: 0.84;
|
||||
}
|
||||
.type-sample .b {
|
||||
font-size: 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.radii {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
.radii div {
|
||||
border: 1px solid var(--line);
|
||||
background: #211a12;
|
||||
}
|
||||
.r1 {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.r2 {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.r3 {
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
border-radius: 32px;
|
||||
}
|
||||
.component-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.component {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
background: #120f0b;
|
||||
padding: 16px;
|
||||
min-height: 150px;
|
||||
}
|
||||
.field {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
background: #0b0907;
|
||||
color: var(--muted);
|
||||
margin-top: 12px;
|
||||
}
|
||||
.timeline {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.event {
|
||||
display: grid;
|
||||
grid-template-columns: 54px 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.time {
|
||||
color: var(--accent-2);
|
||||
font-weight: 1000;
|
||||
}
|
||||
.bar {
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: #2b2115;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-2), var(--accent));
|
||||
border-radius: 999px;
|
||||
}
|
||||
.rule {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.rule div {
|
||||
border-radius: 22px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
@media (max-width: 1050px) {
|
||||
.hero,
|
||||
.book {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.phone-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.component-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.nav {
|
||||
position: relative;
|
||||
top: 0;
|
||||
}
|
||||
.hero {
|
||||
margin-top: 18px;
|
||||
}
|
||||
.pad {
|
||||
padding: 22px;
|
||||
}
|
||||
.phone {
|
||||
height: 640px;
|
||||
}
|
||||
.component-grid,
|
||||
.rule {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.h1 {
|
||||
font-size: 64px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<nav class="nav">
|
||||
<div class="brand">Fight<span>Rate</span> / Corner Command</div>
|
||||
<div>
|
||||
<a href="00-index.html">Índice</a><a href="#book">Design book</a
|
||||
><a href="#screens">Telas</a>
|
||||
</div>
|
||||
</nav>
|
||||
<section class="hero">
|
||||
<div class="panel pad">
|
||||
<div class="eyebrow">Direção 01 / tático premium</div>
|
||||
<div class="h1">mesa de córner para treino real.</div>
|
||||
<p class="lead">
|
||||
Visual de equipamento sério: preto fosco, couro queimado, vermelho
|
||||
de corte e âmbar de sinal. O app parece uma ferramenta que fica no
|
||||
canto do tatame/ringue, não uma dashboard genérica.
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<button class="btn">Iniciar treino <i>↗</i></button
|
||||
><button class="btn secondary">Ver histórico</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel pad">
|
||||
<div class="eyebrow">Regra 60 / 30 / 10</div>
|
||||
<div class="swatches">
|
||||
<div style="background: #090806; color: #fff3dd">
|
||||
60% base preta
|
||||
</div>
|
||||
<div style="background: #1b1710; color: #fff3dd">
|
||||
30% superfície couro
|
||||
</div>
|
||||
<div style="background: #e23b2e; color: white">10% impacto</div>
|
||||
</div>
|
||||
<p class="lead">
|
||||
O âmbar aparece só como estado e leitura auxiliar. O vermelho é ação
|
||||
crítica e intensidade.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="screens" class="section">
|
||||
<h2>Exemplos de telas mobile</h2>
|
||||
<p class="lead">
|
||||
Seis telas para validar o padrão: início, captura ativa, fim de
|
||||
treino, histórico, sensor e insight.
|
||||
</p>
|
||||
<div class="phone-grid">
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="topline">
|
||||
<span>pré-treino</span><span>sensor ok</span>
|
||||
</div>
|
||||
<div class="mega">00<span class="unit">bpm</span></div>
|
||||
<p class="lead">
|
||||
Coloque a cinta, inicie a captura e deixe o celular parado.
|
||||
</p>
|
||||
<div class="status">
|
||||
<span class="chip">H003 pronto</span
|
||||
><span class="chip">bateria 84%</span>
|
||||
</div>
|
||||
<div class="dock">
|
||||
<button class="btn">Iniciar</button
|
||||
><button class="btn secondary">check-in</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="topline">
|
||||
<span>captura ativa</span><span>38:12</span>
|
||||
</div>
|
||||
<div class="mega">148<span class="unit">bpm</span></div>
|
||||
<div class="chart">
|
||||
<svg viewBox="0 0 260 120">
|
||||
<path
|
||||
d="M8 90 C38 80,40 48,70 54 S112 95,140 66 S180 28,204 44 S232 76,252 42"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<div class="metric"><b>181</b><span>pico</span></div>
|
||||
<div class="metric"><b>142</b><span>média</span></div>
|
||||
<div class="metric"><b>Z4</b><span>zona</span></div>
|
||||
</div>
|
||||
<div class="dock">
|
||||
<button class="btn stop">Parar treino</button
|
||||
><button class="btn secondary">marcar evento</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="topline"><span>resumo</span><span>salvo</span></div>
|
||||
<div class="mega">47<span class="unit">min</span></div>
|
||||
<div class="status">
|
||||
<span class="chip">carga alta</span
|
||||
><span class="chip">recuperar 18h</span>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<div class="event">
|
||||
<span class="time">0-10</span>
|
||||
<div class="bar"><i style="width: 42%"></i></div>
|
||||
</div>
|
||||
<div class="event">
|
||||
<span class="time">10-30</span>
|
||||
<div class="bar"><i style="width: 86%"></i></div>
|
||||
</div>
|
||||
<div class="event">
|
||||
<span class="time">30-47</span>
|
||||
<div class="bar"><i style="width: 64%"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dock">
|
||||
<button class="btn">Relatório</button
|
||||
><button class="btn secondary">exportar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="topline">
|
||||
<span>histórico</span><span>junho</span>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<div class="metric"><b>8</b><span>treinos</span></div>
|
||||
<div class="metric"><b>5h</b><span>tempo</span></div>
|
||||
<div class="metric"><b>72%</b><span>carga</span></div>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<div class="event">
|
||||
<span class="time">Hoje</span>
|
||||
<div>
|
||||
<b>Sparring</b><br /><span style="color: var(--muted)"
|
||||
>47 min · pico 181</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="event">
|
||||
<span class="time">Ontem</span>
|
||||
<div>
|
||||
<b>Drill</b><br /><span style="color: var(--muted)"
|
||||
>32 min · base técnica</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="event">
|
||||
<span class="time">Seg</span>
|
||||
<div>
|
||||
<b>Muay Thai</b><br /><span style="color: var(--muted)"
|
||||
>61 min · alta carga</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="topline"><span>sensor</span><span>ble</span></div>
|
||||
<div class="mega">-61<span class="unit">rssi</span></div>
|
||||
<div class="component">
|
||||
<b>Heart Rate Service</b>
|
||||
<div class="field">180D / 2A37 / notify</div>
|
||||
<div class="field">Battery 180F / read</div>
|
||||
<div class="field">Body Location 2A38</div>
|
||||
</div>
|
||||
<div class="dock">
|
||||
<button class="btn secondary">diagnóstico</button
|
||||
><button class="btn secondary">reconectar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="topline"><span>insight</span><span>útil</span></div>
|
||||
<div class="mega">Z4<span class="unit">38%</span></div>
|
||||
<p class="lead">
|
||||
Você sustentou intensidade de luta por tempo relevante. Bom
|
||||
sinal para condicionamento específico.
|
||||
</p>
|
||||
<div class="component">
|
||||
<b>Por que importa?</b>
|
||||
<p style="color: var(--muted); margin-bottom: 0">
|
||||
Ajuda a medir capacidade de manter pressão sem depender de
|
||||
sensação subjetiva.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="book" class="section">
|
||||
<h2>Design book</h2>
|
||||
<div class="book">
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Cores</div>
|
||||
<div class="rule">
|
||||
<div style="background: #090806">Base #090806<br />60%</div>
|
||||
<div style="background: #1b1710">Surface #1B1710<br />30%</div>
|
||||
<div style="background: #e23b2e">Accent #E23B2E<br />10%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="spec type-sample">
|
||||
<div class="eyebrow">Tipografia</div>
|
||||
<div class="a">148 BPM</div>
|
||||
<div class="b">
|
||||
Condensed grotesk para números. Corpo com espaçamento curto, tom
|
||||
técnico e direto.
|
||||
</div>
|
||||
</div>
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Raio e borda</div>
|
||||
<div class="radii">
|
||||
<div class="r1"></div>
|
||||
<div class="r2"></div>
|
||||
<div class="r3"></div>
|
||||
</div>
|
||||
<p>
|
||||
Raio grande nas telas e painéis; raio menor só em campos técnicos.
|
||||
Bordas cor de metal queimado, nunca cinza genérico.
|
||||
</p>
|
||||
</div>
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Botões</div>
|
||||
<button class="btn">Primário <i>↗</i></button
|
||||
><button class="btn secondary" style="margin-left: 8px">
|
||||
Secundário
|
||||
</button>
|
||||
<p>
|
||||
Primário só para ação decisiva. Parar treino usa fundo claro para
|
||||
contraste emergencial.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Componentes-base</h2>
|
||||
<div class="component-grid">
|
||||
<div class="component">
|
||||
<div class="eyebrow">Status band</div>
|
||||
<span class="chip">captura ativa</span
|
||||
><span class="chip">sensor ok</span>
|
||||
<div class="field">Usado no topo da captura.</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Metric block</div>
|
||||
<div class="mega" style="font-size: 54px">181</div>
|
||||
<span class="chip">pico</span>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Input</div>
|
||||
<div class="field">Nome do treino</div>
|
||||
<div class="field">RPE 0-10</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Alert</div>
|
||||
<b style="color: var(--accent-2)">Zona alta sustentada</b>
|
||||
<p style="color: var(--muted)">
|
||||
Use vibração curta, sem interromper o treino.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,652 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FightRate Direction 02 — Athlete Lab</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4f8f4;
|
||||
--paper: #fcfffc;
|
||||
--ink: #102019;
|
||||
--muted: #607168;
|
||||
--line: #d7e4dc;
|
||||
--green: #12a36b;
|
||||
--lime: #c6ee3f;
|
||||
--blue: #1769ff;
|
||||
--warn: #ef8b18;
|
||||
--r-sm: 10px;
|
||||
--r-md: 16px;
|
||||
--r-lg: 24px;
|
||||
--r-xl: 34px;
|
||||
--shadow: 0 24px 80px rgba(16, 32, 25, 0.1);
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: "Aptos", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
body:before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(rgba(16, 32, 25, 0.045) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(16, 32, 25, 0.045) 1px, transparent 1px),
|
||||
radial-gradient(
|
||||
circle at 85% 4%,
|
||||
rgba(198, 238, 63, 0.28),
|
||||
transparent 30%
|
||||
);
|
||||
background-size:
|
||||
28px 28px,
|
||||
28px 28px,
|
||||
auto;
|
||||
}
|
||||
.wrap {
|
||||
width: min(1220px, calc(100% - 28px));
|
||||
margin: auto;
|
||||
padding: 34px 0 80px;
|
||||
position: relative;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: rgba(252, 255, 252, 0.82);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
padding: 12px 16px;
|
||||
position: sticky;
|
||||
top: 14px;
|
||||
z-index: 3;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
.nav a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
margin-left: 16px;
|
||||
}
|
||||
.brand {
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 0.9fr 1.1fr;
|
||||
gap: 18px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.hero-card {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-xl);
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 900;
|
||||
color: var(--green);
|
||||
}
|
||||
h1 {
|
||||
font-size: clamp(50px, 8vw, 104px);
|
||||
line-height: 0.84;
|
||||
letter-spacing: -0.08em;
|
||||
margin: 16px 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 42px;
|
||||
letter-spacing: -0.06em;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.lead {
|
||||
font-size: 18px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.token-row {
|
||||
display: grid;
|
||||
grid-template-columns: 6fr 3fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.token {
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
min-height: 98px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
background: var(--green);
|
||||
color: white;
|
||||
padding: 14px 18px;
|
||||
font-weight: 900;
|
||||
box-shadow: 0 16px 36px rgba(18, 163, 107, 0.22);
|
||||
}
|
||||
.btn.secondary {
|
||||
background: #edf6f0;
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--green);
|
||||
border: 1px solid var(--green);
|
||||
box-shadow: none;
|
||||
}
|
||||
.section {
|
||||
margin-top: 30px;
|
||||
}
|
||||
.lab-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
.phone {
|
||||
height: 590px;
|
||||
background: #111;
|
||||
border-radius: 42px;
|
||||
padding: 10px;
|
||||
box-shadow: 0 30px 80px rgba(16, 32, 25, 0.18);
|
||||
}
|
||||
.screen {
|
||||
height: 100%;
|
||||
background: var(--paper);
|
||||
border-radius: 34px;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--muted);
|
||||
font-weight: 900;
|
||||
}
|
||||
.number {
|
||||
font-size: 76px;
|
||||
letter-spacing: -0.09em;
|
||||
line-height: 0.86;
|
||||
font-weight: 950;
|
||||
}
|
||||
.unit {
|
||||
font-size: 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.tile {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: #f7fbf8;
|
||||
padding: 12px;
|
||||
}
|
||||
.tile b {
|
||||
font-size: 24px;
|
||||
}
|
||||
.tile span {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--muted);
|
||||
font-weight: 900;
|
||||
}
|
||||
.chart {
|
||||
height: 130px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, #fafffa, #edf7f0);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chart svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.chart path {
|
||||
fill: none;
|
||||
stroke: var(--green);
|
||||
stroke-width: 4;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.zones {
|
||||
display: flex;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.zones i:nth-child(1) {
|
||||
background: #9bd8ff;
|
||||
flex: 1;
|
||||
}
|
||||
.zones i:nth-child(2) {
|
||||
background: #b7ecb4;
|
||||
flex: 1.4;
|
||||
}
|
||||
.zones i:nth-child(3) {
|
||||
background: #c6ee3f;
|
||||
flex: 1.8;
|
||||
}
|
||||
.zones i:nth-child(4) {
|
||||
background: #efb84d;
|
||||
flex: 1.2;
|
||||
}
|
||||
.zones i:nth-child(5) {
|
||||
background: #e75b4a;
|
||||
flex: 0.7;
|
||||
}
|
||||
.dock {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.dock .btn {
|
||||
padding: 14px 8px;
|
||||
}
|
||||
.spec-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.spec {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
padding: 18px;
|
||||
}
|
||||
.spec h3 {
|
||||
margin: 6px 0;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.type .big {
|
||||
font-size: 62px;
|
||||
letter-spacing: -0.08em;
|
||||
line-height: 0.86;
|
||||
}
|
||||
.radii {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
.radii div {
|
||||
background: #eaf3ed;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.r1 {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.r2 {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.r3 {
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
.component-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.component {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
padding: 14px;
|
||||
min-height: 154px;
|
||||
}
|
||||
.field {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
background: #f7fbf8;
|
||||
color: var(--muted);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.table {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 8px 0;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
border-radius: 999px;
|
||||
padding: 6px 9px;
|
||||
background: #e7f6ee;
|
||||
color: #08734c;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.principles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.principle {
|
||||
border-left: 5px solid var(--green);
|
||||
background: var(--paper);
|
||||
border-radius: 0 22px 22px 0;
|
||||
padding: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.hero,
|
||||
.lab-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.spec-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.component-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.principles {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.hero-card {
|
||||
padding: 22px;
|
||||
}
|
||||
.spec-grid,
|
||||
.component-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.phone {
|
||||
height: 650px;
|
||||
}
|
||||
.nav {
|
||||
position: relative;
|
||||
top: 0;
|
||||
}
|
||||
.nav div:last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<nav class="nav">
|
||||
<div class="brand">FightRate / Athlete Lab</div>
|
||||
<div>
|
||||
<a href="00-index.html">Índice</a><a href="#screens">Telas</a
|
||||
><a href="#system">Sistema</a>
|
||||
</div>
|
||||
</nav>
|
||||
<section class="hero">
|
||||
<div class="hero-card">
|
||||
<div class="eyebrow">Direção 02 / ciência esportiva</div>
|
||||
<h1>laboratório limpo para performance real.</h1>
|
||||
<p class="lead">
|
||||
A direção mais confiável para relatórios, histórico, sensores e
|
||||
decisões pós-treino. Visual claro, médico-esportivo sem parecer
|
||||
hospital.
|
||||
</p>
|
||||
<button class="btn">Capturar treino</button>
|
||||
<button class="btn secondary">Ver relatório</button>
|
||||
</div>
|
||||
<div class="hero-card">
|
||||
<div class="eyebrow">Regra 60 / 30 / 10</div>
|
||||
<div class="token-row">
|
||||
<div class="token" style="background: #fcfffc">60% paper</div>
|
||||
<div class="token" style="background: #eaf3ed">30% lab surface</div>
|
||||
<div class="token" style="background: #12a36b; color: white">
|
||||
10% green
|
||||
</div>
|
||||
</div>
|
||||
<p class="lead">
|
||||
Azul só para informação técnica; amarelo para alerta leve; vermelho
|
||||
apenas para risco/limite.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section id="screens" class="section">
|
||||
<h2>Telas mobile</h2>
|
||||
<div class="lab-grid">
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top">
|
||||
<span>pronto para captura</span><span>h003</span>
|
||||
</div>
|
||||
<div class="number">--<span class="unit">bpm</span></div>
|
||||
<p class="lead">
|
||||
Comece antes do treino. O relatório será construído pela janela
|
||||
inteira.
|
||||
</p>
|
||||
<div class="strip">
|
||||
<div class="tile"><b>84%</b><span>bateria</span></div>
|
||||
<div class="tile"><b>-58</b><span>rssi</span></div>
|
||||
<div class="tile"><b>0</b><span>amostras</span></div>
|
||||
</div>
|
||||
<div class="dock">
|
||||
<button class="btn">Iniciar</button
|
||||
><button class="btn secondary">check-in</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top">
|
||||
<span>captura ativa</span><span>42:18</span>
|
||||
</div>
|
||||
<div class="number">154<span class="unit">bpm</span></div>
|
||||
<div class="chart">
|
||||
<svg viewBox="0 0 280 130">
|
||||
<path
|
||||
d="M8 92 C44 88,54 58,78 64 S118 96,146 76 S196 32,226 48 S250 84,272 58"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="zones"><i></i><i></i><i></i><i></i><i></i></div>
|
||||
<div class="strip">
|
||||
<div class="tile"><b>146</b><span>média</span></div>
|
||||
<div class="tile"><b>183</b><span>pico</span></div>
|
||||
<div class="tile"><b>41%</b><span>Z4/Z5</span></div>
|
||||
</div>
|
||||
<div class="dock">
|
||||
<button class="btn secondary">Evento</button
|
||||
><button class="btn" style="background: #e75b4a">Parar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top"><span>relatório</span><span>salvo</span></div>
|
||||
<div class="number">96<span class="unit">trimp</span></div>
|
||||
<div class="table">
|
||||
<div class="row"><span>Tempo em carga</span><b>28m</b></div>
|
||||
<div class="row"><span>Pico sustentado</span><b>181</b></div>
|
||||
<div class="row"><span>Recuperação</span><b>18h</b></div>
|
||||
<div class="row"><span>Consistência</span><b>Boa</b></div>
|
||||
</div>
|
||||
<span class="badge">carga alta controlada</span>
|
||||
<div class="dock">
|
||||
<button class="btn">Detalhar</button
|
||||
><button class="btn secondary">Exportar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top">
|
||||
<span>histórico</span><span>4 semanas</span>
|
||||
</div>
|
||||
<div class="strip">
|
||||
<div class="tile"><b>11</b><span>treinos</span></div>
|
||||
<div class="tile"><b>7h</b><span>total</span></div>
|
||||
<div class="tile"><b>+8%</b><span>carga</span></div>
|
||||
</div>
|
||||
<div class="table">
|
||||
<div class="row"><span>Sparring — hoje</span><b>96</b></div>
|
||||
<div class="row"><span>Drills — ontem</span><b>54</b></div>
|
||||
<div class="row"><span>Muay Thai — seg</span><b>110</b></div>
|
||||
<div class="row"><span>Boxe — sex</span><b>77</b></div>
|
||||
</div>
|
||||
<button class="btn secondary">Comparar semanas</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top"><span>sensor lab</span><span>ble</span></div>
|
||||
<div class="number">2A37</div>
|
||||
<div class="table">
|
||||
<div class="row"><span>Heart Rate</span><b>notify</b></div>
|
||||
<div class="row"><span>Battery</span><b>read</b></div>
|
||||
<div class="row"><span>Body Sensor</span><b>read</b></div>
|
||||
<div class="row"><span>RSSI</span><b>-61</b></div>
|
||||
</div>
|
||||
<span class="badge">perfil estável</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top">
|
||||
<span>insights</span><span>explicável</span>
|
||||
</div>
|
||||
<div class="number">18h<span class="unit">rec</span></div>
|
||||
<p class="lead">
|
||||
Carga cardiovascular alta. Priorize sono e treino técnico leve
|
||||
na próxima sessão.
|
||||
</p>
|
||||
<div class="tile">
|
||||
<b>Por que importa?</b
|
||||
><span>Evita acumular fadiga sem perder evolução.</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<b>Confiança</b><span>Baseada em 2.844 amostras.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="system" class="section">
|
||||
<h2>Design system</h2>
|
||||
<div class="spec-grid">
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Cores</div>
|
||||
<h3>Verde = performance</h3>
|
||||
<p class="lead">
|
||||
Nunca usar verde em tudo. Ele guia progresso, estabilidade e ação
|
||||
primária.
|
||||
</p>
|
||||
</div>
|
||||
<div class="spec type">
|
||||
<div class="eyebrow">Tipo</div>
|
||||
<div class="big">72%</div>
|
||||
<p class="lead">
|
||||
Números gigantes, labels técnicos pequenos, corpo legível.
|
||||
</p>
|
||||
</div>
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Raios</div>
|
||||
<div class="radii">
|
||||
<div class="r1"></div>
|
||||
<div class="r2"></div>
|
||||
<div class="r3"></div>
|
||||
</div>
|
||||
<p class="lead">Raios médios. Menos agressivo que Corner.</p>
|
||||
</div>
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Bordas</div>
|
||||
<p class="lead">
|
||||
Borda suave verde/cinza; elevação quase invisível. Parece
|
||||
instrumento científico, não card de rede social.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Componentes</h2>
|
||||
<div class="component-grid">
|
||||
<div class="component">
|
||||
<div class="eyebrow">Primary CTA</div>
|
||||
<button class="btn">Iniciar captura</button
|
||||
><button class="btn secondary" style="margin-top: 8px">
|
||||
Secundário
|
||||
</button>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Form</div>
|
||||
<div class="field">Tipo de treino</div>
|
||||
<div class="field">RPE pós-treino</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Metric</div>
|
||||
<div class="number" style="font-size: 54px">146</div>
|
||||
<span class="badge">bpm médio</span>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Insight</div>
|
||||
<b>Carga subiu 8%</b>
|
||||
<p class="lead">Comparado à semana anterior.</p>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Sensor row</div>
|
||||
<div class="row"><span>2A37</span><b>notify</b></div>
|
||||
<div class="row"><span>2A19</span><b>read</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Princípios</h2>
|
||||
<div class="principles">
|
||||
<div class="principle">
|
||||
<b>Clareza antes de estilo</b>
|
||||
<p class="lead">Durante treino, o atleta entende em 1 segundo.</p>
|
||||
</div>
|
||||
<div class="principle">
|
||||
<b>Densidade no histórico</b>
|
||||
<p class="lead">
|
||||
Depois do treino, a tela pode ser mais informativa.
|
||||
</p>
|
||||
</div>
|
||||
<div class="principle">
|
||||
<b>Explicável sempre</b>
|
||||
<p class="lead">Toda métrica importante diz por que importa.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,695 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FightRate Direction 03 — Fight Night</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #120209;
|
||||
--ink: #fff4f6;
|
||||
--muted: #be9aa4;
|
||||
--panel: #210712;
|
||||
--panel2: #310b19;
|
||||
--line: #5b1c2d;
|
||||
--pink: #ff3156;
|
||||
--gold: #ffc14a;
|
||||
--violet: #6c4dff;
|
||||
--r-sm: 6px;
|
||||
--r-md: 12px;
|
||||
--r-lg: 20px;
|
||||
--r-xl: 32px;
|
||||
--shadow: 0 34px 90px rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: Impact, "Arial Black", system-ui, sans-serif;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
body:before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 18% 10%,
|
||||
rgba(255, 49, 86, 0.36),
|
||||
transparent 28%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 80% 8%,
|
||||
rgba(108, 77, 255, 0.28),
|
||||
transparent 30%
|
||||
),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.05) 0 1px,
|
||||
transparent 1px 22px
|
||||
);
|
||||
}
|
||||
.wrap {
|
||||
width: min(1240px, calc(100% - 28px));
|
||||
margin: auto;
|
||||
padding: 34px 0 90px;
|
||||
position: relative;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(18, 2, 9, 0.72);
|
||||
border-radius: 999px;
|
||||
padding: 10px 16px;
|
||||
backdrop-filter: blur(14px);
|
||||
position: sticky;
|
||||
top: 14px;
|
||||
z-index: 4;
|
||||
}
|
||||
.nav a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
margin-left: 16px;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
.brand {
|
||||
font-size: 22px;
|
||||
}
|
||||
.hero {
|
||||
margin-top: 26px;
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
gap: 18px;
|
||||
}
|
||||
.poster {
|
||||
background: linear-gradient(145deg, #270814, #120209);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-xl);
|
||||
padding: 34px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.poster:before {
|
||||
content: "FIGHTRATE";
|
||||
position: absolute;
|
||||
font-size: 170px;
|
||||
line-height: 0.7;
|
||||
letter-spacing: -0.12em;
|
||||
color: rgba(255, 255, 255, 0.035);
|
||||
left: -18px;
|
||||
top: 18px;
|
||||
}
|
||||
.eyebrow {
|
||||
position: relative;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.24em;
|
||||
color: var(--gold);
|
||||
font-weight: 900;
|
||||
}
|
||||
h1 {
|
||||
position: relative;
|
||||
font-size: clamp(62px, 12vw, 150px);
|
||||
line-height: 0.72;
|
||||
letter-spacing: -0.08em;
|
||||
margin: 18px 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.lead {
|
||||
position: relative;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 18px;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
max-width: 720px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--pink);
|
||||
color: white;
|
||||
font-weight: 1000;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 15px 18px;
|
||||
box-shadow: 0 20px 46px rgba(255, 49, 86, 0.32);
|
||||
}
|
||||
.btn.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink);
|
||||
box-shadow: none;
|
||||
}
|
||||
.tokens {
|
||||
display: grid;
|
||||
grid-template-columns: 6fr 3fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.token {
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
min-height: 112px;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-weight: 900;
|
||||
}
|
||||
.section {
|
||||
margin-top: 30px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 52px;
|
||||
line-height: 0.82;
|
||||
letter-spacing: -0.07em;
|
||||
margin: 0 0 14px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.screen-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
.phone {
|
||||
height: 590px;
|
||||
background: #050104;
|
||||
border-radius: 40px;
|
||||
padding: 10px;
|
||||
border: 1px solid #681b30;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.screen {
|
||||
height: 100%;
|
||||
border-radius: 30px;
|
||||
background: linear-gradient(180deg, #240713, #13030a);
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 13px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.top {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.score {
|
||||
font-size: 84px;
|
||||
line-height: 0.74;
|
||||
letter-spacing: -0.08em;
|
||||
}
|
||||
.unit {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 16px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.belt {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.belt span {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
background: #3b0d1d;
|
||||
color: #ffb8c3;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 7px 9px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 900;
|
||||
}
|
||||
.chart {
|
||||
height: 125px;
|
||||
background: #16030b;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chart:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent 0 18px,
|
||||
rgba(255, 255, 255, 0.04) 18px 19px
|
||||
);
|
||||
}
|
||||
.chart svg {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.chart path {
|
||||
fill: none;
|
||||
stroke: var(--pink);
|
||||
stroke-width: 5;
|
||||
stroke-linecap: round;
|
||||
filter: drop-shadow(0 0 12px rgba(255, 49, 86, 0.9));
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.stat {
|
||||
border: 1px solid var(--line);
|
||||
background: #1b0610;
|
||||
border-radius: 14px;
|
||||
padding: 11px;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
.stat b {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
}
|
||||
.stat span {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.dock {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.dock .btn {
|
||||
padding: 13px 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.system {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.spec {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
padding: 18px;
|
||||
}
|
||||
.spec h3 {
|
||||
font-size: 28px;
|
||||
line-height: 0.9;
|
||||
margin: 8px 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.type-big {
|
||||
font-size: 68px;
|
||||
line-height: 0.75;
|
||||
}
|
||||
.radii {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
.radii div {
|
||||
background: #360b1a;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.r1 {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.r2 {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.r3 {
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.components {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.component {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
padding: 14px;
|
||||
min-height: 156px;
|
||||
}
|
||||
.field {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
background: #13030a;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.fight-card {
|
||||
display: grid;
|
||||
grid-template-columns: 52px 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 10px 0;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
.round {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--pink);
|
||||
font-weight: 1000;
|
||||
}
|
||||
.principles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.principle {
|
||||
background: linear-gradient(145deg, #210712, #120209);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
padding: 22px;
|
||||
}
|
||||
.principle b {
|
||||
font-size: 24px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.hero,
|
||||
.screen-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.system {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.components {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.principles {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.nav {
|
||||
position: relative;
|
||||
top: 0;
|
||||
}
|
||||
.nav div:last-child {
|
||||
display: none;
|
||||
}
|
||||
.poster {
|
||||
padding: 22px;
|
||||
}
|
||||
.system,
|
||||
.components {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.phone {
|
||||
height: 650px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 70px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<nav class="nav">
|
||||
<div class="brand">FIGHTRATE / FIGHT NIGHT</div>
|
||||
<div>
|
||||
<a href="00-index.html">Índice</a><a href="#screens">Telas</a
|
||||
><a href="#system">Sistema</a>
|
||||
</div>
|
||||
</nav>
|
||||
<section class="hero">
|
||||
<div class="poster">
|
||||
<div class="eyebrow">Direção 03 / esportivo editorial</div>
|
||||
<h1>energia de evento para virar marca.</h1>
|
||||
<p class="lead">
|
||||
A direção mais agressiva visualmente. Boa para marketing,
|
||||
comunidade, social sharing e identidade memorável. Menos clínica;
|
||||
mais luta, pressão, noite, placar e pôster.
|
||||
</p>
|
||||
<button class="btn">Começar captura</button>
|
||||
<button class="btn secondary">Ver card da noite</button>
|
||||
</div>
|
||||
<div class="poster">
|
||||
<div class="eyebrow">Regra 60 / 30 / 10</div>
|
||||
<div class="tokens">
|
||||
<div class="token" style="background: #120209">
|
||||
60% arena escura
|
||||
</div>
|
||||
<div class="token" style="background: #310b19">
|
||||
30% vinho profundo
|
||||
</div>
|
||||
<div class="token" style="background: #ff3156">10% neon golpe</div>
|
||||
</div>
|
||||
<p class="lead">
|
||||
Dourado é prêmio, recorde e destaque editorial. Roxo só para
|
||||
profundidade.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section id="screens" class="section">
|
||||
<h2>Telas mobile</h2>
|
||||
<div class="screen-grid">
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top"><span>fight night</span><span>h003</span></div>
|
||||
<div class="score">READY</div>
|
||||
<p class="lead">
|
||||
Capture o treino como se fosse seu fight card pessoal.
|
||||
</p>
|
||||
<div class="belt">
|
||||
<span>cinta ok</span><span>pré-treino</span
|
||||
><span>sem round manual</span>
|
||||
</div>
|
||||
<div class="dock">
|
||||
<button class="btn">Iniciar</button
|
||||
><button class="btn secondary">perfil</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top"><span>ao vivo</span><span>37:54</span></div>
|
||||
<div class="score">151<span class="unit">bpm</span></div>
|
||||
<div class="chart">
|
||||
<svg viewBox="0 0 280 130">
|
||||
<path
|
||||
d="M8 98 C34 88,38 44,70 58 S110 104,140 72 S190 20,220 48 S250 92,272 48"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><b>185</b><span>pico</span></div>
|
||||
<div class="stat"><b>41%</b><span>z4/z5</span></div>
|
||||
<div class="stat"><b>7</b><span>eventos</span></div>
|
||||
</div>
|
||||
<div class="dock">
|
||||
<button class="btn secondary">Marcar</button
|
||||
><button class="btn">Parar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top"><span>resultado</span><span>salvo</span></div>
|
||||
<div class="score">W</div>
|
||||
<div class="belt">
|
||||
<span>carga alta</span><span>pico 185</span><span>48 min</span>
|
||||
</div>
|
||||
<div class="fight-card">
|
||||
<span class="round">1</span>
|
||||
<div>Entrou em ritmo</div>
|
||||
<b>138</b>
|
||||
</div>
|
||||
<div class="fight-card">
|
||||
<span class="round">2</span>
|
||||
<div>Pico do treino</div>
|
||||
<b>181</b>
|
||||
</div>
|
||||
<div class="fight-card">
|
||||
<span class="round">3</span>
|
||||
<div>Queda controlada</div>
|
||||
<b>152</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top"><span>histórico</span><span>cards</span></div>
|
||||
<div class="fight-card">
|
||||
<span class="round">S</span>
|
||||
<div>
|
||||
<b>Sparring</b><br /><span style="color: var(--muted)"
|
||||
>Hoje · 48 min</span
|
||||
>
|
||||
</div>
|
||||
<b>96</b>
|
||||
</div>
|
||||
<div class="fight-card">
|
||||
<span class="round">M</span>
|
||||
<div>
|
||||
<b>Muay Thai</b><br /><span style="color: var(--muted)"
|
||||
>Seg · 61 min</span
|
||||
>
|
||||
</div>
|
||||
<b>110</b>
|
||||
</div>
|
||||
<div class="fight-card">
|
||||
<span class="round">B</span>
|
||||
<div>
|
||||
<b>Boxe</b><br /><span style="color: var(--muted)"
|
||||
>Sex · 42 min</span
|
||||
>
|
||||
</div>
|
||||
<b>77</b>
|
||||
</div>
|
||||
<button class="btn secondary">Ranking pessoal</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top"><span>sensor</span><span>corner tech</span></div>
|
||||
<div class="score">BLE</div>
|
||||
<div class="belt">
|
||||
<span>180D</span><span>2A37</span><span>-61 RSSI</span>
|
||||
</div>
|
||||
<div class="field">Heart Rate Measurement — notify</div>
|
||||
<div class="field">Battery Level — read</div>
|
||||
<div class="field">Body Sensor Location — read</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top"><span>insight</span><span>headline</span></div>
|
||||
<div class="score">PRESSÃO</div>
|
||||
<p class="lead">
|
||||
Você sustentou alta intensidade por mais tempo que a média das
|
||||
últimas sessões.
|
||||
</p>
|
||||
<div class="belt"><span>+12%</span><span>boa evolução</span></div>
|
||||
<div class="field">
|
||||
Use amanhã para treino técnico, não guerra.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="system" class="section">
|
||||
<h2>Design book</h2>
|
||||
<div class="system">
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Cores</div>
|
||||
<h3>neon controlado</h3>
|
||||
<p class="lead">
|
||||
Rosa só para ação, pico e vitória. Se tudo brilha, nada importa.
|
||||
</p>
|
||||
</div>
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Tipo</div>
|
||||
<div class="type-big">185</div>
|
||||
<p class="lead">
|
||||
Display condensado, letras de pôster, corpo neutro só nos textos
|
||||
longos.
|
||||
</p>
|
||||
</div>
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Raio</div>
|
||||
<div class="radii">
|
||||
<div class="r1"></div>
|
||||
<div class="r2"></div>
|
||||
<div class="r3"></div>
|
||||
</div>
|
||||
<p class="lead">
|
||||
Raios menores, mais angular, sensação de esporte e placar.
|
||||
</p>
|
||||
</div>
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Bordas</div>
|
||||
<p class="lead">
|
||||
Bordas vinho/neon, contraste alto, nada de cinza corporativo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Componentes</h2>
|
||||
<div class="components">
|
||||
<div class="component">
|
||||
<div class="eyebrow">CTA</div>
|
||||
<button class="btn">Iniciar</button
|
||||
><button class="btn secondary" style="margin-top: 8px">
|
||||
Secundário
|
||||
</button>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Fight card row</div>
|
||||
<div class="fight-card">
|
||||
<span class="round">S</span>
|
||||
<div>Sparring</div>
|
||||
<b>96</b>
|
||||
</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Badge</div>
|
||||
<div class="belt"><span>recorde</span><span>z5 08%</span></div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Input</div>
|
||||
<div class="field">Nome do treino</div>
|
||||
<div class="field">Categoria</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Share</div>
|
||||
<p class="lead">
|
||||
Este padrão é o melhor para gerar imagem compartilhável
|
||||
pós-treino.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Princípios</h2>
|
||||
<div class="principles">
|
||||
<div class="principle">
|
||||
<b>Memorável</b>
|
||||
<p class="lead">O usuário deve reconhecer FightRate de longe.</p>
|
||||
</div>
|
||||
<div class="principle">
|
||||
<b>Editorial</b>
|
||||
<p class="lead">Relatórios viram cards visuais de treino.</p>
|
||||
</div>
|
||||
<div class="principle">
|
||||
<b>Com risco</b>
|
||||
<p class="lead">Pode cansar visualmente se usado em todo app.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,642 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FightRate Direction 04 — Sensor Instrument</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #02070a;
|
||||
--ink: #dffaff;
|
||||
--muted: #7aa6b1;
|
||||
--panel: #061118;
|
||||
--panel2: #0a1c25;
|
||||
--line: #14313c;
|
||||
--cyan: #5be6ff;
|
||||
--blue: #2d8cff;
|
||||
--green: #35ef9f;
|
||||
--red: #ff4c5d;
|
||||
--amber: #ffd166;
|
||||
--r-sm: 6px;
|
||||
--r-md: 10px;
|
||||
--r-lg: 16px;
|
||||
--r-xl: 26px;
|
||||
--mono: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
||||
--sans: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
}
|
||||
body:before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(rgba(91, 230, 255, 0.07) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(91, 230, 255, 0.07) 1px, transparent 1px),
|
||||
radial-gradient(
|
||||
circle at 50% 0%,
|
||||
rgba(91, 230, 255, 0.18),
|
||||
transparent 36%
|
||||
);
|
||||
background-size:
|
||||
22px 22px,
|
||||
22px 22px,
|
||||
auto;
|
||||
}
|
||||
.wrap {
|
||||
width: min(1240px, calc(100% - 28px));
|
||||
margin: auto;
|
||||
padding: 34px 0 86px;
|
||||
position: relative;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: rgba(6, 17, 24, 0.82);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
font-family: var(--mono);
|
||||
position: sticky;
|
||||
top: 14px;
|
||||
z-index: 3;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
.nav a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
margin-left: 14px;
|
||||
}
|
||||
.brand {
|
||||
color: var(--cyan);
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.terminal {
|
||||
background: linear-gradient(180deg, #061118, #03090d);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-xl);
|
||||
padding: 26px;
|
||||
box-shadow:
|
||||
0 28px 80px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.eyebrow {
|
||||
font-family: var(--mono);
|
||||
color: var(--cyan);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--mono);
|
||||
font-size: clamp(38px, 7vw, 82px);
|
||||
line-height: 0.9;
|
||||
letter-spacing: -0.05em;
|
||||
margin: 16px 0;
|
||||
}
|
||||
h2 {
|
||||
font-family: var(--mono);
|
||||
font-size: 36px;
|
||||
letter-spacing: -0.04em;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.lead {
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
font-size: 17px;
|
||||
}
|
||||
.cmd {
|
||||
font-family: var(--mono);
|
||||
background: #02070a;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
color: var(--green);
|
||||
margin-top: 16px;
|
||||
}
|
||||
.tokens {
|
||||
display: grid;
|
||||
grid-template-columns: 6fr 3fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.token {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
min-height: 98px;
|
||||
font-family: var(--mono);
|
||||
font-weight: 800;
|
||||
}
|
||||
.section {
|
||||
margin-top: 30px;
|
||||
}
|
||||
.screen-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
.phone {
|
||||
height: 590px;
|
||||
background: #000;
|
||||
border-radius: 38px;
|
||||
padding: 9px;
|
||||
border: 1px solid #16404d;
|
||||
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.58);
|
||||
}
|
||||
.screen {
|
||||
height: 100%;
|
||||
border-radius: 30px;
|
||||
background: #041017;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #092532;
|
||||
}
|
||||
.top {
|
||||
font-family: var(--mono);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.big {
|
||||
font-family: var(--mono);
|
||||
font-size: 64px;
|
||||
line-height: 0.9;
|
||||
letter-spacing: -0.06em;
|
||||
color: var(--cyan);
|
||||
}
|
||||
.unit {
|
||||
font-size: 16px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.scope {
|
||||
height: 120px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: #02070a;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scope:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent 0 19px,
|
||||
rgba(91, 230, 255, 0.06) 19px 20px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent 0 19px,
|
||||
rgba(91, 230, 255, 0.06) 19px 20px
|
||||
);
|
||||
}
|
||||
.scope svg {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.scope path {
|
||||
fill: none;
|
||||
stroke: var(--cyan);
|
||||
stroke-width: 3;
|
||||
filter: drop-shadow(0 0 8px var(--cyan));
|
||||
}
|
||||
.grid2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.module {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: #061822;
|
||||
padding: 10px;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.module b {
|
||||
color: var(--ink);
|
||||
}
|
||||
.module span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 8px 0;
|
||||
}
|
||||
.ok {
|
||||
color: var(--green);
|
||||
}
|
||||
.warn {
|
||||
color: var(--amber);
|
||||
}
|
||||
.bad {
|
||||
color: var(--red);
|
||||
}
|
||||
.dock {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.btn {
|
||||
border: 1px solid var(--cyan);
|
||||
background: rgba(91, 230, 255, 0.08);
|
||||
color: var(--cyan);
|
||||
border-radius: 10px;
|
||||
padding: 13px 8px;
|
||||
font-family: var(--mono);
|
||||
font-weight: 900;
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--cyan);
|
||||
color: #021015;
|
||||
}
|
||||
.system {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.spec {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
}
|
||||
.type-big {
|
||||
font-family: var(--mono);
|
||||
font-size: 50px;
|
||||
color: var(--cyan);
|
||||
}
|
||||
.radii {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
.radii div {
|
||||
background: #061822;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.r1 {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.r2 {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.r3 {
|
||||
width: 86px;
|
||||
height: 86px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.components {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.component {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
min-height: 150px;
|
||||
}
|
||||
.log {
|
||||
font-family: var(--mono);
|
||||
background: #02070a;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.principles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.principle {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
padding: 18px;
|
||||
}
|
||||
.principle b {
|
||||
font-family: var(--mono);
|
||||
color: var(--cyan);
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.hero,
|
||||
.screen-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.system,
|
||||
.components {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.principles {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.nav {
|
||||
position: relative;
|
||||
top: 0;
|
||||
}
|
||||
.nav div:last-child {
|
||||
display: none;
|
||||
}
|
||||
.system,
|
||||
.components {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.phone {
|
||||
height: 650px;
|
||||
}
|
||||
.terminal {
|
||||
padding: 20px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 44px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<nav class="nav">
|
||||
<div class="brand">FightRate::SensorInstrument</div>
|
||||
<div>
|
||||
<a href="00-index.html">index</a><a href="#screens">screens</a
|
||||
><a href="#system">system</a>
|
||||
</div>
|
||||
</nav>
|
||||
<section class="hero">
|
||||
<div class="terminal">
|
||||
<div class="eyebrow">Direção 04 / telemetria BLE</div>
|
||||
<h1>instrumento técnico, confiável, sem decoração inútil.</h1>
|
||||
<p class="lead">
|
||||
A melhor direção se a tela Sensor e a confiança na captura forem
|
||||
diferenciais do app. Parece equipamento de medição, não aplicativo
|
||||
de lifestyle.
|
||||
</p>
|
||||
<div class="cmd">
|
||||
$ fightrate capture --sensor Magene-H003 --mode continuous
|
||||
</div>
|
||||
</div>
|
||||
<div class="terminal">
|
||||
<div class="eyebrow">Regra 60 / 30 / 10</div>
|
||||
<div class="tokens">
|
||||
<div class="token" style="background: #02070a">
|
||||
60% console preto
|
||||
</div>
|
||||
<div class="token" style="background: #061118">
|
||||
30% módulo técnico
|
||||
</div>
|
||||
<div class="token" style="background: #5be6ff; color: #021015">
|
||||
10% sinal cyan
|
||||
</div>
|
||||
</div>
|
||||
<p class="lead">
|
||||
Verde = ok; âmbar = atenção; vermelho = falha. Azul é informação
|
||||
secundária.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section id="screens" class="section">
|
||||
<h2>Mobile screens</h2>
|
||||
<div class="screen-grid">
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top">
|
||||
<span>CAPTURE/READY</span><span>BLE OK</span>
|
||||
</div>
|
||||
<div class="big">H003</div>
|
||||
<div class="grid2">
|
||||
<div class="module"><b>RSSI</b><span>-58 dBm</span></div>
|
||||
<div class="module"><b>BATT</b><span>84%</span></div>
|
||||
<div class="module">
|
||||
<b>HRM</b><span class="ok">notify</span>
|
||||
</div>
|
||||
<div class="module"><b>SAMPLES</b><span>0</span></div>
|
||||
</div>
|
||||
<div class="dock">
|
||||
<button class="btn primary">START</button
|
||||
><button class="btn">CHECK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top">
|
||||
<span>CAPTURE/RUN</span><span>00:42:18</span>
|
||||
</div>
|
||||
<div class="big">154<span class="unit">bpm</span></div>
|
||||
<div class="scope">
|
||||
<svg viewBox="0 0 280 120">
|
||||
<path
|
||||
d="M4 84 C30 82,42 46,70 58 S114 98,146 66 S188 28,220 48 S252 76,276 54"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="module"><b>AVG</b><span>146</span></div>
|
||||
<div class="module"><b>PEAK</b><span>183</span></div>
|
||||
<div class="module"><b>Z4/Z5</b><span>41%</span></div>
|
||||
<div class="module"><b>DROP</b><span class="ok">0</span></div>
|
||||
</div>
|
||||
<div class="dock">
|
||||
<button class="btn">MARK</button
|
||||
><button class="btn primary">STOP</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top"><span>REPORT</span><span>SAVED</span></div>
|
||||
<div class="big">2844</div>
|
||||
<div class="row"><span>Samples</span><b class="ok">valid</b></div>
|
||||
<div class="row"><span>Duration</span><b>47:12</b></div>
|
||||
<div class="row"><span>Peak sustained</span><b>181</b></div>
|
||||
<div class="row">
|
||||
<span>Signal drops</span><b class="ok">0</b>
|
||||
</div>
|
||||
<div class="log">
|
||||
report.source = local.sqlite<br />confidence = high
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top"><span>HISTORY</span><span>LOCAL DB</span></div>
|
||||
<div class="row"><span>2026-06-10 sparring</span><b>96</b></div>
|
||||
<div class="row"><span>2026-06-09 drill</span><b>54</b></div>
|
||||
<div class="row"><span>2026-06-07 muay thai</span><b>110</b></div>
|
||||
<div class="row"><span>2026-06-05 boxe</span><b>77</b></div>
|
||||
<div class="scope">
|
||||
<svg viewBox="0 0 280 120">
|
||||
<path
|
||||
d="M10 88 L42 78 L80 92 L116 50 L148 72 L188 38 L226 60 L268 44"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top">
|
||||
<span>SENSOR PROFILE</span><span>GATT</span>
|
||||
</div>
|
||||
<div class="big">180D</div>
|
||||
<div class="row">
|
||||
<span>2A37 Measurement</span><b class="ok">notify</b>
|
||||
</div>
|
||||
<div class="row"><span>2A38 Body Location</span><b>read</b></div>
|
||||
<div class="row"><span>180F Battery</span><b>read</b></div>
|
||||
<div class="row">
|
||||
<span>Device Info</span><b class="warn">maybe</b>
|
||||
</div>
|
||||
<div class="dock">
|
||||
<button class="btn">SCAN</button
|
||||
><button class="btn primary">LOG</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top">
|
||||
<span>INSIGHT ENGINE</span><span>EXPLAIN</span>
|
||||
</div>
|
||||
<div class="big">LOAD</div>
|
||||
<div class="module">
|
||||
<b>Signal confidence</b
|
||||
><span class="ok">high: 2.844 valid samples</span>
|
||||
</div>
|
||||
<div class="module">
|
||||
<b>Training note</b
|
||||
><span>high cardiac load; prioritize recovery</span>
|
||||
</div>
|
||||
<div class="module">
|
||||
<b>Why</b><span>time in Z4/Z5 above personal baseline</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="system" class="section">
|
||||
<h2>Design system</h2>
|
||||
<div class="system">
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Cores</div>
|
||||
<p class="lead">
|
||||
Cyan representa sinal. Não usar para botões destrutivos. Verde,
|
||||
amarelo e vermelho são semânticos.
|
||||
</p>
|
||||
</div>
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Tipo</div>
|
||||
<div class="type-big">2A37</div>
|
||||
<p class="lead">
|
||||
Monospace real para UUIDs, logs e dados técnicos.
|
||||
</p>
|
||||
</div>
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Raios</div>
|
||||
<div class="radii">
|
||||
<div class="r1"></div>
|
||||
<div class="r2"></div>
|
||||
<div class="r3"></div>
|
||||
</div>
|
||||
<p class="lead">Mais angular. Parece instrumento.</p>
|
||||
</div>
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Bordas</div>
|
||||
<p class="lead">
|
||||
Hairlines cyan escuras. Bordas são grade técnica, não decoração.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Componentes</h2>
|
||||
<div class="components">
|
||||
<div class="component">
|
||||
<div class="eyebrow">Command CTA</div>
|
||||
<button class="btn primary">START</button
|
||||
><button class="btn" style="margin-top: 8px">SECONDARY</button>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">GATT row</div>
|
||||
<div class="row"><span>2A37</span><b class="ok">notify</b></div>
|
||||
<div class="row"><span>2A19</span><b>read</b></div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Log</div>
|
||||
<div class="log">connected=true<br />rssi=-61<br />mtu=default</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Metric</div>
|
||||
<div class="big" style="font-size: 42px">-61</div>
|
||||
<span class="ok">stable</span>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Alert</div>
|
||||
<span class="bad">signal lost</span>
|
||||
<div class="log">retry in 2s</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Princípios</h2>
|
||||
<div class="principles">
|
||||
<div class="principle">
|
||||
<b>Transparência técnica</b>
|
||||
<p class="lead">
|
||||
Mostrar o que o sensor expõe e o que o app está usando.
|
||||
</p>
|
||||
</div>
|
||||
<div class="principle">
|
||||
<b>Confiança</b>
|
||||
<p class="lead">Dados têm status, origem e qualidade.</p>
|
||||
</div>
|
||||
<div class="principle">
|
||||
<b>Risco</b>
|
||||
<p class="lead">Pode parecer técnico demais para usuário casual.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,653 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FightRate Direction 05 — Minimal Performance</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f3eee3;
|
||||
--paper: #fffaf0;
|
||||
--ink: #16130e;
|
||||
--muted: #726a5d;
|
||||
--line: #d8cdb9;
|
||||
--accent: #11110f;
|
||||
--sand: #e9ddc7;
|
||||
--red: #c83a2a;
|
||||
--green: #168a5a;
|
||||
--amber: #d48a12;
|
||||
--r-xs: 4px;
|
||||
--r-sm: 9px;
|
||||
--r-md: 14px;
|
||||
--r-lg: 22px;
|
||||
--r-xl: 30px;
|
||||
--shadow: 0 28px 80px rgba(22, 19, 14, 0.1);
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: "New York", Georgia, serif;
|
||||
}
|
||||
body:before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 14% 2%,
|
||||
rgba(200, 58, 42, 0.12),
|
||||
transparent 34%
|
||||
),
|
||||
linear-gradient(rgba(22, 19, 14, 0.035) 1px, transparent 1px);
|
||||
background-size:
|
||||
auto,
|
||||
100% 34px;
|
||||
}
|
||||
.wrap {
|
||||
width: min(1180px, calc(100% - 28px));
|
||||
margin: auto;
|
||||
padding: 34px 0 82px;
|
||||
position: relative;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 16px 0;
|
||||
}
|
||||
.nav a {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 12px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
.brand {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-weight: 950;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.05fr 0.95fr;
|
||||
gap: 24px;
|
||||
margin-top: 38px;
|
||||
}
|
||||
.hero-panel {
|
||||
background: rgba(255, 250, 240, 0.72);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-xl);
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.eyebrow {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
font-size: 11px;
|
||||
color: var(--red);
|
||||
font-weight: 900;
|
||||
}
|
||||
h1 {
|
||||
font-size: clamp(52px, 9vw, 112px);
|
||||
line-height: 0.86;
|
||||
letter-spacing: -0.075em;
|
||||
margin: 16px 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 44px;
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 0.96;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.lead {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
color: var(--muted);
|
||||
font-size: 18px;
|
||||
line-height: 1.55;
|
||||
max-width: 720px;
|
||||
}
|
||||
.tokens {
|
||||
display: grid;
|
||||
grid-template-columns: 6fr 3fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.token {
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
min-height: 100px;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-weight: 900;
|
||||
}
|
||||
.btn {
|
||||
border: 1px solid var(--ink);
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
border-radius: 999px;
|
||||
padding: 14px 18px;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-weight: 900;
|
||||
}
|
||||
.btn.secondary {
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
}
|
||||
.section {
|
||||
margin-top: 34px;
|
||||
}
|
||||
.screen-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
.phone {
|
||||
height: 590px;
|
||||
background: #111;
|
||||
border-radius: 40px;
|
||||
padding: 9px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.screen {
|
||||
height: 100%;
|
||||
border-radius: 32px;
|
||||
background: var(--paper);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--muted);
|
||||
font-weight: 900;
|
||||
}
|
||||
.number {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 82px;
|
||||
line-height: 0.84;
|
||||
letter-spacing: -0.09em;
|
||||
font-weight: 950;
|
||||
}
|
||||
.unit {
|
||||
font-size: 17px;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.rule-line {
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
.chart {
|
||||
height: 136px;
|
||||
border-bottom: 1px solid var(--ink);
|
||||
position: relative;
|
||||
}
|
||||
.chart svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.chart path {
|
||||
fill: none;
|
||||
stroke: var(--ink);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.metric {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 10px;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
.metric b {
|
||||
font-size: 24px;
|
||||
}
|
||||
.metric span {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
font-weight: 900;
|
||||
}
|
||||
.dock {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.dock .btn {
|
||||
padding: 14px 8px;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.item {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
.item span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.note {
|
||||
background: #f3eadb;
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
.system {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.spec {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
padding: 18px;
|
||||
}
|
||||
.spec h3 {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.04em;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.typebig {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 62px;
|
||||
letter-spacing: -0.08em;
|
||||
line-height: 0.86;
|
||||
}
|
||||
.radii {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: end;
|
||||
}
|
||||
.radii div {
|
||||
background: var(--sand);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.r1 {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.r2 {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.r3 {
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
border-radius: 30px;
|
||||
}
|
||||
.components {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.component {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
padding: 14px;
|
||||
min-height: 154px;
|
||||
}
|
||||
.field {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
background: #fbf3e5;
|
||||
color: var(--muted);
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
padding: 7px 10px;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.principles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.principle {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
padding: 20px;
|
||||
}
|
||||
.principle b {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 22px;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.hero,
|
||||
.screen-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.system,
|
||||
.components {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.principles {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.nav div:last-child {
|
||||
display: none;
|
||||
}
|
||||
.hero-panel {
|
||||
padding: 22px;
|
||||
}
|
||||
.system,
|
||||
.components {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.phone {
|
||||
height: 650px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<nav class="nav">
|
||||
<div class="brand">FightRate / Minimal Performance</div>
|
||||
<div>
|
||||
<a href="00-index.html">Índice</a><a href="#screens">Telas</a
|
||||
><a href="#system">Sistema</a>
|
||||
</div>
|
||||
</nav>
|
||||
<section class="hero">
|
||||
<div class="hero-panel">
|
||||
<div class="eyebrow">Direção 05 / premium minimal</div>
|
||||
<h1>menos interface, mais verdade de treino.</h1>
|
||||
<p class="lead">
|
||||
Um app calmo e caro. Ideal se a marca quiser parecer precisa, madura
|
||||
e confiável, sem parecer hospital nem dashboard corporativa.
|
||||
</p>
|
||||
<button class="btn">Iniciar captura</button>
|
||||
<button class="btn secondary">Histórico</button>
|
||||
</div>
|
||||
<div class="hero-panel">
|
||||
<div class="eyebrow">Regra 60 / 30 / 10</div>
|
||||
<div class="tokens">
|
||||
<div class="token" style="background: #fffaf0">
|
||||
60% paper quente
|
||||
</div>
|
||||
<div class="token" style="background: #e9ddc7">30% areia</div>
|
||||
<div class="token" style="background: #16130e; color: #fffaf0">
|
||||
10% tinta
|
||||
</div>
|
||||
</div>
|
||||
<p class="lead">
|
||||
Vermelho e verde aparecem só como semântica, nunca como tema
|
||||
inteiro.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section id="screens" class="section">
|
||||
<h2>Telas mobile</h2>
|
||||
<div class="screen-grid">
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top"><span>pronto</span><span>sensor ok</span></div>
|
||||
<div class="number">--<span class="unit">bpm</span></div>
|
||||
<div class="rule-line"></div>
|
||||
<p class="lead">
|
||||
Inicie a captura antes do treino. Todo o restante pode esperar.
|
||||
</p>
|
||||
<div class="metrics">
|
||||
<div class="metric"><b>84%</b><span>bateria</span></div>
|
||||
<div class="metric"><b>-58</b><span>rssi</span></div>
|
||||
<div class="metric"><b>0</b><span>amostras</span></div>
|
||||
</div>
|
||||
<div class="dock">
|
||||
<button class="btn">Iniciar</button
|
||||
><button class="btn secondary">check-in</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top"><span>captura</span><span>42:18</span></div>
|
||||
<div class="number">154<span class="unit">bpm</span></div>
|
||||
<div class="chart">
|
||||
<svg viewBox="0 0 280 130">
|
||||
<path
|
||||
d="M8 96 C36 88,44 54,76 61 S116 94,145 70 S190 38,222 50 S250 78,272 54"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<div class="metric"><b>146</b><span>média</span></div>
|
||||
<div class="metric"><b>183</b><span>pico</span></div>
|
||||
<div class="metric"><b>41%</b><span>z4/z5</span></div>
|
||||
</div>
|
||||
<div class="dock">
|
||||
<button class="btn secondary">Evento</button
|
||||
><button
|
||||
class="btn"
|
||||
style="background: var(--red); border-color: var(--red)"
|
||||
>
|
||||
Parar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top"><span>resumo</span><span>salvo</span></div>
|
||||
<div class="number">47<span class="unit">min</span></div>
|
||||
<div class="list">
|
||||
<div class="item">
|
||||
<div>Tempo em carga<br /><span>Z4/Z5</span></div>
|
||||
<b>28m</b>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div>Pico sustentado<br /><span>melhor trecho</span></div>
|
||||
<b>181</b>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div>Recuperação<br /><span>estimada</span></div>
|
||||
<b>18h</b>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note">
|
||||
Carga alta controlada. Amanhã prefira técnica leve.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top">
|
||||
<span>histórico</span><span>4 semanas</span>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<div class="metric"><b>11</b><span>treinos</span></div>
|
||||
<div class="metric"><b>7h</b><span>total</span></div>
|
||||
<div class="metric"><b>+8%</b><span>carga</span></div>
|
||||
</div>
|
||||
<div class="list">
|
||||
<div class="item">
|
||||
<div>Sparring<br /><span>Hoje · 47 min</span></div>
|
||||
<b>96</b>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div>Muay Thai<br /><span>Seg · 61 min</span></div>
|
||||
<b>110</b>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div>Boxe<br /><span>Sex · 42 min</span></div>
|
||||
<b>77</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top"><span>sensor</span><span>ble</span></div>
|
||||
<div class="number">-61<span class="unit">rssi</span></div>
|
||||
<div class="list">
|
||||
<div class="item">
|
||||
<div>Heart Rate<br /><span>2A37</span></div>
|
||||
<b>notify</b>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div>Battery<br /><span>2A19</span></div>
|
||||
<b>read</b>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div>Body Sensor<br /><span>2A38</span></div>
|
||||
<b>read</b>
|
||||
</div>
|
||||
</div>
|
||||
<span class="pill">perfil estável</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="top"><span>insight</span><span>curto</span></div>
|
||||
<div class="number">18h<span class="unit">rec</span></div>
|
||||
<p class="lead">
|
||||
Carga alta. Você passou mais tempo em intensidade de luta que na
|
||||
média recente.
|
||||
</p>
|
||||
<div class="note">
|
||||
<b>Por que importa?</b><br />Ajuda a decidir se o próximo treino
|
||||
deve ser duro ou técnico.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="system" class="section">
|
||||
<h2>Design system</h2>
|
||||
<div class="system">
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Cores</div>
|
||||
<h3>quente, não fitness genérico</h3>
|
||||
<p class="lead">
|
||||
Evita verde neon, azul SaaS e gradiente artificial.
|
||||
</p>
|
||||
</div>
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Tipo</div>
|
||||
<div class="typebig">154</div>
|
||||
<p class="lead">Números sans; títulos serifados editoriais.</p>
|
||||
</div>
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Raio</div>
|
||||
<div class="radii">
|
||||
<div class="r1"></div>
|
||||
<div class="r2"></div>
|
||||
<div class="r3"></div>
|
||||
</div>
|
||||
<p class="lead">Raios elegantes. Não exagerar.</p>
|
||||
</div>
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Bordas</div>
|
||||
<p class="lead">
|
||||
Linhas finas e quentes. Pouca sombra. Muito respiro.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Componentes</h2>
|
||||
<div class="components">
|
||||
<div class="component">
|
||||
<div class="eyebrow">Botões</div>
|
||||
<button class="btn">Primário</button
|
||||
><button class="btn secondary" style="margin-top: 8px">
|
||||
Secundário
|
||||
</button>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Campo</div>
|
||||
<div class="field">Nome do treino</div>
|
||||
<div class="field">Observação</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Métrica</div>
|
||||
<div class="number" style="font-size: 52px">96</div>
|
||||
<span class="pill">trimp</span>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Lista</div>
|
||||
<div class="item">
|
||||
<div>Sessão</div>
|
||||
<b>47m</b>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div>Pico</div>
|
||||
<b>181</b>
|
||||
</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Nota</div>
|
||||
<div class="note">Texto curto. Uma conclusão por bloco.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Princípios</h2>
|
||||
<div class="principles">
|
||||
<div class="principle">
|
||||
<b>Silêncio visual</b>
|
||||
<p class="lead">Só o dado importante grita.</p>
|
||||
</div>
|
||||
<div class="principle">
|
||||
<b>Marca premium</b>
|
||||
<p class="lead">Bom para vender seriedade e maturidade.</p>
|
||||
</div>
|
||||
<div class="principle">
|
||||
<b>Risco</b>
|
||||
<p class="lead">
|
||||
Pode parecer frio demais se não houver personalidade verbal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
# FightRate React Native Redesign — Direções de Design
|
||||
|
||||
Este pacote descarta a direção visual antiga do projeto para fins de exploração.
|
||||
|
||||
Não é implementação do app. É um conjunto de propostas profissionais para decidir o design system antes de reconstruir o FightRate em React Native.
|
||||
|
||||
## Arquivos
|
||||
|
||||
- `00-index.html` — visão geral comparativa das direções.
|
||||
- `01-corner-command.html` — direção tática premium, estilo mesa de córner/treino real.
|
||||
- `02-athlete-lab.html` — direção científica, limpa, laboratório de performance.
|
||||
- `03-fight-night.html` — direção editorial/esportiva, energia de evento e combate.
|
||||
- `04-sensor-instrument.html` — direção técnica, instrumentação BLE e telemetria.
|
||||
- `05-minimal-performance.html` — direção minimalista premium, foco máximo em dados úteis.
|
||||
|
||||
## Regras para próxima etapa
|
||||
|
||||
1. Não portar o design antigo.
|
||||
2. Não começar React Native antes de escolher uma direção visual.
|
||||
3. Cada direção deve ser avaliada por:
|
||||
- legibilidade durante treino;
|
||||
- confiança visual;
|
||||
- facilidade de virar marca;
|
||||
- clareza de dados;
|
||||
- qualidade em telas pequenas;
|
||||
- viabilidade em React Native.
|
||||
4. Round é secundário. O fluxo principal é captura contínua do treino.
|
||||
5. O design system final deve virar tokens React Native: cores, tipografia, espaçamento, raio, bordas, elevação, estados, motion e componentes.
|
||||
@@ -0,0 +1,544 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FightRate — Design Directions v2</title>
|
||||
<style>
|
||||
:root {
|
||||
--paper: #f4efe5;
|
||||
--ink: #12100c;
|
||||
--muted: #70695d;
|
||||
--line: #d7cab7;
|
||||
--red: #d33b2f;
|
||||
--green: #13855d;
|
||||
--blue: #0f6cff;
|
||||
--night: #080705;
|
||||
--r: 30px;
|
||||
--shadow: 0 32px 100px rgba(18, 16, 12, 0.12);
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
Segoe UI,
|
||||
sans-serif;
|
||||
line-height: 1.42;
|
||||
}
|
||||
body:before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 10% 0%,
|
||||
rgba(211, 59, 47, 0.12),
|
||||
transparent 34%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 86% 12%,
|
||||
rgba(19, 133, 93, 0.12),
|
||||
transparent 30%
|
||||
),
|
||||
linear-gradient(rgba(18, 16, 12, 0.035) 1px, transparent 1px);
|
||||
background-size:
|
||||
auto,
|
||||
auto,
|
||||
100% 36px;
|
||||
}
|
||||
.wrap {
|
||||
width: min(1240px, calc(100% - 28px));
|
||||
margin: auto;
|
||||
padding: 44px 0 86px;
|
||||
position: relative;
|
||||
}
|
||||
.hero {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 38px;
|
||||
background: rgba(255, 250, 241, 0.7);
|
||||
padding: 34px;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.24em;
|
||||
font-weight: 950;
|
||||
color: var(--red);
|
||||
}
|
||||
h1 {
|
||||
font-family: Georgia, serif;
|
||||
font-size: clamp(54px, 10vw, 124px);
|
||||
line-height: 0.82;
|
||||
letter-spacing: -0.085em;
|
||||
margin: 18px 0 20px;
|
||||
max-width: 1080px;
|
||||
}
|
||||
p {
|
||||
color: var(--muted);
|
||||
font-size: 18px;
|
||||
max-width: 820px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.direction {
|
||||
position: relative;
|
||||
min-height: 620px;
|
||||
border-radius: 34px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fffaf1;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 24px 70px rgba(18, 16, 12, 0.08);
|
||||
}
|
||||
.direction.dark {
|
||||
background: #080705;
|
||||
color: #f7ead8;
|
||||
border-color: #30261b;
|
||||
}
|
||||
.direction.athlete {
|
||||
background: #f7fbf6;
|
||||
}
|
||||
.direction:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.08),
|
||||
transparent 40%
|
||||
);
|
||||
}
|
||||
.top {
|
||||
padding: 24px;
|
||||
}
|
||||
.num {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
font-weight: 950;
|
||||
}
|
||||
.dark .num {
|
||||
color: #b7a68a;
|
||||
}
|
||||
.direction h2 {
|
||||
font-size: 44px;
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.06em;
|
||||
margin: 10px 0 12px;
|
||||
}
|
||||
.direction p {
|
||||
font-size: 15px;
|
||||
}
|
||||
.phone {
|
||||
width: 245px;
|
||||
height: 500px;
|
||||
margin: 12px auto 0;
|
||||
border-radius: 42px;
|
||||
background: #0c0b09;
|
||||
padding: 10px;
|
||||
box-shadow: 0 26px 70px rgba(0, 0, 0, 0.24);
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
.direction:nth-child(2) .phone {
|
||||
transform: rotate(1.5deg);
|
||||
}
|
||||
.direction:nth-child(3) .phone {
|
||||
transform: rotate(-1deg);
|
||||
}
|
||||
.screen {
|
||||
height: 100%;
|
||||
border-radius: 32px;
|
||||
padding: 18px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dark .screen {
|
||||
background: linear-gradient(180deg, #15100b, #080705);
|
||||
color: #f8ead7;
|
||||
}
|
||||
.athlete .screen {
|
||||
background: #fcfffc;
|
||||
color: #12231b;
|
||||
}
|
||||
.editorial .screen {
|
||||
background: #fff7e8;
|
||||
color: #15120d;
|
||||
}
|
||||
.status {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-weight: 900;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.bpm {
|
||||
font-size: 78px;
|
||||
line-height: 0.85;
|
||||
letter-spacing: -0.1em;
|
||||
font-weight: 1000;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.unit {
|
||||
font-size: 18px;
|
||||
letter-spacing: 0;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.wave {
|
||||
height: 140px;
|
||||
margin-top: 18px;
|
||||
border-radius: 22px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dark .wave {
|
||||
background: #21170f;
|
||||
border: 1px solid #3b2b1e;
|
||||
}
|
||||
.athlete .wave {
|
||||
background: #edf7f0;
|
||||
border: 1px solid #d7e4dc;
|
||||
}
|
||||
.editorial .wave {
|
||||
background: #f2e3cb;
|
||||
border: 1px solid #decdb4;
|
||||
}
|
||||
.wave svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.wave path {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 4;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.chips {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.chip {
|
||||
border-radius: 16px;
|
||||
padding: 10px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 950;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
text-align: center;
|
||||
}
|
||||
.dark .chip {
|
||||
background: #24190f;
|
||||
color: #ffbd49;
|
||||
}
|
||||
.athlete .chip {
|
||||
background: #e7f5ed;
|
||||
color: #08734c;
|
||||
}
|
||||
.editorial .chip {
|
||||
background: #eadbc2;
|
||||
color: #15120d;
|
||||
}
|
||||
.cta {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
bottom: 22px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.cta a {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
border-radius: 999px;
|
||||
padding: 13px 12px;
|
||||
font-weight: 950;
|
||||
color: inherit;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
.cta a.primary {
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
border-color: var(--ink);
|
||||
}
|
||||
.dark .cta a.primary {
|
||||
background: #e23b2e;
|
||||
border-color: #e23b2e;
|
||||
color: white;
|
||||
}
|
||||
.athlete .cta a.primary {
|
||||
background: #13855d;
|
||||
border-color: #13855d;
|
||||
color: white;
|
||||
}
|
||||
.matrix {
|
||||
margin-top: 32px;
|
||||
display: grid;
|
||||
grid-template-columns: 1.25fr repeat(3, 1fr);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
background: #fffaf1;
|
||||
}
|
||||
.matrix div {
|
||||
padding: 16px;
|
||||
border-right: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 14px;
|
||||
}
|
||||
.matrix div:nth-child(4n) {
|
||||
border-right: 0;
|
||||
}
|
||||
.matrix .head {
|
||||
font-weight: 950;
|
||||
background: #fff;
|
||||
}
|
||||
.recommend {
|
||||
margin-top: 26px;
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
gap: 18px;
|
||||
}
|
||||
.note {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 28px;
|
||||
background: #fffaf1;
|
||||
padding: 22px;
|
||||
box-shadow: 0 18px 50px rgba(18, 16, 12, 0.06);
|
||||
}
|
||||
.note b {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.note ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
@media (max-width: 1040px) {
|
||||
.grid,
|
||||
.recommend {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.direction {
|
||||
min-height: 650px;
|
||||
}
|
||||
.matrix {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.matrix div {
|
||||
border-right: 0;
|
||||
}
|
||||
.phone {
|
||||
width: 270px;
|
||||
height: 540px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.wrap {
|
||||
padding-top: 20px;
|
||||
}
|
||||
.hero {
|
||||
padding: 22px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
}
|
||||
.direction h2 {
|
||||
font-size: 38px;
|
||||
}
|
||||
.phone {
|
||||
width: 250px;
|
||||
height: 520px;
|
||||
}
|
||||
.cta {
|
||||
position: static;
|
||||
margin: 20px 24px 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<section class="hero">
|
||||
<div class="eyebrow">FightRate / React Native design directions v2</div>
|
||||
<h1>Três caminhos fortes. Não cinco ideias fracas.</h1>
|
||||
<p>
|
||||
Esta versão substitui os exemplos anteriores. Cada direção agora é
|
||||
pensada como base real de marca e design system para um app React
|
||||
Native: captura contínua, histórico, sensor BLE e relatório
|
||||
pós-treino.
|
||||
</p>
|
||||
</section>
|
||||
<section class="grid">
|
||||
<article class="direction dark">
|
||||
<div class="top">
|
||||
<div class="num">01 / recomendado para treino</div>
|
||||
<h2>Corner Instrument</h2>
|
||||
<p>
|
||||
Equipamento de córner: escuro, intenso, legível, com cara de
|
||||
ferramenta física. Melhor para a tela de captura ao vivo.
|
||||
</p>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="status">
|
||||
<span>captura ativa</span><span>42:18</span>
|
||||
</div>
|
||||
<div class="bpm">154<span class="unit">bpm</span></div>
|
||||
<div class="wave">
|
||||
<svg viewBox="0 0 280 140">
|
||||
<path
|
||||
d="M8 98 C36 82,44 48,78 58 S120 102,150 70 S190 26,222 52 S250 88,272 48"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">Méd 146</span
|
||||
><span class="chip">Pico 183</span
|
||||
><span class="chip">Z4 41%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cta">
|
||||
<a class="primary" href="01-corner-instrument-v2.html"
|
||||
>Abrir direção</a
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
<article class="direction athlete">
|
||||
<div class="top">
|
||||
<div class="num">02 / recomendado para app base</div>
|
||||
<h2>Athlete OS</h2>
|
||||
<p>
|
||||
Sistema operacional do atleta: científico sem ser hospitalar,
|
||||
excelente para histórico, relatórios e confiança nos dados.
|
||||
</p>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="status"><span>relatório</span><span>salvo</span></div>
|
||||
<div class="bpm">96<span class="unit">trimp</span></div>
|
||||
<div class="wave">
|
||||
<svg viewBox="0 0 280 140">
|
||||
<path
|
||||
d="M8 100 C52 92,62 70,92 75 S134 106,162 72 S204 42,236 56 S256 76,272 54"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">28m carga</span
|
||||
><span class="chip">18h rec</span><span class="chip">Alta</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cta">
|
||||
<a class="primary" href="02-athlete-os-v2.html">Abrir direção</a>
|
||||
</div>
|
||||
</article>
|
||||
<article class="direction editorial">
|
||||
<div class="top">
|
||||
<div class="num">03 / recomendado para marca</div>
|
||||
<h2>Combat Editorial</h2>
|
||||
<p>
|
||||
Marca premium e editorial: minimalista, madura e memorável. Melhor
|
||||
para identidade pública e experiência pós-treino.
|
||||
</p>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="status">
|
||||
<span>semana</span><span>4 treinos</span>
|
||||
</div>
|
||||
<div class="bpm">+8<span class="unit">%</span></div>
|
||||
<div class="wave">
|
||||
<svg viewBox="0 0 280 140">
|
||||
<path
|
||||
d="M8 96 L42 80 L74 92 L116 58 L150 70 L190 42 L230 62 L272 50"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">Carga</span><span class="chip">Evolução</span
|
||||
><span class="chip">Risco baixo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cta">
|
||||
<a class="primary" href="03-combat-editorial-v2.html"
|
||||
>Abrir direção</a
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<section class="matrix">
|
||||
<div class="head">Critério</div>
|
||||
<div class="head">Corner Instrument</div>
|
||||
<div class="head">Athlete OS</div>
|
||||
<div class="head">Combat Editorial</div>
|
||||
<div>Melhor uso</div>
|
||||
<div>Captura ao vivo</div>
|
||||
<div>App inteiro / relatório</div>
|
||||
<div>Marca / histórico premium</div>
|
||||
<div>Energia</div>
|
||||
<div>Alta</div>
|
||||
<div>Média controlada</div>
|
||||
<div>Baixa e sofisticada</div>
|
||||
<div>Dados densos</div>
|
||||
<div>Bom</div>
|
||||
<div>Excelente</div>
|
||||
<div>Bom</div>
|
||||
<div>React Native</div>
|
||||
<div>Viável</div>
|
||||
<div>Muito viável</div>
|
||||
<div>Muito viável</div>
|
||||
<div>Risco</div>
|
||||
<div>Ficar escuro demais</div>
|
||||
<div>Ficar clínico demais</div>
|
||||
<div>Ficar frio demais</div>
|
||||
</section>
|
||||
<section class="recommend">
|
||||
<div class="note">
|
||||
<b>Minha recomendação agora</b>
|
||||
<ul>
|
||||
<li>Base do app: <b>Athlete OS</b>.</li>
|
||||
<li>Tela de captura: <b>Corner Instrument</b>.</li>
|
||||
<li>
|
||||
Identidade pública/relatórios compartilháveis: elementos de
|
||||
<b>Combat Editorial</b>.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="note">
|
||||
<b>Próxima decisão</b>
|
||||
<p>
|
||||
Escolher se o app deve parecer mais “equipamento de treino” ou
|
||||
“sistema científico do atleta”. Depois disso eu crio o DESIGN.md
|
||||
final e só então começa o Expo/React Native.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,831 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FightRate v2 — Corner Instrument</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #070605;
|
||||
--ink: #f8ead8;
|
||||
--muted: #b9a98e;
|
||||
--dim: #746754;
|
||||
--panel: #12100c;
|
||||
--panel2: #1d170f;
|
||||
--line: #392d1e;
|
||||
--red: #e23b2e;
|
||||
--amber: #ffb43d;
|
||||
--green: #34c77b;
|
||||
--cream: #fff0dc;
|
||||
--r1: 10px;
|
||||
--r2: 16px;
|
||||
--r3: 24px;
|
||||
--r4: 36px;
|
||||
--r5: 48px;
|
||||
--shadow: 0 34px 110px rgba(0, 0, 0, 0.54);
|
||||
--bezier: cubic-bezier(0.18, 0.9, 0.18, 1);
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
Segoe UI,
|
||||
sans-serif;
|
||||
line-height: 1.42;
|
||||
}
|
||||
body:before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 12% -8%,
|
||||
rgba(226, 59, 46, 0.36),
|
||||
transparent 34%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 86% 12%,
|
||||
rgba(255, 180, 61, 0.14),
|
||||
transparent 30%
|
||||
),
|
||||
linear-gradient(
|
||||
115deg,
|
||||
rgba(255, 255, 255, 0.045) 0 1px,
|
||||
transparent 1px 20px
|
||||
);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.wrap {
|
||||
width: min(1260px, calc(100% - 28px));
|
||||
margin: auto;
|
||||
padding: 30px 0 90px;
|
||||
position: relative;
|
||||
}
|
||||
.nav {
|
||||
position: sticky;
|
||||
top: 14px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: rgba(18, 16, 12, 0.82);
|
||||
backdrop-filter: blur(16px);
|
||||
padding: 10px 12px 10px 18px;
|
||||
}
|
||||
.brand {
|
||||
font-weight: 1000;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
.brand span {
|
||||
color: var(--red);
|
||||
}
|
||||
.nav a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.17em;
|
||||
margin-left: 16px;
|
||||
}
|
||||
.hero {
|
||||
margin-top: 28px;
|
||||
display: grid;
|
||||
grid-template-columns: 1.05fr 0.95fr;
|
||||
gap: 18px;
|
||||
}
|
||||
.slab {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r5);
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(29, 23, 15, 0.96),
|
||||
rgba(10, 8, 6, 0.98)
|
||||
);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.slab:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 1px;
|
||||
border-radius: calc(var(--r5) - 1px);
|
||||
box-shadow: inset 0 1px rgba(255, 255, 255, 0.08);
|
||||
pointer-events: none;
|
||||
}
|
||||
.pad {
|
||||
padding: 34px;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber);
|
||||
font-weight: 950;
|
||||
}
|
||||
.title {
|
||||
font-size: clamp(58px, 10vw, 132px);
|
||||
line-height: 0.76;
|
||||
letter-spacing: -0.095em;
|
||||
margin: 18px 0 20px;
|
||||
font-weight: 1000;
|
||||
}
|
||||
.lead {
|
||||
font-size: 18px;
|
||||
color: var(--muted);
|
||||
max-width: 780px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 26px;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--red);
|
||||
color: white;
|
||||
padding: 14px 18px;
|
||||
font-weight: 1000;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
box-shadow: 0 18px 42px rgba(226, 59, 46, 0.32);
|
||||
transition: transform 0.22s var(--bezier);
|
||||
}
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.btn.secondary {
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn .dot {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.color-strip {
|
||||
display: grid;
|
||||
grid-template-columns: 6fr 3fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.color {
|
||||
min-height: 122px;
|
||||
border-radius: 22px;
|
||||
padding: 16px;
|
||||
font-weight: 1000;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
}
|
||||
.section {
|
||||
margin-top: 28px;
|
||||
}
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.section h2 {
|
||||
font-size: 48px;
|
||||
line-height: 0.88;
|
||||
letter-spacing: -0.065em;
|
||||
margin: 0;
|
||||
}
|
||||
.section p {
|
||||
color: var(--muted);
|
||||
}
|
||||
.phone-wall {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
.device {
|
||||
height: 640px;
|
||||
border-radius: 48px;
|
||||
background: #030302;
|
||||
padding: 10px;
|
||||
border: 1px solid #3b2d1c;
|
||||
box-shadow: 0 26px 80px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.screen {
|
||||
height: 100%;
|
||||
border-radius: 38px;
|
||||
background: linear-gradient(180deg, #19130c, #0b0805);
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.topline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--dim);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-weight: 950;
|
||||
}
|
||||
.bpm {
|
||||
font-size: 82px;
|
||||
line-height: 0.82;
|
||||
letter-spacing: -0.1em;
|
||||
font-weight: 1000;
|
||||
}
|
||||
.unit {
|
||||
font-size: 18px;
|
||||
letter-spacing: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.chart {
|
||||
height: 136px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, #251a10, #100c08);
|
||||
border: 1px solid var(--line);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chart:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(transparent 24px, rgba(255, 255, 255, 0.045) 25px),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent 24px,
|
||||
rgba(255, 255, 255, 0.045) 25px
|
||||
);
|
||||
background-size: 25px 25px;
|
||||
}
|
||||
.chart svg {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.chart path {
|
||||
fill: none;
|
||||
stroke: var(--red);
|
||||
stroke-width: 4.5;
|
||||
stroke-linecap: round;
|
||||
filter: drop-shadow(0 0 12px rgba(226, 59, 46, 0.55));
|
||||
}
|
||||
.data-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.tile {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: #16110b;
|
||||
padding: 12px;
|
||||
}
|
||||
.tile b {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
.tile span {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 950;
|
||||
}
|
||||
.dock {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.dock .btn {
|
||||
justify-content: center;
|
||||
padding: 14px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.stop {
|
||||
background: var(--cream);
|
||||
color: #120b07;
|
||||
box-shadow: none;
|
||||
}
|
||||
.tag-row {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tag {
|
||||
border-radius: 999px;
|
||||
background: #251a10;
|
||||
color: var(--amber);
|
||||
padding: 7px 9px;
|
||||
font-size: 10px;
|
||||
font-weight: 950;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 10px 0;
|
||||
}
|
||||
.badge {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--red);
|
||||
font-weight: 1000;
|
||||
}
|
||||
.field {
|
||||
border: 1px solid var(--line);
|
||||
background: #0d0906;
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.system-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 0.9fr 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
.spec {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 30px;
|
||||
background: rgba(18, 16, 12, 0.8);
|
||||
padding: 22px;
|
||||
}
|
||||
.spec h3 {
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.045em;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.scale {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.scale div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 8px 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.scale b {
|
||||
color: var(--ink);
|
||||
}
|
||||
.radii {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.radii i {
|
||||
display: block;
|
||||
background: #24190f;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.ra {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.rb {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
.rc {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 40px;
|
||||
}
|
||||
.component-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.component {
|
||||
min-height: 160px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
background: #120f0b;
|
||||
padding: 14px;
|
||||
}
|
||||
.motion {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.motion-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 26px;
|
||||
background: #120f0b;
|
||||
padding: 18px;
|
||||
}
|
||||
.motion-card b {
|
||||
color: var(--amber);
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.phone-wall {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.component-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.hero,
|
||||
.system-grid,
|
||||
.motion {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.section-head {
|
||||
display: block;
|
||||
}
|
||||
.device {
|
||||
height: 680px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.nav {
|
||||
position: relative;
|
||||
top: 0;
|
||||
}
|
||||
.nav div:last-child {
|
||||
display: none;
|
||||
}
|
||||
.pad {
|
||||
padding: 22px;
|
||||
}
|
||||
.title {
|
||||
font-size: 64px;
|
||||
}
|
||||
.phone-wall,
|
||||
.component-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.device {
|
||||
height: 700px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<nav class="nav">
|
||||
<div class="brand">Fight<span>Rate</span> / Corner Instrument</div>
|
||||
<div>
|
||||
<a href="00-v2-index.html">v2 index</a><a href="#screens">telas</a
|
||||
><a href="#system">sistema</a><a href="#components">componentes</a>
|
||||
</div>
|
||||
</nav>
|
||||
<section class="hero">
|
||||
<div class="slab pad">
|
||||
<div class="eyebrow">Direção 01 / equipamento de córner</div>
|
||||
<div class="title">treino ligado. celular esquecido.</div>
|
||||
<p class="lead">
|
||||
O app deve parecer uma peça de equipamento no canto do tatame:
|
||||
forte, confiável e legível de longe. Poucos elementos. Dados
|
||||
grandes. Ação primária óbvia.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="btn">
|
||||
Iniciar treino <span class="dot">↗</span></button
|
||||
><button class="btn secondary">Ver relatório</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slab pad">
|
||||
<div class="eyebrow">Tokens de marca</div>
|
||||
<div class="color-strip">
|
||||
<div class="color" style="background: #070605">60% preto lona</div>
|
||||
<div class="color" style="background: #1d170f">
|
||||
30% couro queimado
|
||||
</div>
|
||||
<div class="color" style="background: #e23b2e">10% golpe</div>
|
||||
</div>
|
||||
<p class="lead">
|
||||
Âmbar sinaliza leitura auxiliar; vermelho é ação, pico ou risco.
|
||||
Nada de azul SaaS, nada de verde fitness genérico.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section id="screens" class="section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>Telas principais</h2>
|
||||
<p>O mesmo sistema aplicado a seis momentos reais do produto.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone-wall">
|
||||
<div class="device">
|
||||
<div class="screen">
|
||||
<div class="topline">
|
||||
<span>pré-treino</span><span>h003 ok</span>
|
||||
</div>
|
||||
<div class="bpm">--<span class="unit">bpm</span></div>
|
||||
<p class="lead">
|
||||
Coloque a cinta, toque uma vez e largue o celular.
|
||||
</p>
|
||||
<div class="tag-row">
|
||||
<span class="tag">sensor pronto</span
|
||||
><span class="tag">bateria 84%</span>
|
||||
</div>
|
||||
<div class="data-grid">
|
||||
<div class="tile"><b>-58</b><span>rssi</span></div>
|
||||
<div class="tile"><b>0</b><span>drops</span></div>
|
||||
<div class="tile"><b>0</b><span>amostras</span></div>
|
||||
</div>
|
||||
<div class="dock">
|
||||
<button class="btn">Iniciar</button
|
||||
><button class="btn secondary">Check-in</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device">
|
||||
<div class="screen">
|
||||
<div class="topline">
|
||||
<span>captura ativa</span><span>42:18</span>
|
||||
</div>
|
||||
<div class="bpm">154<span class="unit">bpm</span></div>
|
||||
<div class="chart">
|
||||
<svg viewBox="0 0 280 140">
|
||||
<path
|
||||
d="M8 104 C36 86,44 46,78 60 S120 106,150 72 S190 28,222 54 S250 92,272 48"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="data-grid">
|
||||
<div class="tile"><b>146</b><span>média</span></div>
|
||||
<div class="tile"><b>183</b><span>pico</span></div>
|
||||
<div class="tile"><b>41%</b><span>Z4/Z5</span></div>
|
||||
</div>
|
||||
<div class="dock">
|
||||
<button class="btn secondary">Evento</button
|
||||
><button class="btn stop">Parar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device">
|
||||
<div class="screen">
|
||||
<div class="topline">
|
||||
<span>pós-treino</span><span>salvo</span>
|
||||
</div>
|
||||
<div class="bpm">47<span class="unit">min</span></div>
|
||||
<div class="tag-row">
|
||||
<span class="tag">carga alta</span
|
||||
><span class="tag">rec 18h</span>
|
||||
</div>
|
||||
<div class="list">
|
||||
<div class="item">
|
||||
<span class="badge">1</span>
|
||||
<div>
|
||||
Entrada em ritmo<br /><span style="color: var(--muted)"
|
||||
>0-10 min</span
|
||||
>
|
||||
</div>
|
||||
<b>138</b>
|
||||
</div>
|
||||
<div class="item">
|
||||
<span class="badge">2</span>
|
||||
<div>
|
||||
Bloco mais intenso<br /><span style="color: var(--muted)"
|
||||
>10-31 min</span
|
||||
>
|
||||
</div>
|
||||
<b>181</b>
|
||||
</div>
|
||||
<div class="item">
|
||||
<span class="badge">3</span>
|
||||
<div>
|
||||
Queda controlada<br /><span style="color: var(--muted)"
|
||||
>31-47 min</span
|
||||
>
|
||||
</div>
|
||||
<b>152</b>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dock">
|
||||
<button class="btn">Relatório</button
|
||||
><button class="btn secondary">Exportar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device">
|
||||
<div class="screen">
|
||||
<div class="topline">
|
||||
<span>histórico</span><span>junho</span>
|
||||
</div>
|
||||
<div class="data-grid">
|
||||
<div class="tile"><b>8</b><span>treinos</span></div>
|
||||
<div class="tile"><b>5h</b><span>tempo</span></div>
|
||||
<div class="tile"><b>+8%</b><span>carga</span></div>
|
||||
</div>
|
||||
<div class="list">
|
||||
<div class="item">
|
||||
<span class="badge">S</span>
|
||||
<div>
|
||||
Sparring<br /><span style="color: var(--muted)"
|
||||
>Hoje · 47 min · pico 183</span
|
||||
>
|
||||
</div>
|
||||
<b>96</b>
|
||||
</div>
|
||||
<div class="item">
|
||||
<span class="badge">M</span>
|
||||
<div>
|
||||
Muay Thai<br /><span style="color: var(--muted)"
|
||||
>Seg · 61 min · alta carga</span
|
||||
>
|
||||
</div>
|
||||
<b>110</b>
|
||||
</div>
|
||||
<div class="item">
|
||||
<span class="badge">B</span>
|
||||
<div>
|
||||
Boxe<br /><span style="color: var(--muted)"
|
||||
>Sex · 42 min</span
|
||||
>
|
||||
</div>
|
||||
<b>77</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device">
|
||||
<div class="screen">
|
||||
<div class="topline">
|
||||
<span>sensor</span><span>ble profile</span>
|
||||
</div>
|
||||
<div class="bpm">-61<span class="unit">rssi</span></div>
|
||||
<div class="field">Heart Rate Service · 180D</div>
|
||||
<div class="field">Measurement · 2A37 · notify</div>
|
||||
<div class="field">Battery · 2A19 · read</div>
|
||||
<div class="field">Body Sensor · 2A38 · read</div>
|
||||
<div class="tag-row">
|
||||
<span class="tag">perfil estável</span
|
||||
><span class="tag">0 quedas</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device">
|
||||
<div class="screen">
|
||||
<div class="topline">
|
||||
<span>insight</span><span>explicável</span>
|
||||
</div>
|
||||
<div class="bpm">18h<span class="unit">rec</span></div>
|
||||
<p class="lead">
|
||||
Carga cardiovascular alta. Amanhã: técnica leve ou descanso
|
||||
ativo.
|
||||
</p>
|
||||
<div class="tile">
|
||||
<b>Por que importa?</b
|
||||
><span>Você ficou acima da sua média recente em Z4/Z5.</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<b>Confiança</b
|
||||
><span>2.844 amostras válidas. Sem quedas de sinal.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="system" class="section">
|
||||
<h2>Design book visual</h2>
|
||||
<div class="system-grid">
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Cor</div>
|
||||
<h3>Escuro com propósito</h3>
|
||||
<p class="lead">
|
||||
A tela ativa usa fundo escuro para reduzir distração no treino. O
|
||||
vermelho não é decoração; é ação, intensidade e risco.
|
||||
</p>
|
||||
<div class="color-strip">
|
||||
<div class="color" style="background: #070605">base</div>
|
||||
<div class="color" style="background: #1d170f">surface</div>
|
||||
<div class="color" style="background: #e23b2e">accent</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Tipografia</div>
|
||||
<h3>Número primeiro</h3>
|
||||
<div class="bpm" style="font-size: 72px">154</div>
|
||||
<p class="lead">
|
||||
Display condensado/forte para BPM. Labels pequenos, uppercase e
|
||||
espaçados. Texto longo só no pós-treino.
|
||||
</p>
|
||||
</div>
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Forma</div>
|
||||
<h3>Material físico</h3>
|
||||
<div class="radii">
|
||||
<i class="ra"></i><i class="rb"></i><i class="rc"></i>
|
||||
</div>
|
||||
<p class="lead">
|
||||
Painéis grandes com raio generoso; campos técnicos com raio médio;
|
||||
badges redondos para estados.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="components" class="section">
|
||||
<h2>Componentes</h2>
|
||||
<div class="component-grid">
|
||||
<div class="component">
|
||||
<div class="eyebrow">Primary button</div>
|
||||
<button class="btn">Iniciar <span class="dot">↗</span></button
|
||||
><button class="btn secondary" style="margin-top: 10px">
|
||||
Secundário
|
||||
</button>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Stop button</div>
|
||||
<button class="btn stop">Parar treino</button>
|
||||
<p class="lead" style="font-size: 13px">
|
||||
Contraste claro para ação crítica.
|
||||
</p>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Metric tile</div>
|
||||
<div class="tile"><b>183</b><span>pico bpm</span></div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Sensor row</div>
|
||||
<div class="field">2A37 · notify</div>
|
||||
<div class="field">2A19 · read</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Insight</div>
|
||||
<div class="tile">
|
||||
<b>Carga alta</b><span>reduza intensidade amanhã</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Tag states</div>
|
||||
<div class="tag-row">
|
||||
<span class="tag">ok</span><span class="tag">z4</span
|
||||
><span class="tag">rec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Motion e uso no React Native</h2>
|
||||
<div class="motion">
|
||||
<div class="motion-card">
|
||||
<b>Captura</b>
|
||||
<p class="lead">
|
||||
Animação mínima: pulso discreto no status ativo e transições de
|
||||
número. Nada de blur pesado.
|
||||
</p>
|
||||
</div>
|
||||
<div class="motion-card">
|
||||
<b>Histórico</b>
|
||||
<p class="lead">
|
||||
Entrada em lista com fade/translate curto. A densidade vem da
|
||||
hierarquia, não de cards infinitos.
|
||||
</p>
|
||||
</div>
|
||||
<div class="motion-card">
|
||||
<b>Sensor</b>
|
||||
<p class="lead">
|
||||
Logs e características com expansão progressiva. Dados técnicos
|
||||
precisam de ordem e monospace pontual.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,726 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FightRate v2 — Athlete OS</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4f7f2;
|
||||
--paper: #fefffc;
|
||||
--ink: #102019;
|
||||
--muted: #65766d;
|
||||
--line: #d6e1d9;
|
||||
--surface: #eaf3ed;
|
||||
--green: #12875d;
|
||||
--lime: #c9ef55;
|
||||
--blue: #1769ff;
|
||||
--red: #d84c3e;
|
||||
--amber: #d98a15;
|
||||
--r1: 10px;
|
||||
--r2: 16px;
|
||||
--r3: 24px;
|
||||
--r4: 34px;
|
||||
--shadow: 0 30px 90px rgba(16, 32, 25, 0.1);
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
Segoe UI,
|
||||
sans-serif;
|
||||
line-height: 1.45;
|
||||
}
|
||||
body:before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(rgba(16, 32, 25, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(16, 32, 25, 0.04) 1px, transparent 1px),
|
||||
radial-gradient(
|
||||
circle at 82% 0%,
|
||||
rgba(201, 239, 85, 0.34),
|
||||
transparent 30%
|
||||
);
|
||||
background-size:
|
||||
32px 32px,
|
||||
32px 32px,
|
||||
auto;
|
||||
}
|
||||
.wrap {
|
||||
width: min(1260px, calc(100% - 28px));
|
||||
margin: auto;
|
||||
padding: 30px 0 90px;
|
||||
position: relative;
|
||||
}
|
||||
.nav {
|
||||
position: sticky;
|
||||
top: 14px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: rgba(254, 255, 252, 0.78);
|
||||
backdrop-filter: blur(16px);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.brand {
|
||||
font-weight: 950;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
.nav a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.17em;
|
||||
margin-left: 16px;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 0.9fr 1.1fr;
|
||||
gap: 18px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 38px;
|
||||
background: rgba(254, 255, 252, 0.86);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 34px;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.23em;
|
||||
text-transform: uppercase;
|
||||
color: var(--green);
|
||||
font-weight: 950;
|
||||
}
|
||||
.title {
|
||||
font-size: clamp(56px, 9vw, 118px);
|
||||
line-height: 0.8;
|
||||
letter-spacing: -0.09em;
|
||||
font-weight: 1000;
|
||||
margin: 18px 0 20px;
|
||||
}
|
||||
.lead {
|
||||
font-size: 18px;
|
||||
color: var(--muted);
|
||||
max-width: 800px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 26px;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
background: var(--green);
|
||||
color: white;
|
||||
padding: 14px 18px;
|
||||
font-weight: 950;
|
||||
box-shadow: 0 16px 38px rgba(18, 135, 93, 0.22);
|
||||
}
|
||||
.btn.secondary {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: none;
|
||||
}
|
||||
.token-strip {
|
||||
display: grid;
|
||||
grid-template-columns: 6fr 3fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.token {
|
||||
min-height: 116px;
|
||||
border-radius: 20px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
font-weight: 950;
|
||||
}
|
||||
.section {
|
||||
margin-top: 30px;
|
||||
}
|
||||
.section h2 {
|
||||
font-size: 48px;
|
||||
line-height: 0.9;
|
||||
letter-spacing: -0.065em;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.phone-wall {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
.device {
|
||||
height: 640px;
|
||||
border-radius: 46px;
|
||||
background: #111;
|
||||
padding: 10px;
|
||||
box-shadow: 0 24px 70px rgba(16, 32, 25, 0.18);
|
||||
}
|
||||
.screen {
|
||||
height: 100%;
|
||||
border-radius: 36px;
|
||||
background: var(--paper);
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.topline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
font-weight: 950;
|
||||
}
|
||||
.metric-main {
|
||||
font-size: 80px;
|
||||
line-height: 0.84;
|
||||
letter-spacing: -0.095em;
|
||||
font-weight: 1000;
|
||||
}
|
||||
.unit {
|
||||
font-size: 18px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.chart {
|
||||
height: 140px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, #f8fff9, #edf7f0);
|
||||
border: 1px solid var(--line);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.chart:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(transparent 27px, rgba(16, 32, 25, 0.045) 28px),
|
||||
linear-gradient(90deg, transparent 27px, rgba(16, 32, 25, 0.045) 28px);
|
||||
background-size: 28px 28px;
|
||||
}
|
||||
.chart svg {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.chart path {
|
||||
fill: none;
|
||||
stroke: var(--green);
|
||||
stroke-width: 4;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.tile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.tile {
|
||||
background: #f7fbf8;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 12px;
|
||||
}
|
||||
.tile b {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
.tile span {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
font-weight: 950;
|
||||
}
|
||||
.zones {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.2fr 1.7fr 1.2fr 0.7fr;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.zones i:nth-child(1) {
|
||||
background: #92d7ff;
|
||||
}
|
||||
.zones i:nth-child(2) {
|
||||
background: #aee8c5;
|
||||
}
|
||||
.zones i:nth-child(3) {
|
||||
background: #c9ef55;
|
||||
}
|
||||
.zones i:nth-child(4) {
|
||||
background: #e7a83f;
|
||||
}
|
||||
.zones i:nth-child(5) {
|
||||
background: #d84c3e;
|
||||
}
|
||||
.dock {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.dock .btn {
|
||||
padding: 14px 8px;
|
||||
}
|
||||
.table {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.row span {
|
||||
color: var(--muted);
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
width: max-content;
|
||||
border-radius: 999px;
|
||||
background: #e6f5ec;
|
||||
color: #08734c;
|
||||
padding: 7px 10px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 950;
|
||||
}
|
||||
.sensor {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.system-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
.spec {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 28px;
|
||||
padding: 22px;
|
||||
}
|
||||
.spec h3 {
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.045em;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.type-demo {
|
||||
font-size: 72px;
|
||||
line-height: 0.86;
|
||||
letter-spacing: -0.09em;
|
||||
font-weight: 1000;
|
||||
}
|
||||
.radii {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
.radii i {
|
||||
display: block;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.ra {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.rb {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.rc {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 30px;
|
||||
}
|
||||
.component-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.component {
|
||||
min-height: 160px;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
padding: 14px;
|
||||
}
|
||||
.field {
|
||||
background: #f7fbf8;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.principles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.principle {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-left: 6px solid var(--green);
|
||||
border-radius: 0 24px 24px 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.principle b {
|
||||
font-size: 22px;
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.phone-wall {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.component-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.hero,
|
||||
.system-grid,
|
||||
.principles {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.device {
|
||||
height: 680px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.nav {
|
||||
position: relative;
|
||||
top: 0;
|
||||
}
|
||||
.nav div:last-child {
|
||||
display: none;
|
||||
}
|
||||
.panel {
|
||||
padding: 22px;
|
||||
}
|
||||
.title {
|
||||
font-size: 64px;
|
||||
}
|
||||
.phone-wall,
|
||||
.component-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.device {
|
||||
height: 700px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<nav class="nav">
|
||||
<div class="brand">FightRate / Athlete OS</div>
|
||||
<div>
|
||||
<a href="00-v2-index.html">v2 index</a><a href="#screens">telas</a
|
||||
><a href="#system">sistema</a><a href="#components">componentes</a>
|
||||
</div>
|
||||
</nav>
|
||||
<section class="hero">
|
||||
<div class="panel">
|
||||
<div class="eyebrow">Direção 02 / sistema do atleta</div>
|
||||
<div class="title">ciência de treino sem cara de hospital.</div>
|
||||
<p class="lead">
|
||||
A direção mais forte para o app inteiro. O atleta confia porque tudo
|
||||
tem origem, contexto e explicação. É limpo, mas não vazio.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="btn">Capturar treino</button
|
||||
><button class="btn secondary">Abrir histórico</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="eyebrow">Tokens de marca</div>
|
||||
<div class="token-strip">
|
||||
<div class="token" style="background: #fefffc">
|
||||
60% paper clínico quente
|
||||
</div>
|
||||
<div class="token" style="background: #eaf3ed">
|
||||
30% superfície lab
|
||||
</div>
|
||||
<div class="token" style="background: #12875d; color: white">
|
||||
10% ação
|
||||
</div>
|
||||
</div>
|
||||
<p class="lead">
|
||||
Verde é estabilidade e ação primária. Azul só para técnica. Vermelho
|
||||
só para alerta real.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section id="screens" class="section">
|
||||
<h2>Telas principais</h2>
|
||||
<div class="phone-wall">
|
||||
<div class="device">
|
||||
<div class="screen">
|
||||
<div class="topline">
|
||||
<span>pronto</span><span>sensor ok</span>
|
||||
</div>
|
||||
<div class="metric-main">--<span class="unit">bpm</span></div>
|
||||
<p class="lead">
|
||||
A janela inteira do treino será registrada. Uma ação, nenhum
|
||||
controle durante a luta.
|
||||
</p>
|
||||
<div class="tile-grid">
|
||||
<div class="tile"><b>84%</b><span>bateria</span></div>
|
||||
<div class="tile"><b>-58</b><span>rssi</span></div>
|
||||
<div class="tile"><b>0</b><span>amostras</span></div>
|
||||
</div>
|
||||
<div class="dock">
|
||||
<button class="btn">Iniciar</button
|
||||
><button class="btn secondary">Check-in</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device">
|
||||
<div class="screen">
|
||||
<div class="topline">
|
||||
<span>captura ativa</span><span>42:18</span>
|
||||
</div>
|
||||
<div class="metric-main">154<span class="unit">bpm</span></div>
|
||||
<div class="chart">
|
||||
<svg viewBox="0 0 280 140">
|
||||
<path
|
||||
d="M8 100 C42 94,54 62,82 66 S122 98,154 74 S204 38,232 54 S254 78,274 56"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="zones"><i></i><i></i><i></i><i></i><i></i></div>
|
||||
<div class="tile-grid">
|
||||
<div class="tile"><b>146</b><span>média</span></div>
|
||||
<div class="tile"><b>183</b><span>pico</span></div>
|
||||
<div class="tile"><b>41%</b><span>Z4/Z5</span></div>
|
||||
</div>
|
||||
<div class="dock">
|
||||
<button class="btn secondary">Evento</button
|
||||
><button class="btn" style="background: var(--red)">
|
||||
Parar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device">
|
||||
<div class="screen">
|
||||
<div class="topline">
|
||||
<span>relatório</span><span>alta confiança</span>
|
||||
</div>
|
||||
<div class="metric-main">96<span class="unit">trimp</span></div>
|
||||
<div class="table">
|
||||
<div class="row"><span>Tempo em carga</span><b>28m</b></div>
|
||||
<div class="row">
|
||||
<span>Pico sustentado</span><b>181 bpm</b>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span>Recuperação sugerida</span><b>18h</b>
|
||||
</div>
|
||||
<div class="row"><span>Amostras válidas</span><b>2.844</b></div>
|
||||
</div>
|
||||
<span class="badge">carga alta controlada</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device">
|
||||
<div class="screen">
|
||||
<div class="topline">
|
||||
<span>histórico</span><span>4 semanas</span>
|
||||
</div>
|
||||
<div class="tile-grid">
|
||||
<div class="tile"><b>11</b><span>treinos</span></div>
|
||||
<div class="tile"><b>7h</b><span>tempo</span></div>
|
||||
<div class="tile"><b>+8%</b><span>carga</span></div>
|
||||
</div>
|
||||
<div class="table">
|
||||
<div class="row"><span>Sparring · hoje</span><b>96</b></div>
|
||||
<div class="row"><span>Muay Thai · seg</span><b>110</b></div>
|
||||
<div class="row"><span>Boxe · sex</span><b>77</b></div>
|
||||
<div class="row"><span>Drill · qua</span><b>54</b></div>
|
||||
</div>
|
||||
<div class="chart">
|
||||
<svg viewBox="0 0 280 140">
|
||||
<path
|
||||
d="M12 110 L46 94 L82 104 L118 62 L154 78 L194 44 L232 60 L270 48"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device">
|
||||
<div class="screen">
|
||||
<div class="topline">
|
||||
<span>sensor lab</span><span>gatt</span>
|
||||
</div>
|
||||
<div class="metric-main sensor">2A37</div>
|
||||
<div class="table sensor">
|
||||
<div class="row">
|
||||
<span>Heart Rate Service</span><b>180D</b>
|
||||
</div>
|
||||
<div class="row"><span>Measurement</span><b>notify</b></div>
|
||||
<div class="row"><span>Battery</span><b>read</b></div>
|
||||
<div class="row"><span>Body Sensor</span><b>read</b></div>
|
||||
<div class="row"><span>RSSI</span><b>-61</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device">
|
||||
<div class="screen">
|
||||
<div class="topline">
|
||||
<span>insights</span><span>explicável</span>
|
||||
</div>
|
||||
<div class="metric-main">18h<span class="unit">rec</span></div>
|
||||
<p class="lead">
|
||||
Você acumulou carga alta com sinal estável. Próximo treino:
|
||||
técnica ou recuperação ativa.
|
||||
</p>
|
||||
<div class="tile">
|
||||
<b>Por que importa?</b
|
||||
><span
|
||||
>Evita somar fadiga quando o corpo já respondeu com alta
|
||||
intensidade.</span
|
||||
>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<b>Base</b><span>Comparação com 4 semanas e 11 sessões.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="system" class="section">
|
||||
<h2>Design book visual</h2>
|
||||
<div class="system-grid">
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Cor</div>
|
||||
<h3>Confiança antes de impacto</h3>
|
||||
<p class="lead">
|
||||
O app base é claro. A captura pode escurecer se adotarmos o
|
||||
híbrido com Corner Instrument.
|
||||
</p>
|
||||
<div class="token-strip">
|
||||
<div class="token" style="background: #fefffc">paper</div>
|
||||
<div class="token" style="background: #eaf3ed">surface</div>
|
||||
<div class="token" style="background: #12875d; color: white">
|
||||
action
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Tipo</div>
|
||||
<h3>Hierarquia clínica</h3>
|
||||
<div class="type-demo">96</div>
|
||||
<p class="lead">
|
||||
Números grandes. Labels pequenos. Texto explicativo apenas onde
|
||||
ajuda decisão.
|
||||
</p>
|
||||
</div>
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Forma</div>
|
||||
<h3>Raios médios</h3>
|
||||
<div class="radii">
|
||||
<i class="ra"></i><i class="rb"></i><i class="rc"></i>
|
||||
</div>
|
||||
<p class="lead">
|
||||
Menos agressivo; mais confiável. Bordas sempre suaves e
|
||||
funcionais.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="components" class="section">
|
||||
<h2>Componentes</h2>
|
||||
<div class="component-grid">
|
||||
<div class="component">
|
||||
<div class="eyebrow">Button</div>
|
||||
<button class="btn">Primário</button
|
||||
><button class="btn secondary" style="margin-top: 10px">
|
||||
Secundário
|
||||
</button>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Metric</div>
|
||||
<div class="tile"><b>146</b><span>bpm médio</span></div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Zone bar</div>
|
||||
<div class="zones" style="margin-top: 12px">
|
||||
<i></i><i></i><i></i><i></i><i></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Form</div>
|
||||
<div class="field">Tipo de treino</div>
|
||||
<div class="field">RPE 0-10</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Sensor</div>
|
||||
<div class="row sensor"><span>2A37</span><b>notify</b></div>
|
||||
<div class="row sensor"><span>2A19</span><b>read</b></div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Insight</div>
|
||||
<div class="tile">
|
||||
<b>Carga alta</b><span>recomendação clara + base dos dados</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Princípios</h2>
|
||||
<div class="principles">
|
||||
<div class="principle">
|
||||
<b>Todo dado tem origem</b>
|
||||
<p class="lead">
|
||||
Amostras, sessão, comparação e confiança aparecem quando importam.
|
||||
</p>
|
||||
</div>
|
||||
<div class="principle">
|
||||
<b>Captura sem distração</b>
|
||||
<p class="lead">O usuário não opera round no meio da luta.</p>
|
||||
</div>
|
||||
<div class="principle">
|
||||
<b>Histórico denso</b>
|
||||
<p class="lead">
|
||||
Pós-treino aceita mais informação, desde que organizada.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,736 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FightRate v2 — Combat Editorial</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #efe5d5;
|
||||
--paper: #fff7e8;
|
||||
--ink: #15110c;
|
||||
--muted: #746a5d;
|
||||
--line: #d8c6aa;
|
||||
--black: #080705;
|
||||
--red: #c93a2e;
|
||||
--gold: #bd7a17;
|
||||
--olive: #66724e;
|
||||
--r1: 6px;
|
||||
--r2: 14px;
|
||||
--r3: 24px;
|
||||
--r4: 34px;
|
||||
--shadow: 0 32px 90px rgba(21, 17, 12, 0.12);
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
line-height: 1.42;
|
||||
}
|
||||
body:before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 8% 0%,
|
||||
rgba(201, 58, 46, 0.13),
|
||||
transparent 34%
|
||||
),
|
||||
linear-gradient(rgba(21, 17, 12, 0.04) 1px, transparent 1px);
|
||||
background-size:
|
||||
auto,
|
||||
100% 38px;
|
||||
}
|
||||
.wrap {
|
||||
width: min(1260px, calc(100% - 28px));
|
||||
margin: auto;
|
||||
padding: 30px 0 90px;
|
||||
position: relative;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 18px 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(239, 229, 213, 0.86);
|
||||
backdrop-filter: blur(14px);
|
||||
z-index: 3;
|
||||
}
|
||||
.brand {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-weight: 950;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
.nav a {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.17em;
|
||||
margin-left: 16px;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
gap: 18px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.editorial {
|
||||
background: rgba(255, 247, 232, 0.74);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 34px;
|
||||
padding: 34px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.eyebrow {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: var(--red);
|
||||
font-weight: 950;
|
||||
}
|
||||
.title {
|
||||
font-size: clamp(58px, 10vw, 130px);
|
||||
line-height: 0.78;
|
||||
letter-spacing: -0.085em;
|
||||
margin: 18px 0 20px;
|
||||
}
|
||||
.lead {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
color: var(--muted);
|
||||
font-size: 18px;
|
||||
max-width: 790px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 26px;
|
||||
}
|
||||
.btn {
|
||||
border: 1px solid var(--black);
|
||||
border-radius: 999px;
|
||||
background: var(--black);
|
||||
color: var(--paper);
|
||||
padding: 14px 18px;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-weight: 950;
|
||||
}
|
||||
.btn.secondary {
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
}
|
||||
.token-strip {
|
||||
display: grid;
|
||||
grid-template-columns: 6fr 3fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.token {
|
||||
min-height: 116px;
|
||||
border-radius: 20px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-weight: 950;
|
||||
}
|
||||
.section {
|
||||
margin-top: 32px;
|
||||
}
|
||||
.section h2 {
|
||||
font-size: 48px;
|
||||
line-height: 0.9;
|
||||
letter-spacing: -0.065em;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.phone-wall {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
.device {
|
||||
height: 640px;
|
||||
border-radius: 44px;
|
||||
background: #111;
|
||||
padding: 10px;
|
||||
box-shadow: 0 30px 90px rgba(21, 17, 12, 0.22);
|
||||
}
|
||||
.screen {
|
||||
height: 100%;
|
||||
border-radius: 34px;
|
||||
background: var(--paper);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.topline {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
font-weight: 950;
|
||||
}
|
||||
.display {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 80px;
|
||||
line-height: 0.84;
|
||||
letter-spacing: -0.1em;
|
||||
font-weight: 1000;
|
||||
}
|
||||
.unit {
|
||||
font-size: 18px;
|
||||
letter-spacing: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.rule {
|
||||
height: 1px;
|
||||
background: var(--ink);
|
||||
opacity: 0.22;
|
||||
}
|
||||
.chart {
|
||||
height: 140px;
|
||||
border-bottom: 2px solid var(--ink);
|
||||
position: relative;
|
||||
}
|
||||
.chart:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 28px;
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
box-shadow:
|
||||
0 28px 0 var(--line),
|
||||
0 56px 0 var(--line);
|
||||
}
|
||||
.chart svg {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.chart path {
|
||||
fill: none;
|
||||
stroke: var(--ink);
|
||||
stroke-width: 3.5;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.metric {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 10px;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
.metric b {
|
||||
font-size: 24px;
|
||||
}
|
||||
.metric span {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--muted);
|
||||
font-weight: 950;
|
||||
}
|
||||
.dock {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.dock .btn {
|
||||
padding: 14px 8px;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.item {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 14px 0;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
.item small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.note {
|
||||
background: #f1e4cf;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
padding: 14px;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
width: max-content;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
padding: 7px 10px;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 950;
|
||||
}
|
||||
.system-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
.spec {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 28px;
|
||||
padding: 22px;
|
||||
}
|
||||
.spec h3 {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.045em;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.type-demo {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 72px;
|
||||
line-height: 0.86;
|
||||
letter-spacing: -0.09em;
|
||||
font-weight: 1000;
|
||||
}
|
||||
.radii {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
.radii i {
|
||||
display: block;
|
||||
background: #eadcc5;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.ra {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.rb {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.rc {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 30px;
|
||||
}
|
||||
.component-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.component {
|
||||
min-height: 160px;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
padding: 14px;
|
||||
}
|
||||
.field {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
background: #fbf0dc;
|
||||
color: var(--muted);
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.principles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.principle {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 28px;
|
||||
padding: 20px;
|
||||
}
|
||||
.principle b {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 22px;
|
||||
}
|
||||
.share-card {
|
||||
border-radius: 28px;
|
||||
background: var(--black);
|
||||
color: var(--paper);
|
||||
padding: 18px;
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.share-card .display {
|
||||
font-size: 58px;
|
||||
}
|
||||
.share-card .lead {
|
||||
color: #bea98c;
|
||||
font-size: 14px;
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.phone-wall {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.component-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.hero,
|
||||
.system-grid,
|
||||
.principles {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.device {
|
||||
height: 680px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.nav {
|
||||
position: relative;
|
||||
}
|
||||
.nav div:last-child {
|
||||
display: none;
|
||||
}
|
||||
.editorial {
|
||||
padding: 22px;
|
||||
}
|
||||
.title {
|
||||
font-size: 64px;
|
||||
}
|
||||
.phone-wall,
|
||||
.component-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.device {
|
||||
height: 700px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<nav class="nav">
|
||||
<div class="brand">FightRate / Combat Editorial</div>
|
||||
<div>
|
||||
<a href="00-v2-index.html">v2 index</a><a href="#screens">telas</a
|
||||
><a href="#system">sistema</a><a href="#components">componentes</a>
|
||||
</div>
|
||||
</nav>
|
||||
<section class="hero">
|
||||
<div class="editorial">
|
||||
<div class="eyebrow">Direção 03 / marca premium</div>
|
||||
<div class="title">
|
||||
relatório de treino como editorial de performance.
|
||||
</div>
|
||||
<p class="lead">
|
||||
Essa direção deixa o FightRate com cara de marca madura: menos neon,
|
||||
menos dashboard, mais publicação premium de dados pessoais. Ideal
|
||||
para histórico, relatório e compartilhamento.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="btn">Ver semana</button
|
||||
><button class="btn secondary">Abrir captura</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editorial">
|
||||
<div class="eyebrow">Tokens de marca</div>
|
||||
<div class="token-strip">
|
||||
<div class="token" style="background: #fff7e8">
|
||||
60% papel quente
|
||||
</div>
|
||||
<div class="token" style="background: #e8d8bd">
|
||||
30% areia editorial
|
||||
</div>
|
||||
<div class="token" style="background: #15110c; color: #fff7e8">
|
||||
10% tinta
|
||||
</div>
|
||||
</div>
|
||||
<p class="lead">
|
||||
Vermelho é assinatura e alerta. Dourado é conquista. A interface não
|
||||
precisa gritar para parecer forte.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section id="screens" class="section">
|
||||
<h2>Telas principais</h2>
|
||||
<div class="phone-wall">
|
||||
<div class="device">
|
||||
<div class="screen">
|
||||
<div class="topline">
|
||||
<span>captura pronta</span><span>h003</span>
|
||||
</div>
|
||||
<div class="display">--<span class="unit">bpm</span></div>
|
||||
<div class="rule"></div>
|
||||
<p class="lead">
|
||||
Comece antes do treino. O app some; o dado fica.
|
||||
</p>
|
||||
<div class="metrics">
|
||||
<div class="metric"><b>84%</b><span>bateria</span></div>
|
||||
<div class="metric"><b>-58</b><span>rssi</span></div>
|
||||
<div class="metric"><b>0</b><span>drops</span></div>
|
||||
</div>
|
||||
<div class="dock">
|
||||
<button class="btn">Iniciar</button
|
||||
><button class="btn secondary">Check-in</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device">
|
||||
<div class="screen">
|
||||
<div class="topline"><span>ao vivo</span><span>42:18</span></div>
|
||||
<div class="display">154<span class="unit">bpm</span></div>
|
||||
<div class="chart">
|
||||
<svg viewBox="0 0 280 140">
|
||||
<path
|
||||
d="M8 98 C42 88,52 62,82 68 S122 98,154 74 S204 44,232 58 S254 76,274 54"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<div class="metric"><b>146</b><span>média</span></div>
|
||||
<div class="metric"><b>183</b><span>pico</span></div>
|
||||
<div class="metric"><b>41%</b><span>z4/z5</span></div>
|
||||
</div>
|
||||
<div class="dock">
|
||||
<button class="btn secondary">Evento</button
|
||||
><button
|
||||
class="btn"
|
||||
style="background: var(--red); border-color: var(--red)"
|
||||
>
|
||||
Parar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device">
|
||||
<div class="screen">
|
||||
<div class="topline">
|
||||
<span>relatório</span><span>salvo</span>
|
||||
</div>
|
||||
<div class="display">47<span class="unit">min</span></div>
|
||||
<div class="list">
|
||||
<div class="item">
|
||||
<div>Tempo em carga<small>Z4/Z5</small></div>
|
||||
<b>28m</b>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div>Pico sustentado<small>melhor bloco</small></div>
|
||||
<b>181</b>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div>Recuperação<small>estimativa</small></div>
|
||||
<b>18h</b>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note">
|
||||
Carga alta, mas sinal limpo. Bom treino para avaliar pressão
|
||||
sustentada.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device">
|
||||
<div class="screen">
|
||||
<div class="topline">
|
||||
<span>histórico</span><span>junho</span>
|
||||
</div>
|
||||
<div class="display">+8<span class="unit">%</span></div>
|
||||
<div class="metrics">
|
||||
<div class="metric"><b>11</b><span>treinos</span></div>
|
||||
<div class="metric"><b>7h</b><span>tempo</span></div>
|
||||
<div class="metric"><b>3</b><span>picos</span></div>
|
||||
</div>
|
||||
<div class="list">
|
||||
<div class="item">
|
||||
<div>Sparring<small>hoje · 47 min</small></div>
|
||||
<b>96</b>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div>Muay Thai<small>seg · 61 min</small></div>
|
||||
<b>110</b>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div>Boxe<small>sex · 42 min</small></div>
|
||||
<b>77</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device">
|
||||
<div class="screen">
|
||||
<div class="topline"><span>sensor</span><span>ble</span></div>
|
||||
<div class="display">-61<span class="unit">rssi</span></div>
|
||||
<div class="list">
|
||||
<div class="item">
|
||||
<div>Heart Rate<small>2A37</small></div>
|
||||
<b>notify</b>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div>Battery<small>2A19</small></div>
|
||||
<b>read</b>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div>Body Sensor<small>2A38</small></div>
|
||||
<b>read</b>
|
||||
</div>
|
||||
</div>
|
||||
<span class="pill">perfil estável</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device">
|
||||
<div class="screen">
|
||||
<div class="topline">
|
||||
<span>share</span><span>fight report</span>
|
||||
</div>
|
||||
<div class="share-card">
|
||||
<div>
|
||||
<div class="display">181</div>
|
||||
<p class="lead">
|
||||
pico do treino · 47 min · carga alta controlada
|
||||
</p>
|
||||
</div>
|
||||
<span class="pill">FightRate Report</span>
|
||||
</div>
|
||||
<p class="lead">
|
||||
Essa direção funciona muito bem para cards compartilháveis e
|
||||
marca.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="system" class="section">
|
||||
<h2>Design book visual</h2>
|
||||
<div class="system-grid">
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Cor</div>
|
||||
<h3>Papel, tinta, assinatura</h3>
|
||||
<p class="lead">
|
||||
Mais editorial que aplicativo fitness. Reduz ruído e aumenta valor
|
||||
percebido.
|
||||
</p>
|
||||
<div class="token-strip">
|
||||
<div class="token" style="background: #fff7e8">paper</div>
|
||||
<div class="token" style="background: #e8d8bd">sand</div>
|
||||
<div class="token" style="background: #15110c; color: #fff7e8">
|
||||
ink
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Tipo</div>
|
||||
<h3>Display contido</h3>
|
||||
<div class="type-demo">+8</div>
|
||||
<p class="lead">
|
||||
Serif para títulos da marca; sans pesada para números. Contraste
|
||||
sem carnaval.
|
||||
</p>
|
||||
</div>
|
||||
<div class="spec">
|
||||
<div class="eyebrow">Forma</div>
|
||||
<h3>Premium sem espuma</h3>
|
||||
<div class="radii">
|
||||
<i class="ra"></i><i class="rb"></i><i class="rc"></i>
|
||||
</div>
|
||||
<p class="lead">
|
||||
Raios limpos. Linhas finas. Poucas sombras. Muita intenção.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="components" class="section">
|
||||
<h2>Componentes</h2>
|
||||
<div class="component-grid">
|
||||
<div class="component">
|
||||
<div class="eyebrow">Button</div>
|
||||
<button class="btn">Primário</button
|
||||
><button class="btn secondary" style="margin-top: 10px">
|
||||
Secundário
|
||||
</button>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Metric</div>
|
||||
<div class="metric"><b>183</b><span>pico</span></div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Field</div>
|
||||
<div class="field">Nome do treino</div>
|
||||
<div class="field">Observação</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">List row</div>
|
||||
<div class="item">
|
||||
<div>Sessão<small>47 min</small></div>
|
||||
<b>96</b>
|
||||
</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Insight</div>
|
||||
<div class="note">Uma conclusão. Uma explicação. Uma ação.</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="eyebrow">Share card</div>
|
||||
<div class="share-card" style="min-height: 130px">
|
||||
<div class="display" style="font-size: 42px">181</div>
|
||||
<span class="pill">pico</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Princípios</h2>
|
||||
<div class="principles">
|
||||
<div class="principle">
|
||||
<b>Pouco, mas pesado</b>
|
||||
<p class="lead">Cada elemento precisa merecer espaço.</p>
|
||||
</div>
|
||||
<div class="principle">
|
||||
<b>Marca antes de interface</b>
|
||||
<p class="lead">
|
||||
Ideal para transformar FightRate em produto reconhecível.
|
||||
</p>
|
||||
</div>
|
||||
<div class="principle">
|
||||
<b>Captura simples</b>
|
||||
<p class="lead">
|
||||
Pode usar Corner Instrument na captura para mais energia.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
# FightRate React Native Redesign — v2
|
||||
|
||||
Status: **direção escolhida**
|
||||
|
||||
A direção oficial para seguir é:
|
||||
|
||||
```text
|
||||
01 — Corner Instrument
|
||||
```
|
||||
|
||||
Arquivo visual de referência:
|
||||
|
||||
```text
|
||||
01-corner-instrument-v2.html
|
||||
```
|
||||
|
||||
Design system canônico:
|
||||
|
||||
```text
|
||||
../../../../FIGHTRATE_DESIGN_SYSTEM.md
|
||||
```
|
||||
|
||||
## Decisão
|
||||
|
||||
Vamos seguir com o FightRate como **equipamento de córner / instrumento de captura**.
|
||||
|
||||
A experiência deve ser escura, forte, legível e direta. O app não deve parecer dashboard genérico, SaaS, app fitness comum ou coleção de cards.
|
||||
|
||||
## Direções não escolhidas
|
||||
|
||||
- `02-athlete-os-v2.html` fica apenas como referência de clareza de dados.
|
||||
- `03-combat-editorial-v2.html` fica apenas como referência futura para relatórios compartilháveis.
|
||||
|
||||
Nenhuma dessas duas é base visual principal neste momento.
|
||||
|
||||
## Próxima fase
|
||||
|
||||
Antes de codar React Native, usar `FIGHTRATE_DESIGN_SYSTEM.md` como contrato visual e transformar os tokens em tema Expo/React Native.
|
||||
@@ -0,0 +1,336 @@
|
||||
# FightRate — métricas científicas derivadas de frequência cardíaca e HRV/VFC
|
||||
|
||||
> Documento técnico-educacional. Nenhuma métrica abaixo deve ser apresentada como diagnóstico médico. Interpretação clínica exige contexto, validação, exame adequado, controle de artefatos e revisão profissional.
|
||||
|
||||
## Escopo
|
||||
|
||||
Este relatório consolida fórmulas, algoritmos e limites de interpretação para transformar dados reais do FightRate em indicadores de saúde, desempenho e carga interna. A base foi limitada a literatura acadêmica indexada em PubMed/PMC ou páginas de periódico científico.
|
||||
|
||||
## Premissa de dados do app
|
||||
|
||||
O FightRate já pode usar:
|
||||
|
||||
- `bpm`: frequência cardíaca instantânea enviada pelo Heart Rate Service `180D`, Measurement `2A37`.
|
||||
- `timestamp`: instante real de cada amostra persistida.
|
||||
- `rrIntervalsMs`: intervalos RR quando o sensor enviar esse campo no pacote `2A37`.
|
||||
- `workout_sessions`: janelas de treino marcadas dentro do fluxo contínuo.
|
||||
- `continuous_heart_rate_samples`: amostras contínuas, inclusive fora da janela de treino.
|
||||
|
||||
Separação crítica:
|
||||
|
||||
1. **Métricas BPM-only**: podem ser calculadas hoje com FC timestampada.
|
||||
2. **Métricas dependentes de perfil**: exigem FCmáx, FCrepouso, idade e/ou sexo informados ou estimados.
|
||||
3. **Métricas HRV/VFC reais**: exigem RR intervals confiáveis, filtragem de artefatos e janela adequada.
|
||||
|
||||
## Matriz de implementação
|
||||
|
||||
| Métrica | Dados mínimos | Pode entrar já? | Observação |
|
||||
| --- | --- | --- | --- |
|
||||
| FC atual/média/mín/máx | BPM + timestamp | Sim | Baixo risco; métrica central. |
|
||||
| Amplitude e variabilidade de BPM | BPM + timestamp | Sim | Útil para esforço estável vs intervalado. |
|
||||
| Tempo em zonas fixas | BPM + timestamp | Sim | Usar como zona operacional até existir perfil. |
|
||||
| Tendência/deriva de FC | BPM + timestamp | Sim | Rotular como tendência, não causa fisiológica. |
|
||||
| Recuperação pós-janela | BPM contínuo após fim da janela | Sim, quando houver dados pós-esforço | Precisa continuar monitorando após encerrar janela. |
|
||||
| Zonas por FCmáx | BPM + FCmáx ou idade | Parcial | FCmáx por idade tem erro individual. Preferir FCmáx manual. |
|
||||
| Karvonen/HRR | BPM + FCmáx + FCrepouso | Parcial | Mais individualizado, mas exige perfil. |
|
||||
| TRIMP | BPM + duração + FCmáx + FCrepouso + coeficiente | Parcial | Útil como carga estimada; limitações em intervalados. |
|
||||
| RMSSD/SDNN/pNN50 | RR intervals limpos | Só se RR vier do sensor | Não derivar de BPM médio. |
|
||||
| LF/HF/frequência | RR + interpolação + janela adequada | Futuro | Alto risco de interpretação; evitar MVP. |
|
||||
| HRV-guided readiness | RR matinal padronizado | Futuro | Depende de protocolo diário consistente. |
|
||||
|
||||
## 1. Frequência cardíaca atual, média, mínima e máxima
|
||||
|
||||
**Fonte fisiológica:** monitoramento direto de FC é prático e barato, mas a relação FC↔intensidade é afetada por hidratação, temperatura, fadiga, cafeína, estresse e deriva cardiovascular. Achten & Jeukendrup destacam que FC é útil em campo, mas limitada para estimar gasto energético ou VO₂ individual com alta precisão.
|
||||
|
||||
**Dados:** BPM válido e timestamp.
|
||||
|
||||
**Fórmulas:**
|
||||
|
||||
```text
|
||||
FC_atual = último BPM válido
|
||||
FC_média = soma(BPM) / n
|
||||
FC_mín = min(BPM)
|
||||
FC_máx = max(BPM)
|
||||
Amplitude = FC_máx - FC_mín
|
||||
```
|
||||
|
||||
**Uso no FightRate:**
|
||||
|
||||
- Mostrar como leitura primária nos Insights.
|
||||
- Usar `sampleCount` e duração para indicar qualidade/confiança.
|
||||
- Não exibir quando não houver amostras reais.
|
||||
|
||||
## 2. Variabilidade simples de BPM
|
||||
|
||||
Esta não é HRV/VFC. É variabilidade da frequência cardíaca agregada por amostras de BPM. Ainda assim, é útil para caracterizar treino contínuo, intervalado ou caótico.
|
||||
|
||||
**Dados:** BPM timestampado.
|
||||
|
||||
**Fórmulas:**
|
||||
|
||||
```text
|
||||
média = soma(BPM) / n
|
||||
variância = soma((BPM - média)^2) / n
|
||||
DP_BPM = sqrt(variância)
|
||||
CV_BPM = DP_BPM / média
|
||||
Amplitude = FC_máx - FC_mín
|
||||
```
|
||||
|
||||
**Classificação operacional sugerida:**
|
||||
|
||||
```text
|
||||
CV_BPM < 0.06 = esforço estável
|
||||
0.06–0.12 = misto / progressivo
|
||||
> 0.12 = intervalado / oscilante
|
||||
```
|
||||
|
||||
**Limitação:** CV alto pode indicar treino intervalado, sensor instável ou mudanças bruscas de contexto. Rotular como “oscilação de FC”, não HRV.
|
||||
|
||||
## 3. FC máxima estimada e zonas por %FCmáx
|
||||
|
||||
Tanaka, Monahan e Seals revisaram a fórmula clássica `220 - idade` e propuseram uma equação mais robusta para adultos saudáveis:
|
||||
|
||||
```text
|
||||
FCmáx_estimada = 208 - 0.7 × idade
|
||||
%FCmáx = BPM / FCmáx
|
||||
```
|
||||
|
||||
**Zonas operacionais comuns:**
|
||||
|
||||
| Zona | %FCmáx | Uso prático |
|
||||
| --- | ---: | --- |
|
||||
| Z1 | <60% | recuperação / muito leve |
|
||||
| Z2 | 60–70% | base aeróbia |
|
||||
| Z3 | 70–80% | moderado / tempo |
|
||||
| Z4 | 80–90% | alta pressão |
|
||||
| Z5 | >90% | redline / esforço máximo relativo |
|
||||
|
||||
**Limitação:** Tanaka et al. deixam claro que FCmáx é amplamente explicada pela idade em população saudável, mas a estimativa individual pode errar bastante. Para o app, a FCmáx manual do usuário deve ter prioridade sobre fórmula.
|
||||
|
||||
## 4. Reserva cardíaca / Karvonen / HRR
|
||||
|
||||
Karvonen, Kentala e Mustala popularizaram o uso da faixa entre FC de repouso e FC máxima como referência de intensidade. Swain & Leutholtz demonstraram que `%HRR` se alinha melhor a `%VO₂ reserve` do que a `%VO₂max`.
|
||||
|
||||
**Dados necessários:** BPM, FCmáx, FCrepouso.
|
||||
|
||||
**Fórmulas:**
|
||||
|
||||
```text
|
||||
Reserva = FCmáx - FCrepouso
|
||||
Intensidade_HRR = (BPM - FCrepouso) / Reserva
|
||||
Alvo_BPM = FCrepouso + Intensidade_alvo × Reserva
|
||||
```
|
||||
|
||||
**Uso no FightRate:**
|
||||
|
||||
- Quando perfil existir, mostrar zonas por HRR como modo mais personalizado.
|
||||
- Sem FCrepouso confiável, cair para zonas por %FCmáx ou zonas operacionais fixas.
|
||||
|
||||
## 5. Tempo em zona
|
||||
|
||||
Tempo em zona é um dos indicadores mais úteis para leitura visual compacta, porque combina intensidade e duração.
|
||||
|
||||
**Dados:** BPM timestampado e função `zona(BPM)`.
|
||||
|
||||
**Algoritmo:**
|
||||
|
||||
```text
|
||||
para i de 1 até n-1:
|
||||
delta = timestamp[i] - timestamp[i-1]
|
||||
delta_limitado = min(delta, gapMaxMs)
|
||||
zona = zona(BPM[i])
|
||||
tempo[zona] += delta_limitado
|
||||
```
|
||||
|
||||
**Regras de robustez:**
|
||||
|
||||
- Ignorar ou limitar gaps grandes para não atribuir minutos sem sinal a uma zona.
|
||||
- Exibir duração e percentual.
|
||||
- Usar o mesmo cálculo para janela de treino, uso contínuo e faixa customizada.
|
||||
|
||||
## 6. Carga interna / TRIMP
|
||||
|
||||
Banister introduziu o conceito de treinamento como “dose” fisiológica, combinando duração e intensidade. O TRIMP clássico usa FC relativa à reserva e um peso exponencial. Desgorces et al. discutem que TRIMP é histórico e útil, mas não é intercambiável com métodos alternativos e pode falhar em exercícios intermitentes quando usa FC média.
|
||||
|
||||
**Dados necessários:** duração, BPM, FCmáx, FCrepouso, coeficiente.
|
||||
|
||||
**Banister TRIMP clássico:**
|
||||
|
||||
```text
|
||||
HRr = (FCmédia - FCrepouso) / (FCmáx - FCrepouso)
|
||||
TRIMP_homem = duração_min × HRr × 0.64 × e^(1.92 × HRr)
|
||||
TRIMP_mulher = duração_min × HRr × 0.86 × e^(1.67 × HRr)
|
||||
```
|
||||
|
||||
**TRIMP acumulado por amostra, mais adequado ao FightRate:**
|
||||
|
||||
```text
|
||||
para cada intervalo entre amostras:
|
||||
HRr_i = clamp((BPM_i - FCrepouso) / (FCmáx - FCrepouso), 0, 1.2)
|
||||
peso_i = coeficiente_a × e^(coeficiente_b × HRr_i)
|
||||
TRIMP += delta_min × HRr_i × peso_i
|
||||
```
|
||||
|
||||
**Uso no FightRate:**
|
||||
|
||||
- Chamar de **carga estimada**.
|
||||
- Se FCmáx/FCrepouso não existirem, mostrar “perfil incompleto”.
|
||||
- Para treino de luta intervalado, preferir versão acumulada, não FC média única.
|
||||
|
||||
## 7. Recuperação cardíaca pós-esforço (HRR recovery)
|
||||
|
||||
Cole et al. definiram recuperação como a queda da FC do pico ao primeiro minuto após o exercício; em teste clínico, queda `≤ 12 bpm` no primeiro minuto foi associada a maior mortalidade. No FightRate, isso deve ser usado apenas como referência educativa, pois o contexto é treino livre, não teste clínico padronizado.
|
||||
|
||||
**Dados necessários:** fim da janela de treino e monitoramento contínuo por 1–2 min depois.
|
||||
|
||||
**Fórmulas:**
|
||||
|
||||
```text
|
||||
HRR_1min = FC_fim_ou_pico - FC_60s_após
|
||||
HRR_2min = FC_fim_ou_pico - FC_120s_após
|
||||
```
|
||||
|
||||
**Algoritmo recomendado:**
|
||||
|
||||
1. Ao fechar a janela, manter monitor contínuo ativo.
|
||||
2. Guardar FC no fim da janela e pico dos últimos minutos.
|
||||
3. Buscar amostra mais próxima de `endedAt + 60s` e `endedAt + 120s`.
|
||||
4. Exibir apenas se houver amostras suficientes depois da janela.
|
||||
|
||||
## 8. Deriva cardiovascular / tendência de drift
|
||||
|
||||
Coyle & González-Alonso definem a deriva cardiovascular como queda progressiva do volume sistólico após 10–20 min de exercício, acompanhada por aumento de FC. Sem potência, velocidade, temperatura e hidratação, o app só deve inferir “tendência de deriva de FC”, não causa.
|
||||
|
||||
**Dados:** série BPM longa e timestampada.
|
||||
|
||||
**Fórmulas operacionais:**
|
||||
|
||||
```text
|
||||
média_inicial = média de uma janela inicial estável
|
||||
média_final = média da janela final
|
||||
Deriva_abs = média_final - média_inicial
|
||||
Deriva_% = Deriva_abs / média_inicial × 100
|
||||
```
|
||||
|
||||
**Janela sugerida para app:**
|
||||
|
||||
- Se duração ≥ 20 min: comparar primeiros 20% vs últimos 20%, ignorando aquecimento curto.
|
||||
- Se duração < 10 min: não calcular deriva; mostrar “janela curta”.
|
||||
|
||||
## 9. HRV/VFC com RR intervals
|
||||
|
||||
A Task Force ESC/NASPE estabeleceu padrões clássicos de medição e interpretação de HRV. Shaffer & Ginsberg detalham métricas de domínio do tempo, frequência e não lineares, reforçando que duração da janela, artefatos, respiração, idade, sexo e método de medição mudam a interpretação.
|
||||
|
||||
**Dados necessários:** RR/NN intervals em milissegundos, limpos de artefatos.
|
||||
|
||||
### Domínio do tempo
|
||||
|
||||
```text
|
||||
SDNN = desvio padrão dos intervalos NN
|
||||
RMSSD = sqrt(média((NN[i+1] - NN[i])²))
|
||||
NN50 = contagem de pares sucessivos com |ΔNN| > 50 ms
|
||||
pNN50 = NN50 / número_de_pares × 100
|
||||
```
|
||||
|
||||
**Interpretação resumida:**
|
||||
|
||||
- RMSSD é uma das métricas mais usadas para atividade vagal de curto prazo.
|
||||
- SDNN depende fortemente da duração da janela; 24h e 5 min não são intercambiáveis.
|
||||
- pNN50 é correlacionado com RMSSD, mas RMSSD costuma ser preferido.
|
||||
|
||||
### Domínio de frequência
|
||||
|
||||
Bandas clássicas:
|
||||
|
||||
```text
|
||||
VLF: 0.0033–0.04 Hz
|
||||
LF: 0.04–0.15 Hz
|
||||
HF: 0.15–0.40 Hz
|
||||
LF/HF = potência_LF / potência_HF
|
||||
```
|
||||
|
||||
**Limitação importante:** LF/HF é controverso como “balanço simpático-vagal”; respiração, postura e não estacionaridade podem distorcer a leitura. Para MVP, não usar LF/HF como score principal.
|
||||
|
||||
### Não lineares
|
||||
|
||||
```text
|
||||
SD1 = dispersão perpendicular no Poincaré; relacionado a RMSSD
|
||||
SD2 = dispersão longitudinal no Poincaré
|
||||
SampEn = regularidade/complexidade da série
|
||||
DFA α1 = correlação fractal de curto prazo
|
||||
```
|
||||
|
||||
**Uso no FightRate:** futuro, apenas após validação de RR do sensor e filtros de artefato.
|
||||
|
||||
## 10. HRV-guided readiness / prontidão
|
||||
|
||||
Kiviniemi et al. testaram prescrição diária guiada por HRV em treino de endurance: quando HRV matinal caía significativamente, o treino era reduzido para baixa intensidade ou repouso. Buchheit argumenta que medidas quase diárias de 5 min em repouso e FC submáxima podem ser úteis quando interpretadas individualmente com erro de medida, menor mudança importante e contexto de carga.
|
||||
|
||||
**Requisitos para FightRate:**
|
||||
|
||||
- Protocolo matinal padronizado, não amostra aleatória de treino.
|
||||
- RR intervals reais.
|
||||
- Baseline individual de vários dias.
|
||||
- Exibir como prontidão/protocolo, não diagnóstico.
|
||||
|
||||
**Algoritmo futuro:**
|
||||
|
||||
```text
|
||||
baseline = média móvel de LnRMSSD dos últimos 7–10 dias
|
||||
limite = baseline - desvio_ou_SWC
|
||||
se LnRMSSD_hoje < limite:
|
||||
sugestão = reduzir intensidade / recuperar
|
||||
senão:
|
||||
sugestão = treino planejado permitido
|
||||
```
|
||||
|
||||
## 11. Alertas e anomalias
|
||||
|
||||
**Dados:** BPM, timestamp, perfil opcional.
|
||||
|
||||
Regras não clínicas úteis:
|
||||
|
||||
```text
|
||||
Sem sinal = nenhuma amostra válida por N segundos
|
||||
Pico abrupto = |BPM_atual - BPM_anterior| > limiar em poucos segundos
|
||||
Alta sustentada = BPM > limite por N segundos
|
||||
Queda pós-pico = FC cai X bpm em 60/120s após janela
|
||||
```
|
||||
|
||||
**Cuidado:** alertas de saúde reais exigem validação médica. No app, usar linguagem operacional: “verifique sensor”, “pausa recomendada”, “zona alta sustentada”.
|
||||
|
||||
## Recomendações diretas para a tela Insights
|
||||
|
||||
1. Substituir lista simples por uma **grade tabular de instrumentos** com linhas compactas.
|
||||
2. Priorizar métricas calculáveis hoje:
|
||||
- FC atual/média/mín/máx;
|
||||
- duração e amostras;
|
||||
- tempo em zonas;
|
||||
- variabilidade BPM;
|
||||
- carga observada/carga estimada;
|
||||
- tendência de deriva;
|
||||
- recuperação pós-janela quando existir.
|
||||
3. Exibir métricas dependentes de perfil como **bloqueadas** ou “perfil necessário”, sem inventar FCmáx/FCrepouso silenciosamente.
|
||||
4. Exibir HRV/VFC como **indisponível** quando não houver RR intervals confiáveis.
|
||||
5. Cada métrica deve ter:
|
||||
- valor;
|
||||
- unidade;
|
||||
- status (`OK`, `perfil`, `RR`, `janela curta`, `sem dados`);
|
||||
- frase curta de leitura;
|
||||
- base de cálculo/confiança.
|
||||
6. Para uso espacial eficiente, usar tabela em linhas densas, trilhos e separadores do Corner Instrument — não cards genéricos.
|
||||
|
||||
## Referências acadêmicas
|
||||
|
||||
1. Task Force ESC/NASPE. *Heart rate variability: standards of measurement, physiological interpretation and clinical use*. Circulation. 1996. PubMed: https://pubmed.ncbi.nlm.nih.gov/8598068/
|
||||
2. Shaffer F, Ginsberg JP. *An Overview of Heart Rate Variability Metrics and Norms*. Front Public Health. 2017. PMC: https://pmc.ncbi.nlm.nih.gov/articles/PMC5624990/
|
||||
3. Tanaka H, Monahan KD, Seals DR. *Age-predicted maximal heart rate revisited*. J Am Coll Cardiol. 2001. PubMed: https://pubmed.ncbi.nlm.nih.gov/11153730/
|
||||
4. Karvonen MJ, Kentala E, Mustala O. *The effects of training on heart rate; a longitudinal study*. Ann Med Exp Biol Fenn. 1957. PubMed: https://pubmed.ncbi.nlm.nih.gov/13470504/
|
||||
5. Swain DP, Leutholtz BC. *Heart rate reserve is equivalent to %VO2 reserve, not to %VO2max*. Med Sci Sports Exerc. 1997. PubMed: https://pubmed.ncbi.nlm.nih.gov/9139182/
|
||||
6. Cole CR, Blackstone EH, Pashkow FJ, Snader CE, Lauer MS. *Heart-rate recovery immediately after exercise as a predictor of mortality*. N Engl J Med. 1999. PubMed: https://pubmed.ncbi.nlm.nih.gov/10536127/
|
||||
7. Achten J, Jeukendrup AE. *Heart rate monitoring: applications and limitations*. Sports Med. 2003. PubMed: https://pubmed.ncbi.nlm.nih.gov/12762827/
|
||||
8. Coyle EF, González-Alonso J. *Cardiovascular drift during prolonged exercise: new perspectives*. Exerc Sport Sci Rev. 2001. PubMed: https://pubmed.ncbi.nlm.nih.gov/11337829/
|
||||
9. Buchheit M. *Monitoring training status with HR measures: do all roads lead to Rome?* Front Physiol. 2014. PubMed/PMC: https://pubmed.ncbi.nlm.nih.gov/24578692/
|
||||
10. Kiviniemi AM, Hautala AJ, Kinnunen H, Tulppo MP. *Endurance training guided individually by daily heart rate variability measurements*. Eur J Appl Physiol. 2007. PubMed: https://pubmed.ncbi.nlm.nih.gov/17849143/
|
||||
11. Desgorces FD, Hourcade JC, Dubois R, Toussaint JF, Noirez P. *Training load quantification of high intensity exercises: discrepancies between original and alternative methods*. PLoS One. 2020. PMC: https://pmc.ncbi.nlm.nih.gov/articles/PMC7398532/
|
||||
12. Foster C, Florhaug JA, Franklin J, et al. *A new approach to monitoring exercise training*. J Strength Cond Res. 2001. PubMed: https://pubmed.ncbi.nlm.nih.gov/11708692/
|
||||
@@ -0,0 +1,147 @@
|
||||
# FightRate Android MVP 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:** Build the first Android-native FightRate app scaffold for live combat-sport heart-rate tracking with Magene H003 BLE support.
|
||||
|
||||
**Architecture:** Android-native Kotlin app using Jetpack Compose for UI, pure Kotlin domain/parse logic, Android BLE APIs isolated behind a BLE package, and local persistence planned with Room. The first implementation slice creates a buildable app shell plus a tested Heart Rate Measurement parser.
|
||||
|
||||
**Tech Stack:** Kotlin, Gradle Kotlin DSL, Android Gradle Plugin, Jetpack Compose Material 3, Coroutines/Flow, Android BLE APIs, JUnit.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```text
|
||||
settings.gradle.kts Project module declaration
|
||||
build.gradle.kts Root Gradle plugin versions
|
||||
gradle.properties Android/Kotlin build flags
|
||||
app/build.gradle.kts Android app dependencies and config
|
||||
app/src/main/AndroidManifest.xml Permissions, activity, BLE feature flags
|
||||
app/src/main/java/com/healthtracking/fightrate/MainActivity.kt
|
||||
app/src/main/java/com/healthtracking/fightrate/ble/HeartRateParser.kt
|
||||
app/src/main/java/com/healthtracking/fightrate/domain/model/FightSport.kt
|
||||
app/src/main/java/com/healthtracking/fightrate/domain/model/HeartRateMeasurement.kt
|
||||
app/src/main/java/com/healthtracking/fightrate/ui/theme/FightRateTheme.kt
|
||||
app/src/main/java/com/healthtracking/fightrate/ui/theme/Color.kt
|
||||
app/src/main/java/com/healthtracking/fightrate/ui/screens/HomeScreen.kt
|
||||
app/src/test/java/com/healthtracking/fightrate/ble/HeartRateParserTest.kt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Android project scaffold
|
||||
|
||||
**Files:**
|
||||
- Create: `settings.gradle.kts`
|
||||
- Create: `build.gradle.kts`
|
||||
- Create: `gradle.properties`
|
||||
- Create: `app/build.gradle.kts`
|
||||
- Create: `app/src/main/AndroidManifest.xml`
|
||||
- Create: `app/src/main/java/com/healthtracking/fightrate/MainActivity.kt`
|
||||
|
||||
- [ ] **Step 1: Create Gradle project files**
|
||||
|
||||
Use Android app module `:app`, package `com.healthtracking.fightrate`, min SDK 26, target/compile SDK 35, Kotlin 2.x compatible Compose.
|
||||
|
||||
- [ ] **Step 2: Create manifest**
|
||||
|
||||
Declare Bluetooth permissions for Android 12+, legacy Bluetooth/location permissions for Android 11 and below, BLE feature, foreground service permissions, and `MainActivity`.
|
||||
|
||||
- [ ] **Step 3: Create minimal MainActivity**
|
||||
|
||||
Use `ComponentActivity`, `setContent`, `FightRateTheme`, and `HomeScreen`.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run: `./gradlew :app:assembleDebug`
|
||||
|
||||
Expected: build succeeds when Gradle/Android SDK are installed.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Heart Rate Measurement parser, TDD first
|
||||
|
||||
**Files:**
|
||||
- Create: `app/src/main/java/com/healthtracking/fightrate/ble/HeartRateParser.kt`
|
||||
- Create: `app/src/main/java/com/healthtracking/fightrate/domain/model/HeartRateMeasurement.kt`
|
||||
- Create: `app/src/test/java/com/healthtracking/fightrate/ble/HeartRateParserTest.kt`
|
||||
|
||||
- [ ] **Step 1: Write tests**
|
||||
|
||||
Cover these Bluetooth Heart Rate Measurement payloads:
|
||||
|
||||
```kotlin
|
||||
byteArrayOf(0x00, 72) // uint8 BPM
|
||||
byteArrayOf(0x01, 0x2C, 0x01) // uint16 BPM = 300
|
||||
byteArrayOf(0x08, 90, 0x34, 0x12) // energy expended = 0x1234
|
||||
byteArrayOf(0x10, 120, 0x00, 0x04) // RR interval = 1024 -> 1000ms
|
||||
byteArrayOf() // invalid empty payload
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement parser**
|
||||
|
||||
Parse flags, BPM format, optional energy expended, optional RR intervals. Throw `IllegalArgumentException` for malformed payloads.
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
|
||||
Run: `./gradlew :app:testDebugUnitTest`
|
||||
|
||||
Expected: all parser tests pass.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: FightRate visual shell
|
||||
|
||||
**Files:**
|
||||
- Create: `app/src/main/java/com/healthtracking/fightrate/ui/theme/Color.kt`
|
||||
- Create: `app/src/main/java/com/healthtracking/fightrate/ui/theme/FightRateTheme.kt`
|
||||
- Create: `app/src/main/java/com/healthtracking/fightrate/domain/model/FightSport.kt`
|
||||
- Create: `app/src/main/java/com/healthtracking/fightrate/ui/screens/HomeScreen.kt`
|
||||
|
||||
- [ ] **Step 1: Define design tokens**
|
||||
|
||||
Use the FightRate palette from the design system: black canvas, charcoal surfaces, fight orange, redline red, recovery green, muted gray text.
|
||||
|
||||
- [ ] **Step 2: Build static HomeScreen**
|
||||
|
||||
Include static UI cards for sensor status, current BPM, round timer, zone bar, sport chips, and session metrics.
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
|
||||
Run: `./gradlew :app:assembleDebug`
|
||||
|
||||
Expected: app compiles and opens to a dark FightRate dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: BLE connection layer placeholder
|
||||
|
||||
**Files:**
|
||||
- Create: `app/src/main/java/com/healthtracking/fightrate/ble/HeartRateBleConstants.kt`
|
||||
- Create: `app/src/main/java/com/healthtracking/fightrate/ble/HeartRateBleScanner.kt`
|
||||
- Create: `app/src/main/java/com/healthtracking/fightrate/ble/HeartRateBleClient.kt`
|
||||
|
||||
- [ ] **Step 1: Define UUID constants**
|
||||
|
||||
```kotlin
|
||||
HEART_RATE_SERVICE = 0000180d-0000-1000-8000-00805f9b34fb
|
||||
HEART_RATE_MEASUREMENT = 00002a37-0000-1000-8000-00805f9b34fb
|
||||
BODY_SENSOR_LOCATION = 00002a38-0000-1000-8000-00805f9b34fb
|
||||
BATTERY_SERVICE = 0000180f-0000-1000-8000-00805f9b34fb
|
||||
BATTERY_LEVEL = 00002a19-0000-1000-8000-00805f9b34fb
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Scanner contract**
|
||||
|
||||
Expose a Kotlin API that can later be wired to `BluetoothLeScanner` and emits scan results.
|
||||
|
||||
- [ ] **Step 3: Client contract**
|
||||
|
||||
Expose connect/disconnect and `Flow<HeartRateMeasurement>` API.
|
||||
|
||||
---
|
||||
|
||||
## Current Execution Choice
|
||||
|
||||
The user asked to proceed with app creation, so start with **Inline Execution** for Tasks 1–3, stopping after verification evidence and reporting environment blockers if Android SDK/Gradle is unavailable.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Implementation Plan: FightRate P1 Essential Features
|
||||
|
||||
## Overview
|
||||
Implement the next FightRate feature batch as incremental, testable slices: workout sessions, round timer, configurable alerts, more robust BLE state, and sensor metadata (battery/body location). Keep PT-BR visible copy and English code identifiers.
|
||||
|
||||
## Architecture Decisions
|
||||
- Keep local-first SQLite as source of truth.
|
||||
- Add session data without replacing existing sample storage.
|
||||
- Prefer pure domain logic for round timer and alerts so behavior is unit-testable.
|
||||
- Keep BLE foreground service as owner of the device connection.
|
||||
- Expose sensor metadata through existing service broadcasts to avoid direct BLE ownership in the ViewModel.
|
||||
|
||||
## Task List
|
||||
|
||||
### Phase 1: Domain foundation
|
||||
- [ ] Add `WorkoutSession`, `WorkoutSessionStatus`, `RoundTimerConfig`, `RoundTimerSnapshot`, and calculators.
|
||||
- [ ] Add unit tests for timer transitions and session summary calculations.
|
||||
|
||||
### Phase 2: Persistence
|
||||
- [ ] Add `workout_sessions` table and nullable `session_id` on `heart_rate_samples`.
|
||||
- [ ] Add safe migration from current database version.
|
||||
- [ ] Add store methods to start/pause/resume/finish active sessions and insert samples with active session id.
|
||||
|
||||
### Phase 3: UI/ViewModel integration
|
||||
- [ ] Add session and round timer state to `LiveHeartRateUiState`.
|
||||
- [ ] Add ViewModel actions: start/pause/resume/finish session and update timer config.
|
||||
- [ ] Add visible Compose controls in Live screen.
|
||||
|
||||
### Phase 4: Alerts
|
||||
- [ ] Add persisted alert settings with safe defaults.
|
||||
- [ ] Add configurable vibration/sound toggles and zone/limit thresholds.
|
||||
- [ ] Trigger alerts from service/ViewModel without spamming.
|
||||
|
||||
### Phase 5: BLE hardening and metadata
|
||||
- [ ] Add BLE status events for scan/connect/service/descriptor failures and unexpected disconnects.
|
||||
- [ ] Add conservative reconnect/backoff in foreground service.
|
||||
- [ ] Read battery level and body sensor location when available.
|
||||
- [ ] Show metadata in Sensor screen.
|
||||
|
||||
## Review/Verification
|
||||
- [ ] Run unit tests after domain/persistence slices.
|
||||
- [ ] Run `./gradlew :app:testDebugUnitTest :app:assembleDebug` before final response.
|
||||
- [ ] Run code review and front/UI review via fresh-context reviewers.
|
||||
- [ ] Reconcile findings and fix required issues.
|
||||
|
||||
## Risks
|
||||
| Risk | Impact | Mitigation |
|
||||
|---|---|---|
|
||||
| Database migration loses samples | High | Add additive migration, no DROP for current versions |
|
||||
| BLE changes break screen-off tracking | High | Keep service ownership; add metadata/status only |
|
||||
| UI becomes cluttered | Medium | Compact cards and PT-BR labels |
|
||||
| Alert spam | Medium | Throttle alerts and use safe defaults |
|
||||
@@ -0,0 +1,207 @@
|
||||
# FightRate — Plano Data-First para histórico, gráficos e UI profissional
|
||||
|
||||
## Situação crítica
|
||||
|
||||
O feedback atual mostra dois bugs de produto, não apenas problema visual:
|
||||
|
||||
1. O gráfico tem espaço vazio demais e exige rolar horizontalmente por muito tempo até encontrar leituras.
|
||||
2. O histórico não está confiável: dados parecem não salvar ou não ficam acessíveis depois.
|
||||
|
||||
Conclusão: dados e persistência devem voltar a ser prioridade absoluta antes de polimento visual.
|
||||
|
||||
## Objetivo
|
||||
|
||||
Transformar o FightRate em um app data-first:
|
||||
|
||||
- Cada batimento recebido deve ser persistido localmente.
|
||||
- Cada treino deve ter uma janela temporal confiável.
|
||||
- Histórico deve mostrar dados reais recuperáveis.
|
||||
- Gráficos devem usar domínio baseado apenas nas amostras reais disponíveis.
|
||||
- Não pode existir scroll infinito/área vazia até achar leitura.
|
||||
- Interface deve priorizar dados e navegação histórica, seguindo o design system Combat Lab.
|
||||
|
||||
## Skills aplicadas
|
||||
|
||||
- `fightrate-quality-loop`: spec → auditoria → implementação em fatias → build.
|
||||
- `systematic-debugging`: encontrar causa raiz antes de corrigir.
|
||||
- `test-driven-development`: criar testes de regressão para domínio de gráfico/histórico.
|
||||
- `design-system` e `ui-ux-pro-max`: estado vazio útil, chart/data UX, consistência visual.
|
||||
|
||||
## Hipóteses a investigar
|
||||
|
||||
### H1 — Domínio X do Vico está incluindo janela cheia, não amostras reais
|
||||
|
||||
Sintoma: usuário rola para a direita “para sempre” até encontrar leituras.
|
||||
|
||||
Possível causa:
|
||||
|
||||
- O gráfico usa `windowStartMillis`/`windowEndMillis` grandes, mas as amostras estão concentradas em pequeno trecho.
|
||||
- O X é calculado relativo à janela selecionada, então se a leitura está no fim da janela, sobra um enorme vazio à esquerda/direita.
|
||||
- O scroll/zoom inicial pode estar mostrando início/fim errado.
|
||||
|
||||
Correção esperada:
|
||||
|
||||
- Domínio padrão do gráfico deve ser `min(sample.timestamp)` → `max(sample.timestamp)`.
|
||||
- A janela selecionada filtra dados, mas não força o domínio visual a incluir todo o período vazio.
|
||||
- Exibir texto com a janela consultada e o intervalo real encontrado.
|
||||
|
||||
### H2 — Histórico busca janela errada ou estado não atualiza após persistência
|
||||
|
||||
Sintoma: dados históricos não aparecem/salvam.
|
||||
|
||||
Possíveis causas:
|
||||
|
||||
- `publishLiveSample` salva, mas histórico não recarrega após cada nova amostra.
|
||||
- `selectedPeriod` usa uma janela curta ou errada.
|
||||
- Sessão finalizada tem samples com `sessionId` nulo.
|
||||
- `finishCurrentSession` não associa amostras sem sessão dentro da janela.
|
||||
- UI exibe `historySamples`, mas dados reais ficam apenas em `visibleSamples`/`dailySamples`.
|
||||
|
||||
Correções esperadas:
|
||||
|
||||
- Confirmar que `HeartRateSampleStore.save` recebe `sessionId` ativo.
|
||||
- Confirmar que `samplesForSession` funciona depois do finish.
|
||||
- Confirmar que `samplesBetween(start, end)` recupera dados persistidos.
|
||||
- Atualizar estado de histórico depois de salvar amostra e depois de finalizar treino.
|
||||
|
||||
### H3 — UI não oferece navegação histórica suficiente
|
||||
|
||||
Sintoma: “os dados devem poder ser vistos de qualquer maneira”.
|
||||
|
||||
Correção esperada:
|
||||
|
||||
- Histórico com modos claros:
|
||||
- Últimos 10 min
|
||||
- Última hora
|
||||
- Hoje
|
||||
- Sessão atual/finalizada
|
||||
- Se janela consultada não tem dados, mostrar “nenhum dado nessa janela” e sugerir janela que tem dados.
|
||||
- Se existe sessão finalizada, exibir relatório e gráfico daquela sessão.
|
||||
|
||||
## Arquitetura desejada
|
||||
|
||||
### Domínio puro novo/revisado
|
||||
|
||||
Criar `ChartDataWindow.kt` se necessário.
|
||||
|
||||
Responsabilidades:
|
||||
|
||||
- Receber samples + janela consultada.
|
||||
- Retornar:
|
||||
- `visibleSamples`: somente amostras reais e úteis.
|
||||
- `dataStartMillis` / `dataEndMillis`: domínio real do gráfico.
|
||||
- `queryStartMillis` / `queryEndMillis`: janela consultada.
|
||||
- `status`: Empty, SinglePoint, Ready.
|
||||
- resumo: duração real, média, min, max, densidade.
|
||||
- Não depender de Compose/Vico/Android.
|
||||
|
||||
### Persistência
|
||||
|
||||
Reutilizar `HeartRateSampleStore`.
|
||||
|
||||
Necessário auditar/corrigir:
|
||||
|
||||
- insert de samples;
|
||||
- active session id;
|
||||
- associação de sample com session id;
|
||||
- queries por sessão;
|
||||
- queries por período;
|
||||
- refresh após write.
|
||||
|
||||
Não fazer migração destrutiva.
|
||||
|
||||
### ViewModel
|
||||
|
||||
- Não mover BLE para ViewModel.
|
||||
- Garantir que o estado de histórico seja atualizado após persistência.
|
||||
- Expor chart windows já preparados para UI.
|
||||
- Evitar que UI calcule regras complexas.
|
||||
|
||||
### UI / Gráfico
|
||||
|
||||
- `HeartRateLineChart` deve receber domínio real de dados.
|
||||
- X deve ser relativo a `dataStartMillis`, não ao começo da janela vazia.
|
||||
- Se `samples.size < 2`, não chamar Vico.
|
||||
- Mostrar estado vazio editorial.
|
||||
- Mostrar uma linha de contexto:
|
||||
- “Consultado: Hoje”
|
||||
- “Dados encontrados: 14:03–14:17”
|
||||
- Zoom/scroll habilitado, mas sem espaço fantasma.
|
||||
- Não usar Canvas custom chart.
|
||||
|
||||
## Testes obrigatórios
|
||||
|
||||
### `ChartDataWindowTest`
|
||||
|
||||
1. Janela grande com dados no fim não gera domínio vazio gigante.
|
||||
2. Sem dados retorna Empty.
|
||||
3. Um ponto retorna SinglePoint.
|
||||
4. Dados fora de ordem são ordenados.
|
||||
5. Dados filtrados preservam timestamp real.
|
||||
6. Downsampling mantém primeiro e último ponto.
|
||||
|
||||
### Store/persistência
|
||||
|
||||
Se for viável em teste local unitário:
|
||||
|
||||
1. Salvar sample e recuperar por período.
|
||||
2. Salvar sample em sessão ativa e recuperar por session id.
|
||||
3. Finalizar sessão e recuperar samples da sessão.
|
||||
|
||||
Se não for viável sem Robolectric/instrumented test, adicionar diagnóstico via código e validar pelo fluxo ViewModel/build.
|
||||
|
||||
## Implementação em fatias
|
||||
|
||||
### Fase 1 — Auditoria e causa raiz
|
||||
|
||||
- Ler `HeartRateSampleStore`.
|
||||
- Ler `LiveHeartRateViewModel`.
|
||||
- Ler `LiveHeartRateUiState`.
|
||||
- Ler `HomeScreen` chart/history.
|
||||
- Localizar onde sample é salvo.
|
||||
- Localizar onde `historySamples` é atualizado.
|
||||
- Localizar como Vico define X/domain.
|
||||
|
||||
### Fase 2 — Domínio de janela de gráfico
|
||||
|
||||
- Criar `ChartDataWindow.kt`.
|
||||
- Criar `ChartDataWindowTest.kt`.
|
||||
- Garantir domínio real `dataStartMillis`→`dataEndMillis`.
|
||||
- Garantir estado Empty/SinglePoint/Ready.
|
||||
|
||||
### Fase 3 — Persistência e refresh
|
||||
|
||||
- Corrigir sample/session association se necessário.
|
||||
- Corrigir reload de histórico depois de salvar/finalizar.
|
||||
- Adicionar método de query se faltar.
|
||||
|
||||
### Fase 4 — Gráfico profissional
|
||||
|
||||
- Atualizar `HeartRateLineChart` para usar domínio real.
|
||||
- Remover scroll fantasma.
|
||||
- Adicionar zoom/scroll Vico com domínio real.
|
||||
- Remover render de gráfico vazio.
|
||||
- Mostrar resumo consultado vs encontrado.
|
||||
|
||||
### Fase 5 — Interface data-first
|
||||
|
||||
- Histórico como centro de dados, não card secundário.
|
||||
- Sessões e dados recentes com hierarquia clara.
|
||||
- Evitar dezenas de cards iguais.
|
||||
- Estados vazios úteis e específicos.
|
||||
|
||||
### Fase 6 — Validação final
|
||||
|
||||
- `./gradlew :app:testDebugUnitTest :app:assembleDebug`
|
||||
- Atualizar Obsidian roadmap.
|
||||
- Enviar APK Telegram.
|
||||
|
||||
## Critérios de sucesso
|
||||
|
||||
- Usuário abre Histórico e vê dados reais sem procurar em espaço vazio.
|
||||
- O gráfico começa/termina no intervalo real dos dados encontrados.
|
||||
- Sem dados: nenhum gráfico é desenhado.
|
||||
- Dados salvos são recuperáveis por período e por sessão.
|
||||
- Testes de domínio passam.
|
||||
- Build completo passa.
|
||||
- APK enviado.
|
||||
@@ -0,0 +1,691 @@
|
||||
# Spec: FightRate P0 — Perfil Avançado e Insights do Lutador
|
||||
|
||||
## Objective
|
||||
|
||||
Criar a primeira versão do Perfil Avançado do Lutador para coletar dados pessoais, cardiovasculares, subjetivos e de recuperação suficientes para gerar insights úteis de treino sem deixar o app inchado.
|
||||
|
||||
O objetivo não é diagnóstico médico. O objetivo é apoio esportivo: entender carga, recuperação, hidratação, intensidade e evolução do atleta usando os batimentos reais do sensor BLE somados a dados simples informados pelo usuário.
|
||||
|
||||
Usuário principal: lutador ou praticante de treino de combate que usa o FightRate para acompanhar batimentos durante treinos, rounds e sessões.
|
||||
|
||||
## Scope P0
|
||||
|
||||
Implementar somente o núcleo inicial:
|
||||
|
||||
1. Perfil do Lutador.
|
||||
2. Check-in pré-treino.
|
||||
3. Registro pós-treino.
|
||||
4. Insights básicos do treino e recuperação.
|
||||
|
||||
Não implementar neste P0:
|
||||
|
||||
- exames médicos avançados;
|
||||
- integração com wearable externo além do sensor BLE atual;
|
||||
- Health Connect;
|
||||
- prescrição médica;
|
||||
- VO2max avançado;
|
||||
- IA generativa para recomendação clínica;
|
||||
- dashboard muito complexo.
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. O app continua Android nativo Kotlin + Jetpack Compose.
|
||||
2. Textos visíveis continuam em PT-BR.
|
||||
3. Identificadores de código continuam em inglês.
|
||||
4. SQLite local-first continua como fonte de verdade.
|
||||
5. O sensor BLE continua sendo fonte dos batimentos em tempo real.
|
||||
6. Cálculos de saúde serão apresentados como estimativas esportivas, não diagnósticos.
|
||||
7. O P0 deve priorizar campos que geram cálculos imediatos.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Kotlin
|
||||
- Jetpack Compose Material3
|
||||
- SQLite via `SQLiteOpenHelper` existente em `HeartRateSampleStore`
|
||||
- Gradle Kotlin DSL
|
||||
- JUnit 4 para testes unitários
|
||||
- Vico para gráficos existentes
|
||||
- Lucide para ícones existentes
|
||||
|
||||
## Commands
|
||||
|
||||
Build completo:
|
||||
|
||||
```bash
|
||||
./gradlew :app:testDebugUnitTest :app:assembleDebug
|
||||
```
|
||||
|
||||
Teste unitário focado:
|
||||
|
||||
```bash
|
||||
./gradlew :app:testDebugUnitTest --tests "*AthleteInsights*"
|
||||
```
|
||||
|
||||
Compilação rápida:
|
||||
|
||||
```bash
|
||||
./gradlew :app:compileDebugKotlin
|
||||
```
|
||||
|
||||
Instalação debug:
|
||||
|
||||
```bash
|
||||
./gradlew :app:installDebug
|
||||
```
|
||||
|
||||
## Existing Code to Reuse
|
||||
|
||||
Reaproveitar antes de criar novo:
|
||||
|
||||
- `AthleteProfile.kt`
|
||||
- `HeartRateZones.kt`
|
||||
- `WorkoutSession.kt`
|
||||
- `HeartRateStats.kt`
|
||||
- `HeartRateSampleStore.kt`
|
||||
- `LiveHeartRateViewModel.kt`
|
||||
- `LiveHeartRateUiState.kt`
|
||||
- `HomeScreen.kt`
|
||||
- `FightCard`
|
||||
- `MetricTile`
|
||||
- `LiveMetricPill`
|
||||
- cards de Perfil existentes
|
||||
- SQLite `fightrate.db`
|
||||
|
||||
## Data Model — P0 Fields
|
||||
|
||||
### 1. Perfil básico
|
||||
|
||||
Campos:
|
||||
|
||||
- `age`
|
||||
- `biologicalSex`
|
||||
- `heightCm`
|
||||
- `weightKg`
|
||||
- `fightSport`
|
||||
- `weightClassKg`
|
||||
- `trainingGoal`
|
||||
|
||||
Uso:
|
||||
|
||||
- personalizar zonas;
|
||||
- calcular IMC;
|
||||
- acompanhar categoria de peso;
|
||||
- contextualizar insights.
|
||||
|
||||
### 2. Perfil cardiovascular
|
||||
|
||||
Campos:
|
||||
|
||||
- `restingHeartRate`
|
||||
- `measuredMaxHeartRate`
|
||||
|
||||
Uso:
|
||||
|
||||
- limite cardíaco personalizado;
|
||||
- zonas por percentual;
|
||||
- reserva cardíaca;
|
||||
- alertas de esforço.
|
||||
|
||||
### 3. Check-in pré-treino
|
||||
|
||||
Campos por sessão:
|
||||
|
||||
- `preWorkoutEnergy` escala 1–5
|
||||
- `preWorkoutFatigue` escala 1–5
|
||||
- `muscleSoreness` escala 1–5
|
||||
- `sleepHours`
|
||||
- `sleepQuality` escala 1–5
|
||||
- `preWorkoutWeightKg`
|
||||
- `waterIntakeMl`
|
||||
- `symptomsNote`
|
||||
|
||||
Uso:
|
||||
|
||||
- prontidão;
|
||||
- risco de fadiga;
|
||||
- contexto para batimentos altos;
|
||||
- hidratação estimada.
|
||||
|
||||
### 4. Pós-treino
|
||||
|
||||
Campos por sessão:
|
||||
|
||||
- `postWorkoutRpe` escala 1–10
|
||||
- `postWorkoutFatigue` escala 1–5
|
||||
- `postWorkoutSoreness` escala 1–5
|
||||
- `postWorkoutWeightKg`
|
||||
- `postWorkoutNote`
|
||||
|
||||
Uso:
|
||||
|
||||
- carga subjetiva;
|
||||
- perda hídrica;
|
||||
- recuperação;
|
||||
- comparação entre percepção e batimentos.
|
||||
|
||||
## Calculations — P0
|
||||
|
||||
### 1. IMC
|
||||
|
||||
Entrada:
|
||||
|
||||
- peso
|
||||
- altura
|
||||
|
||||
Resultado:
|
||||
|
||||
- `bmi`
|
||||
- faixa textual simples
|
||||
|
||||
### 2. Reserva cardíaca
|
||||
|
||||
Entrada:
|
||||
|
||||
- limite cardíaco real ou estimado
|
||||
- FC repouso
|
||||
|
||||
Resultado:
|
||||
|
||||
- `heartRateReserve`
|
||||
|
||||
### 3. Zonas por Karvonen
|
||||
|
||||
Entrada:
|
||||
|
||||
- limite cardíaco
|
||||
- FC repouso
|
||||
|
||||
Resultado:
|
||||
|
||||
- zonas mais personalizadas para o atleta.
|
||||
|
||||
### 4. Score de treino
|
||||
|
||||
Entrada:
|
||||
|
||||
- duração da sessão
|
||||
- BPM médio
|
||||
- BPM pico
|
||||
- tempo em zonas
|
||||
- RPE pós-treino
|
||||
|
||||
Resultado:
|
||||
|
||||
- score 0–100
|
||||
- rótulo: leve, moderado, intenso, extremo
|
||||
|
||||
### 5. Carga subjetiva
|
||||
|
||||
Entrada:
|
||||
|
||||
- RPE pós-treino
|
||||
- duração em minutos
|
||||
|
||||
Resultado:
|
||||
|
||||
- `sessionLoad = RPE × durationMinutes`
|
||||
|
||||
### 6. Score de recuperação
|
||||
|
||||
Entrada:
|
||||
|
||||
- sono
|
||||
- qualidade do sono
|
||||
- energia antes
|
||||
- fadiga antes
|
||||
- dor muscular
|
||||
- FC repouso opcional
|
||||
|
||||
Resultado:
|
||||
|
||||
- score 0–100
|
||||
- rótulo: baixa, ok, boa, excelente
|
||||
|
||||
### 7. Hidratação estimada
|
||||
|
||||
Entrada:
|
||||
|
||||
- peso antes
|
||||
- peso depois
|
||||
- água ingerida
|
||||
|
||||
Resultado:
|
||||
|
||||
- perda de peso no treino
|
||||
- perda hídrica estimada
|
||||
- alerta se perda relativa for alta
|
||||
|
||||
### 8. Tempo em zona de luta
|
||||
|
||||
Entrada:
|
||||
|
||||
- samples de BPM da sessão
|
||||
- zonas cardíacas
|
||||
|
||||
Resultado:
|
||||
|
||||
- tempo em zona alta/intensa
|
||||
- percentual do treino em zona de luta
|
||||
|
||||
### 9. Alertas de segurança esportiva
|
||||
|
||||
Entrada:
|
||||
|
||||
- sintomas
|
||||
- fadiga alta
|
||||
- sono ruim
|
||||
- dor alta
|
||||
- esforço cardíaco alto
|
||||
|
||||
Resultado:
|
||||
|
||||
- mensagens de atenção no app.
|
||||
|
||||
Exemplos:
|
||||
|
||||
- “Sono baixo + fadiga alta: considere treino técnico ou leve.”
|
||||
- “Perda de peso alta no treino: reponha líquidos.”
|
||||
- “Esforço muito alto para uma recuperação baixa.”
|
||||
|
||||
## UI/UX
|
||||
|
||||
### Tela Perfil
|
||||
|
||||
Adicionar seções recolhíveis ou cards:
|
||||
|
||||
1. Básico
|
||||
2. Cardiovascular
|
||||
3. Luta
|
||||
|
||||
Evitar formulário gigante em uma tela só.
|
||||
|
||||
### Tela Check-in
|
||||
|
||||
Fluxo simples antes de iniciar sessão:
|
||||
|
||||
- energia
|
||||
- fadiga
|
||||
- dor
|
||||
- sono
|
||||
- peso
|
||||
- água
|
||||
- sintomas
|
||||
|
||||
Pode ser card ou bottom sheet antes de iniciar treino.
|
||||
|
||||
### Tela Pós-treino
|
||||
|
||||
Após encerrar sessão:
|
||||
|
||||
- RPE
|
||||
- fadiga
|
||||
- dor
|
||||
- peso pós
|
||||
- observação
|
||||
|
||||
### Tela Insights do Lutador
|
||||
|
||||
Primeira versão com cards:
|
||||
|
||||
- Score do treino
|
||||
- Recuperação
|
||||
- Hidratação
|
||||
- Tempo em zona de luta
|
||||
- Carga subjetiva
|
||||
- Alertas
|
||||
|
||||
## Project Structure
|
||||
|
||||
Novos arquivos prováveis:
|
||||
|
||||
```text
|
||||
app/src/main/java/com/healthtracking/fightrate/domain/AthleteInsights.kt
|
||||
app/src/main/java/com/healthtracking/fightrate/domain/ReadinessScore.kt
|
||||
app/src/main/java/com/healthtracking/fightrate/domain/HydrationEstimate.kt
|
||||
app/src/test/java/com/healthtracking/fightrate/domain/AthleteInsightsTest.kt
|
||||
```
|
||||
|
||||
Arquivos existentes a alterar:
|
||||
|
||||
```text
|
||||
AthleteProfile.kt
|
||||
WorkoutSession.kt
|
||||
HeartRateSampleStore.kt
|
||||
LiveHeartRateUiState.kt
|
||||
LiveHeartRateViewModel.kt
|
||||
HomeScreen.kt
|
||||
```
|
||||
|
||||
A criação de novos arquivos deve ser mínima e justificada por responsabilidade clara.
|
||||
|
||||
## Code Style
|
||||
|
||||
Exemplo esperado:
|
||||
|
||||
```kotlin
|
||||
data class ReadinessInput(
|
||||
val sleepHours: Double?,
|
||||
val sleepQuality: Int?,
|
||||
val energy: Int?,
|
||||
val fatigue: Int?,
|
||||
val soreness: Int?,
|
||||
)
|
||||
|
||||
object ReadinessScoreCalculator {
|
||||
fun calculate(input: ReadinessInput): ReadinessScore {
|
||||
val score = 100
|
||||
.minus(input.fatigue.orDefaultPenalty(multiplier = 8))
|
||||
.minus(input.soreness.orDefaultPenalty(multiplier = 6))
|
||||
.plus(input.energy.orDefaultBonus(multiplier = 5))
|
||||
.coerceIn(0, 100)
|
||||
|
||||
return ReadinessScore(value = score)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Convenções:
|
||||
|
||||
- código em inglês;
|
||||
- UI em PT-BR;
|
||||
- cálculo puro no domínio;
|
||||
- Compose apenas renderiza estado;
|
||||
- evitar mágica sem teste.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Testar com JUnit:
|
||||
|
||||
- IMC
|
||||
- reserva cardíaca
|
||||
- Karvonen
|
||||
- score de recuperação
|
||||
- carga subjetiva
|
||||
- hidratação estimada
|
||||
- tempo em zona de luta
|
||||
- alertas esportivos
|
||||
|
||||
Comandos:
|
||||
|
||||
```bash
|
||||
./gradlew :app:testDebugUnitTest --tests "*AthleteInsights*"
|
||||
./gradlew :app:testDebugUnitTest :app:assembleDebug
|
||||
```
|
||||
|
||||
## Boundaries
|
||||
|
||||
### Always
|
||||
|
||||
- Criar cálculo no domínio antes da UI.
|
||||
- Usar testes para cálculo.
|
||||
- Manter campos opcionais quando possível.
|
||||
- Explicar que cálculos são estimativas esportivas.
|
||||
- Reaproveitar `AthleteProfile`, `WorkoutSession`, `HeartRateSampleStore`.
|
||||
- Salvar dados localmente.
|
||||
|
||||
### Ask first
|
||||
|
||||
- Adicionar dependência nova.
|
||||
- Adicionar Health Connect.
|
||||
- Criar tela nova separada se puder ser card inicialmente.
|
||||
- Fazer alteração destrutiva no banco.
|
||||
- Criar cálculo médico sensível.
|
||||
- Coletar dados muito pessoais sem justificativa.
|
||||
|
||||
### Never
|
||||
|
||||
- Apresentar diagnóstico médico.
|
||||
- Bloquear treino como decisão médica.
|
||||
- Criar formulário gigante difícil de usar.
|
||||
- Adicionar campos que não alimentam nenhum cálculo.
|
||||
- Criar código morto para métricas futuras.
|
||||
- Misturar todos os cálculos dentro da UI.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Usuário consegue preencher dados P0 sem fricção excessiva.
|
||||
- App calcula pelo menos:
|
||||
- IMC;
|
||||
- reserva cardíaca;
|
||||
- score de treino;
|
||||
- carga subjetiva;
|
||||
- score de recuperação;
|
||||
- hidratação estimada;
|
||||
- tempo em zona de luta.
|
||||
- Dados ficam persistidos localmente.
|
||||
- Insights aparecem em PT-BR.
|
||||
- Cálculos puros têm testes.
|
||||
- Build final passa.
|
||||
- Nenhuma dependência nova é adicionada sem aprovação.
|
||||
|
||||
## Decisions Approved
|
||||
|
||||
1. Check-in pré-treino será obrigatório antes de iniciar uma sessão.
|
||||
2. Insights será uma nova aba no menu inferior.
|
||||
3. Sexo biológico será coletado como `Masculino` ou `Feminino`.
|
||||
4. Energia, fadiga, dor e sono usarão escala 0–10.
|
||||
5. Campos avançados devem poder ficar ocultos/expandidos para manter a interface limpa.
|
||||
|
||||
## BLE Reliability and Battery Plan
|
||||
|
||||
### Objective
|
||||
|
||||
A leitura do sensor deve continuar enquanto o app estiver em treino, inclusive com tela bloqueada, salvo quando:
|
||||
|
||||
1. o usuário desconectar manualmente;
|
||||
2. o usuário encerrar o treino;
|
||||
3. o Android remover o processo por condição extrema fora do controle do app.
|
||||
|
||||
Quando houver queda por distância/sinal, o serviço deve tentar reconectar automaticamente quando o usuário se aproximar novamente. Se o usuário desconectar manualmente, o app não deve reconectar sozinho.
|
||||
|
||||
### BLE Rules
|
||||
|
||||
- O `HeartRateForegroundService` continua sendo dono da conexão BLE.
|
||||
- O ViewModel não deve abrir conexão direta com o sensor.
|
||||
- Toda queda inesperada deve ser registrada como estado de conexão e mensagem visível.
|
||||
- Desconexão manual deve salvar uma flag `manualDisconnect = true`.
|
||||
- Queda inesperada deve salvar `manualDisconnect = false` e iniciar retry/backoff.
|
||||
- O último sensor selecionado deve ser preservado para reconexão automática.
|
||||
|
||||
### Reconnect Strategy
|
||||
|
||||
Estados sugeridos:
|
||||
|
||||
```text
|
||||
Idle
|
||||
Connecting
|
||||
Connected
|
||||
LostSignal
|
||||
RetryWaiting
|
||||
ManualDisconnected
|
||||
Failed
|
||||
```
|
||||
|
||||
Backoff conservador:
|
||||
|
||||
```text
|
||||
1ª tentativa: 2s
|
||||
2ª tentativa: 5s
|
||||
3ª tentativa: 10s
|
||||
4ª tentativa em diante: 30s
|
||||
```
|
||||
|
||||
Regras:
|
||||
|
||||
- Não tentar reconectar se `manualDisconnect = true`.
|
||||
- Não tentar reconectar se Bluetooth estiver desligado; aguardar próximo ciclo.
|
||||
- Não tentar reconectar sem permissão Bluetooth.
|
||||
- Não fazer scan agressivo contínuo.
|
||||
- Para sensor já conhecido, tentar reconectar por `deviceId` direto antes de scan.
|
||||
- Scan só deve ser usado quando não houver deviceId válido.
|
||||
|
||||
### Battery Strategy
|
||||
|
||||
Para menor consumo:
|
||||
|
||||
- Foreground service único durante sessão.
|
||||
- Sem polling pesado no ViewModel a cada batimento.
|
||||
- SQLite insert apenas por amostra recebida.
|
||||
- Notificação com throttle.
|
||||
- Reconexão com backoff, nunca loop apertado.
|
||||
- Metadata do sensor lida na conexão e depois em intervalo longo, não a cada amostra.
|
||||
- Gráficos atualizam pelo estado recebido, sem reconsultar o banco inteiro.
|
||||
- Evitar wake locks permanentes. Usar foreground service e orientação de ignorar otimização de bateria.
|
||||
|
||||
### Acceptance BLE
|
||||
|
||||
- Tela bloqueada não interrompe leitura durante treino.
|
||||
- Distância/sinal perdido mostra status de sensor perdido.
|
||||
- Ao aproximar novamente, o app tenta reconectar sozinho.
|
||||
- Se usuário tocar em Desconectar, reconexão automática fica desativada.
|
||||
- Se usuário escolher o sensor/iniciar novamente, reconexão automática volta a valer.
|
||||
- Build passa.
|
||||
|
||||
## Recommended First Implementation Plan
|
||||
|
||||
Após aprovação desta spec:
|
||||
|
||||
1. Criar domínio puro de insights e testes.
|
||||
2. Expandir modelo de perfil e sessão com campos P0.
|
||||
3. Adicionar migração SQLite aditiva.
|
||||
4. Criar UI de Perfil Avançado com seções expansíveis.
|
||||
5. Criar check-in pré-treino obrigatório antes de iniciar sessão.
|
||||
6. Criar pós-treino ao encerrar sessão.
|
||||
7. Criar nova aba Insights com cards básicos.
|
||||
8. Melhorar BLE com desconexão manual vs queda inesperada, reconexão automática e backoff.
|
||||
|
||||
|
||||
9. Rodar build/test completo.
|
||||
|
||||
---
|
||||
|
||||
# Spec Expansion: Gráficos, Janela de Treino e Relatório Científico
|
||||
|
||||
## Objective
|
||||
|
||||
Transformar a sessão do FightRate em uma janela de coleta confiável com relatório pós-treino detalhado, gráficos Vico mais legíveis/interativos e métricas esportivas fundamentadas em frequência cardíaca real do sensor BLE.
|
||||
|
||||
O objetivo é interpretação esportiva para lutas, não diagnóstico médico. Toda métrica estimada deve ser rotulada como estimativa.
|
||||
|
||||
## Research Summary
|
||||
|
||||
Fontes consultadas antes da implementação:
|
||||
|
||||
1. Cole et al., NEJM 1999, PubMed: `https://pubmed.ncbi.nlm.nih.gov/10536127/`
|
||||
- HR Recovery é queda da FC do pico para 1 minuto pós-exercício.
|
||||
- Queda <= 12 bpm em 1 min foi considerada anormal no estudo clínico.
|
||||
2. Brubaker & Kitzman, Circulation review, PMC: `https://pmc.ncbi.nlm.nih.gov/articles/PMC3065291/`
|
||||
- Resposta cronotrópica é capacidade de aumentar FC com demanda.
|
||||
- Critérios clínicos exigem teste progressivo e contexto; no app usar só interpretação esportiva.
|
||||
- HRR 1 min < 12 bpm, 2 min < 42 bpm aparecem como thresholds de risco em contexto clínico, mas o app não diagnostica.
|
||||
3. Uth et al., Eur J Appl Physiol 2004, PubMed: `https://pubmed.ncbi.nlm.nih.gov/14624296/`
|
||||
- VO2max estimado pode usar `15.3 * (HRmax / HRrest)` em população treinada.
|
||||
- Aplicabilidade fora desse perfil deve ser tratada como aproximação.
|
||||
4. Boxing HR response, PMC: `https://pmc.ncbi.nlm.nih.gov/articles/PMC3871409/`
|
||||
- Boxe simulado passou grande parte acima de VT2; HR aumenta por round.
|
||||
- Reforça foco em tempo em zonas altas, recuperação entre rounds e drift.
|
||||
5. Muay Thai physiological response, Ingenta: `https://www.ingentaconnect.com/content/uwic/ujpa/2012/00000012/00000003/art00004`
|
||||
- Muay Thai competitivo teve FC média próxima do máximo e alto estresse cardiovascular.
|
||||
6. Edwards/TRIMP summaries: `https://www.trainingimpulse.com/edwards-trimp` e `https://journals.lww.com/nsca-scj/fulltext/2018/04000/quantification_of_training_load_in_endurance.12.aspx`
|
||||
- Edwards TRIMP é prático por zonas, mas coeficientes são arbitrários.
|
||||
- Banister usa reserva cardíaca e ponderação exponencial; ambos devem ser apresentados como carga interna estimada.
|
||||
7. HRV em Muay Thai, PMC: `https://pmc.ncbi.nlm.nih.gov/articles/PMC12143277/`
|
||||
- RMSSD/SDNN exigem RR/NN intervals confiáveis. O app hoje coleta BPM, portanto HRV fica fora do escopo até o parser/persistência salvarem RR intervals.
|
||||
|
||||
## Implementable Metrics From Current Data
|
||||
|
||||
Viáveis agora:
|
||||
|
||||
- duração total da sessão;
|
||||
- duração ativa aproximada e pausada;
|
||||
- BPM inicial/final/mínimo/médio/máximo;
|
||||
- desvio padrão;
|
||||
- qualidade dos dados por densidade, gaps e duração;
|
||||
- tempo e percentual por zona;
|
||||
- tempo em zona de luta, RedLine e Limite;
|
||||
- maior sequência contínua em zona intensa;
|
||||
- picos e quedas relevantes;
|
||||
- HR Recovery 1min/2min quando houver amostras pós-pico;
|
||||
- drift primeira metade vs segunda metade;
|
||||
- slope de BPM por minuto;
|
||||
- resposta cronotrópica inicial aproximada;
|
||||
- tempo até zona vigorosa/luta;
|
||||
- Edwards TRIMP;
|
||||
- Banister TRIMP quando houver FC repouso e limite cardíaco;
|
||||
- carga subjetiva RPE x duração;
|
||||
- VO2max estimado quando houver FC repouso e limite cardíaco;
|
||||
- hidratação estimada com peso pré/pós e água;
|
||||
- interpretação cruzada com energy/fatigue/soreness/sleep/symptoms/RPE.
|
||||
|
||||
Não viável agora sem novos dados:
|
||||
|
||||
- HRV RMSSD/SDNN/pNN50 real;
|
||||
- diagnóstico de arritmia;
|
||||
- limiar ventilatório real;
|
||||
- VO2 real;
|
||||
- economia cardíaca precisa entre sessões sem normalizar tipo/duração/intensidade.
|
||||
|
||||
## Data Collection / Time Window
|
||||
|
||||
Reaproveitar `workout_sessions`:
|
||||
|
||||
- `startedAtMillis` define início do range.
|
||||
- `endedAtMillis` define fim do range.
|
||||
- `session_id` em `heart_rate_samples` associa amostra à sessão ativa.
|
||||
- Ao finalizar, buscar amostras por `session_id`; fallback por intervalo se necessário.
|
||||
- Foreground service continua dono do BLE.
|
||||
- ViewModel não assume BLE.
|
||||
- Migração SQLite só aditiva se relatório persistido exigir cache.
|
||||
|
||||
## Domain Design
|
||||
|
||||
Criar ou expandir domínio puro para relatório:
|
||||
|
||||
- `TrainingReportMetrics`
|
||||
- `SessionReportBuilder`
|
||||
- `DataQualityAnalyzer`
|
||||
- `ZoneDistributionCalculator`
|
||||
- `HeartRateRecoveryCalculator`
|
||||
- `CardiacDriftCalculator`
|
||||
- `ChronotropicResponseCalculator`
|
||||
- `TrainingLoadCalculator`
|
||||
- `HydrationTrainingInterpreter`
|
||||
|
||||
Business rules fora do Compose.
|
||||
|
||||
## UI / Charts
|
||||
|
||||
Reaproveitar `HeartRateLineChart`, `FightCard`, `MetricTile`.
|
||||
|
||||
Adicionar:
|
||||
|
||||
- chart summary pills de mínimo/médio/máximo;
|
||||
- legenda de zonas;
|
||||
- destaque textual para tempo em zona de luta/limite;
|
||||
- estados profissionais para poucos dados;
|
||||
- leitura de eixo X por tempo real;
|
||||
- relatório em seções expansíveis:
|
||||
1. Resumo do treino;
|
||||
2. Gráfico principal;
|
||||
3. Zonas cardíacas;
|
||||
4. Recuperação;
|
||||
5. Carga de treino;
|
||||
6. Drift e fadiga;
|
||||
7. Hidratação;
|
||||
8. Comparação com histórico;
|
||||
9. Alertas e interpretação.
|
||||
|
||||
Evitar Canvas customizado e dependências novas.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Spec atualizada antes do código.
|
||||
- Código existente auditado e reaproveitado.
|
||||
- Testes JUnit cobrem todos os cálculos de domínio novos.
|
||||
- Sessão finalizada gera relatório com amostras do range correto.
|
||||
- Histórico permite abrir relatório de sessão.
|
||||
- UI deixa claro estimativa esportiva vs dado medido.
|
||||
- BLE continua no foreground service.
|
||||
- Sem migração destrutiva.
|
||||
- `./gradlew :app:testDebugUnitTest :app:assembleDebug` termina com `BUILD SUCCESSFUL`.
|
||||
- APK gerado e tentativa de `telegram_attach(localPath)` realizada.
|
||||
@@ -0,0 +1,27 @@
|
||||
# FightRate RN — Background Capture + Corner Instrument Redesign
|
||||
|
||||
## Objective
|
||||
Make the bare React Native Android app capture heart-rate data continuously until the athlete manually stops the workout, including when the screen is locked or the app is backgrounded. Replace foreground-JS BLE ownership and generic card UI with native Android capture ownership, local SQLite persistence, and Corner Instrument screens.
|
||||
|
||||
## Success Criteria
|
||||
- Capture is started/stopped from React Native, but BLE scan/connect/monitor runs in an Android foreground service.
|
||||
- Android manifest declares Bluetooth, notification, foreground service, and battery optimization permissions needed for continuous capture.
|
||||
- The foreground service repeats scan/reconnect attempts unless stopped manually.
|
||||
- Heart Rate Service `180D` and Heart Rate Measurement `2A37` are used for real BLE BPM data.
|
||||
- Each BPM sample is timestamped and persisted locally in SQLite.
|
||||
- History and insights use persisted real sessions/samples; no mock BPM/history/insight data is displayed.
|
||||
- UI follows `FIGHTRATE_DESIGN_SYSTEM.md` / Corner Instrument: dark physical instrument surfaces, dominant BPM, technical strips, no nested generic card dashboard.
|
||||
- Release APK builds as an installable arm64 artifact and is sent to Telegram.
|
||||
|
||||
## Boundaries
|
||||
- React Native bare CLI only; no Expo.
|
||||
- Android-first implementation; iOS background BLE is out of scope.
|
||||
- Physical Magene H003 validation remains required on device.
|
||||
- Manual destructive deletion of non-design project files is avoided.
|
||||
|
||||
## Verification
|
||||
- `npm run typecheck`
|
||||
- `npm run lint`
|
||||
- `npm test -- --runInBand`
|
||||
- `npm audit --audit-level=moderate`
|
||||
- `cd FightRateRN/android && ./gradlew assembleRelease -PreactNativeArchitectures=arm64-v8a`
|
||||
@@ -0,0 +1,86 @@
|
||||
# FightRate — Redesign de gráficos e sistema visual
|
||||
|
||||
## Objetivo
|
||||
|
||||
Corrigir a direção visual do FightRate: gráficos profissionais, úteis e interativos; interface menos genérica/card-heavy; design system original e obrigatório para próximas telas.
|
||||
|
||||
## Problemas atuais
|
||||
|
||||
- Gráficos aparecem mesmo sem dados úteis.
|
||||
- Estados vazios parecem gráficos quebrados/amadores.
|
||||
- Vico está subutilizado: pouca interação, pouco contexto visual, sem zoom/scroll claro.
|
||||
- UI tem excesso de cards iguais, pouca hierarquia e pouco padrão visual.
|
||||
- O aplicativo parece montado por IA, não por um produto esportivo premium.
|
||||
|
||||
## Decisões
|
||||
|
||||
- Manter Vico. Pesquisa em 2026-06 mostra Vico como biblioteca Compose/Kotlin madura com suporte a `scrollState` e `zoomState`.
|
||||
- Não trocar dependências nesta fase.
|
||||
- Não desenhar chart customizado em Canvas.
|
||||
- Não renderizar gráfico se houver menos de 2 pontos reais.
|
||||
- Se houver poucos dados, mostrar insight/empty state, não eixos vazios.
|
||||
- O eixo temporal deve continuar baseado em timestamps reais.
|
||||
- O design system passa a ser a fonte visual do app.
|
||||
|
||||
## Design System: FightRate Combat Lab
|
||||
|
||||
### Conceito
|
||||
|
||||
Interface de laboratório de performance para lutas: escura, precisa, atlética, com sensação de equipamento esportivo premium. Menos cartões genéricos, mais painéis editoriais, faixas de dados, chips compactos, e superfícies com função clara.
|
||||
|
||||
### Tokens principais
|
||||
|
||||
- Fundo: `#090A0B` — preto quente de OLED.
|
||||
- Surface: `#111315` — bloco primário.
|
||||
- Surface Elevated: `#181B1E` — módulo de leitura.
|
||||
- Ink: `#F6F0E7` — texto principal quente.
|
||||
- Muted: `#A79C8F` — texto secundário.
|
||||
- Dim: `#6E665D` — metadados.
|
||||
- Accent: `#FF6A1A` — FightRate Orange.
|
||||
- Amber: `#F6B44B` — atenção.
|
||||
- Redline: `#FF3B30` — limite/risco.
|
||||
- Cyan: `#48C7D8` — recuperação/ciência.
|
||||
- Line: `#2A2E32` — divisores sutis.
|
||||
|
||||
### Forma
|
||||
|
||||
- Telas: blocos editoriais com seções claras, não grade de cards repetidos.
|
||||
- Painéis principais: radius 28–32 dp, borda hairline, padding 20–24 dp.
|
||||
- Métricas pequenas: strips/pills, não cards grandes.
|
||||
- Gráfico: painel amplo, prioridade visual máxima, legenda compacta.
|
||||
- Empty state: peça editorial com próxima ação, nunca gráfico vazio.
|
||||
|
||||
### Tipografia
|
||||
|
||||
- Números grandes, tabulares quando possível.
|
||||
- Títulos curtos, fortes, em PT-BR.
|
||||
- Corpo pequeno, mas legível. Nada abaixo de 10sp para informação essencial.
|
||||
- Evitar muitos pesos/tamanhos diferentes.
|
||||
|
||||
### Gráficos
|
||||
|
||||
- Mostrar gráfico somente se `samples.size >= 2` e duração > 0.
|
||||
- Para 1 ponto: mostrar leitura única e instrução de continuar medindo.
|
||||
- Para 0 pontos: mostrar estado vazio contextual.
|
||||
- Linha com espessura alta, cor FightRate Orange, área/ambiente escuro.
|
||||
- Scroll/zoom habilitado via Vico quando possível.
|
||||
- Controles explícitos de janela/zoom fora do gráfico.
|
||||
- Resumo sempre visível: média, pico, mínima, duração, densidade de amostras.
|
||||
- Não usar índices como tempo; usar segundos/minutos reais desde o início da janela.
|
||||
|
||||
## Fatias de implementação
|
||||
|
||||
1. Consolidar tokens em tema/design system.
|
||||
2. Criar componentes visuais base: `FightSection`, `FightMetricStrip`, `FightEmptyState`, `FightChartPanel`.
|
||||
3. Atualizar `HeartRateLineChart` para não renderizar vazio e usar zoom/scroll Vico.
|
||||
4. Reorganizar Home/Histórico para priorizar gráfico e dados úteis.
|
||||
5. Atualizar documentação e roadmap.
|
||||
6. Rodar testes/build e gerar APK.
|
||||
|
||||
## Critérios de sucesso
|
||||
|
||||
- Nenhum gráfico vazio é renderizado.
|
||||
- Gráfico com dados tem zoom/scroll ou controle equivalente claro.
|
||||
- Histórico sem dados mostra estado vazio premium.
|
||||
- UI usa tokens e componentes do design system.
|
||||
- Build final passa com `./gradlew :app:testDebugUnitTest :app:assembleDebug`.
|
||||
@@ -0,0 +1,41 @@
|
||||
# FightRate — Correção de produto: captura contínua, gráfico e sensor
|
||||
|
||||
## Contexto
|
||||
|
||||
Feedback do usuário: o app serve para monitorar batimento cardíaco enquanto o atleta treina. Durante luta/treino, o usuário não vai pegar o celular para iniciar/parar rounds manualmente.
|
||||
|
||||
## Objetivos
|
||||
|
||||
1. Tornar captura contínua de treino inteiro o fluxo principal.
|
||||
2. Manter modo round como secundário/opcional, sem dominar a tela principal.
|
||||
3. Corrigir gráfico para abrir com a janela selecionada completa, sem zoom inicial estranho.
|
||||
4. Tornar eixo temporal adaptativo e menos poluído.
|
||||
5. Melhorar tela de sensor para exibir dados/serviços disponíveis do dispositivo.
|
||||
6. Melhorar insights com informação densa, útil e explicável sem textão.
|
||||
|
||||
## Regras de produto
|
||||
|
||||
- Fluxo principal: iniciar monitoramento antes do treino, largar celular, parar ao final, analisar sessão inteira.
|
||||
- Round é alternativa opcional. Preferir segmentação automática pré-configurada em vez de controles manuais durante treino.
|
||||
- Histórico precisa ser rico e organizado: sessões, janela, zonas, carga, recuperação, pico, média, densidade de amostras.
|
||||
- Gráfico deve priorizar legibilidade e fidelidade temporal.
|
||||
|
||||
## Critérios de aceitação desta fatia
|
||||
|
||||
- Tela Live comunica “captura contínua” como ação principal.
|
||||
- Botões de sessão usam linguagem de treino inteiro: iniciar/parar captura/treino.
|
||||
- Round continua disponível/visível como informação secundária, mas não como conceito central.
|
||||
- Gráfico inicia mostrando a janela de dados selecionada, sem pre-zoom e com `initialZoom = null` quando possível.
|
||||
- Labels do eixo X usam granularidade por duração:
|
||||
- até 10 min: HH:mm
|
||||
- até 1 h: HH:mm
|
||||
- até 24 h: HH'h'
|
||||
- zoom/duração muito curta: HH:mm:ss apenas quando necessário.
|
||||
- Tela Sensor exibe painel de capacidades do sensor: BPM, local do sensor, bateria, metadados, RSSI e serviços BLE conhecidos/disponíveis.
|
||||
- Build final passa com `./gradlew :app:testDebugUnitTest :app:assembleDebug`.
|
||||
|
||||
## Fora desta fatia
|
||||
|
||||
- Implementar segmentação automática completa por round.
|
||||
- Persistir todos os valores de serviços BLE desconhecidos.
|
||||
- Reescrever toda a arquitetura visual do app.
|
||||
@@ -0,0 +1,69 @@
|
||||
# FightRate — monitoramento contínuo, histórico customizado e indicadores BPM
|
||||
|
||||
## Objetivo
|
||||
Corrigir o fluxo para que o FightRate se comporte como um monitor contínuo de sensor cardíaco, e não como um app dependente da janela de treino. A janela de treino deve apenas destacar um intervalo dentro do fluxo contínuo. Também adicionar consulta histórica customizada por data/hora/minuto, estabilizar a UI do histórico e documentar indicadores úteis derivados de BPM.
|
||||
|
||||
## Problemas relatados
|
||||
1. BPM em tempo real só aparece depois de abrir e fechar uma janela de treino.
|
||||
2. Histórico precisa consultar intervalo preciso por dia, hora e minuto, inclusive múltiplos dias.
|
||||
3. Histórico tem divisores redundantes entre controles, janelas salvas e registros.
|
||||
4. Em “Uso Total”, o item expandido colapsa quando entra novo dado em tempo real.
|
||||
5. É necessário mapear indicadores técnicos/científicos extraíveis de BPM para evolução futura.
|
||||
|
||||
## Critérios de sucesso
|
||||
- Ao abrir o app, se o monitor estava ativo ou há sensor salvo, o app retoma o monitoramento contínuo automaticamente.
|
||||
- Após conectar ao sensor peitoral, os dados começam a atualizar o app inteiro sem depender de janela de treino.
|
||||
- Abrir/fechar janela de treino não interrompe nem inicia artificialmente o stream contínuo; só marca um intervalo para histórico.
|
||||
- Histórico permite modo “Faixa” com início/fim definidos por dia, hora e minuto.
|
||||
- Faixa customizada funciona no mesmo dia e entre dias diferentes.
|
||||
- Uso Total mantém o item expandido mesmo quando novas amostras chegam em segundo plano.
|
||||
- Divisores do histórico ficam lineares: uma linha sutil por seção/registro, sem duplicação visual.
|
||||
- Pesquisa de indicadores BPM gera relatório com fórmulas, dados necessários, limites e recomendações.
|
||||
- Sem dados fake; gráficos e métricas continuam derivados de amostras reais.
|
||||
|
||||
## Arquitetura proposta
|
||||
### Monitoramento contínuo
|
||||
- Manter BLE e background capture no serviço nativo Android.
|
||||
- No hook RN, diferenciar claramente:
|
||||
- `isMonitoring`: stream contínuo ativo.
|
||||
- `isCapturing`: janela de treino ativa.
|
||||
- `samples`: amostras contínuas em memória recente.
|
||||
- Na inicialização do hook, depois de `getCaptureSnapshot`, se o snapshot indicar monitor ativo/sensor salvo, retomar/confirmar `startMonitoring` sem abrir janela.
|
||||
|
||||
### Histórico customizado
|
||||
- Criar lógica pura para filtrar amostras por intervalo `[start, end]`.
|
||||
- Usar a mesma fonte de verdade (`continuous_heart_rate_samples`) para “Uso Total” e faixa customizada.
|
||||
- Representar a seleção com componentes simples sem dependência nova: campos de data e hora/minuto via `TextInput`, com validação e presets.
|
||||
|
||||
### Estabilidade do Uso Total
|
||||
- Não usar ID derivado de `startedAt-endedAt-count` como única identidade quando blocos vivos mudam com novas amostras.
|
||||
- Manter expansão por `startedAt` do bloco e re-resolver o bloco depois de cada atualização.
|
||||
|
||||
### Indicadores BPM
|
||||
- Produzir documento separado com métricas implementáveis depois:
|
||||
- FC média/mínima/máxima;
|
||||
- zonas de intensidade;
|
||||
- tempo em zona;
|
||||
- carga interna/TRIMP;
|
||||
- recuperação pós-esforço;
|
||||
- deriva cardiovascular;
|
||||
- variabilidade de FC quando houver RR intervals;
|
||||
- alertas de limiar e anomalias.
|
||||
|
||||
## Fora de escopo
|
||||
- Trocar BLE stack.
|
||||
- Adicionar dependências nativas de calendário/data.
|
||||
- Alterar design aprovado fora dos pontos pedidos.
|
||||
- Criar diagnóstico médico automatizado sem validação clínica.
|
||||
|
||||
## Testes esperados
|
||||
- Testes unitários para agrupamento de uso contínuo estável.
|
||||
- Testes unitários para filtro de intervalo customizado.
|
||||
- Testes existentes continuam passando.
|
||||
|
||||
## Verificação
|
||||
- `npm run typecheck`
|
||||
- `npm run lint`
|
||||
- `npm test -- --runInBand`
|
||||
- `npm audit --audit-level=moderate`
|
||||
- `./gradlew assembleRelease -PreactNativeArchitectures=arm64-v8a`
|
||||
@@ -0,0 +1,25 @@
|
||||
# FightRate RN — Continuous Monitoring + Training Windows
|
||||
|
||||
## Objective
|
||||
Change FightRate so BLE heart-rate monitoring is continuous and locally persisted, while a workout/training session is only a marked time window inside that continuous stream. Starting a workout begins a highlighted window; stopping it closes that window and keeps monitoring alive.
|
||||
|
||||
## Success Criteria
|
||||
- The native Android foreground service can start monitoring without creating a workout window.
|
||||
- Samples are saved continuously even when no workout window is active.
|
||||
- Start/stop workout only opens/closes a `workout_sessions` window; it does not stop BLE monitoring.
|
||||
- History lists saved windows and each item can be opened to inspect details from samples inside that window.
|
||||
- Insight and history screens use only real timestamped samples.
|
||||
- Capture/History/Insights UI removes generic card-heavy containers and uses Corner Instrument rails, strips, dividers, and instrument surfaces.
|
||||
- Existing bare React Native CLI constraint remains; no Expo.
|
||||
|
||||
## Boundaries
|
||||
- No new dependencies unless unavoidable.
|
||||
- Do not delete existing tables destructively; migrate by adding a continuous sample table and deriving session summaries from timestamps.
|
||||
- Android physical BLE validation remains required after APK install.
|
||||
|
||||
## Verification
|
||||
- `npm run typecheck`
|
||||
- `npm run lint`
|
||||
- `npm test -- --runInBand`
|
||||
- `npm audit --audit-level=moderate`
|
||||
- `cd FightRateRN/android && ./gradlew assembleRelease -PreactNativeArchitectures=arm64-v8a`
|
||||
@@ -0,0 +1,81 @@
|
||||
# FightRate Insights Científicos — Mini-spec
|
||||
|
||||
## Objetivo
|
||||
|
||||
Implementar uma nova visualização de cálculos e insights na aba `Insights`, usando métricas derivadas do relatório científico `docs/research/bpm-indicators-fightrate-2026-06-10.md`, sem inventar dados e sem abandonar o design Corner Instrument.
|
||||
|
||||
## Escopo
|
||||
|
||||
### Incluir agora
|
||||
|
||||
- Grade/tabela compacta de métricas com melhor aproveitamento vertical.
|
||||
- Cálculos com dados reais já disponíveis:
|
||||
- FC atual, média, mínima e máxima;
|
||||
- duração observada e contagem de amostras;
|
||||
- amplitude de BPM;
|
||||
- desvio padrão de BPM;
|
||||
- coeficiente de variação de BPM;
|
||||
- classificação operacional de estabilidade/oscilação;
|
||||
- tempo em zonas já calculadas;
|
||||
- carga observada existente;
|
||||
- tendência de deriva de FC quando a janela for longa o suficiente;
|
||||
- recuperação pós-janela quando houver amostras contínuas após o fim da sessão;
|
||||
- status de HRV/VFC baseado na presença de `rrIntervalsMs`.
|
||||
- Estados honestos para métricas que exigem perfil ou RR intervals.
|
||||
|
||||
### Não incluir agora
|
||||
|
||||
- Cadastro de perfil do atleta.
|
||||
- Diagnóstico médico.
|
||||
- LF/HF, frequência espectral, SampEn ou DFA reais sem pipeline de RR validado.
|
||||
- Mudanças amplas de design fora de `Insights`.
|
||||
- Novas dependências.
|
||||
|
||||
## Arquitetura
|
||||
|
||||
- Reutilizar `src/training/trainingMetrics.ts` para cálculos puros.
|
||||
- Estender `src/training/trainingTypes.ts` com tipos de resumo de insights.
|
||||
- Atualizar `src/screens/InsightsScreen.tsx` para renderizar uma tabela/grade de instrumentos, não lista simples.
|
||||
- Atualizar `src/app/FightRateApp.tsx` para passar `getContinuousSamples` ao Insights, permitindo cálculo de recuperação pós-janela com dados persistidos.
|
||||
- Testar cálculos puros em `__tests__/trainingMetrics.test.ts`.
|
||||
|
||||
## Critérios de sucesso
|
||||
|
||||
- Insights usa somente amostras reais ou mostra estado indisponível.
|
||||
- Visual evita cards genéricos; usa trilhos, linhas tabulares, separadores e linguagem Corner Instrument.
|
||||
- Métricas BPM-only aparecem com valor/unidade/status/leitura curta.
|
||||
- HRV/VFC aparece como disponível somente quando existirem RR intervals; caso contrário, informa que requer RR.
|
||||
- Recuperação pós-janela só aparece quando há amostras depois de `endedAt`.
|
||||
- Typecheck, lint e testes passam.
|
||||
- Build release Android é gerado e enviado ao Telegram ao final.
|
||||
|
||||
## Reuso vs criação
|
||||
|
||||
### Reutilizar
|
||||
|
||||
- `calculateLiveMetrics`, `calculateZones`, `formatDuration` em `trainingMetrics.ts`.
|
||||
- `HeartRateSample`, `CompletedTrainingSession` em `trainingTypes.ts`.
|
||||
- Tokens existentes em `src/theme`.
|
||||
- Fluxo nativo `getContinuousSamples` já exposto pelo hook.
|
||||
|
||||
### Criar/alterar
|
||||
|
||||
- Criar funções puras em `trainingMetrics.ts`:
|
||||
- `buildInsightDashboard()`;
|
||||
- `calculateBpmVariability()`;
|
||||
- `calculateHeartRateDrift()`;
|
||||
- `calculatePostSessionRecovery()`;
|
||||
- `summarizeRrAvailability()`.
|
||||
- Criar tipos em `trainingTypes.ts`:
|
||||
- `InsightMetricStatus`;
|
||||
- `InsightMetricRow`;
|
||||
- `InsightDashboard`.
|
||||
- Reescrever o layout interno de `InsightsScreen.tsx` para matriz tabular.
|
||||
|
||||
## Plano de execução
|
||||
|
||||
1. Escrever testes para cálculos puros.
|
||||
2. Implementar funções puras em `trainingMetrics.ts`.
|
||||
3. Passar `getContinuousSamples` para `InsightsScreen` e carregar amostras persistidas de forma segura.
|
||||
4. Recriar UI da aba Insights com tabela compacta e blocos de instrumentos.
|
||||
5. Rodar validação completa.
|
||||
@@ -0,0 +1,108 @@
|
||||
# FightRate React Native Bare Rebuild — Mini-spec
|
||||
|
||||
Data: 2026-06-10
|
||||
Status: implementação autorizada
|
||||
Stack: **React Native Community CLI, sem Expo**
|
||||
|
||||
## Objetivo
|
||||
|
||||
Criar a nova base do FightRate em React Native bare, sem Expo, usando a direção visual oficial **Corner Instrument**.
|
||||
|
||||
A implementação inicial deve provar:
|
||||
|
||||
- identidade visual escolhida;
|
||||
- fluxo principal de captura contínua;
|
||||
- estrutura de código React Native escalável;
|
||||
- telas base para Captura, Histórico, Sensor e Insights;
|
||||
- dados mockados apenas como estado de protótipo local, não como gráfico ou amostra real persistida.
|
||||
|
||||
## Fora de escopo nesta primeira fatia
|
||||
|
||||
- BLE real Magene H003.
|
||||
- SQLite real.
|
||||
- Build de APK final assinado.
|
||||
- Migração de todos os recursos do Android legado.
|
||||
- Expo ou qualquer dependência Expo.
|
||||
|
||||
## Decisões
|
||||
|
||||
- Criar novo app em `FightRateRN/` para não destruir o Android legado antes da base RN compilar.
|
||||
- Usar React Native Community CLI.
|
||||
- Usar TypeScript.
|
||||
- Começar com UI e estado local; BLE/persistência entram em fatias posteriores.
|
||||
- Fonte visual obrigatória: `FIGHTRATE_DESIGN_SYSTEM.md`.
|
||||
|
||||
## Estrutura desejada
|
||||
|
||||
```text
|
||||
FightRateRN/
|
||||
src/
|
||||
app/
|
||||
FightRateApp.tsx
|
||||
components/
|
||||
ActionButton.tsx
|
||||
MetricTile.tsx
|
||||
SectionPanel.tsx
|
||||
StatusTag.tsx
|
||||
TrainingChart.tsx
|
||||
screens/
|
||||
CaptureScreen.tsx
|
||||
HistoryScreen.tsx
|
||||
SensorScreen.tsx
|
||||
InsightsScreen.tsx
|
||||
theme/
|
||||
colors.ts
|
||||
radius.ts
|
||||
spacing.ts
|
||||
typography.ts
|
||||
index.ts
|
||||
data/
|
||||
demoTraining.ts
|
||||
```
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
1. Projeto React Native bare criado sem Expo.
|
||||
2. `package.json` não contém dependências Expo.
|
||||
3. App inicial mostra a direção Corner Instrument.
|
||||
4. Captura contínua é o fluxo principal.
|
||||
5. Round manual não domina a interface.
|
||||
6. Design tokens estão centralizados em `src/theme`.
|
||||
7. Componentes principais reutilizáveis existem.
|
||||
8. Telas visíveis:
|
||||
- Captura;
|
||||
- Histórico;
|
||||
- Sensor;
|
||||
- Insights.
|
||||
9. Verificação mínima:
|
||||
|
||||
```bash
|
||||
cd FightRateRN
|
||||
npm install
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm test -- --runInBand
|
||||
```
|
||||
|
||||
Build Android debug validado neste ambiente:
|
||||
|
||||
```bash
|
||||
cd FightRateRN/android
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
Observação local: o PATH atual tem JRE, mas não `javac`. Para o build Gradle RN,
|
||||
use o JBR do Android Studio como `JAVA_HOME`:
|
||||
|
||||
```bash
|
||||
export JAVA_HOME="/c/Users/Evandro/AppData/Local/Programs/Android Studio/jbr"
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
cd FightRateRN/android
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
## Riscos
|
||||
|
||||
- React Native CLI pode mudar template conforme versão atual.
|
||||
- Ambiente local pode não ter Android SDK/NDK adequado para build RN.
|
||||
- BLE real exigirá dependência nativa futura e teste físico.
|
||||
@@ -0,0 +1,93 @@
|
||||
# FightRate RN — Real BLE / No Mocks Corrective Slice
|
||||
|
||||
Data: 2026-06-10
|
||||
Status: implementado / APK enviado ao Telegram
|
||||
Stack: React Native Community CLI bare, sem Expo
|
||||
|
||||
## Problema
|
||||
|
||||
A primeira versão React Native enviada era instalável, mas não funcional como app de treino real:
|
||||
|
||||
- usava dados de demonstração;
|
||||
- não escaneava BLE;
|
||||
- indicava estados como conectado/pronto sem sensor real;
|
||||
- gráficos não eram alimentados por amostras reais;
|
||||
- histórico e insights pareciam conteúdo estático;
|
||||
- navegação inferior estava visualmente fraca;
|
||||
- logo/ícone do projeto não aparecia na UI.
|
||||
|
||||
## Objetivo desta fatia
|
||||
|
||||
Transformar a versão RN em uma base real de captura:
|
||||
|
||||
1. Remover dados mockados da UI.
|
||||
2. Adicionar BLE real com `react-native-ble-plx`.
|
||||
3. Pedir permissões Android Bluetooth/localização.
|
||||
4. Escanear sensores com Heart Rate Service `180D`.
|
||||
5. Conectar ao sensor encontrado.
|
||||
6. Monitorar Heart Rate Measurement `2A37`.
|
||||
7. Parsear BPM real a partir do payload BLE.
|
||||
8. Atualizar BPM, média, pico e amostras em tempo real.
|
||||
9. Renderizar gráfico somente com amostras reais recebidas.
|
||||
10. Exibir histórico/insights como estados vazios honestos quando não houver sessão real.
|
||||
11. Melhorar bottom nav: ícones, barra flutuante, sem faixa preta atrás.
|
||||
12. Remover indicador `READY` do topo.
|
||||
13. Usar logo existente do projeto na UI.
|
||||
14. Gerar APK release e enviar ao Telegram.
|
||||
|
||||
## Dependências aprovadas pela necessidade do pedido
|
||||
|
||||
- `react-native-ble-plx`: BLE real no React Native bare.
|
||||
- `react-native-svg`: gráfico real leve e ícones SVG.
|
||||
- `lucide-react-native`: ícones para navegação/estado.
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- App não mostra BPM mockado.
|
||||
- Tela de captura mostra estado de permissão/scan/conexão real.
|
||||
- Botão de captura dispara permissões e scanning BLE.
|
||||
- Se nenhum sensor for encontrado, a UI diz isso claramente.
|
||||
- Se sensor conectar, BPM vem do characteristic `2A37`.
|
||||
- Gráfico só aparece depois de amostras reais.
|
||||
- Histórico não finge sessões inexistentes.
|
||||
- Insights não fingem análise sem dados.
|
||||
- APK release abre sem Metro.
|
||||
|
||||
## Validação
|
||||
|
||||
```bash
|
||||
cd FightRateRN
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm test -- --runInBand
|
||||
npm audit --audit-level=moderate
|
||||
```
|
||||
|
||||
Resultado:
|
||||
|
||||
```text
|
||||
tsc OK
|
||||
ESLint: No issues found
|
||||
Tests: 2 suites / 4 tests passed
|
||||
found 0 vulnerabilities
|
||||
```
|
||||
|
||||
Build Android release arm64:
|
||||
|
||||
```bash
|
||||
cd FightRateRN/android
|
||||
./gradlew assembleRelease -PreactNativeArchitectures=arm64-v8a
|
||||
```
|
||||
|
||||
Resultado:
|
||||
|
||||
```text
|
||||
BUILD SUCCESSFUL
|
||||
APK: FightRateRN/android/app/build/outputs/apk/release/app-release.apk
|
||||
Tamanho: 29.7 MB
|
||||
Telegram: enviado
|
||||
```
|
||||
|
||||
## Limitação ainda aberta
|
||||
|
||||
A conexão BLE real precisa ser validada no aparelho físico com a cinta Magene H003. O app agora escaneia/conecta/monitora BLE real, mas só o teste com o sensor confirma comportamento final de campo.
|
||||
@@ -0,0 +1,54 @@
|
||||
# FightRate — Sensor contínuo e histórico geral
|
||||
|
||||
## Objetivo
|
||||
Separar claramente três conceitos:
|
||||
|
||||
1. **Sensor**: descobrir, mostrar disponibilidade e permitir conexão ao sensor BLE.
|
||||
2. **Monitoramento contínuo**: depois de conectado, receber, mostrar e salvar BPM real o tempo todo no app.
|
||||
3. **Janela de treino**: ação disponível somente em Captura; marca um intervalo dentro do fluxo contínuo para destaque no histórico.
|
||||
|
||||
## Critérios de sucesso
|
||||
- A tela Sensor não mostra botão de abrir/iniciar janela de treino.
|
||||
- A tela Sensor mostra se o sensor está disponível/conectado e permite conectar/desconectar/monitorar pelo fluxo de sensor.
|
||||
- Conectar o sensor inicia o recebimento contínuo via serviço nativo e os dados alimentam Captura, Sensor, Histórico e Insights.
|
||||
- A janela de treino continua existindo apenas na tela Captura e não interrompe o monitoramento ao fechar.
|
||||
- Histórico mantém a lista de janelas de treino e adiciona uma visão de **uso total** baseada em `continuous_heart_rate_samples`.
|
||||
- O histórico geral não usa dados falsos: se não houver amostras contínuas, mostra estado vazio honesto.
|
||||
- BLE services/characteristics ficam mais legíveis, com hierarquia compacta e sem espaçamento excessivo.
|
||||
- Código visível ao usuário em PT-BR; identificadores em inglês.
|
||||
|
||||
## Arquitetura recomendada
|
||||
- Reusar `FightRateCaptureStore` como fonte local-first.
|
||||
- Reusar `continuous_heart_rate_samples` como verdade para histórico geral.
|
||||
- Criar uma pequena camada de domínio TS para agrupar amostras contínuas em blocos de uso por lacunas de tempo. Essa lógica é pura e testável.
|
||||
- Expor do módulo nativo um método de leitura paginada/resumida das amostras contínuas, sem mudar o contrato BLE.
|
||||
- Manter `workout_sessions` apenas para janelas destacadas.
|
||||
|
||||
## Responsabilidades
|
||||
### Reusar
|
||||
- `useHeartRateMonitor`: estado app-wide recebido do módulo nativo.
|
||||
- `TrainingChart`: gráfico real e interativo com amostras timestampadas.
|
||||
- `FightRateCaptureStore`: persistência SQLite local.
|
||||
- `HistoryScreen`: entrada natural para alternar entre janelas e uso total.
|
||||
|
||||
### Criar/alterar
|
||||
- `src/training/continuousUsage.ts`: agrupar amostras contínuas em blocos de uso e sumarizar BPM/duração.
|
||||
- Teste Jest para `continuousUsage`.
|
||||
- API nativa `getContinuousSamples(limit?)`.
|
||||
- Wrapper TS para `getContinuousSamples`.
|
||||
- UI de Histórico com seção/alternância para “Janelas” e “Uso total”.
|
||||
- UI de Sensor sem ação de treino e com diagnóstico BLE mais claro.
|
||||
|
||||
## Fora de escopo
|
||||
- Não trocar biblioteca BLE.
|
||||
- Não adicionar dependências.
|
||||
- Não mudar o design aprovado fora dos pontos pedidos.
|
||||
- Não criar dados demo/fake.
|
||||
- Não mexer no app Android legado.
|
||||
|
||||
## Verificação
|
||||
- `npm run typecheck`
|
||||
- `npm run lint`
|
||||
- `npm test -- --runInBand`
|
||||
- `npm audit --audit-level=moderate`
|
||||
- `./gradlew assembleRelease -PreactNativeArchitectures=arm64-v8a`
|
||||
@@ -0,0 +1,190 @@
|
||||
# FightRate RN — Gráfico dinâmico: zoom, pan, pin e auto-follow
|
||||
|
||||
## Objetivo
|
||||
|
||||
Substituir o `TrainingChart` atual (estático, 120 últimos samples, sem zoom) por um gráfico dinâmico com viewport temporal, zoom por pinch, pan, granularidade adaptativa do eixo X, e seleção pinada que **sobrevive a novas amostras em tempo real**. O comportamento de auto-follow deve retomar automaticamente quando o usuário arrasta o viewport até a borda direita (último ponto conhecido).
|
||||
|
||||
Aplica-se aos três call sites atuais: `CaptureScreen` (tempo real), `HistoryScreen` (janela salva) e `HistoryScreen` (faixa customizada).
|
||||
|
||||
## Problema atual
|
||||
|
||||
`src/components/TrainingChart.tsx` (versão vigente):
|
||||
|
||||
```ts
|
||||
const chart = useMemo(() => buildChart(samples.slice(-120)), [samples]);
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
```
|
||||
|
||||
- `samples.slice(-120)` corta o array por contagem, não por janela temporal. Janelas longas (1h+) ficam visualmente compactadas a 120 pontos; janelas curtas (10s) ficam visualizadas com 120 pontos (impossível na prática, mas o problema é simétrico).
|
||||
- `selectedIndex` é a **posição no array de pontos**. Quando novas amostras chegam, `buildChart` re-cria o array `points`, e o índice anteriormente válido passa a apontar para uma amostra diferente — o ponto pinado "pula" e, no limite, some do viewport. Em tempo real isso é especialmente grave: o atleta clica num pico, e o gráfico "come" o pin a cada BLE notify.
|
||||
- Sem zoom (não há pinch). O gráfico é uma janela fixa do início ao fim do dataset.
|
||||
- Eixo X fixo, em segundos totais. Granularidade dos labels nunca se adapta.
|
||||
- Hit-testing em `onResponderMove` usa `event.nativeEvent.locationX` dividido por `layoutWidth` para mapear ao viewBox 320-wide — perde precisão fora do viewBox e não tolera gestos de 2 dedos.
|
||||
|
||||
## Decisões
|
||||
|
||||
### Stack
|
||||
|
||||
- Manter `react-native-svg` como renderer.
|
||||
- Adicionar `react-native-gesture-handler` (~v2) e `react-native-reanimated` (~v3) para pinch/pan nativos. Setup padrão de RN bare: import `react-native-gesture-handler` no topo de `index.js`, envolver `App` com `GestureHandlerRootView`. Habilitar reanimated via Babel plugin (`react-native-reanimated/plugin` em `babel.config.js`).
|
||||
- Sem troca de chart lib. `react-native-svg` continua sendo o motor de render.
|
||||
|
||||
### Modelo conceitual
|
||||
|
||||
Substituir "render dos últimos N samples" por **viewport temporal**:
|
||||
|
||||
```ts
|
||||
type Viewport = {
|
||||
readonly startMs: number; // primeira timestamp visível
|
||||
readonly endMs: number; // última timestamp visível (inclusive)
|
||||
readonly liveTail: boolean; // true quando endMs === lastSampleTimestamp
|
||||
};
|
||||
```
|
||||
|
||||
```ts
|
||||
type ChartPin = {
|
||||
readonly timestampMs: number;
|
||||
readonly sample: HeartRateSample;
|
||||
} | null;
|
||||
```
|
||||
|
||||
Estado interno do `TrainingChart` (hooks, não Redux):
|
||||
|
||||
```ts
|
||||
const [viewport, setViewport] = useState<Viewport>(initialViewport);
|
||||
const [pin, setPin] = useState<ChartPin>(null);
|
||||
```
|
||||
|
||||
`viewport` é o único "contrato" entre zoom/pan e o render. O SVG é derivado do `viewport` + samples. `pin` é independente de posição no array — é por `timestampMs`. Amostras novas entram, o `viewport` é recalculado somente se `liveTail` estiver `true`; o `pin` continua válido.
|
||||
|
||||
### Granularidade adaptativa do eixo X
|
||||
|
||||
A duração visível do viewport (`endMs - startMs`) decide a granularidade dos ticks:
|
||||
|
||||
| Duração visível | Granularidade dos ticks principais | Label |
|
||||
|---|---|---|
|
||||
| `>= 5 min` | 1 min | `HH:mm` |
|
||||
| `>= 1 min` | 30 s | `HH:mm:ss` |
|
||||
| `>= 30 s` | 15 s | `HH:mm:ss` |
|
||||
| `>= 10 s` | 10 s | `HH:mm:ss` |
|
||||
| `>= 2 s` | 1 s | `HH:mm:ss` |
|
||||
| `< 2 s` | 0.5 s | `HH:mm:ss.S` |
|
||||
|
||||
Regras:
|
||||
- Mostrar no máximo 4–5 ticks para não poluir.
|
||||
- Tick 0 (início do viewport) sempre renderizado.
|
||||
- Não usar ticks decorativos. Se o dataset não tem amostra no tick, o tick vira referência de eixo apenas.
|
||||
|
||||
### Zoom
|
||||
|
||||
- **Pinch** (dois dedos): escala o viewport em torno do ponto focado (ponto médio entre os dois dedos em coordenadas de tempo).
|
||||
- **Botões `+` / `−`** no canto superior direito do painel gráfico: zoom in/out de 2x mantendo o centro do viewport.
|
||||
- **Double-tap**: zoom in de 2x no ponto clicado; três taps em 1.2 s volta ao fit da janela selecionada (de acordo com o `windowSamples` que o pai passou).
|
||||
- Limites: mínimo 1 s visível; máximo = janela total selecionada (fit).
|
||||
- Os gestos de pinch e pan **não devem disparar tap**; gesto horizontal no gráfico move viewport; gesto vertical isolado é ignorado (a altura do gráfico não muda).
|
||||
|
||||
### Pan
|
||||
|
||||
- 1 dedo arrastando = pan. `pan` ajusta `startMs` e `endMs` proporcionalmente ao movimento em pixels (relação `pixelPorMs = layoutWidth / (endMs - startMs)`).
|
||||
- Quando o usuário inicia pan, `liveTail` vai para `false` imediatamente.
|
||||
- `liveTail` retorna a `true` **somente** quando, ao final do gesto, o `endMs` do viewport estiver a menos de `5%` da duração visível da última amostra conhecida (snap zone).
|
||||
- Quando `liveTail` está `true` e novas amostras chegam, o viewport estende o `endMs` automaticamente (a "esteira" do tempo real).
|
||||
- Quando `liveTail` está `false`, novas amostras **não** movem o viewport (mas o `lastPoint` continua sendo o último da coleção, para render do "tail off-screen indicator").
|
||||
|
||||
### Pin (clique em ponto)
|
||||
|
||||
- Tap em um ponto = `setPin({ timestampMs, sample })`.
|
||||
- Pin sobrevive a:
|
||||
- novas amostras chegarem (o `timestampMs` continua existindo no array de samples, a menos que o ponto esteja fora do novo viewport);
|
||||
- zoom in/out (o pin continua naquele `timestampMs`);
|
||||
- pan (idem).
|
||||
- Pin some se:
|
||||
- o usuário tocar fora do gráfico;
|
||||
- o usuário tocar de novo no mesmo ponto pinado (toggle);
|
||||
- o `timestampMs` do pin ficar **fora do viewport** e o usuário não voltar para ele (auto-clear opcional, mas default é manter — fica "off-screen" e um pequeno indicador pode avisar).
|
||||
- Render do pin: círculo âmbar + linha tracejada vertical até o eixo X (visual atual mantido, refinado).
|
||||
- O `readout` (painel inferior) mostra o sample pinado. Sem pin, mostra "TOQUE/ARRASTE PARA INSPECIONAR" e mantém o BPM atual.
|
||||
|
||||
### Hit-testing
|
||||
|
||||
- O gesto de tap é resolvido por `Gesture.Tap().onEnd((e) => pickPointAtX(e.x, viewport, samples))`.
|
||||
- `pickPointAtX` mapeia `e.x` (em pixels no SVG) para `timestampMs = startMs + (e.x - PAD_X) / (CHART_WIDTH - 2*PAD_X) * (endMs - startMs)`, e então busca a amostra de `samples` mais próxima no tempo (busca binária por timestamp, fallback linear para datasets pequenos).
|
||||
- Tolerância de snap: se a distância para a amostra mais próxima for maior que `10%` da duração visível, **não** seta pin (considera tap em área vazia).
|
||||
|
||||
## Estrutura de código
|
||||
|
||||
### Novos arquivos
|
||||
|
||||
- `src/training/chartViewport.ts` — funções puras (testáveis) para:
|
||||
- `granularityForSpan(spanMs: number): Granularity`
|
||||
- `formatTickLabel(timestampMs: number, granularity: Granularity): string`
|
||||
- `buildChartViewport(samples, options)` — calcula o viewport inicial
|
||||
- `applyZoomIn(viewport, focusMs)` / `applyZoomOut(viewport, focusMs)`
|
||||
- `applyPan(viewport, dxPixels, pixelsPerMs)`
|
||||
- `isLiveTailSnap(viewport, lastSampleTimestampMs)` — regra do snap zone
|
||||
- `pickSampleAtTimestamp(samples, timestampMs, toleranceMs)`
|
||||
- tipos: `Viewport`, `ChartPin`, `Granularity`
|
||||
- `src/training/__tests__/chartViewport.test.ts` — testes TDD das funções puras.
|
||||
|
||||
### Modificados
|
||||
|
||||
- `src/components/TrainingChart.tsx` — reescrito. Mantém:
|
||||
- viewBox 320×148 (compatibilidade com tokens);
|
||||
- cor de linha `colors.action`, fundo `colors.surfaceHot`, raio `radius.md`;
|
||||
- painel `readout` (label + valor) abaixo do gráfico;
|
||||
- pin: círculo âmbar + linha tracejada.
|
||||
Acrescenta:
|
||||
- pinch + pan via `react-native-gesture-handler`;
|
||||
- tap handler para pin;
|
||||
- eixo X com ticks adaptativos (função pura);
|
||||
- barra/cluster de botões `+` / `−` no canto;
|
||||
- indicador "off-screen latest" quando `!liveTail` (uma seta/marca à direita mostrando que há dados mais novos).
|
||||
- `src/components/ActionButton.tsx` — pode receber uso novo (botão pequeno icon-only para +/−). Avaliar reuso antes de criar componente novo.
|
||||
- `FightRateRN/index.js` — adicionar `import 'react-native-gesture-handler'` no topo.
|
||||
- `FightRateRN/babel.config.js` — adicionar `react-native-reanimated/plugin` (deve ser o último da lista).
|
||||
- `FightRateRN/App.tsx` — envolver conteúdo em `GestureHandlerRootView` (verificar se já está).
|
||||
- `package.json` — adicionar `react-native-gesture-handler` e `react-native-reanimated`.
|
||||
- `android/app/src/main/java/com/healthtracking/fightrate/MainActivity.kt` — verificar se herda de `ReactActivity` (gesture-handler detecta automaticamente; nada a fazer em geral).
|
||||
- `docs/FIGHTRATE_DEVELOPMENT_WORKFLOW.md` — atualizar a nota de que a skill `fightrate-quality-loop` recomenda aprovação antes de adicionar dependências (já cobre).
|
||||
|
||||
### Não modificados
|
||||
|
||||
- `useHeartRateMonitor.ts` — o gráfico consome `samples` como prop. O comportamento de auto-follow é responsabilidade do gráfico, não do hook.
|
||||
- `CaptureScreen.tsx`, `HistoryScreen.tsx` — call sites do `TrainingChart`. A interface do componente (`samples`) permanece compatível; nenhum `prop` novo obrigatório é exposto ao pai nesta fatia (zoom/pan/pin são internos).
|
||||
|
||||
## Critérios de sucesso
|
||||
|
||||
1. **Auto-follow funcional**: ao entrar no `CaptureScreen` e a captura estar rodando, o gráfico se mantém mostrando os últimos N segundos; cada novo BPM real reposiciona o viewport para incluir a nova amostra à direita.
|
||||
2. **Pin sobrevive a novas amostras**: tocar num ponto, deixar o sensor entregar 5 novas leituras → o pin continua visível na mesma posição, com o mesmo BPM e timestamp. O gráfico pode crescer à direita.
|
||||
3. **Pan sai do live**: arrastar para a esquerda → `liveTail = false`; novas amostras param de mover o viewport. O indicador "off-screen latest" aparece à direita.
|
||||
4. **Retomada do live**: arrastar de volta até a snap zone (últimos 5% da duração visível) → `liveTail = true` e o gráfico volta a rolar.
|
||||
5. **Pinch**: 2 dedos abrindo → zoom in em torno do ponto médio; fechar → zoom out. Não dispara tap acidental.
|
||||
6. **Botões `+` / `−`**: zoom in/out de 2x no centro.
|
||||
7. **Granularidade adaptativa**: 10 min de janela visível → labels em `HH:mm`; 1 min → `HH:mm:ss` 30s; 30 s → 15 s; 10 s → 10 s; 2 s → 1 s; < 2 s → 0.5 s. Trocas suaves, sem flicker.
|
||||
8. **Hit-test estável**: tap a 30% da largura do gráfico fixa o pin na amostra mais próxima no tempo, mesmo que o layout tenha width real diferente de `CHART_WIDTH`.
|
||||
9. **Hit-test fora da área**: tap longe de qualquer amostra (gap > 10% da duração) não seta pin; o readout continua mostrando "TOQUE/ARRASTE PARA INSPECIONAR".
|
||||
10. **Sem regressão visual**: tokens do Corner Instrument mantidos (cores, raio, borda, tipografia label, fundo `colors.surfaceHot`).
|
||||
11. **Sem gráfico fake**: se `samples.length < 2`, mostra o estado vazio atual (sem render de linha). Nenhuma mudança nesse fluxo.
|
||||
12. **Testes passam**: `npm test -- --runInBand` cobre `chartViewport.ts` (granularidade, zoom, pan, snap, pick). `App.test.tsx` continua passando.
|
||||
13. **Build OK**: `npm run typecheck` e `npm run lint` exit 0. Build Android (`./gradlew :app:assembleDebug` em `FightRateRN/android`) só é rodado se JDK/JBR estiver disponível no ambiente.
|
||||
|
||||
## Riscos e mitigações
|
||||
|
||||
- **Risco**: gesture-handler requer setup de imports no Android (já é o caso em RN 0.86, mas o `MainActivity` pode precisar de `createReactActivityDelegate` atualizado).
|
||||
**Mitigação**: seguir o passo-a-passo da documentação oficial; se o build falhar, voltar à abordagem `PanResponder + botões` e reusar o resto do spec.
|
||||
- **Risco**: reanimated v3 + RN 0.86 — compatibilidade.
|
||||
**Mitigação**: travar versões compatíveis (`react-native-reanimated@~3.16.0` recomendado para RN 0.86). Documentar a versão exata no `package.json` após smoke test.
|
||||
- **Risco**: conflito entre gesto de pan do `ScrollView` pai e pan do gráfico.
|
||||
**Mitigação**: usar `simultaneousWithExternalGesture` no `GestureDetector` do gráfico para desativar o pan do `ScrollView` apenas dentro da área do gráfico; testar com `CaptureScreen` (que está dentro de `ScrollView`).
|
||||
- **Risco**: a busca binária de `pickSampleAtTimestamp` precisa de `samples` ordenados. Garantir invariante no hook e/ou ordenar defensivamente.
|
||||
**Mitigação**: função pura de ordenação dedicada; teste cobre o caso de samples fora de ordem.
|
||||
|
||||
## Não-objetivos (fora do escopo)
|
||||
|
||||
- Crosshair, brush selection, comparação de duas janelas no mesmo gráfico.
|
||||
- Exportar gráfico como imagem.
|
||||
- Reorganizar layout da `CaptureScreen` ou da `HistoryScreen` (apenas a parte do `TrainingChart`).
|
||||
- Trocar a paleta de cores.
|
||||
- Adicionar leitura de RR no eixo Y (permanece em BPM).
|
||||
- Sincronizar dois gráficos lado a lado.
|
||||
- Persistir o último zoom/pan entre sessões (state efêmero).
|
||||
|
After Width: | Height: | Size: 940 KiB |
@@ -0,0 +1,41 @@
|
||||
|
||||
Welcome to React Native v0.86
|
||||
Starting dev server on http://localhost:8081
|
||||
|
||||
|
||||
▒▒▓▓▓▓▒▒
|
||||
▒▓▓▓▒▒░░▒▒▓▓▓▒
|
||||
▒▓▓▓▓░░░▒▒▒▒░░░▓▓▓▓▒
|
||||
▓▓▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▓▓
|
||||
▓▓░░░░░▒▓▓▓▓▓▓▒░░░░░▓▓
|
||||
▓▓░░▓▓▒░░░▒▒░░░▒▓▒░░▓▓
|
||||
▓▓░░▓▓▓▓▓▒▒▒▒▓▓▓▓▒░░▓▓
|
||||
▓▓░░▓▓▓▓▓▓▓▓▓▓▓▓▓▒░░▓▓
|
||||
▓▓▒░░▒▒▓▓▓▓▓▓▓▓▒░░░▒▓▓
|
||||
▒▓▓▓▒░░░▒▓▓▒░░░▒▓▓▓▒
|
||||
▒▓▓▓▒░░░░▒▓▓▓▒
|
||||
▒▒▓▓▓▓▒▒
|
||||
|
||||
|
||||
WARN the transform cache was reset.
|
||||
Welcome to Metro v0.84.4
|
||||
Fast - Scalable - Integrated
|
||||
|
||||
|
||||
INFO Dev server ready. Press Ctrl+C to exit.
|
||||
INFO Interactive mode is not supported in this environment
|
||||
D:\Documents\Github\HealthTracking\node_modules\invariant\invariant.js:47
|
||||
throw error;
|
||||
^
|
||||
|
||||
Invariant Violation: Detected addition or modification of file node_modules, but it is tracked as a non-empty directory
|
||||
at invariant (D:\Documents\Github\HealthTracking\node_modules\invariant\invariant.js:40:15)
|
||||
at TreeFS.bulkAddOrModify (D:\Documents\Github\HealthTracking\node_modules\metro-file-map\src\lib\TreeFS.js:329:34)
|
||||
at TreeFS.addOrModify (D:\Documents\Github\HealthTracking\node_modules\metro-file-map\src\lib\TreeFS.js:295:10)
|
||||
at Timeout.emitChange [as _onTimeout] (D:\Documents\Github\HealthTracking\node_modules\metro-file-map\src\index.js:520:22)
|
||||
at listOnTimeout (node:internal/timers:585:17)
|
||||
at process.processTimers (node:internal/timers:521:7) {
|
||||
framesToPop: 1
|
||||
}
|
||||
|
||||
Node.js v22.22.2
|
||||
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/plugin-transform-react-jsx": "^7.29.7",
|
||||
"@babel/preset-env": "^7.25.3",
|
||||
"@babel/runtime": "^7.25.0",
|
||||
"@react-native-community/cli": "^20.1.3",
|
||||
@@ -32,6 +33,7 @@
|
||||
"@react-native/jest-preset": "^0.86.0",
|
||||
"@react-native/metro-config": "0.86.0",
|
||||
"@react-native/typescript-config": "0.86.0",
|
||||
"@testing-library/react-native": "^12.9.0",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
|
||||
@@ -17,27 +17,29 @@ jest.mock('react-native-reanimated', () => {
|
||||
|
||||
import { PressableSurface } from '../components/PressableSurface';
|
||||
|
||||
const children = <></>;
|
||||
|
||||
describe('PressableSurface', () => {
|
||||
it('renders children inside', () => {
|
||||
let tree: any;
|
||||
TestRenderer.act(() => {
|
||||
tree = TestRenderer.create(
|
||||
React.createElement(PressableSurface, { onPress: () => undefined, testID: 'surface' },
|
||||
React.createElement('Text', null, 'Hi'),
|
||||
),
|
||||
<PressableSurface onPress={() => undefined} testID="surface">
|
||||
{children}
|
||||
</PressableSurface>,
|
||||
);
|
||||
});
|
||||
const json = tree.toJSON();
|
||||
expect(JSON.stringify(json)).toContain('Hi');
|
||||
expect(JSON.stringify(json)).toContain('Pressable');
|
||||
});
|
||||
|
||||
it('has accessibilityRole=button when accessibilityLabel is set', () => {
|
||||
it('has accessibilityLabel when set', () => {
|
||||
let tree: any;
|
||||
TestRenderer.act(() => {
|
||||
tree = TestRenderer.create(
|
||||
React.createElement(PressableSurface, { onPress: () => undefined, accessibilityLabel: 'Tap', testID: 'surface' },
|
||||
React.createElement('Text', null, 'Hi'),
|
||||
),
|
||||
<PressableSurface onPress={() => undefined} accessibilityLabel="Tap">
|
||||
{children}
|
||||
</PressableSurface>,
|
||||
);
|
||||
});
|
||||
const json = JSON.stringify(tree.toJSON());
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Image,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Bluetooth,
|
||||
ChartNoAxesCombined,
|
||||
History,
|
||||
Radio,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useHeartRateMonitor } from '../ble/useHeartRateMonitor';
|
||||
import { CaptureScreen } from '../screens/CaptureScreen';
|
||||
import { HistoryScreen } from '../screens/HistoryScreen';
|
||||
import { InsightsScreen } from '../screens/InsightsScreen';
|
||||
import { SensorScreen } from '../screens/SensorScreen';
|
||||
import { colors, radius, spacing, typography } from '../theme';
|
||||
|
||||
type TabKey = 'capture' | 'history' | 'sensor' | 'insights';
|
||||
|
||||
type Tab = {
|
||||
readonly key: TabKey;
|
||||
readonly label: string;
|
||||
readonly icon: LucideIcon;
|
||||
};
|
||||
|
||||
const tabs: readonly Tab[] = [
|
||||
{ key: 'capture', label: 'Captura', icon: Radio },
|
||||
{ key: 'history', label: 'Histórico', icon: History },
|
||||
{ key: 'sensor', label: 'Sensor', icon: Bluetooth },
|
||||
{ key: 'insights', label: 'Insights', icon: ChartNoAxesCombined },
|
||||
];
|
||||
|
||||
export function FightRateApp() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const monitor = useHeartRateMonitor();
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('capture');
|
||||
|
||||
const onCaptureAction = useCallback(async () => {
|
||||
if (monitor.isCapturing) {
|
||||
await monitor.stopCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
await monitor.startCapture();
|
||||
}, [monitor]);
|
||||
|
||||
const onMonitorAction = useCallback(async () => {
|
||||
if (monitor.isMonitoring) {
|
||||
await monitor.stopMonitoring();
|
||||
return;
|
||||
}
|
||||
|
||||
await monitor.startMonitoring();
|
||||
}, [monitor]);
|
||||
const activeTitle = useMemo(() => {
|
||||
if (activeTab === 'capture') {
|
||||
if (monitor.isCapturing) {
|
||||
return 'Janela de treino ativa';
|
||||
}
|
||||
if (monitor.isMonitoring) {
|
||||
return 'Monitor contínuo';
|
||||
}
|
||||
return 'Ativar sensor';
|
||||
}
|
||||
|
||||
if (activeTab === 'history') {
|
||||
return 'Histórico real';
|
||||
}
|
||||
|
||||
if (activeTab === 'sensor') {
|
||||
return 'Sensor BLE';
|
||||
}
|
||||
|
||||
return 'Análise real';
|
||||
}, [activeTab, monitor.isCapturing, monitor.isMonitoring]);
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<StatusBar barStyle="light-content" backgroundColor={colors.canvas} />
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.content,
|
||||
{
|
||||
paddingTop: insets.top + spacing.lg,
|
||||
paddingBottom: insets.bottom + 118,
|
||||
},
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.brandRow}>
|
||||
<Image
|
||||
source={require('../assets/fightrate-logo.png')}
|
||||
style={styles.logo}
|
||||
/>
|
||||
<View style={styles.titleColumn}>
|
||||
<Text style={styles.kicker}>FightRate</Text>
|
||||
<Text style={styles.title}>{activeTitle}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{activeTab === 'capture' ? (
|
||||
<CaptureScreen
|
||||
monitor={monitor}
|
||||
onCaptureAction={onCaptureAction}
|
||||
onMonitorAction={onMonitorAction}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === 'history' ? (
|
||||
<HistoryScreen
|
||||
sessions={monitor.sessions}
|
||||
getSessionSamples={monitor.getSessionSamples}
|
||||
getContinuousSamples={monitor.getContinuousSamples}
|
||||
liveSamples={monitor.samples}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === 'sensor' ? (
|
||||
<SensorScreen monitor={monitor} onMonitorAction={onMonitorAction} />
|
||||
) : null}
|
||||
{activeTab === 'insights' ? (
|
||||
<InsightsScreen
|
||||
liveSamples={monitor.samples}
|
||||
sessions={monitor.sessions}
|
||||
getContinuousSamples={monitor.getContinuousSamples}
|
||||
/>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
|
||||
<View style={[styles.navWrap, { bottom: insets.bottom + spacing.sm }]}>
|
||||
<View style={styles.navRail}>
|
||||
{tabs.map(tab => {
|
||||
const selected = tab.key === activeTab;
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="tab"
|
||||
accessibilityState={{ selected }}
|
||||
key={tab.key}
|
||||
onPress={() => setActiveTab(tab.key)}
|
||||
style={[styles.navItem, selected ? styles.navItemActive : null]}
|
||||
>
|
||||
<Icon
|
||||
color={selected ? colors.text : colors.textMuted}
|
||||
size={18}
|
||||
strokeWidth={2.4}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.navLabel,
|
||||
selected ? styles.navLabelActive : null,
|
||||
]}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.canvas,
|
||||
},
|
||||
content: {
|
||||
paddingHorizontal: spacing.md,
|
||||
gap: spacing.lg,
|
||||
},
|
||||
brandRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
},
|
||||
logo: {
|
||||
width: 54,
|
||||
height: 54,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
titleColumn: {
|
||||
flex: 1,
|
||||
},
|
||||
kicker: {
|
||||
...typography.label,
|
||||
color: colors.action,
|
||||
},
|
||||
title: {
|
||||
marginTop: spacing.xs,
|
||||
color: colors.text,
|
||||
fontSize: 34,
|
||||
lineHeight: 36,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -2.1,
|
||||
},
|
||||
navWrap: {
|
||||
position: 'absolute',
|
||||
left: spacing.md,
|
||||
right: spacing.md,
|
||||
},
|
||||
navRail: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.line,
|
||||
borderRadius: radius.xl,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
padding: spacing.xs,
|
||||
flexDirection: 'row',
|
||||
gap: spacing.xs,
|
||||
shadowColor: colors.black,
|
||||
shadowOpacity: 0.45,
|
||||
shadowRadius: 24,
|
||||
elevation: 18,
|
||||
},
|
||||
navItem: {
|
||||
flex: 1,
|
||||
minHeight: 54,
|
||||
borderRadius: radius.xl,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xxs,
|
||||
},
|
||||
navItemActive: {
|
||||
backgroundColor: colors.action,
|
||||
},
|
||||
navLabel: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
fontSize: 9,
|
||||
},
|
||||
navLabelActive: {
|
||||
color: colors.text,
|
||||
},
|
||||
});
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,94 @@
|
||||
export type HeartRateMeasurement = {
|
||||
/* eslint-disable no-bitwise */
|
||||
readonly bpm: number;
|
||||
readonly contactSupported: boolean;
|
||||
readonly contactDetected: boolean | null;
|
||||
readonly energyExpended: number | null;
|
||||
readonly rrIntervalsMs: readonly number[];
|
||||
};
|
||||
|
||||
const BASE64_ALPHABET =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
|
||||
|
||||
export function parseHeartRateMeasurementFromBase64(
|
||||
value: string,
|
||||
): HeartRateMeasurement {
|
||||
return parseHeartRateMeasurement(decodeBase64(value));
|
||||
}
|
||||
|
||||
export function parseHeartRateMeasurement(
|
||||
bytes: readonly number[],
|
||||
): HeartRateMeasurement {
|
||||
if (bytes.length < 2) {
|
||||
throw new Error('Heart Rate Measurement payload is too short.');
|
||||
}
|
||||
|
||||
const flags = bytes[0];
|
||||
const isUint16 = (flags & 0x01) !== 0;
|
||||
const contactSupported = (flags & 0x04) !== 0;
|
||||
const contactDetected = contactSupported ? (flags & 0x02) !== 0 : null;
|
||||
const hasEnergyExpended = (flags & 0x08) !== 0;
|
||||
const hasRrIntervals = (flags & 0x10) !== 0;
|
||||
|
||||
let offset = 1;
|
||||
const bpm = isUint16 ? readUInt16Le(bytes, offset) : bytes[offset];
|
||||
offset += isUint16 ? 2 : 1;
|
||||
|
||||
const energyExpended = hasEnergyExpended ? readUInt16Le(bytes, offset) : null;
|
||||
if (hasEnergyExpended) {
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
const rrIntervalsMs: number[] = [];
|
||||
if (hasRrIntervals) {
|
||||
while (offset + 1 < bytes.length) {
|
||||
const rrRaw = readUInt16Le(bytes, offset);
|
||||
rrIntervalsMs.push((rrRaw / 1024) * 1000);
|
||||
offset += 2;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bpm,
|
||||
contactSupported,
|
||||
contactDetected,
|
||||
energyExpended,
|
||||
rrIntervalsMs,
|
||||
};
|
||||
}
|
||||
|
||||
function readUInt16Le(bytes: readonly number[], offset: number) {
|
||||
if (offset + 1 >= bytes.length) {
|
||||
throw new Error('Heart Rate Measurement payload ended unexpectedly.');
|
||||
}
|
||||
|
||||
return bytes[offset] | (bytes[offset + 1] << 8);
|
||||
}
|
||||
|
||||
function decodeBase64(input: string): number[] {
|
||||
const clean = input.replace(/[^A-Za-z0-9+/=]/g, '');
|
||||
const bytes: number[] = [];
|
||||
|
||||
for (let index = 0; index < clean.length; index += 4) {
|
||||
const encoded1 = BASE64_ALPHABET.indexOf(clean.charAt(index));
|
||||
const encoded2 = BASE64_ALPHABET.indexOf(clean.charAt(index + 1));
|
||||
const encoded3 = BASE64_ALPHABET.indexOf(clean.charAt(index + 2));
|
||||
const encoded4 = BASE64_ALPHABET.indexOf(clean.charAt(index + 3));
|
||||
|
||||
const chr1 = (encoded1 << 2) | (encoded2 >> 4);
|
||||
const chr2 = ((encoded2 & 15) << 4) | (encoded3 >> 2);
|
||||
const chr3 = ((encoded3 & 3) << 6) | encoded4;
|
||||
|
||||
bytes.push(chr1 & 0xff);
|
||||
|
||||
if (encoded3 !== 64 && encoded3 !== -1) {
|
||||
bytes.push(chr2 & 0xff);
|
||||
}
|
||||
|
||||
if (encoded4 !== 64 && encoded4 !== -1) {
|
||||
bytes.push(chr3 & 0xff);
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
const ANDROID_RSSI_SENTINEL = -2147483648;
|
||||
const MIN_PLAUSIBLE_BLE_RSSI = -127;
|
||||
const MAX_PLAUSIBLE_BLE_RSSI = 20;
|
||||
|
||||
export function normalizeRssi(value?: number | null): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value === ANDROID_RSSI_SENTINEL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value < MIN_PLAUSIBLE_BLE_RSSI || value > MAX_PLAUSIBLE_BLE_RSSI) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.round(value);
|
||||
}
|
||||
|
||||
export function formatRssi(value?: number | null): string {
|
||||
const rssi = normalizeRssi(value);
|
||||
return typeof rssi === 'number' ? `${rssi} dBm` : 'sem leitura';
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
connectNativeDevice,
|
||||
fightRateCaptureEvents,
|
||||
getNativeSessionSamples,
|
||||
getNativeContinuousSamples,
|
||||
getCaptureSnapshot,
|
||||
normalizeDevicesEvent,
|
||||
normalizeMetadataEvent,
|
||||
normalizeSample,
|
||||
normalizeStatusEvent,
|
||||
openBatteryOptimizationSettings,
|
||||
requestCapturePermissions,
|
||||
startNativeMonitoring,
|
||||
startNativeCapture,
|
||||
stopNativeCapture,
|
||||
stopNativeMonitoring,
|
||||
type NativeConnectedDevice,
|
||||
type NativeDiscoveredDevice,
|
||||
type NativePhase,
|
||||
} from '../native/FightRateCaptureNative';
|
||||
import {
|
||||
calculateLiveMetrics,
|
||||
isReliableHeartRateSample,
|
||||
} from '../training/trainingMetrics';
|
||||
import {
|
||||
type CompletedTrainingSession,
|
||||
type HeartRateSample,
|
||||
} from '../training/trainingTypes';
|
||||
|
||||
export type BlePhase = NativePhase;
|
||||
|
||||
export type DiscoveredHeartRateDevice = NativeDiscoveredDevice;
|
||||
|
||||
export type HeartRateMonitorState = {
|
||||
readonly phase: BlePhase;
|
||||
readonly isCapturing: boolean;
|
||||
readonly isMonitoring: boolean;
|
||||
readonly connectedDevice: NativeConnectedDevice | null;
|
||||
readonly discoveredDevices: readonly DiscoveredHeartRateDevice[];
|
||||
readonly samples: readonly HeartRateSample[];
|
||||
readonly sessions: readonly CompletedTrainingSession[];
|
||||
readonly services: readonly string[];
|
||||
readonly characteristics: readonly string[];
|
||||
readonly currentBpm: number | null;
|
||||
readonly averageBpm: number | null;
|
||||
readonly peakBpm: number | null;
|
||||
readonly minBpm: number | null;
|
||||
readonly sampleRatePerMinute: number | null;
|
||||
readonly loadScore: number;
|
||||
readonly highIntensityPercent: number;
|
||||
readonly startedAt: number | null;
|
||||
readonly statusMessage: string | null;
|
||||
readonly errorMessage: string | null;
|
||||
readonly batteryOptimizationIgnored: boolean;
|
||||
readonly startCapture: () => Promise<void>;
|
||||
readonly stopCapture: () => Promise<void>;
|
||||
readonly startMonitoring: () => Promise<void>;
|
||||
readonly stopMonitoring: () => Promise<void>;
|
||||
readonly getSessionSamples: (
|
||||
sessionId: string,
|
||||
) => Promise<readonly HeartRateSample[]>;
|
||||
readonly getContinuousSamples: (
|
||||
limit?: number,
|
||||
) => Promise<readonly HeartRateSample[]>;
|
||||
readonly connectToDevice: (deviceId: string) => Promise<void>;
|
||||
readonly openBatterySettings: () => Promise<void>;
|
||||
readonly clearError: () => void;
|
||||
};
|
||||
|
||||
export function useHeartRateMonitor(): HeartRateMonitorState {
|
||||
const [phase, setPhase] = useState<BlePhase>('idle');
|
||||
const [isCapturing, setIsCapturing] = useState(false);
|
||||
const [isMonitoring, setIsMonitoring] = useState(false);
|
||||
const [connectedDevice, setConnectedDevice] =
|
||||
useState<NativeConnectedDevice | null>(null);
|
||||
const [discoveredDevices, setDiscoveredDevices] = useState<
|
||||
DiscoveredHeartRateDevice[]
|
||||
>([]);
|
||||
const [samples, setSamples] = useState<HeartRateSample[]>([]);
|
||||
const [sessions, setSessions] = useState<CompletedTrainingSession[]>([]);
|
||||
const [services, setServices] = useState<string[]>([]);
|
||||
const [characteristics, setCharacteristics] = useState<string[]>([]);
|
||||
const [startedAt, setStartedAt] = useState<number | null>(null);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [batteryOptimizationIgnored, setBatteryOptimizationIgnored] =
|
||||
useState(false);
|
||||
const [clockTick, setClockTick] = useState(Date.now());
|
||||
|
||||
const metricSamples = useMemo(
|
||||
() =>
|
||||
startedAt
|
||||
? samples.filter(sample => sample.timestamp >= startedAt)
|
||||
: samples,
|
||||
[samples, startedAt],
|
||||
);
|
||||
const metrics = useMemo(
|
||||
() =>
|
||||
calculateLiveMetrics(metricSamples, {
|
||||
now: clockTick,
|
||||
staleAfterMs: 12_000,
|
||||
}),
|
||||
[clockTick, metricSamples],
|
||||
);
|
||||
|
||||
const applySnapshot = useCallback(
|
||||
(snapshot: Awaited<ReturnType<typeof getCaptureSnapshot>>) => {
|
||||
setPhase(snapshot.phase);
|
||||
setIsCapturing(snapshot.isCapturing);
|
||||
setIsMonitoring(snapshot.isMonitoring);
|
||||
setConnectedDevice(snapshot.connectedDevice);
|
||||
setDiscoveredDevices([...snapshot.discoveredDevices]);
|
||||
setSamples([...snapshot.samples]);
|
||||
setSessions([...snapshot.sessions]);
|
||||
setServices([...snapshot.services]);
|
||||
setCharacteristics([...snapshot.characteristics]);
|
||||
setStartedAt(snapshot.startedAt);
|
||||
setStatusMessage(snapshot.statusMessage);
|
||||
setBatteryOptimizationIgnored(snapshot.batteryOptimizationIgnored);
|
||||
setErrorMessage(
|
||||
snapshot.phase === 'error' ? snapshot.statusMessage : null,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshSnapshot = useCallback(async () => {
|
||||
const snapshot = await getCaptureSnapshot();
|
||||
applySnapshot(snapshot);
|
||||
}, [applySnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMonitoring) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => setClockTick(Date.now()), 2_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isMonitoring]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshSnapshot().catch(error => setErrorMessage(toErrorMessage(error)));
|
||||
|
||||
if (!fightRateCaptureEvents) {
|
||||
setPhase('error');
|
||||
setErrorMessage('Módulo nativo FightRate indisponível neste build.');
|
||||
return;
|
||||
}
|
||||
|
||||
const sampleSubscription = fightRateCaptureEvents.addListener(
|
||||
'FightRateSample',
|
||||
event => {
|
||||
const sample = normalizeSample(event);
|
||||
if (!isReliableHeartRateSample(sample)) {
|
||||
setPhase('connected');
|
||||
setStatusMessage(
|
||||
'Sensor conectado, mas sem batimento válido. Aguardando sinal real.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setClockTick(Date.now());
|
||||
setSamples(current => [...current.slice(-1799), sample]);
|
||||
},
|
||||
);
|
||||
|
||||
const statusSubscription = fightRateCaptureEvents.addListener(
|
||||
'FightRateStatus',
|
||||
event => {
|
||||
const status = normalizeStatusEvent(event);
|
||||
setPhase(status.phase);
|
||||
setStatusMessage(status.statusMessage);
|
||||
setConnectedDevice(status.connectedDevice);
|
||||
setIsCapturing(typeof status.sessionId === 'number');
|
||||
setIsMonitoring(status.phase !== 'idle');
|
||||
setStartedAt(current =>
|
||||
typeof status.sessionId === 'number' ? current ?? Date.now() : null,
|
||||
);
|
||||
setErrorMessage(status.phase === 'error' ? status.statusMessage : null);
|
||||
},
|
||||
);
|
||||
|
||||
const devicesSubscription = fightRateCaptureEvents.addListener(
|
||||
'FightRateDevices',
|
||||
event => {
|
||||
setDiscoveredDevices([...normalizeDevicesEvent(event).devices]);
|
||||
},
|
||||
);
|
||||
|
||||
const metadataSubscription = fightRateCaptureEvents.addListener(
|
||||
'FightRateMetadata',
|
||||
event => {
|
||||
const metadata = normalizeMetadataEvent(event);
|
||||
setServices([...metadata.services]);
|
||||
setCharacteristics([...metadata.characteristics]);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
sampleSubscription.remove();
|
||||
statusSubscription.remove();
|
||||
devicesSubscription.remove();
|
||||
metadataSubscription.remove();
|
||||
};
|
||||
}, [refreshSnapshot]);
|
||||
|
||||
const startMonitoring = useCallback(async () => {
|
||||
setPhase('requesting-permission');
|
||||
setErrorMessage(null);
|
||||
const hasPermission = await requestCapturePermissions();
|
||||
if (!hasPermission) {
|
||||
setPhase('permission-denied');
|
||||
setErrorMessage(
|
||||
'Permissão negada. Autorize Bluetooth, Localização e Notificações para manter captura em segundo plano.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await startNativeMonitoring(connectedDevice);
|
||||
applySnapshot(snapshot);
|
||||
}, [applySnapshot, connectedDevice]);
|
||||
const startCapture = useCallback(async () => {
|
||||
setPhase('requesting-permission');
|
||||
setErrorMessage(null);
|
||||
const hasPermission = await requestCapturePermissions();
|
||||
if (!hasPermission) {
|
||||
setPhase('permission-denied');
|
||||
setErrorMessage(
|
||||
'Permissão negada. Autorize Bluetooth, Localização e Notificações para manter captura em segundo plano.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await startNativeCapture(connectedDevice);
|
||||
applySnapshot({
|
||||
...snapshot,
|
||||
isMonitoring: true,
|
||||
isCapturing: true,
|
||||
phase: snapshot.phase === 'idle' ? 'scanning' : snapshot.phase,
|
||||
});
|
||||
}, [applySnapshot, connectedDevice]);
|
||||
|
||||
const stopCapture = useCallback(async () => {
|
||||
const snapshot = await stopNativeCapture();
|
||||
applySnapshot(snapshot);
|
||||
await refreshSnapshot();
|
||||
}, [applySnapshot, refreshSnapshot]);
|
||||
|
||||
const stopMonitoring = useCallback(async () => {
|
||||
const snapshot = await stopNativeMonitoring();
|
||||
applySnapshot(snapshot);
|
||||
}, [applySnapshot]);
|
||||
|
||||
const getSessionSamples = useCallback(async (sessionId: string) => {
|
||||
return getNativeSessionSamples(sessionId);
|
||||
}, []);
|
||||
|
||||
const getContinuousSamples = useCallback(async (limit?: number) => {
|
||||
return getNativeContinuousSamples(limit);
|
||||
}, []);
|
||||
const connectToDevice = useCallback(
|
||||
async (deviceId: string) => {
|
||||
const device = discoveredDevices.find(
|
||||
candidate => candidate.id === deviceId,
|
||||
);
|
||||
if (!device) {
|
||||
setErrorMessage(
|
||||
'Sensor não está mais na lista de dispositivos encontrados.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await connectNativeDevice(device);
|
||||
applySnapshot({
|
||||
...snapshot,
|
||||
isMonitoring: true,
|
||||
isCapturing: snapshot.isCapturing,
|
||||
phase: snapshot.phase === 'idle' ? 'connecting' : snapshot.phase,
|
||||
});
|
||||
},
|
||||
[applySnapshot, discoveredDevices],
|
||||
);
|
||||
|
||||
const openBatterySettings = useCallback(async () => {
|
||||
await openBatteryOptimizationSettings();
|
||||
await refreshSnapshot();
|
||||
}, [refreshSnapshot]);
|
||||
|
||||
return {
|
||||
phase,
|
||||
isCapturing,
|
||||
isMonitoring,
|
||||
connectedDevice,
|
||||
discoveredDevices,
|
||||
samples,
|
||||
sessions,
|
||||
services,
|
||||
characteristics,
|
||||
currentBpm: metrics.currentBpm,
|
||||
averageBpm: metrics.averageBpm,
|
||||
peakBpm: metrics.peakBpm,
|
||||
minBpm: metrics.minBpm,
|
||||
sampleRatePerMinute: metrics.sampleRatePerMinute,
|
||||
loadScore: metrics.loadScore,
|
||||
highIntensityPercent: metrics.highIntensityPercent,
|
||||
startedAt,
|
||||
statusMessage,
|
||||
errorMessage,
|
||||
batteryOptimizationIgnored,
|
||||
startCapture,
|
||||
stopCapture,
|
||||
startMonitoring,
|
||||
stopMonitoring,
|
||||
getSessionSamples,
|
||||
getContinuousSamples,
|
||||
connectToDevice,
|
||||
openBatterySettings,
|
||||
clearError: () => setErrorMessage(null),
|
||||
};
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'Erro inesperado na captura BLE.';
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native';
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { colors, motion, radius, spacing, typography } from '../theme';
|
||||
|
||||
type ActionButtonVariant = 'primary' | 'secondary' | 'stop' | 'stopAlert';
|
||||
|
||||
type Props = {
|
||||
readonly label: string;
|
||||
readonly onPress?: () => void;
|
||||
readonly variant?: ActionButtonVariant;
|
||||
readonly fullWidth?: boolean;
|
||||
readonly showIcon?: boolean;
|
||||
};
|
||||
|
||||
export function ActionButton({
|
||||
label,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
fullWidth = false,
|
||||
showIcon = true,
|
||||
}: Props) {
|
||||
return (
|
||||
<View style={[styles.shell, fullWidth ? styles.fullWidth : null]}>
|
||||
{variant === 'stopAlert' ? <AlertRing /> : null}
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [
|
||||
styles.base,
|
||||
styles[variant],
|
||||
fullWidth ? styles.fullWidth : null,
|
||||
pressed ? styles.pressed : null,
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.label, styles[`${variant}Label`]]}>{label}</Text>
|
||||
{variant === 'primary' && showIcon ? (
|
||||
<View style={styles.iconBubble}>
|
||||
<Text style={styles.iconText}>›</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertRing() {
|
||||
const scale = useSharedValue(1);
|
||||
const opacity = useSharedValue(0.55);
|
||||
|
||||
useEffect(() => {
|
||||
scale.value = withRepeat(
|
||||
withTiming(1.06, { duration: motion.duration.pulseSlow / 2, easing: Easing.inOut(Easing.quad) }),
|
||||
-1,
|
||||
true,
|
||||
);
|
||||
opacity.value = withRepeat(
|
||||
withTiming(0.2, { duration: motion.duration.pulseSlow / 2, easing: Easing.inOut(Easing.quad) }),
|
||||
-1,
|
||||
true,
|
||||
);
|
||||
}, [scale, opacity]);
|
||||
|
||||
const ringStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
return <Animated.View style={[styles.alertRing, ringStyle]} pointerEvents="none" />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
shell: {
|
||||
position: 'relative',
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
fullWidth: {
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
base: {
|
||||
minHeight: 48,
|
||||
borderRadius: radius.xl,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
primary: {
|
||||
backgroundColor: colors.action,
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: 'transparent',
|
||||
borderColor: colors.line,
|
||||
borderWidth: 1,
|
||||
},
|
||||
stop: {
|
||||
backgroundColor: colors.stopSurface,
|
||||
},
|
||||
stopAlert: {
|
||||
backgroundColor: colors.action,
|
||||
},
|
||||
pressed: {
|
||||
transform: [{ scale: 0.96 }],
|
||||
opacity: 0.82,
|
||||
},
|
||||
label: {
|
||||
...typography.button,
|
||||
},
|
||||
primaryLabel: {
|
||||
color: colors.text,
|
||||
},
|
||||
secondaryLabel: {
|
||||
color: colors.text,
|
||||
},
|
||||
stopLabel: {
|
||||
color: colors.stopText,
|
||||
},
|
||||
stopAlertLabel: {
|
||||
color: colors.text,
|
||||
},
|
||||
iconBubble: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.16)',
|
||||
},
|
||||
iconText: {
|
||||
color: colors.text,
|
||||
fontWeight: '900',
|
||||
fontSize: 20,
|
||||
lineHeight: 22,
|
||||
},
|
||||
alertRing: {
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
left: -4,
|
||||
right: -4,
|
||||
bottom: -4,
|
||||
borderRadius: radius.xl + 4,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.ringWarn,
|
||||
} as ViewStyle,
|
||||
});
|
||||
@@ -44,7 +44,7 @@ export function InsightRow({
|
||||
const indexLabel = String(index).padStart(2, '0');
|
||||
const opacity = useSharedValue(0);
|
||||
const translateY = useSharedValue(8);
|
||||
const color = toneColor(tone);
|
||||
const indexColor = toneColor(tone);
|
||||
|
||||
useEffect(() => {
|
||||
const delay = index < 8 ? index * motion.stagger.row : 0;
|
||||
@@ -63,7 +63,6 @@ export function InsightRow({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
}));
|
||||
|
||||
const indexStyle: ViewStyle = { color };
|
||||
return (
|
||||
<Animated.View
|
||||
accessibilityLabel={`${label}, status ${statusLabel}, valor ${value}${unit ? ' ' + unit : ''}`}
|
||||
@@ -74,13 +73,13 @@ export function InsightRow({
|
||||
<View style={styles.body}>
|
||||
<View style={styles.topLine}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Text style={[styles.status, indexStyle]}>{statusLabel}</Text>
|
||||
<Text style={[styles.status, { color: indexColor }]}>{statusLabel}</Text>
|
||||
</View>
|
||||
<Text style={styles.summary}>{summary}</Text>
|
||||
<Text style={styles.detail}>{detail}</Text>
|
||||
</View>
|
||||
<View style={styles.valueBox}>
|
||||
<Text style={[styles.value, indexStyle]}>{value}</Text>
|
||||
<Text style={[styles.value, { color: indexColor }]}>{value}</Text>
|
||||
{unit ? <Text style={styles.unit}>{unit}</Text> : null}
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { colors, radius, spacing, typography } from '../theme';
|
||||
|
||||
type Props = {
|
||||
readonly value: string;
|
||||
readonly label: string;
|
||||
};
|
||||
|
||||
export function MetricTile({ value, label }: Props) {
|
||||
return (
|
||||
<View style={styles.tile}>
|
||||
<Text style={styles.value}>{value}</Text>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tile: {
|
||||
flex: 1,
|
||||
minHeight: 74,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.line,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.surface,
|
||||
padding: spacing.sm,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
value: {
|
||||
color: colors.text,
|
||||
fontSize: 24,
|
||||
lineHeight: 28,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -1,
|
||||
},
|
||||
label: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
marginTop: spacing.xxs,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import React, { type ReactNode } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { colors, radius, spacing, typography } from '../theme';
|
||||
|
||||
type Props = {
|
||||
readonly title: string;
|
||||
readonly eyebrow?: string;
|
||||
readonly children: ReactNode;
|
||||
};
|
||||
|
||||
export function SectionPanel({ title, eyebrow, children }: Props) {
|
||||
return (
|
||||
<View style={styles.panel}>
|
||||
{eyebrow ? <Text style={styles.eyebrow}>{eyebrow}</Text> : null}
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<View style={styles.content}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
panel: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.line,
|
||||
borderRadius: radius.lg,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
padding: spacing.md,
|
||||
},
|
||||
eyebrow: {
|
||||
...typography.label,
|
||||
color: colors.warning,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 24,
|
||||
lineHeight: 27,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -1.2,
|
||||
},
|
||||
content: {
|
||||
marginTop: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { colors, radius, spacing, typography } from '../theme';
|
||||
|
||||
type Props = {
|
||||
readonly label: string;
|
||||
readonly tone?: 'amber' | 'green' | 'red';
|
||||
};
|
||||
|
||||
export function StatusTag({ label, tone = 'amber' }: Props) {
|
||||
return (
|
||||
<View style={[styles.base, styles[tone]]}>
|
||||
<Text style={[styles.text, styles[`${tone}Text`]]}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
alignSelf: 'flex-start',
|
||||
borderRadius: radius.xl,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderWidth: 1,
|
||||
},
|
||||
amber: {
|
||||
backgroundColor: colors.surfaceHot,
|
||||
borderColor: colors.line,
|
||||
},
|
||||
green: {
|
||||
backgroundColor: 'rgba(52,199,123,0.12)',
|
||||
borderColor: 'rgba(52,199,123,0.34)',
|
||||
},
|
||||
red: {
|
||||
backgroundColor: 'rgba(226,59,46,0.14)',
|
||||
borderColor: 'rgba(226,59,46,0.42)',
|
||||
},
|
||||
text: {
|
||||
...typography.label,
|
||||
},
|
||||
amberText: {
|
||||
color: colors.warning,
|
||||
},
|
||||
greenText: {
|
||||
color: colors.success,
|
||||
},
|
||||
redText: {
|
||||
color: colors.action,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
export type HeartRatePoint = {
|
||||
readonly second: number;
|
||||
readonly bpm: number;
|
||||
};
|
||||
|
||||
export type TrainingSession = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly dateLabel: string;
|
||||
readonly durationLabel: string;
|
||||
readonly avgBpm: number;
|
||||
readonly peakBpm: number;
|
||||
readonly loadScore: number;
|
||||
readonly zoneLoadPercent: number;
|
||||
};
|
||||
|
||||
export type SensorCharacteristic = {
|
||||
readonly uuid: string;
|
||||
readonly label: string;
|
||||
readonly property: 'notify' | 'read' | 'write';
|
||||
readonly value: string;
|
||||
};
|
||||
|
||||
export type Insight = {
|
||||
readonly title: string;
|
||||
readonly summary: string;
|
||||
readonly why: string;
|
||||
readonly action: string;
|
||||
};
|
||||
|
||||
export const activeTrainingPoints: readonly HeartRatePoint[] = [
|
||||
{ second: 0, bpm: 108 },
|
||||
{ second: 90, bpm: 126 },
|
||||
{ second: 210, bpm: 148 },
|
||||
{ second: 360, bpm: 139 },
|
||||
{ second: 520, bpm: 166 },
|
||||
{ second: 680, bpm: 181 },
|
||||
{ second: 840, bpm: 154 },
|
||||
{ second: 980, bpm: 172 },
|
||||
{ second: 1200, bpm: 149 },
|
||||
{ second: 1500, bpm: 160 },
|
||||
{ second: 1900, bpm: 142 },
|
||||
{ second: 2538, bpm: 154 },
|
||||
];
|
||||
|
||||
export const sessions: readonly TrainingSession[] = [
|
||||
{
|
||||
id: 'sparring-today',
|
||||
name: 'Sparring técnico',
|
||||
dateLabel: 'Hoje',
|
||||
durationLabel: '47 min',
|
||||
avgBpm: 146,
|
||||
peakBpm: 183,
|
||||
loadScore: 96,
|
||||
zoneLoadPercent: 41,
|
||||
},
|
||||
{
|
||||
id: 'muay-thai-monday',
|
||||
name: 'Muay Thai',
|
||||
dateLabel: 'Seg',
|
||||
durationLabel: '61 min',
|
||||
avgBpm: 151,
|
||||
peakBpm: 187,
|
||||
loadScore: 110,
|
||||
zoneLoadPercent: 46,
|
||||
},
|
||||
{
|
||||
id: 'boxing-friday',
|
||||
name: 'Boxe ritmo',
|
||||
dateLabel: 'Sex',
|
||||
durationLabel: '42 min',
|
||||
avgBpm: 137,
|
||||
peakBpm: 172,
|
||||
loadScore: 77,
|
||||
zoneLoadPercent: 29,
|
||||
},
|
||||
];
|
||||
|
||||
export const sensorCharacteristics: readonly SensorCharacteristic[] = [
|
||||
{
|
||||
uuid: '180D',
|
||||
label: 'Heart Rate Service',
|
||||
property: 'notify',
|
||||
value: 'ativo',
|
||||
},
|
||||
{
|
||||
uuid: '2A37',
|
||||
label: 'Heart Rate Measurement',
|
||||
property: 'notify',
|
||||
value: '154 bpm',
|
||||
},
|
||||
{
|
||||
uuid: '180F',
|
||||
label: 'Battery Service',
|
||||
property: 'read',
|
||||
value: '84%',
|
||||
},
|
||||
{
|
||||
uuid: '2A38',
|
||||
label: 'Body Sensor Location',
|
||||
property: 'read',
|
||||
value: 'chest',
|
||||
},
|
||||
];
|
||||
|
||||
export const insights: readonly Insight[] = [
|
||||
{
|
||||
title: 'Carga alta controlada',
|
||||
summary: 'Você sustentou intensidade de luta sem queda de sinal.',
|
||||
why: 'A janela teve 41% do tempo em Z4/Z5, acima da média recente.',
|
||||
action: 'Amanhã prefira treino técnico leve ou recuperação ativa.',
|
||||
},
|
||||
{
|
||||
title: 'Pico útil, não aleatório',
|
||||
summary: 'O pico de 183 bpm aconteceu dentro do bloco mais intenso.',
|
||||
why: 'Picos contextualizados ajudam a separar esforço real de ruído.',
|
||||
action: 'Compare esse bloco com próximos sparrings.',
|
||||
},
|
||||
];
|
||||
@@ -17,11 +17,15 @@ export function useMotionPreference(): boolean {
|
||||
const listener: MotionPreferenceListener = (value) => {
|
||||
if (mounted) setReduceMotion(value);
|
||||
};
|
||||
AccessibilityInfo.addEventListener('reduceMotionChanged', listener);
|
||||
const sub = AccessibilityInfo.addEventListener
|
||||
? AccessibilityInfo.addEventListener('reduceMotionChanged', listener)
|
||||
: undefined;
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
AccessibilityInfo.removeEventListener('reduceMotionChanged', listener);
|
||||
if (sub && typeof (sub as { remove?: () => void }).remove === 'function') {
|
||||
(sub as { remove: () => void }).remove();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -8,12 +8,7 @@ import {
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
type AnimatedStyle = {
|
||||
transform: Array<{ scale: number }>;
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
export function usePulseAnimation(active: boolean, durationMs = 1600): AnimatedStyle {
|
||||
export function usePulseAnimation(active: boolean, durationMs = 1600) {
|
||||
const scale = useSharedValue(1);
|
||||
const opacity = useSharedValue(active ? 1 : 0.5);
|
||||
|
||||
@@ -37,7 +32,7 @@ export function usePulseAnimation(active: boolean, durationMs = 1600): AnimatedS
|
||||
}
|
||||
}, [active, durationMs, scale, opacity]);
|
||||
|
||||
return useAnimatedStyle<AnimatedStyle>(() => ({
|
||||
return useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import {
|
||||
NativeEventEmitter,
|
||||
NativeModules,
|
||||
PermissionsAndroid,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { normalizeRssi } from '../ble/rssi';
|
||||
import {
|
||||
type CompletedTrainingSession,
|
||||
type HeartRateSample,
|
||||
} from '../training/trainingTypes';
|
||||
|
||||
export type NativePhase =
|
||||
| 'idle'
|
||||
| 'requesting-permission'
|
||||
| 'bluetooth-off'
|
||||
| 'scanning'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'receiving'
|
||||
| 'permission-denied'
|
||||
| 'sensor-not-found'
|
||||
| 'error';
|
||||
|
||||
export type NativeDiscoveredDevice = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly rssi: number | null;
|
||||
readonly serviceUuids?: readonly string[];
|
||||
};
|
||||
|
||||
export type NativeConnectedDevice = NativeDiscoveredDevice & {
|
||||
readonly batteryPercent?: number | null;
|
||||
readonly bodySensorLocation?: string | null;
|
||||
};
|
||||
|
||||
export type CaptureSnapshot = {
|
||||
readonly phase: NativePhase;
|
||||
readonly isMonitoring: boolean;
|
||||
readonly isCapturing: boolean;
|
||||
readonly startedAt: number | null;
|
||||
readonly statusMessage: string | null;
|
||||
readonly connectedDevice: NativeConnectedDevice | null;
|
||||
readonly discoveredDevices: readonly NativeDiscoveredDevice[];
|
||||
readonly samples: readonly HeartRateSample[];
|
||||
readonly sessions: readonly CompletedTrainingSession[];
|
||||
readonly services: readonly string[];
|
||||
readonly characteristics: readonly string[];
|
||||
readonly batteryOptimizationIgnored: boolean;
|
||||
};
|
||||
|
||||
export type NativeStatusEvent = {
|
||||
readonly phase: NativePhase;
|
||||
readonly statusMessage: string | null;
|
||||
readonly sessionId: number | null;
|
||||
readonly connectedDevice: NativeConnectedDevice | null;
|
||||
};
|
||||
|
||||
export type NativeDevicesEvent = {
|
||||
readonly devices: readonly NativeDiscoveredDevice[];
|
||||
};
|
||||
|
||||
export type NativeMetadataEvent = {
|
||||
readonly services: readonly string[];
|
||||
readonly characteristics: readonly string[];
|
||||
};
|
||||
|
||||
type FightRateCaptureModule = {
|
||||
readonly startMonitoring: (
|
||||
deviceId: string | null,
|
||||
deviceName: string | null,
|
||||
) => Promise<CaptureSnapshot>;
|
||||
readonly startCapture: (
|
||||
deviceId: string | null,
|
||||
deviceName: string | null,
|
||||
) => Promise<CaptureSnapshot>;
|
||||
readonly startWorkout?: (
|
||||
deviceId: string | null,
|
||||
deviceName: string | null,
|
||||
) => Promise<CaptureSnapshot>;
|
||||
readonly connectToDevice: (
|
||||
deviceId: string,
|
||||
deviceName: string | null,
|
||||
) => Promise<CaptureSnapshot>;
|
||||
readonly stopCapture: () => Promise<CaptureSnapshot>;
|
||||
readonly stopWorkout?: () => Promise<CaptureSnapshot>;
|
||||
readonly stopMonitoring?: () => Promise<CaptureSnapshot>;
|
||||
readonly getSessionSamples: (sessionId: string) => Promise<HeartRateSample[]>;
|
||||
readonly getContinuousSamples: (limit: number) => Promise<HeartRateSample[]>;
|
||||
readonly getSnapshot: () => Promise<CaptureSnapshot>;
|
||||
readonly isIgnoringBatteryOptimizations: () => Promise<boolean>;
|
||||
readonly openBatteryOptimizationSettings: () => Promise<boolean>;
|
||||
readonly addListener: (eventName: string) => void;
|
||||
readonly removeListeners: (count: number) => void;
|
||||
};
|
||||
|
||||
const nativeModule = NativeModules.FightRateCapture as
|
||||
| FightRateCaptureModule
|
||||
| undefined;
|
||||
|
||||
export const fightRateCaptureEvents = nativeModule
|
||||
? new NativeEventEmitter(nativeModule)
|
||||
: null;
|
||||
|
||||
export async function getCaptureSnapshot(): Promise<CaptureSnapshot> {
|
||||
if (!nativeModule) {
|
||||
return emptySnapshot('Módulo nativo FightRate indisponível.');
|
||||
}
|
||||
|
||||
const snapshot = await nativeModule.getSnapshot();
|
||||
return normalizeSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export async function startNativeMonitoring(
|
||||
device?: NativeDiscoveredDevice | null,
|
||||
): Promise<CaptureSnapshot> {
|
||||
if (!nativeModule) {
|
||||
return emptySnapshot('Módulo nativo FightRate indisponível.');
|
||||
}
|
||||
|
||||
const snapshot = await nativeModule.startMonitoring(
|
||||
device?.id ?? null,
|
||||
device?.name ?? null,
|
||||
);
|
||||
return normalizeSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export async function startNativeCapture(
|
||||
device?: NativeDiscoveredDevice | null,
|
||||
): Promise<CaptureSnapshot> {
|
||||
if (!nativeModule) {
|
||||
return emptySnapshot('Módulo nativo FightRate indisponível.');
|
||||
}
|
||||
|
||||
const starter = nativeModule.startWorkout ?? nativeModule.startCapture;
|
||||
const snapshot = await starter(device?.id ?? null, device?.name ?? null);
|
||||
return normalizeSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export async function connectNativeDevice(
|
||||
device: NativeDiscoveredDevice,
|
||||
): Promise<CaptureSnapshot> {
|
||||
if (!nativeModule) {
|
||||
return emptySnapshot('Módulo nativo FightRate indisponível.');
|
||||
}
|
||||
|
||||
const snapshot = await nativeModule.connectToDevice(device.id, device.name);
|
||||
return normalizeSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export async function stopNativeCapture(): Promise<CaptureSnapshot> {
|
||||
if (!nativeModule) {
|
||||
return emptySnapshot('Módulo nativo FightRate indisponível.');
|
||||
}
|
||||
|
||||
const stopper = nativeModule.stopWorkout ?? nativeModule.stopCapture;
|
||||
const snapshot = await stopper();
|
||||
return normalizeSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export async function stopNativeMonitoring(): Promise<CaptureSnapshot> {
|
||||
if (!nativeModule) {
|
||||
return emptySnapshot('Módulo nativo FightRate indisponível.');
|
||||
}
|
||||
|
||||
const snapshot = nativeModule.stopMonitoring
|
||||
? await nativeModule.stopMonitoring()
|
||||
: await nativeModule.stopCapture();
|
||||
return normalizeSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export async function getNativeSessionSamples(
|
||||
sessionId: string,
|
||||
): Promise<HeartRateSample[]> {
|
||||
if (!nativeModule) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const samples = await nativeModule.getSessionSamples(sessionId);
|
||||
return Array.isArray(samples) ? samples.map(normalizeSample) : [];
|
||||
}
|
||||
|
||||
export async function getNativeContinuousSamples(
|
||||
limit = 0,
|
||||
): Promise<HeartRateSample[]> {
|
||||
if (!nativeModule) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const samples = await nativeModule.getContinuousSamples(limit);
|
||||
return Array.isArray(samples) ? samples.map(normalizeSample) : [];
|
||||
}
|
||||
|
||||
export async function openBatteryOptimizationSettings() {
|
||||
if (!nativeModule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return nativeModule.openBatteryOptimizationSettings();
|
||||
}
|
||||
|
||||
export async function requestCapturePermissions() {
|
||||
if (Platform.OS !== 'android') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const apiLevel = Number(Platform.Version);
|
||||
const permissions = [PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION];
|
||||
|
||||
if (apiLevel >= 31) {
|
||||
permissions.push(
|
||||
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
|
||||
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
|
||||
);
|
||||
}
|
||||
|
||||
if (apiLevel >= 33) {
|
||||
permissions.push(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS);
|
||||
}
|
||||
|
||||
const result = await PermissionsAndroid.requestMultiple(permissions);
|
||||
return permissions.every(
|
||||
permission => result[permission] === PermissionsAndroid.RESULTS.GRANTED,
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeSample(
|
||||
sample: Partial<HeartRateSample>,
|
||||
): HeartRateSample {
|
||||
return {
|
||||
id: typeof sample.id === 'number' ? sample.id : undefined,
|
||||
sessionId:
|
||||
typeof sample.sessionId === 'number' && sample.sessionId >= 0
|
||||
? sample.sessionId
|
||||
: undefined,
|
||||
bpm: Number(sample.bpm ?? 0),
|
||||
timestamp: Number(sample.timestamp ?? Date.now()),
|
||||
contactDetected:
|
||||
typeof sample.contactDetected === 'boolean'
|
||||
? sample.contactDetected
|
||||
: null,
|
||||
rrIntervalsMs: Array.isArray(sample.rrIntervalsMs)
|
||||
? sample.rrIntervalsMs.map(Number)
|
||||
: [],
|
||||
deviceName: sample.deviceName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeStatusEvent(
|
||||
event: Partial<NativeStatusEvent>,
|
||||
): NativeStatusEvent {
|
||||
return {
|
||||
phase: normalizePhase(event.phase),
|
||||
statusMessage: event.statusMessage ?? null,
|
||||
sessionId:
|
||||
typeof event.sessionId === 'number' && event.sessionId >= 0
|
||||
? event.sessionId
|
||||
: null,
|
||||
connectedDevice: normalizeConnectedDevice(event.connectedDevice),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDevicesEvent(
|
||||
event: Partial<NativeDevicesEvent>,
|
||||
): NativeDevicesEvent {
|
||||
return {
|
||||
devices: Array.isArray(event.devices)
|
||||
? event.devices.map(normalizeDevice)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeMetadataEvent(
|
||||
event: Partial<NativeMetadataEvent>,
|
||||
): NativeMetadataEvent {
|
||||
return {
|
||||
services: Array.isArray(event.services) ? event.services.map(String) : [],
|
||||
characteristics: Array.isArray(event.characteristics)
|
||||
? event.characteristics.map(String)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSnapshot(
|
||||
snapshot: Partial<CaptureSnapshot>,
|
||||
): CaptureSnapshot {
|
||||
return {
|
||||
phase: normalizePhase(snapshot.phase),
|
||||
isMonitoring: Boolean(
|
||||
snapshot.isMonitoring ??
|
||||
(snapshot.phase ? snapshot.phase !== 'idle' : false),
|
||||
),
|
||||
isCapturing: Boolean(snapshot.isCapturing),
|
||||
startedAt:
|
||||
typeof snapshot.startedAt === 'number' && snapshot.startedAt > 0
|
||||
? snapshot.startedAt
|
||||
: null,
|
||||
statusMessage: snapshot.statusMessage ?? null,
|
||||
connectedDevice: normalizeConnectedDevice(snapshot.connectedDevice),
|
||||
discoveredDevices: Array.isArray(snapshot.discoveredDevices)
|
||||
? snapshot.discoveredDevices.map(normalizeDevice)
|
||||
: [],
|
||||
samples: Array.isArray(snapshot.samples)
|
||||
? snapshot.samples.map(normalizeSample)
|
||||
: [],
|
||||
sessions: Array.isArray(snapshot.sessions)
|
||||
? snapshot.sessions.map(normalizeSession)
|
||||
: [],
|
||||
services: Array.isArray(snapshot.services)
|
||||
? snapshot.services.map(String)
|
||||
: [],
|
||||
characteristics: Array.isArray(snapshot.characteristics)
|
||||
? snapshot.characteristics.map(String)
|
||||
: [],
|
||||
batteryOptimizationIgnored: Boolean(snapshot.batteryOptimizationIgnored),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSession(
|
||||
session: Partial<CompletedTrainingSession>,
|
||||
): CompletedTrainingSession {
|
||||
return {
|
||||
id: String(session.id ?? `session-${session.startedAt ?? Date.now()}`),
|
||||
startedAt: Number(session.startedAt ?? 0),
|
||||
endedAt: Number(session.endedAt ?? session.startedAt ?? 0),
|
||||
durationMs: Number(session.durationMs ?? 0),
|
||||
sampleCount: Number(session.sampleCount ?? 0),
|
||||
averageBpm: Number(session.averageBpm ?? 0),
|
||||
peakBpm: Number(session.peakBpm ?? 0),
|
||||
minBpm: Number(session.minBpm ?? 0),
|
||||
deviceName: session.deviceName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeConnectedDevice(
|
||||
device: NativeConnectedDevice | null | undefined,
|
||||
): NativeConnectedDevice | null {
|
||||
const hasIdentity = Boolean(device?.id || device?.name);
|
||||
if (!device || !hasIdentity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...normalizeDevice(device),
|
||||
batteryPercent:
|
||||
typeof device.batteryPercent === 'number' && device.batteryPercent >= 0
|
||||
? device.batteryPercent
|
||||
: null,
|
||||
bodySensorLocation: device.bodySensorLocation ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDevice(
|
||||
device: Partial<NativeDiscoveredDevice>,
|
||||
): NativeDiscoveredDevice {
|
||||
return {
|
||||
id: String(device.id ?? ''),
|
||||
name: String(device.name ?? 'Sensor BLE sem nome'),
|
||||
rssi: normalizeRssi(device.rssi),
|
||||
serviceUuids: Array.isArray(device.serviceUuids)
|
||||
? device.serviceUuids.map(String)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePhase(phase: unknown): NativePhase {
|
||||
if (nativePhases.includes(phase as NativePhase)) {
|
||||
return phase as NativePhase;
|
||||
}
|
||||
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
const nativePhases: readonly NativePhase[] = [
|
||||
'idle',
|
||||
'requesting-permission',
|
||||
'bluetooth-off',
|
||||
'scanning',
|
||||
'connecting',
|
||||
'connected',
|
||||
'receiving',
|
||||
'permission-denied',
|
||||
'sensor-not-found',
|
||||
'error',
|
||||
];
|
||||
|
||||
function emptySnapshot(statusMessage: string): CaptureSnapshot {
|
||||
return {
|
||||
phase: 'error',
|
||||
isMonitoring: false,
|
||||
isCapturing: false,
|
||||
startedAt: null,
|
||||
statusMessage,
|
||||
connectedDevice: null,
|
||||
discoveredDevices: [],
|
||||
samples: [],
|
||||
sessions: [],
|
||||
services: [],
|
||||
characteristics: [],
|
||||
batteryOptimizationIgnored: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import {
|
||||
Activity,
|
||||
Battery,
|
||||
RadioTower,
|
||||
ShieldCheck,
|
||||
Timer,
|
||||
} from 'lucide-react-native';
|
||||
import { type HeartRateMonitorState } from '../ble/useHeartRateMonitor';
|
||||
import { ActionButton } from '../components/ActionButton';
|
||||
import { TrainingChart } from '../components/TrainingChart';
|
||||
import { colors, radius, spacing, typography } from '../theme';
|
||||
import {
|
||||
calculateLiveMetrics,
|
||||
formatDuration,
|
||||
} from '../training/trainingMetrics';
|
||||
|
||||
const BPM_EMPTY = '--';
|
||||
|
||||
type Props = {
|
||||
readonly monitor: HeartRateMonitorState;
|
||||
readonly onCaptureAction: () => Promise<void>;
|
||||
readonly onMonitorAction: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function CaptureScreen({
|
||||
monitor,
|
||||
onCaptureAction,
|
||||
onMonitorAction,
|
||||
}: Props) {
|
||||
const now = useTicker(monitor.isMonitoring || monitor.isCapturing);
|
||||
const status = getStatusCopy(monitor);
|
||||
const windowSamples = useMemo(
|
||||
() =>
|
||||
monitor.startedAt
|
||||
? monitor.samples.filter(
|
||||
sample => sample.timestamp >= monitor.startedAt!,
|
||||
)
|
||||
: monitor.samples,
|
||||
[monitor.samples, monitor.startedAt],
|
||||
);
|
||||
const windowMetrics = calculateLiveMetrics(windowSamples);
|
||||
const firstSampleAt = monitor.samples[0]?.timestamp ?? null;
|
||||
const monitorElapsedMs = firstSampleAt ? now - firstSampleAt : 0;
|
||||
const workoutElapsedMs = monitor.startedAt ? now - monitor.startedAt : 0;
|
||||
const actionLabel = monitor.isCapturing
|
||||
? 'Fechar janela de treino'
|
||||
: 'Iniciar janela de treino';
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={styles.topRail}>
|
||||
<View style={styles.stateBlock}>
|
||||
<Text style={styles.eyebrow}>{status.label}</Text>
|
||||
<Text style={styles.sensorName}>{formatDeviceName(monitor)}</Text>
|
||||
</View>
|
||||
<View style={styles.timeStack}>
|
||||
<Timer color={colors.warning} size={16} strokeWidth={2.4} />
|
||||
<Text style={styles.timerText}>
|
||||
{monitor.isCapturing
|
||||
? formatDuration(workoutElapsedMs)
|
||||
: formatDuration(monitorElapsedMs)}
|
||||
</Text>
|
||||
<Text style={styles.timerLabel}>
|
||||
{monitor.isCapturing ? 'janela' : 'monitor'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.bpmRow}>
|
||||
<Text style={styles.bpmValue}>{monitor.currentBpm ?? BPM_EMPTY}</Text>
|
||||
<View style={styles.bpmSide}>
|
||||
<Text style={styles.bpmUnit}>BPM</Text>
|
||||
<Text style={styles.statusLine}>
|
||||
{monitor.errorMessage ?? status.detail}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.captureRail}>
|
||||
<RailMetric
|
||||
label="MÉDIA"
|
||||
value={windowMetrics.averageBpm ?? BPM_EMPTY}
|
||||
/>
|
||||
<RailMetric
|
||||
label="PICO"
|
||||
value={windowMetrics.peakBpm ?? BPM_EMPTY}
|
||||
tone="hot"
|
||||
/>
|
||||
<RailMetric
|
||||
label="AMOSTRAS"
|
||||
value={windowSamples.length || BPM_EMPTY}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TrainingChart samples={windowSamples} />
|
||||
|
||||
<View style={styles.systemStrip}>
|
||||
<SystemSignal
|
||||
icon="radio"
|
||||
label="BLE"
|
||||
value={getBleShortState(monitor.phase, monitor.isMonitoring)}
|
||||
tone={monitor.phase === 'receiving' ? 'green' : 'amber'}
|
||||
/>
|
||||
<SystemSignal
|
||||
icon="battery"
|
||||
label="Bateria"
|
||||
value={getBatteryLabel(monitor)}
|
||||
/>
|
||||
<SystemSignal
|
||||
icon="shield"
|
||||
label="Fundo"
|
||||
value={monitor.batteryOptimizationIgnored ? 'liberado' : 'otimizar'}
|
||||
tone={monitor.batteryOptimizationIgnored ? 'green' : 'amber'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{!monitor.batteryOptimizationIgnored ? (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
monitor.openBatterySettings().catch(() => undefined);
|
||||
}}
|
||||
style={styles.batteryPrompt}
|
||||
>
|
||||
<Text style={styles.batteryPromptTitle}>
|
||||
Manter com tela bloqueada
|
||||
</Text>
|
||||
<Text style={styles.batteryPromptText}>
|
||||
Toque para abrir a configuração de bateria e remover restrições do
|
||||
FightRate.
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<View style={styles.actionGrid}>
|
||||
{!monitor.isMonitoring ? (
|
||||
<ActionButton
|
||||
fullWidth
|
||||
label="Ativar monitor contínuo"
|
||||
onPress={() => {
|
||||
onMonitorAction().catch(() => undefined);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<ActionButton
|
||||
fullWidth
|
||||
label={actionLabel}
|
||||
onPress={() => {
|
||||
onCaptureAction().catch(() => undefined);
|
||||
}}
|
||||
variant={monitor.isCapturing ? 'stop' : 'primary'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.truthLine}>
|
||||
<Activity color={colors.warning} size={18} strokeWidth={2.5} />
|
||||
<Text style={styles.truthText}>
|
||||
Enquanto a cinta estiver reconhecida, todo BPM real é salvo. O botão
|
||||
de treino só marca a janela para análise.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function RailMetric({
|
||||
label,
|
||||
value,
|
||||
tone = 'normal',
|
||||
}: {
|
||||
readonly label: string;
|
||||
readonly value: string | number;
|
||||
readonly tone?: 'normal' | 'hot';
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.railMetric}>
|
||||
<Text
|
||||
style={[styles.railValue, tone === 'hot' ? styles.railValueHot : null]}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
<Text style={styles.railLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemSignal({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
tone = 'amber',
|
||||
}: {
|
||||
readonly icon: 'radio' | 'battery' | 'shield';
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
readonly tone?: 'amber' | 'green';
|
||||
}) {
|
||||
const color = tone === 'green' ? colors.success : colors.warning;
|
||||
const Icon =
|
||||
icon === 'radio' ? RadioTower : icon === 'battery' ? Battery : ShieldCheck;
|
||||
return (
|
||||
<View style={styles.systemSignal}>
|
||||
<Icon color={color} size={16} strokeWidth={2.3} />
|
||||
<View>
|
||||
<Text style={styles.signalLabel}>{label}</Text>
|
||||
<Text style={styles.signalValue}>{value}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function getStatusCopy(monitor: HeartRateMonitorState) {
|
||||
if (monitor.isCapturing) {
|
||||
return {
|
||||
label: 'JANELA DE TREINO',
|
||||
detail: 'Treino destacado enquanto o monitor contínuo segue gravando.',
|
||||
};
|
||||
}
|
||||
if (monitor.phase === 'receiving') {
|
||||
return {
|
||||
label: 'LEITURA ATIVA',
|
||||
detail: 'BPM real chegando e sendo salvo continuamente em SQLite.',
|
||||
};
|
||||
}
|
||||
if (monitor.phase === 'scanning') {
|
||||
return {
|
||||
label: 'PROCURANDO CINTA',
|
||||
detail: 'O serviço continua procurando até encontrar a cinta.',
|
||||
};
|
||||
}
|
||||
if (monitor.phase === 'connecting') {
|
||||
return {
|
||||
label: 'CONECTANDO 2A37',
|
||||
detail:
|
||||
'Abrindo Heart Rate Service 180D e armando notify da característica 2A37.',
|
||||
};
|
||||
}
|
||||
if (monitor.phase === 'permission-denied') {
|
||||
return {
|
||||
label: 'PERMISSÃO NEGADA',
|
||||
detail: 'Autorize Bluetooth, Localização e Notificações.',
|
||||
};
|
||||
}
|
||||
if (monitor.phase === 'bluetooth-off') {
|
||||
return {
|
||||
label: 'BLUETOOTH DESLIGADO',
|
||||
detail: 'Ligue o Bluetooth antes de ativar o monitor.',
|
||||
};
|
||||
}
|
||||
if (monitor.phase === 'error') {
|
||||
return {
|
||||
label: 'ERRO DE CAPTURA',
|
||||
detail: monitor.errorMessage ?? 'Falha no serviço BLE.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: 'MONITOR DESATIVADO',
|
||||
detail: 'Ative o monitor contínuo; depois marque a janela de treino.',
|
||||
};
|
||||
}
|
||||
|
||||
function formatDeviceName(monitor: HeartRateMonitorState) {
|
||||
const name = monitor.connectedDevice?.name;
|
||||
if (!name) return 'Magene H003 ou cinta BLE 180D';
|
||||
if (/magene|h003/i.test(name)) return 'Magene H003 · cinta cardíaca';
|
||||
if (/heart|hrm/i.test(name)) return `${name} · cinta cardíaca`;
|
||||
return `${name} · BLE`;
|
||||
}
|
||||
|
||||
function getBleShortState(
|
||||
phase: HeartRateMonitorState['phase'],
|
||||
isMonitoring: boolean,
|
||||
) {
|
||||
if (!isMonitoring) return 'parado';
|
||||
if (phase === 'receiving') return 'lendo';
|
||||
if (phase === 'scanning') return 'busca';
|
||||
if (phase === 'connecting') return 'conectando';
|
||||
if (phase === 'connected') return '2A37';
|
||||
if (phase === 'error') return 'erro';
|
||||
return 'ativo';
|
||||
}
|
||||
|
||||
function getBatteryLabel(monitor: HeartRateMonitorState) {
|
||||
const percent = monitor.connectedDevice?.batteryPercent;
|
||||
if (typeof percent === 'number') return `${percent}%`;
|
||||
return 'sem leitura';
|
||||
}
|
||||
|
||||
function useTicker(active: boolean) {
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setNow(Date.now());
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [active]);
|
||||
|
||||
return now;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
topRail: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
paddingVertical: spacing.md,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
},
|
||||
stateBlock: {
|
||||
flex: 1,
|
||||
},
|
||||
eyebrow: {
|
||||
...typography.label,
|
||||
color: colors.action,
|
||||
},
|
||||
sensorName: {
|
||||
color: colors.text,
|
||||
fontSize: 18,
|
||||
lineHeight: 23,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.5,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
timeStack: {
|
||||
alignItems: 'flex-end',
|
||||
gap: spacing.xxs,
|
||||
minWidth: 84,
|
||||
},
|
||||
timerText: {
|
||||
...typography.label,
|
||||
color: colors.text,
|
||||
},
|
||||
timerLabel: {
|
||||
...typography.label,
|
||||
color: colors.textDim,
|
||||
fontSize: 9,
|
||||
},
|
||||
bpmRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
gap: spacing.md,
|
||||
},
|
||||
bpmValue: {
|
||||
...typography.display,
|
||||
fontSize: 98,
|
||||
lineHeight: 96,
|
||||
color: colors.text,
|
||||
},
|
||||
bpmSide: {
|
||||
flex: 1,
|
||||
paddingBottom: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
bpmUnit: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
statusLine: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
captureRail: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
railMetric: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingRight: spacing.sm,
|
||||
},
|
||||
railValue: {
|
||||
color: colors.text,
|
||||
fontSize: 28,
|
||||
lineHeight: 30,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -1.2,
|
||||
},
|
||||
railValueHot: {
|
||||
color: colors.action,
|
||||
},
|
||||
railLabel: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
marginTop: spacing.xxs,
|
||||
},
|
||||
systemStrip: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
systemSignal: {
|
||||
flex: 1,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.line,
|
||||
paddingTop: spacing.sm,
|
||||
flexDirection: 'row',
|
||||
gap: spacing.xs,
|
||||
alignItems: 'center',
|
||||
},
|
||||
signalLabel: {
|
||||
...typography.label,
|
||||
color: colors.textDim,
|
||||
fontSize: 9,
|
||||
},
|
||||
signalValue: {
|
||||
color: colors.text,
|
||||
fontSize: 13,
|
||||
lineHeight: 17,
|
||||
fontWeight: '800',
|
||||
},
|
||||
batteryPrompt: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.warning,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.surfaceHot,
|
||||
padding: spacing.md,
|
||||
},
|
||||
batteryPromptTitle: {
|
||||
...typography.label,
|
||||
color: colors.warning,
|
||||
},
|
||||
batteryPromptText: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
actionGrid: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
truthLine: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
truthText: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,922 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { Activity, ChevronDown, ChevronUp } from 'lucide-react-native';
|
||||
import {
|
||||
calculateLiveMetrics,
|
||||
calculateZones,
|
||||
formatDuration,
|
||||
} from '../training/trainingMetrics';
|
||||
import {
|
||||
type CompletedTrainingSession,
|
||||
type HeartRateSample,
|
||||
} from '../training/trainingTypes';
|
||||
import {
|
||||
buildContinuousUsageHistory,
|
||||
filterSamplesByRange,
|
||||
type ContinuousUsageBlock,
|
||||
} from '../training/continuousUsage';
|
||||
import { TrainingChart } from '../components/TrainingChart';
|
||||
import { colors, spacing, typography } from '../theme';
|
||||
|
||||
type HistoryMode = 'windows' | 'continuous' | 'range';
|
||||
|
||||
type Props = {
|
||||
readonly sessions: readonly CompletedTrainingSession[];
|
||||
readonly getSessionSamples: (
|
||||
sessionId: string,
|
||||
) => Promise<readonly HeartRateSample[]>;
|
||||
readonly getContinuousSamples: (
|
||||
limit?: number,
|
||||
) => Promise<readonly HeartRateSample[]>;
|
||||
readonly liveSamples: readonly HeartRateSample[];
|
||||
};
|
||||
|
||||
export function HistoryScreen({
|
||||
sessions,
|
||||
getSessionSamples,
|
||||
getContinuousSamples,
|
||||
liveSamples,
|
||||
}: Props) {
|
||||
const [mode, setMode] = useState<HistoryMode>('windows');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [selectedSamples, setSelectedSamples] = useState<
|
||||
readonly HeartRateSample[]
|
||||
>([]);
|
||||
const [selectedUsageId, setSelectedUsageId] = useState<string | null>(null);
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
const [loadingContinuous, setLoadingContinuous] = useState(true);
|
||||
const [continuousSamples, setContinuousSamples] = useState<
|
||||
readonly HeartRateSample[]
|
||||
>([]);
|
||||
const [rangeStartText, setRangeStartText] = useState(() =>
|
||||
formatRangeInput(Date.now() - 60 * 60 * 1000),
|
||||
);
|
||||
const [rangeEndText, setRangeEndText] = useState(() =>
|
||||
formatRangeInput(Date.now()),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
setLoadingContinuous(true);
|
||||
|
||||
getContinuousSamples()
|
||||
.then(samples => {
|
||||
if (mounted) {
|
||||
setContinuousSamples(samples);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) {
|
||||
setLoadingContinuous(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [getContinuousSamples]);
|
||||
|
||||
const mergedContinuousSamples = useMemo(
|
||||
() => mergeSamples(continuousSamples, liveSamples),
|
||||
[continuousSamples, liveSamples],
|
||||
);
|
||||
|
||||
const usageBlocks = useMemo(
|
||||
() => buildContinuousUsageHistory(mergedContinuousSamples),
|
||||
[mergedContinuousSamples],
|
||||
);
|
||||
const rangeBounds = useMemo(
|
||||
() => parseHistoryRange(rangeStartText, rangeEndText),
|
||||
[rangeStartText, rangeEndText],
|
||||
);
|
||||
const rangeSamples = useMemo(
|
||||
() =>
|
||||
rangeBounds
|
||||
? filterSamplesByRange(mergedContinuousSamples, rangeBounds)
|
||||
: [],
|
||||
[mergedContinuousSamples, rangeBounds],
|
||||
);
|
||||
const selectedMetrics = useMemo(
|
||||
() => calculateLiveMetrics(selectedSamples),
|
||||
[selectedSamples],
|
||||
);
|
||||
const zones = useMemo(
|
||||
() => calculateZones(selectedSamples),
|
||||
[selectedSamples],
|
||||
);
|
||||
const selectedUsage =
|
||||
usageBlocks.find(block => block.id === selectedUsageId) ?? null;
|
||||
|
||||
const openSession = async (session: CompletedTrainingSession) => {
|
||||
if (selectedId === session.id) {
|
||||
setSelectedId(null);
|
||||
setSelectedSamples([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingId(session.id);
|
||||
setSelectedId(session.id);
|
||||
try {
|
||||
const samples = await getSessionSamples(session.id);
|
||||
setSelectedSamples(samples);
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openUsageBlock = (block: ContinuousUsageBlock) => {
|
||||
if (selectedUsageId === block.id) {
|
||||
setSelectedUsageId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedUsageId(block.id);
|
||||
};
|
||||
|
||||
const totalWindowSamples = sessions.reduce(
|
||||
(sum, session) => sum + session.sampleCount,
|
||||
0,
|
||||
);
|
||||
const totalWindowDuration = sessions.reduce(
|
||||
(sum, session) => sum + session.durationMs,
|
||||
0,
|
||||
);
|
||||
const totalContinuousDuration = usageBlocks.reduce(
|
||||
(sum, block) => sum + block.durationMs,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={styles.modeSwitch}>
|
||||
<ModeChip
|
||||
active={mode === 'windows'}
|
||||
label="Janelas"
|
||||
onPress={() => setMode('windows')}
|
||||
/>
|
||||
<ModeChip
|
||||
active={mode === 'continuous'}
|
||||
label="Uso total"
|
||||
onPress={() => setMode('continuous')}
|
||||
/>
|
||||
<ModeChip
|
||||
active={mode === 'range'}
|
||||
label="Faixa"
|
||||
onPress={() => setMode('range')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{mode === 'windows' ? (
|
||||
<WindowHistory
|
||||
loadingId={loadingId}
|
||||
onOpenSession={openSession}
|
||||
selectedId={selectedId}
|
||||
selectedMetrics={selectedMetrics}
|
||||
selectedSamples={selectedSamples}
|
||||
sessions={sessions}
|
||||
totalDuration={totalWindowDuration}
|
||||
totalSamples={totalWindowSamples}
|
||||
zones={zones}
|
||||
/>
|
||||
) : null}
|
||||
{mode === 'continuous' ? (
|
||||
<ContinuousHistory
|
||||
blocks={usageBlocks}
|
||||
loading={loadingContinuous}
|
||||
onOpenBlock={openUsageBlock}
|
||||
selectedBlock={selectedUsage}
|
||||
selectedUsageId={selectedUsageId}
|
||||
totalDuration={totalContinuousDuration}
|
||||
totalSamples={mergedContinuousSamples.length}
|
||||
/>
|
||||
) : null}
|
||||
{mode === 'range' ? (
|
||||
<RangeHistory
|
||||
endText={rangeEndText}
|
||||
rangeBounds={rangeBounds}
|
||||
samples={rangeSamples}
|
||||
setEndText={setRangeEndText}
|
||||
setStartText={setRangeStartText}
|
||||
startText={rangeStartText}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeChip({
|
||||
active,
|
||||
label,
|
||||
onPress,
|
||||
}: {
|
||||
readonly active: boolean;
|
||||
readonly label: string;
|
||||
readonly onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: active }}
|
||||
onPress={onPress}
|
||||
style={[styles.modeChip, active ? styles.modeChipActive : null]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.modeChipText, active ? styles.modeChipTextActive : null]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function RangeHistory({
|
||||
endText,
|
||||
rangeBounds,
|
||||
samples,
|
||||
setEndText,
|
||||
setStartText,
|
||||
startText,
|
||||
}: {
|
||||
readonly endText: string;
|
||||
readonly rangeBounds: { readonly start: number; readonly end: number } | null;
|
||||
readonly samples: readonly HeartRateSample[];
|
||||
readonly setEndText: (value: string) => void;
|
||||
readonly setStartText: (value: string) => void;
|
||||
readonly startText: string;
|
||||
}) {
|
||||
const metrics = calculateLiveMetrics(samples);
|
||||
|
||||
return (
|
||||
<View style={styles.rangePanel}>
|
||||
<View style={styles.archiveHeader}>
|
||||
<Text style={styles.eyebrow}>FAIXA CUSTOMIZADA</Text>
|
||||
<Text style={styles.archiveLabel}>
|
||||
Dia, hora e minuto. Use AAAA-MM-DD HH:mm para início e fim.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.rangeGrid}>
|
||||
<RangeInput
|
||||
label="INÍCIO"
|
||||
onChangeText={setStartText}
|
||||
value={startText}
|
||||
/>
|
||||
<RangeInput label="FIM" onChangeText={setEndText} value={endText} />
|
||||
</View>
|
||||
|
||||
{!rangeBounds ? (
|
||||
<Text style={styles.rangeWarning}>
|
||||
Informe as duas datas no formato AAAA-MM-DD HH:mm.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{rangeBounds && samples.length > 0 ? (
|
||||
<View style={styles.rangeResult}>
|
||||
<TrainingChart samples={samples} />
|
||||
<View style={styles.detailRail}>
|
||||
<DetailMetric label="média" value={metrics.averageBpm ?? '--'} />
|
||||
<DetailMetric label="pico" value={metrics.peakBpm ?? '--'} />
|
||||
<DetailMetric label="amostras" value={samples.length} />
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{rangeBounds && samples.length === 0 ? (
|
||||
<EmptyHistory
|
||||
eyebrow="SEM AMOSTRAS"
|
||||
title="Nada nesta faixa"
|
||||
copy="A consulta usa apenas BPM real salvo no histórico contínuo. Ajuste o início ou fim para encontrar dados."
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function RangeInput({
|
||||
label,
|
||||
onChangeText,
|
||||
value,
|
||||
}: {
|
||||
readonly label: string;
|
||||
readonly onChangeText: (value: string) => void;
|
||||
readonly value: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.rangeInputBlock}>
|
||||
<Text style={styles.rangeInputLabel}>{label}</Text>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
keyboardType="numbers-and-punctuation"
|
||||
onChangeText={onChangeText}
|
||||
placeholder="2026-06-10 09:30"
|
||||
placeholderTextColor={colors.textDim}
|
||||
style={styles.rangeInput}
|
||||
value={value}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowHistory({
|
||||
loadingId,
|
||||
onOpenSession,
|
||||
selectedId,
|
||||
selectedMetrics,
|
||||
selectedSamples,
|
||||
sessions,
|
||||
totalDuration,
|
||||
totalSamples,
|
||||
zones,
|
||||
}: {
|
||||
readonly loadingId: string | null;
|
||||
readonly onOpenSession: (session: CompletedTrainingSession) => void;
|
||||
readonly selectedId: string | null;
|
||||
readonly selectedMetrics: ReturnType<typeof calculateLiveMetrics>;
|
||||
readonly selectedSamples: readonly HeartRateSample[];
|
||||
readonly sessions: readonly CompletedTrainingSession[];
|
||||
readonly totalDuration: number;
|
||||
readonly totalSamples: number;
|
||||
readonly zones: ReturnType<typeof calculateZones>;
|
||||
}) {
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<EmptyHistory
|
||||
eyebrow="JANELAS SALVAS"
|
||||
title="Nenhuma janela salva"
|
||||
copy="O monitor grava continuamente; a janela só destaca um intervalo que você abriu e fechou para análise."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={styles.archiveHeader}>
|
||||
<Text style={styles.eyebrow}>JANELAS SALVAS</Text>
|
||||
<Text style={styles.archiveNumber}>{sessions.length}</Text>
|
||||
<Text style={styles.archiveLabel}>
|
||||
{formatDuration(totalDuration)} · {totalSamples} amostras reais
|
||||
destacadas
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.timeline}>
|
||||
{sessions.map(session => {
|
||||
const isOpen = selectedId === session.id;
|
||||
return (
|
||||
<View key={session.id} style={styles.timelineNode}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => onOpenSession(session)}
|
||||
style={({ pressed }) => [
|
||||
styles.sessionStrip,
|
||||
pressed ? styles.pressed : null,
|
||||
]}
|
||||
>
|
||||
<View style={styles.dateBlock}>
|
||||
<Text style={styles.sessionDay}>
|
||||
{formatSessionDay(session.startedAt)}
|
||||
</Text>
|
||||
<Text style={styles.sessionHour}>
|
||||
{formatSessionHour(session.startedAt)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.sessionBody}>
|
||||
<Text style={styles.sessionTitle}>
|
||||
{session.deviceName || 'Cinta cardíaca BLE'}
|
||||
</Text>
|
||||
<Text style={styles.sessionMeta}>
|
||||
{formatDuration(session.durationMs)} · {session.sampleCount}{' '}
|
||||
amostras · toque para detalhes
|
||||
</Text>
|
||||
<View style={styles.metricLine}>
|
||||
<Text style={styles.metricText}>
|
||||
MÉD {session.averageBpm}
|
||||
</Text>
|
||||
<Text style={styles.metricTextHot}>
|
||||
PICO {session.peakBpm}
|
||||
</Text>
|
||||
<Text style={styles.metricText}>MIN {session.minBpm}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{isOpen ? <OpenIcon /> : <ClosedIcon />}
|
||||
</Pressable>
|
||||
|
||||
{isOpen ? (
|
||||
<WindowDetail
|
||||
loading={loadingId === session.id}
|
||||
metrics={selectedMetrics}
|
||||
samples={selectedSamples}
|
||||
zones={zones}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ContinuousHistory({
|
||||
blocks,
|
||||
loading,
|
||||
onOpenBlock,
|
||||
selectedBlock,
|
||||
selectedUsageId,
|
||||
totalDuration,
|
||||
totalSamples,
|
||||
}: {
|
||||
readonly blocks: readonly ContinuousUsageBlock[];
|
||||
readonly loading: boolean;
|
||||
readonly onOpenBlock: (block: ContinuousUsageBlock) => void;
|
||||
readonly selectedBlock: ContinuousUsageBlock | null;
|
||||
readonly selectedUsageId: string | null;
|
||||
readonly totalDuration: number;
|
||||
readonly totalSamples: number;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.loadingLine}>
|
||||
<Activity color={colors.warning} size={16} strokeWidth={2.4} />
|
||||
<Text style={styles.copy}>Carregando histórico contínuo real...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (blocks.length === 0) {
|
||||
return (
|
||||
<EmptyHistory
|
||||
eyebrow="USO TOTAL"
|
||||
title="Sem uso contínuo ainda"
|
||||
copy="Conecte a cinta. Todo BPM real recebido fora ou dentro de uma janela aparecerá aqui."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={styles.archiveHeader}>
|
||||
<Text style={styles.eyebrow}>USO TOTAL</Text>
|
||||
<Text style={styles.archiveNumber}>{blocks.length}</Text>
|
||||
<Text style={styles.archiveLabel}>
|
||||
{formatDuration(totalDuration)} · {totalSamples} amostras contínuas
|
||||
salvas
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.timeline}>
|
||||
{blocks.map(block => {
|
||||
const isOpen = selectedUsageId === block.id;
|
||||
return (
|
||||
<View key={block.id} style={styles.timelineNode}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => onOpenBlock(block)}
|
||||
style={({ pressed }) => [
|
||||
styles.sessionStrip,
|
||||
pressed ? styles.pressed : null,
|
||||
]}
|
||||
>
|
||||
<View style={styles.dateBlock}>
|
||||
<Text style={styles.sessionDay}>
|
||||
{formatSessionDay(block.startedAt)}
|
||||
</Text>
|
||||
<Text style={styles.sessionHour}>
|
||||
{formatSessionHour(block.startedAt)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.sessionBody}>
|
||||
<Text style={styles.sessionTitle}>
|
||||
{block.deviceName || 'Uso contínuo'}
|
||||
</Text>
|
||||
<Text style={styles.sessionMeta}>
|
||||
{formatUsageRange(block)} · {block.sampleCount} amostras ·
|
||||
toque para detalhes
|
||||
</Text>
|
||||
<View style={styles.metricLine}>
|
||||
<Text style={styles.metricText}>
|
||||
MÉD {block.averageBpm}
|
||||
</Text>
|
||||
<Text style={styles.metricTextHot}>
|
||||
PICO {block.peakBpm}
|
||||
</Text>
|
||||
<Text style={styles.metricText}>MIN {block.minBpm}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{isOpen ? <OpenIcon /> : <ClosedIcon />}
|
||||
</Pressable>
|
||||
|
||||
{isOpen && selectedBlock ? (
|
||||
<UsageDetail block={selectedBlock} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowDetail({
|
||||
loading,
|
||||
metrics,
|
||||
samples,
|
||||
zones,
|
||||
}: {
|
||||
readonly loading: boolean;
|
||||
readonly metrics: ReturnType<typeof calculateLiveMetrics>;
|
||||
readonly samples: readonly HeartRateSample[];
|
||||
readonly zones: ReturnType<typeof calculateZones>;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.detailPanel}>
|
||||
{loading ? (
|
||||
<View style={styles.loadingLine}>
|
||||
<Activity color={colors.warning} size={16} strokeWidth={2.4} />
|
||||
<Text style={styles.copy}>Carregando amostras salvas...</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<TrainingChart samples={samples} />
|
||||
<View style={styles.detailRail}>
|
||||
<DetailMetric label="carga" value={metrics.loadScore || '--'} />
|
||||
<DetailMetric label="alta" value={`${metrics.highIntensityPercent}%`} />
|
||||
<DetailMetric
|
||||
label="taxa"
|
||||
value={`${metrics.sampleRatePerMinute ?? '--'}/min`}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.zoneStack}>
|
||||
{zones.map(zone => (
|
||||
<View key={zone.key} style={styles.zoneRow}>
|
||||
<Text style={styles.zoneLabel}>{zone.label}</Text>
|
||||
<Text style={styles.zoneValue}>
|
||||
{zone.percent}% · {formatDuration(zone.seconds * 1000)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageDetail({ block }: { readonly block: ContinuousUsageBlock }) {
|
||||
const metrics = calculateLiveMetrics(block.samples);
|
||||
|
||||
return (
|
||||
<View style={styles.detailPanel}>
|
||||
<TrainingChart samples={block.samples} />
|
||||
<View style={styles.detailRail}>
|
||||
<DetailMetric
|
||||
label="duração"
|
||||
value={formatDuration(block.durationMs)}
|
||||
/>
|
||||
<DetailMetric label="amostras" value={block.sampleCount} />
|
||||
<DetailMetric
|
||||
label="taxa"
|
||||
value={`${metrics.sampleRatePerMinute ?? '--'}/min`}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyHistory({
|
||||
copy,
|
||||
eyebrow,
|
||||
title,
|
||||
}: {
|
||||
readonly copy: string;
|
||||
readonly eyebrow: string;
|
||||
readonly title: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.emptyBlock}>
|
||||
<Text style={styles.eyebrow}>{eyebrow}</Text>
|
||||
<Text style={styles.emptyTitle}>{title}</Text>
|
||||
<Text style={styles.copy}>{copy}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailMetric({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
readonly label: string;
|
||||
readonly value: string | number;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.detailMetric}>
|
||||
<Text style={styles.detailValue}>{value}</Text>
|
||||
<Text style={styles.detailLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function OpenIcon() {
|
||||
return <ChevronUp color={colors.warning} size={18} strokeWidth={2.6} />;
|
||||
}
|
||||
|
||||
function ClosedIcon() {
|
||||
return <ChevronDown color={colors.textMuted} size={18} strokeWidth={2.6} />;
|
||||
}
|
||||
|
||||
function formatSessionDay(timestamp: number) {
|
||||
return new Date(timestamp).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatSessionHour(timestamp: number) {
|
||||
return new Date(timestamp).toLocaleTimeString('pt-BR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatUsageRange(block: ContinuousUsageBlock) {
|
||||
return `${formatDuration(block.durationMs)} · ${formatSessionHour(
|
||||
block.startedAt,
|
||||
)}-${formatSessionHour(block.endedAt)}`;
|
||||
}
|
||||
|
||||
function mergeSamples(
|
||||
persistedSamples: readonly HeartRateSample[],
|
||||
liveSamples: readonly HeartRateSample[],
|
||||
) {
|
||||
const map = new Map<string, HeartRateSample>();
|
||||
|
||||
[...persistedSamples, ...liveSamples].forEach(sample => {
|
||||
const key = sample.id
|
||||
? `id:${sample.id}`
|
||||
: `ts:${sample.timestamp}:${sample.bpm}`;
|
||||
map.set(key, sample);
|
||||
});
|
||||
|
||||
return [...map.values()].sort(
|
||||
(left, right) => left.timestamp - right.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
function parseHistoryRange(startText: string, endText: string) {
|
||||
const start = parseRangeInput(startText);
|
||||
const end = parseRangeInput(endText);
|
||||
|
||||
if (start === null || end === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function parseRangeInput(value: string) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})$/.exec(
|
||||
value.trim(),
|
||||
);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, year, month, day, hour, minute] = match.map(Number);
|
||||
const timestamp = new Date(
|
||||
year,
|
||||
month - 1,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
0,
|
||||
0,
|
||||
).getTime();
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
}
|
||||
|
||||
function formatRangeInput(timestamp: number) {
|
||||
const date = new Date(timestamp);
|
||||
const year = date.getFullYear();
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${date.getDate()}`.padStart(2, '0');
|
||||
const hour = `${date.getHours()}`.padStart(2, '0');
|
||||
const minute = `${date.getMinutes()}`.padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hour}:${minute}`;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
modeSwitch: {
|
||||
borderTopWidth: 0,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
flexDirection: 'row',
|
||||
paddingVertical: spacing.xs,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
modeChip: {
|
||||
flex: 1,
|
||||
minHeight: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
modeChipActive: {
|
||||
borderColor: colors.line,
|
||||
backgroundColor: colors.surfaceHot,
|
||||
},
|
||||
modeChipText: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
modeChipTextActive: {
|
||||
color: colors.warning,
|
||||
},
|
||||
emptyBlock: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
paddingVertical: spacing.xl,
|
||||
minHeight: 260,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
eyebrow: {
|
||||
...typography.label,
|
||||
color: colors.action,
|
||||
},
|
||||
emptyTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 44,
|
||||
lineHeight: 45,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -2.6,
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
copy: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
flex: 1,
|
||||
},
|
||||
archiveHeader: {
|
||||
borderTopWidth: 0,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
archiveNumber: {
|
||||
...typography.display,
|
||||
color: colors.text,
|
||||
fontSize: 82,
|
||||
lineHeight: 84,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
archiveLabel: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
timeline: {
|
||||
gap: 0,
|
||||
},
|
||||
timelineNode: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.line,
|
||||
},
|
||||
sessionStrip: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.72,
|
||||
},
|
||||
dateBlock: {
|
||||
width: 68,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: colors.line,
|
||||
paddingRight: spacing.sm,
|
||||
},
|
||||
sessionDay: {
|
||||
color: colors.warning,
|
||||
fontSize: 20,
|
||||
lineHeight: 23,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.8,
|
||||
},
|
||||
sessionHour: {
|
||||
...typography.label,
|
||||
color: colors.textDim,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
sessionBody: {
|
||||
flex: 1,
|
||||
},
|
||||
sessionTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 22,
|
||||
lineHeight: 25,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -1,
|
||||
},
|
||||
sessionMeta: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
marginTop: spacing.xxs,
|
||||
},
|
||||
metricLine: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
metricText: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
metricTextHot: {
|
||||
...typography.label,
|
||||
color: colors.action,
|
||||
},
|
||||
rangePanel: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
rangeGrid: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
rangeInputBlock: {
|
||||
flex: 1,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
rangeInputLabel: {
|
||||
...typography.label,
|
||||
color: colors.warning,
|
||||
},
|
||||
rangeInput: {
|
||||
minHeight: 44,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.line,
|
||||
color: colors.text,
|
||||
paddingHorizontal: spacing.sm,
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
rangeWarning: {
|
||||
...typography.body,
|
||||
color: colors.warning,
|
||||
},
|
||||
rangeResult: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
detailPanel: {
|
||||
paddingBottom: spacing.lg,
|
||||
gap: spacing.md,
|
||||
},
|
||||
loadingLine: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
detailRail: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 0,
|
||||
borderColor: colors.line,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
detailMetric: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
detailValue: {
|
||||
color: colors.text,
|
||||
fontSize: 27,
|
||||
lineHeight: 30,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -1.2,
|
||||
},
|
||||
detailLabel: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
zoneStack: {
|
||||
gap: spacing.xs,
|
||||
},
|
||||
zoneRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.line,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
zoneLabel: {
|
||||
...typography.label,
|
||||
color: colors.text,
|
||||
},
|
||||
zoneValue: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,630 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import {
|
||||
Activity,
|
||||
ChartNoAxesCombined,
|
||||
Gauge,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react-native';
|
||||
import {
|
||||
buildInsightDashboard,
|
||||
formatDuration,
|
||||
} from '../training/trainingMetrics';
|
||||
import {
|
||||
type CompletedTrainingSession,
|
||||
type HeartRateSample,
|
||||
type InsightMetricRow,
|
||||
type TrainingInsight,
|
||||
} from '../training/trainingTypes';
|
||||
import { colors, spacing, typography } from '../theme';
|
||||
|
||||
const CONTINUOUS_SAMPLE_LIMIT = 2_000;
|
||||
|
||||
type Props = {
|
||||
readonly liveSamples: readonly HeartRateSample[];
|
||||
readonly sessions: readonly CompletedTrainingSession[];
|
||||
readonly getContinuousSamples: (
|
||||
limit?: number,
|
||||
) => Promise<readonly HeartRateSample[]>;
|
||||
};
|
||||
|
||||
export function InsightsScreen({
|
||||
liveSamples,
|
||||
sessions,
|
||||
getContinuousSamples,
|
||||
}: Props) {
|
||||
const [continuousSamples, setContinuousSamples] = useState<
|
||||
readonly HeartRateSample[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
getContinuousSamples(CONTINUOUS_SAMPLE_LIMIT)
|
||||
.then(samples => {
|
||||
if (mounted) {
|
||||
setContinuousSamples(samples);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) {
|
||||
setContinuousSamples([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [getContinuousSamples, sessions.length]);
|
||||
|
||||
const analysisSamples = useMemo(
|
||||
() => mergeSamples(continuousSamples, liveSamples),
|
||||
[continuousSamples, liveSamples],
|
||||
);
|
||||
|
||||
const dashboard = useMemo(
|
||||
() =>
|
||||
buildInsightDashboard({
|
||||
liveSamples,
|
||||
continuousSamples: analysisSamples,
|
||||
sessions,
|
||||
}),
|
||||
[analysisSamples, liveSamples, sessions],
|
||||
);
|
||||
|
||||
const latestSession = sessions[0] ?? null;
|
||||
const loadRow = dashboard.rows.find(row => row.key === 'load') ?? null;
|
||||
const hrvRow = dashboard.rows.find(row => row.key === 'hrv') ?? null;
|
||||
|
||||
if (dashboard.sampleCount === 0 && sessions.length === 0) {
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={styles.emptyInstrument}>
|
||||
<Text style={styles.eyebrow}>ANÁLISE BLOQUEADA</Text>
|
||||
<Text style={styles.emptyTitle}>Sem dado real, sem conclusão</Text>
|
||||
<Text style={styles.copy}>
|
||||
Ative o monitor contínuo. O FightRate não inventa pico, zona, carga,
|
||||
VFC ou recuperação.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={styles.instrumentHeader}>
|
||||
<View style={styles.headerRail} />
|
||||
<View style={styles.headerCopy}>
|
||||
<Text style={styles.eyebrow}>PAINEL DE CÁLCULO</Text>
|
||||
<Text style={styles.headerTitle}>Matriz científica</Text>
|
||||
<Text style={styles.copy}>
|
||||
Leituras calculadas a partir de BPM timestampado real. VFC só é
|
||||
liberada quando o sensor entrega RR intervals.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.headerScore}>
|
||||
<ChartNoAxesCombined
|
||||
color={colors.warning}
|
||||
size={20}
|
||||
strokeWidth={2.4}
|
||||
/>
|
||||
<Text style={styles.scoreValue}>{loadRow?.value ?? '--'}</Text>
|
||||
<Text style={styles.scoreLabel}>carga</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.evidenceStrip}>
|
||||
<EvidenceCell label="amostras" value={dashboard.sampleCount} />
|
||||
<EvidenceCell
|
||||
label="janela"
|
||||
value={formatDuration(dashboard.durationMs)}
|
||||
/>
|
||||
<EvidenceCell label="range" value={formatTimeRange(dashboard)} />
|
||||
</View>
|
||||
|
||||
<View style={styles.metricMatrix}>
|
||||
<View style={styles.matrixHeader}>
|
||||
<Text style={styles.sectionLabel}>CÁLCULOS</Text>
|
||||
<Text style={styles.matrixNote}>valor · status · leitura</Text>
|
||||
</View>
|
||||
{dashboard.rows.map((row, index) => (
|
||||
<InstrumentMetricRow key={row.key} index={index + 1} row={row} />
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.zonePanel}>
|
||||
<View style={styles.matrixHeader}>
|
||||
<Text style={styles.sectionLabel}>ZONAS OPERACIONAIS</Text>
|
||||
<Text style={styles.matrixNote}>tempo real em cada trilho</Text>
|
||||
</View>
|
||||
{dashboard.zones.map(zone => (
|
||||
<View key={zone.key} style={styles.zoneRow}>
|
||||
<Text style={styles.zoneLabel}>{zone.label}</Text>
|
||||
<View style={styles.zoneTrack}>
|
||||
<View style={[styles.zoneFill, { width: `${zone.percent}%` }]} />
|
||||
</View>
|
||||
<Text style={styles.zoneValue}>
|
||||
{zone.percent}% · {formatDuration(zone.seconds * 1000)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.protocolPanel}>
|
||||
<View style={styles.protocolIcon}>
|
||||
{hrvRow?.status === 'ok' ? (
|
||||
<ShieldCheck color={colors.success} size={18} strokeWidth={2.4} />
|
||||
) : (
|
||||
<Gauge color={colors.warning} size={18} strokeWidth={2.4} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.protocolCopy}>
|
||||
<Text style={styles.protocolTitle}>Protocolo de honestidade</Text>
|
||||
<Text style={styles.copy}>
|
||||
Métricas dependentes de perfil ou RR ficam marcadas como
|
||||
necessárias. Isso impede conclusões falsas sobre FCmáx, Karvonen,
|
||||
TRIMP completo ou VFC.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.insightStack}>
|
||||
<Text style={styles.sectionLabel}>LEITURAS OPERACIONAIS</Text>
|
||||
{dashboard.narrative.length > 0 ? (
|
||||
dashboard.narrative.map((insight, index) => (
|
||||
<InsightNote
|
||||
key={insight.title}
|
||||
insight={insight}
|
||||
index={index + 1}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<View style={styles.waitingPanel}>
|
||||
<Activity color={colors.warning} size={18} strokeWidth={2.5} />
|
||||
<Text style={styles.copy}>
|
||||
Receba mais amostras reais para gerar leitura de tendência e ação.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{latestSession ? (
|
||||
<View style={styles.lastSessionStrip}>
|
||||
<Text style={styles.eyebrow}>ÚLTIMA JANELA SALVA</Text>
|
||||
<Text style={styles.lastSessionText}>
|
||||
{formatDuration(latestSession.durationMs)} · média{' '}
|
||||
{latestSession.averageBpm} · pico {latestSession.peakBpm} ·{' '}
|
||||
{latestSession.sampleCount} amostras
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceCell({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
readonly label: string;
|
||||
readonly value: string | number;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.evidenceCell}>
|
||||
<Text style={styles.evidenceValue}>{value}</Text>
|
||||
<Text style={styles.evidenceLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function InstrumentMetricRow({
|
||||
row,
|
||||
index,
|
||||
}: {
|
||||
readonly row: InsightMetricRow;
|
||||
readonly index: number;
|
||||
}) {
|
||||
const toneColor = colorForTone(row.tone);
|
||||
return (
|
||||
<View style={styles.metricRow}>
|
||||
<View style={[styles.metricIndexRail, { borderColor: toneColor }]}>
|
||||
<Text style={styles.metricIndex}>
|
||||
{index.toString().padStart(2, '0')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metricMain}>
|
||||
<View style={styles.metricTopLine}>
|
||||
<Text style={styles.metricLabel}>{row.label}</Text>
|
||||
<Text style={[styles.statusPill, { color: toneColor }]}>
|
||||
{row.statusLabel}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.metricSummary}>{row.summary}</Text>
|
||||
<Text style={styles.metricDetail}>{row.detail}</Text>
|
||||
</View>
|
||||
<View style={styles.metricValueBox}>
|
||||
<Text style={[styles.metricValue, { color: toneColor }]}>
|
||||
{row.value}
|
||||
</Text>
|
||||
{row.unit ? <Text style={styles.metricUnit}>{row.unit}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function InsightNote({
|
||||
insight,
|
||||
index,
|
||||
}: {
|
||||
readonly insight: TrainingInsight;
|
||||
readonly index: number;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.insightNote}>
|
||||
<Text style={styles.noteIndex}>{index.toString().padStart(2, '0')}</Text>
|
||||
<View style={styles.noteBody}>
|
||||
<Text style={styles.insightTitle}>{insight.title}</Text>
|
||||
<Text style={styles.noteText}>{insight.why}</Text>
|
||||
<Text style={styles.noteAction}>{insight.action}</Text>
|
||||
<Text style={styles.confidence}>{insight.confidence}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function mergeSamples(
|
||||
persistedSamples: readonly HeartRateSample[],
|
||||
liveSamples: readonly HeartRateSample[],
|
||||
) {
|
||||
const byKey = new Map<string, HeartRateSample>();
|
||||
|
||||
[...persistedSamples, ...liveSamples].forEach(sample => {
|
||||
byKey.set(`${sample.timestamp}:${sample.bpm}`, sample);
|
||||
});
|
||||
|
||||
return [...byKey.values()].sort(
|
||||
(left, right) => left.timestamp - right.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
function colorForTone(rowTone: InsightMetricRow['tone']) {
|
||||
if (rowTone === 'good') {
|
||||
return colors.success;
|
||||
}
|
||||
if (rowTone === 'danger') {
|
||||
return colors.action;
|
||||
}
|
||||
if (rowTone === 'warning') {
|
||||
return colors.warning;
|
||||
}
|
||||
return colors.text;
|
||||
}
|
||||
|
||||
function formatTimeRange(dashboard: {
|
||||
readonly startedAt: number | null;
|
||||
readonly endedAt: number | null;
|
||||
}) {
|
||||
if (dashboard.startedAt === null || dashboard.endedAt === null) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
const start = new Date(dashboard.startedAt).toLocaleTimeString('pt-BR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const end = new Date(dashboard.endedAt).toLocaleTimeString('pt-BR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return `${start}–${end}`;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
gap: spacing.lg,
|
||||
},
|
||||
emptyInstrument: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
paddingVertical: spacing.xl,
|
||||
minHeight: 260,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
eyebrow: {
|
||||
...typography.label,
|
||||
color: colors.action,
|
||||
},
|
||||
emptyTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 42,
|
||||
lineHeight: 43,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -2.5,
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
copy: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
instrumentHeader: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
paddingVertical: spacing.md,
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
alignItems: 'stretch',
|
||||
},
|
||||
headerRail: {
|
||||
width: 5,
|
||||
backgroundColor: colors.action,
|
||||
},
|
||||
headerCopy: {
|
||||
flex: 1,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
headerTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 36,
|
||||
lineHeight: 37,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -2,
|
||||
},
|
||||
headerScore: {
|
||||
minWidth: 86,
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: colors.line,
|
||||
paddingLeft: spacing.md,
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
scoreValue: {
|
||||
color: colors.text,
|
||||
fontSize: 34,
|
||||
lineHeight: 36,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -1.6,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
scoreLabel: {
|
||||
...typography.label,
|
||||
color: colors.textDim,
|
||||
},
|
||||
evidenceStrip: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
evidenceCell: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.xs,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: colors.line,
|
||||
},
|
||||
evidenceValue: {
|
||||
color: colors.text,
|
||||
fontSize: 18,
|
||||
lineHeight: 22,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.8,
|
||||
},
|
||||
evidenceLabel: {
|
||||
...typography.label,
|
||||
color: colors.textDim,
|
||||
marginTop: 2,
|
||||
},
|
||||
metricMatrix: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
},
|
||||
matrixHeader: {
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.line,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
sectionLabel: {
|
||||
...typography.label,
|
||||
color: colors.warning,
|
||||
},
|
||||
matrixNote: {
|
||||
...typography.label,
|
||||
color: colors.textDim,
|
||||
textAlign: 'right',
|
||||
flexShrink: 1,
|
||||
},
|
||||
metricRow: {
|
||||
minHeight: 92,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.line,
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
metricIndexRail: {
|
||||
width: 34,
|
||||
borderLeftWidth: 3,
|
||||
justifyContent: 'flex-start',
|
||||
paddingLeft: spacing.xs,
|
||||
},
|
||||
metricIndex: {
|
||||
color: colors.textDim,
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
fontWeight: '900',
|
||||
},
|
||||
metricMain: {
|
||||
flex: 1,
|
||||
gap: spacing.xxs,
|
||||
},
|
||||
metricTopLine: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
metricLabel: {
|
||||
...typography.label,
|
||||
color: colors.text,
|
||||
flexShrink: 1,
|
||||
},
|
||||
statusPill: {
|
||||
...typography.label,
|
||||
textAlign: 'right',
|
||||
},
|
||||
metricSummary: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
lineHeight: 20,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.45,
|
||||
},
|
||||
metricDetail: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
metricValueBox: {
|
||||
width: 74,
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
metricValue: {
|
||||
fontSize: 26,
|
||||
lineHeight: 30,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -1.2,
|
||||
textAlign: 'right',
|
||||
},
|
||||
metricUnit: {
|
||||
...typography.label,
|
||||
color: colors.textDim,
|
||||
textAlign: 'right',
|
||||
},
|
||||
zonePanel: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
},
|
||||
zoneRow: {
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.line,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
zoneLabel: {
|
||||
...typography.label,
|
||||
color: colors.text,
|
||||
},
|
||||
zoneTrack: {
|
||||
height: 9,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
zoneFill: {
|
||||
height: '100%',
|
||||
backgroundColor: colors.action,
|
||||
},
|
||||
zoneValue: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
protocolPanel: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
paddingVertical: spacing.md,
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
},
|
||||
protocolIcon: {
|
||||
width: 34,
|
||||
alignItems: 'center',
|
||||
paddingTop: 2,
|
||||
},
|
||||
protocolCopy: {
|
||||
flex: 1,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
protocolTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 18,
|
||||
lineHeight: 22,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.7,
|
||||
},
|
||||
insightStack: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
insightNote: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.line,
|
||||
paddingTop: spacing.md,
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
},
|
||||
noteIndex: {
|
||||
color: colors.action,
|
||||
fontSize: 18,
|
||||
lineHeight: 24,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.5,
|
||||
width: 34,
|
||||
},
|
||||
noteBody: {
|
||||
flex: 1,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
insightTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 22,
|
||||
lineHeight: 25,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -1,
|
||||
},
|
||||
noteText: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
noteAction: {
|
||||
color: colors.text,
|
||||
fontSize: 14,
|
||||
lineHeight: 19,
|
||||
fontWeight: '800',
|
||||
},
|
||||
confidence: {
|
||||
...typography.label,
|
||||
color: colors.textDim,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
waitingPanel: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.line,
|
||||
paddingTop: spacing.md,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
lastSessionStrip: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
lastSessionText: {
|
||||
color: colors.text,
|
||||
fontSize: 17,
|
||||
lineHeight: 23,
|
||||
fontWeight: '800',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import {
|
||||
Activity,
|
||||
Battery,
|
||||
Bluetooth,
|
||||
RadioReceiver,
|
||||
ShieldCheck,
|
||||
Signal,
|
||||
} from 'lucide-react-native';
|
||||
import { type HeartRateMonitorState } from '../ble/useHeartRateMonitor';
|
||||
import { formatRssi } from '../ble/rssi';
|
||||
import { ActionButton } from '../components/ActionButton';
|
||||
import { colors, radius, spacing, typography } from '../theme';
|
||||
|
||||
type Props = {
|
||||
readonly monitor: HeartRateMonitorState;
|
||||
readonly onMonitorAction: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function SensorScreen({ monitor, onMonitorAction }: Props) {
|
||||
const device = monitor.connectedDevice;
|
||||
const services = monitor.services.length
|
||||
? monitor.services
|
||||
: ['Heart Rate Service · 0x180D · aguardando conexão'];
|
||||
const characteristics = monitor.characteristics.length
|
||||
? monitor.characteristics
|
||||
: ['Heart Rate Measurement · 0x2A37 · notify esperado'];
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={styles.sensorConsole}>
|
||||
<View style={styles.consoleTopline}>
|
||||
<View>
|
||||
<Text style={styles.eyebrow}>SENSOR REAL</Text>
|
||||
<Text style={styles.title}>
|
||||
{friendlyDeviceTitle(device?.name)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.phaseBadge}>
|
||||
<Bluetooth color={colors.warning} size={16} strokeWidth={2.4} />
|
||||
<Text style={styles.phaseText}>{phaseLabel(monitor.phase)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.signalGrid}>
|
||||
<SignalDatum
|
||||
icon="signal"
|
||||
label="RSSI"
|
||||
value={formatRssi(device?.rssi)}
|
||||
/>
|
||||
<SignalDatum
|
||||
icon="activity"
|
||||
label="Leituras"
|
||||
value={`${monitor.samples.length}`}
|
||||
/>
|
||||
<SignalDatum
|
||||
icon="battery"
|
||||
label="Bateria"
|
||||
value={formatBattery(device?.batteryPercent)}
|
||||
/>
|
||||
<SignalDatum
|
||||
icon="shield"
|
||||
label="Fundo"
|
||||
value={monitor.batteryOptimizationIgnored ? 'liberado' : 'restrito'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={styles.explainer}>
|
||||
{monitor.errorMessage ?? sensorExplanation(monitor)}
|
||||
</Text>
|
||||
|
||||
<View style={styles.actionRow}>
|
||||
{!monitor.isMonitoring ? (
|
||||
<ActionButton
|
||||
label="Buscar sensor"
|
||||
onPress={() => {
|
||||
onMonitorAction().catch(() => undefined);
|
||||
}}
|
||||
showIcon={false}
|
||||
variant="primary"
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.livePill}>
|
||||
<Activity color={colors.success} size={15} strokeWidth={2.4} />
|
||||
<Text style={styles.livePillText}>Monitor contínuo ativo</Text>
|
||||
</View>
|
||||
)}
|
||||
{!monitor.batteryOptimizationIgnored ? (
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
monitor.openBatterySettings().catch(() => undefined)
|
||||
}
|
||||
style={styles.secondaryButton}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Bateria</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{monitor.discoveredDevices.length > 0 ? (
|
||||
<View style={styles.discoveryBlock}>
|
||||
<Text style={styles.blockLabel}>ENCONTRADOS AGORA</Text>
|
||||
{monitor.discoveredDevices.map(candidate => (
|
||||
<Pressable
|
||||
key={candidate.id}
|
||||
onPress={() =>
|
||||
monitor.connectToDevice(candidate.id).catch(() => undefined)
|
||||
}
|
||||
style={styles.deviceRow}
|
||||
>
|
||||
<View style={styles.deviceDot} />
|
||||
<View style={styles.deviceInfo}>
|
||||
<Text style={styles.deviceName}>
|
||||
{friendlyDeviceTitle(candidate.name)}
|
||||
</Text>
|
||||
<Text style={styles.deviceMeta}>
|
||||
{candidate.name} · {formatRssi(candidate.rssi)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.connectPill}>
|
||||
<Text style={styles.connectPillText}>Conectar</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<BleInventoryBlock title="SERVIÇOS BLE" rows={services} />
|
||||
<BleInventoryBlock title="CARACTERÍSTICAS" rows={characteristics} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SignalDatum({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
readonly icon: 'signal' | 'battery' | 'shield' | 'activity';
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
}) {
|
||||
const Icon =
|
||||
icon === 'signal'
|
||||
? Signal
|
||||
: icon === 'battery'
|
||||
? Battery
|
||||
: icon === 'activity'
|
||||
? Activity
|
||||
: ShieldCheck;
|
||||
return (
|
||||
<View style={styles.signalDatum}>
|
||||
<Icon color={colors.warning} size={17} strokeWidth={2.3} />
|
||||
<Text style={styles.signalValue}>{value}</Text>
|
||||
<Text style={styles.signalLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function BleInventoryBlock({
|
||||
title,
|
||||
rows,
|
||||
}: {
|
||||
readonly title: string;
|
||||
readonly rows: readonly string[];
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.diagnosticBlock}>
|
||||
<Text style={styles.blockLabel}>{title}</Text>
|
||||
{rows.slice(0, 8).map(row => {
|
||||
const parsed = parseBleRow(row);
|
||||
return (
|
||||
<View key={row} style={styles.diagnosticRow}>
|
||||
<RadioReceiver color={colors.textDim} size={14} strokeWidth={2.2} />
|
||||
<View style={styles.diagnosticBody}>
|
||||
<Text style={styles.diagnosticTitle}>{parsed.name}</Text>
|
||||
<Text style={styles.diagnosticText}>
|
||||
{parsed.uuid} · {parsed.detail}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function friendlyDeviceTitle(name?: string | null) {
|
||||
if (!name) {
|
||||
return 'Cinta cardíaca BLE';
|
||||
}
|
||||
if (/magene|h003/i.test(name)) {
|
||||
return 'Magene H003';
|
||||
}
|
||||
if (/heart|hrm/i.test(name)) {
|
||||
return 'Cinta cardíaca BLE';
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function parseBleRow(row: string) {
|
||||
const [name = row, uuid = 'BLE', ...rest] = row.split(' · ');
|
||||
return {
|
||||
name,
|
||||
uuid,
|
||||
detail: rest.join(' · ') || 'aguardando leitura',
|
||||
};
|
||||
}
|
||||
|
||||
function phaseLabel(phase: HeartRateMonitorState['phase']) {
|
||||
const labels: Record<HeartRateMonitorState['phase'], string> = {
|
||||
idle: 'parado',
|
||||
'requesting-permission': 'permissões',
|
||||
'bluetooth-off': 'bluetooth off',
|
||||
scanning: 'buscando',
|
||||
connecting: 'conectando',
|
||||
connected: 'conectado',
|
||||
receiving: 'recebendo',
|
||||
'permission-denied': 'sem permissão',
|
||||
'sensor-not-found': 'sem sensor',
|
||||
error: 'erro',
|
||||
};
|
||||
return labels[phase];
|
||||
}
|
||||
|
||||
function sensorExplanation(monitor: HeartRateMonitorState) {
|
||||
if (monitor.phase === 'receiving' && monitor.samples.length > 0) {
|
||||
return `Amostras reais chegando: ${monitor.samples.length}. Cada BPM reconhecido está sendo salvo em SQLite.`;
|
||||
}
|
||||
if (monitor.phase === 'receiving' || monitor.phase === 'connected') {
|
||||
return 'Sensor reconhecido. O app está armando a leitura 2A37; se não vier BPM, reativa notify e reconecta sozinho.';
|
||||
}
|
||||
if (monitor.phase === 'scanning') {
|
||||
return 'Busca nativa BLE ativa. Se o app for bloqueado, a notificação mantém o serviço vivo.';
|
||||
}
|
||||
return 'A tela traduz nomes técnicos: 180D é o serviço cardíaco; 2A37 é o dado de BPM.';
|
||||
}
|
||||
|
||||
function formatBattery(percent?: number | null) {
|
||||
if (typeof percent === 'number') {
|
||||
return `${percent}%`;
|
||||
}
|
||||
return '--';
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
sensorConsole: {
|
||||
borderTopWidth: 1,
|
||||
borderColor: colors.line,
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
consoleTopline: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
},
|
||||
eyebrow: {
|
||||
...typography.label,
|
||||
color: colors.action,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 34,
|
||||
lineHeight: 36,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -2,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
phaseBadge: {
|
||||
paddingVertical: spacing.xs,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
height: 36,
|
||||
},
|
||||
phaseText: {
|
||||
...typography.label,
|
||||
color: colors.text,
|
||||
},
|
||||
signalGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
signalDatum: {
|
||||
width: '48.5%',
|
||||
minHeight: 76,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.line,
|
||||
padding: spacing.sm,
|
||||
gap: spacing.xxs,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
signalValue: {
|
||||
color: colors.text,
|
||||
fontSize: 18,
|
||||
lineHeight: 22,
|
||||
fontWeight: '900',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
signalLabel: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
explainer: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
actionRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
alignItems: 'center',
|
||||
},
|
||||
livePill: {
|
||||
minHeight: 48,
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.line,
|
||||
paddingHorizontal: spacing.md,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
livePillText: {
|
||||
...typography.label,
|
||||
color: colors.success,
|
||||
},
|
||||
secondaryButton: {
|
||||
minHeight: 48,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.line,
|
||||
borderRadius: radius.xl,
|
||||
paddingHorizontal: spacing.md,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
secondaryButtonText: {
|
||||
...typography.label,
|
||||
color: colors.warning,
|
||||
},
|
||||
discoveryBlock: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.line,
|
||||
paddingTop: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
blockLabel: {
|
||||
...typography.label,
|
||||
color: colors.warning,
|
||||
},
|
||||
deviceRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.line,
|
||||
},
|
||||
deviceDot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: colors.success,
|
||||
},
|
||||
deviceInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
deviceName: {
|
||||
color: colors.text,
|
||||
fontSize: 18,
|
||||
lineHeight: 22,
|
||||
fontWeight: '900',
|
||||
},
|
||||
deviceMeta: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
marginTop: spacing.xxs,
|
||||
},
|
||||
connectPill: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.line,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
connectPillText: {
|
||||
...typography.label,
|
||||
color: colors.warning,
|
||||
},
|
||||
diagnosticBlock: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.line,
|
||||
paddingTop: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
diagnosticRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.xs,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.line,
|
||||
},
|
||||
diagnosticBody: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
diagnosticTitle: {
|
||||
...typography.label,
|
||||
color: colors.text,
|
||||
},
|
||||
diagnosticText: {
|
||||
flex: 1,
|
||||
color: colors.textMuted,
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export const radius = {
|
||||
xs: 10,
|
||||
sm: 16,
|
||||
md: 24,
|
||||
lg: 36,
|
||||
xl: 48,
|
||||
} as const;
|
||||
@@ -0,0 +1,9 @@
|
||||
export const spacing = {
|
||||
xxs: 4,
|
||||
xs: 8,
|
||||
sm: 12,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
xxl: 48,
|
||||
} as const;
|
||||
@@ -1,3 +1,15 @@
|
||||
import type { TextStyle } from 'react-native';
|
||||
|
||||
const numeric: TextStyle = {
|
||||
fontFamily: 'JetBrainsMono-Bold',
|
||||
fontVariant: ['tabular-nums'] as TextStyle['fontVariant'],
|
||||
};
|
||||
|
||||
const display: TextStyle = {
|
||||
fontFamily: 'JetBrainsMono-ExtraBold',
|
||||
fontVariant: ['tabular-nums'] as TextStyle['fontVariant'],
|
||||
};
|
||||
|
||||
export const typography = {
|
||||
display: {
|
||||
fontSize: 82,
|
||||
@@ -12,32 +24,28 @@ export const typography = {
|
||||
letterSpacing: -3,
|
||||
},
|
||||
displayNumeric: {
|
||||
fontFamily: 'JetBrainsMono-ExtraBold',
|
||||
fontVariant: ['tabular-nums'] as const,
|
||||
...display,
|
||||
fontSize: 110,
|
||||
lineHeight: 100,
|
||||
fontWeight: '900' as const,
|
||||
letterSpacing: -7,
|
||||
},
|
||||
displaySmallNumeric: {
|
||||
fontFamily: 'JetBrainsMono-ExtraBold',
|
||||
fontVariant: ['tabular-nums'] as const,
|
||||
...display,
|
||||
fontSize: 56,
|
||||
lineHeight: 54,
|
||||
fontWeight: '900' as const,
|
||||
letterSpacing: -3,
|
||||
},
|
||||
metricNumeric: {
|
||||
fontFamily: 'JetBrainsMono-Bold',
|
||||
fontVariant: ['tabular-nums'] as const,
|
||||
...numeric,
|
||||
fontSize: 28,
|
||||
lineHeight: 30,
|
||||
fontWeight: '800' as const,
|
||||
letterSpacing: -1.2,
|
||||
},
|
||||
detailNumeric: {
|
||||
fontFamily: 'JetBrainsMono-Bold',
|
||||
fontVariant: ['tabular-nums'] as const,
|
||||
...numeric,
|
||||
fontSize: 18,
|
||||
lineHeight: 22,
|
||||
fontWeight: '800' as const,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { type HeartRateSample } from './trainingTypes';
|
||||
|
||||
export type ContinuousUsageBlock = {
|
||||
readonly id: string;
|
||||
readonly startedAt: number;
|
||||
readonly endedAt: number;
|
||||
readonly durationMs: number;
|
||||
readonly sampleCount: number;
|
||||
readonly averageBpm: number;
|
||||
readonly peakBpm: number;
|
||||
readonly minBpm: number;
|
||||
readonly deviceName: string | null;
|
||||
readonly samples: readonly HeartRateSample[];
|
||||
};
|
||||
|
||||
type ContinuousUsageOptions = {
|
||||
readonly gapMs?: number;
|
||||
};
|
||||
|
||||
export type ContinuousSampleRange = {
|
||||
readonly start: number;
|
||||
readonly end: number;
|
||||
};
|
||||
|
||||
const DEFAULT_GAP_MS = 5 * 60 * 1000;
|
||||
|
||||
export function filterSamplesByRange(
|
||||
samples: readonly HeartRateSample[],
|
||||
range: ContinuousSampleRange,
|
||||
): HeartRateSample[] {
|
||||
const start = Math.min(range.start, range.end);
|
||||
const end = Math.max(range.start, range.end);
|
||||
|
||||
return samples
|
||||
.filter(sample => sample.timestamp >= start && sample.timestamp <= end)
|
||||
.slice()
|
||||
.sort((left, right) => left.timestamp - right.timestamp);
|
||||
}
|
||||
|
||||
export function buildContinuousUsageHistory(
|
||||
samples: readonly HeartRateSample[],
|
||||
options: ContinuousUsageOptions = {},
|
||||
): ContinuousUsageBlock[] {
|
||||
const gapMs = options.gapMs ?? DEFAULT_GAP_MS;
|
||||
const sortedSamples = samples
|
||||
.filter(sample => Number.isFinite(sample.timestamp) && sample.bpm > 0)
|
||||
.slice()
|
||||
.sort((left, right) => left.timestamp - right.timestamp);
|
||||
|
||||
if (sortedSamples.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groups: HeartRateSample[][] = [];
|
||||
let currentGroup: HeartRateSample[] = [];
|
||||
|
||||
sortedSamples.forEach(sample => {
|
||||
const previous = currentGroup[currentGroup.length - 1];
|
||||
const shouldStartNewGroup =
|
||||
previous && sample.timestamp - previous.timestamp > gapMs;
|
||||
|
||||
if (shouldStartNewGroup) {
|
||||
groups.push(currentGroup);
|
||||
currentGroup = [];
|
||||
}
|
||||
|
||||
currentGroup.push(sample);
|
||||
});
|
||||
|
||||
if (currentGroup.length > 0) {
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
|
||||
return groups
|
||||
.map(toUsageBlock)
|
||||
.sort((left, right) => right.startedAt - left.startedAt);
|
||||
}
|
||||
|
||||
function toUsageBlock(
|
||||
samples: readonly HeartRateSample[],
|
||||
): ContinuousUsageBlock {
|
||||
const startedAt = samples[0].timestamp;
|
||||
const endedAt = samples[samples.length - 1].timestamp;
|
||||
const bpms = samples.map(sample => sample.bpm);
|
||||
const averageBpm = Math.round(
|
||||
bpms.reduce((sum, bpm) => sum + bpm, 0) / samples.length,
|
||||
);
|
||||
|
||||
return {
|
||||
id: `usage-${startedAt}`,
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: Math.max(0, endedAt - startedAt),
|
||||
sampleCount: samples.length,
|
||||
averageBpm,
|
||||
peakBpm: Math.max(...bpms),
|
||||
minBpm: Math.min(...bpms),
|
||||
deviceName: mostRecentDeviceName(samples),
|
||||
samples,
|
||||
};
|
||||
}
|
||||
|
||||
function mostRecentDeviceName(samples: readonly HeartRateSample[]) {
|
||||
for (let index = samples.length - 1; index >= 0; index -= 1) {
|
||||
const deviceName = samples[index].deviceName;
|
||||
if (deviceName) {
|
||||
return deviceName;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
import {
|
||||
type BpmVariabilitySummary,
|
||||
type CompletedTrainingSession,
|
||||
type HeartRateDriftSummary,
|
||||
type HeartRateSample,
|
||||
type InsightDashboard,
|
||||
type InsightMetricRow,
|
||||
type IntensityZone,
|
||||
type PostSessionRecoverySummary,
|
||||
type RrAvailabilitySummary,
|
||||
type TrainingInsight,
|
||||
} from './trainingTypes';
|
||||
|
||||
const MIN_VALID_BPM = 30;
|
||||
const MAX_VALID_BPM = 240;
|
||||
const DEFAULT_STALE_AFTER_MS = 12_000;
|
||||
|
||||
type LiveMetricOptions = {
|
||||
readonly now?: number;
|
||||
readonly staleAfterMs?: number;
|
||||
};
|
||||
|
||||
export function isReliableHeartRateSample(sample: HeartRateSample): boolean {
|
||||
return (
|
||||
Number.isFinite(sample.bpm) &&
|
||||
sample.bpm >= MIN_VALID_BPM &&
|
||||
sample.bpm <= MAX_VALID_BPM &&
|
||||
sample.contactDetected !== false
|
||||
);
|
||||
}
|
||||
|
||||
export function filterReliableHeartRateSamples(
|
||||
samples: readonly HeartRateSample[],
|
||||
): HeartRateSample[] {
|
||||
return samples.filter(isReliableHeartRateSample);
|
||||
}
|
||||
|
||||
export function isFreshHeartRateSample(
|
||||
sample: HeartRateSample | null | undefined,
|
||||
now = Date.now(),
|
||||
staleAfterMs = DEFAULT_STALE_AFTER_MS,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
sample &&
|
||||
isReliableHeartRateSample(sample) &&
|
||||
now - sample.timestamp <= staleAfterMs,
|
||||
);
|
||||
}
|
||||
|
||||
export function summarizeCompletedSession(
|
||||
samples: readonly HeartRateSample[],
|
||||
startedAt: number,
|
||||
endedAt: number,
|
||||
): CompletedTrainingSession | null {
|
||||
const reliableSamples = filterReliableHeartRateSamples(samples);
|
||||
if (reliableSamples.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bpms = reliableSamples.map(sample => sample.bpm);
|
||||
const total = bpms.reduce((sum, bpm) => sum + bpm, 0);
|
||||
|
||||
return {
|
||||
id: `session-${startedAt}-${endedAt}`,
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: Math.max(0, endedAt - startedAt),
|
||||
sampleCount: reliableSamples.length,
|
||||
averageBpm: Math.round(total / reliableSamples.length),
|
||||
peakBpm: Math.max(...bpms),
|
||||
minBpm: Math.min(...bpms),
|
||||
deviceName: reliableSamples[reliableSamples.length - 1]?.deviceName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateLiveMetrics(
|
||||
samples: readonly HeartRateSample[],
|
||||
options: LiveMetricOptions = {},
|
||||
) {
|
||||
const reliableSamples = filterReliableHeartRateSamples(samples);
|
||||
if (reliableSamples.length === 0) {
|
||||
return {
|
||||
currentBpm: null,
|
||||
averageBpm: null,
|
||||
peakBpm: null,
|
||||
minBpm: null,
|
||||
sampleRatePerMinute: null,
|
||||
zones: emptyZones(),
|
||||
loadScore: 0,
|
||||
highIntensityPercent: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const latestSample = reliableSamples[reliableSamples.length - 1];
|
||||
const isCurrentFresh =
|
||||
options.now === undefined
|
||||
? true
|
||||
: isFreshHeartRateSample(
|
||||
latestSample,
|
||||
options.now,
|
||||
options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS,
|
||||
);
|
||||
const bpms = reliableSamples.map(sample => sample.bpm);
|
||||
const total = bpms.reduce((sum, bpm) => sum + bpm, 0);
|
||||
const zones = calculateZones(reliableSamples);
|
||||
const durationMinutes = Math.max(
|
||||
1 / 60,
|
||||
(reliableSamples[reliableSamples.length - 1].timestamp -
|
||||
reliableSamples[0].timestamp) /
|
||||
60000,
|
||||
);
|
||||
const highIntensityPercent = zones
|
||||
.filter(zone => zone.key !== 'base')
|
||||
.reduce((sum, zone) => sum + zone.percent, 0);
|
||||
|
||||
return {
|
||||
currentBpm: isCurrentFresh ? latestSample.bpm : null,
|
||||
averageBpm: Math.round(total / reliableSamples.length),
|
||||
peakBpm: Math.max(...bpms),
|
||||
minBpm: Math.min(...bpms),
|
||||
sampleRatePerMinute: Math.round(reliableSamples.length / durationMinutes),
|
||||
zones,
|
||||
loadScore: calculateLoadScore(reliableSamples),
|
||||
highIntensityPercent,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateBpmVariability(
|
||||
samples: readonly HeartRateSample[],
|
||||
): BpmVariabilitySummary {
|
||||
const reliableSamples = filterReliableHeartRateSamples(samples);
|
||||
if (reliableSamples.length === 0) {
|
||||
return {
|
||||
status: 'no-data',
|
||||
averageBpm: null,
|
||||
minBpm: null,
|
||||
maxBpm: null,
|
||||
rangeBpm: 0,
|
||||
standardDeviationBpm: 0,
|
||||
coefficientOfVariation: 0,
|
||||
classification: 'sem-dados',
|
||||
};
|
||||
}
|
||||
|
||||
const bpms = reliableSamples.map(sample => sample.bpm);
|
||||
const rawAverage = bpms.reduce((sum, bpm) => sum + bpm, 0) / bpms.length;
|
||||
const variance =
|
||||
bpms.reduce((sum, bpm) => sum + Math.pow(bpm - rawAverage, 2), 0) /
|
||||
bpms.length;
|
||||
const standardDeviation = Math.sqrt(variance);
|
||||
const coefficientOfVariation =
|
||||
rawAverage > 0 ? standardDeviation / rawAverage : 0;
|
||||
const minBpm = Math.min(...bpms);
|
||||
const maxBpm = Math.max(...bpms);
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
averageBpm: Math.round(rawAverage),
|
||||
minBpm,
|
||||
maxBpm,
|
||||
rangeBpm: maxBpm - minBpm,
|
||||
standardDeviationBpm: roundTo(standardDeviation, 1),
|
||||
coefficientOfVariation: roundTo(coefficientOfVariation, 3),
|
||||
classification: classifyBpmVariation(coefficientOfVariation),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateHeartRateDrift(
|
||||
samples: readonly HeartRateSample[],
|
||||
): HeartRateDriftSummary {
|
||||
const reliableSamples = filterReliableHeartRateSamples(samples);
|
||||
if (reliableSamples.length < 3) {
|
||||
return emptyDrift('short-window');
|
||||
}
|
||||
|
||||
const sortedSamples = sortSamples(reliableSamples);
|
||||
const durationMs =
|
||||
sortedSamples[sortedSamples.length - 1].timestamp -
|
||||
sortedSamples[0].timestamp;
|
||||
|
||||
if (durationMs < 10 * 60_000) {
|
||||
return emptyDrift('short-window');
|
||||
}
|
||||
|
||||
const windowSize = Math.max(2, Math.round(sortedSamples.length * 0.2));
|
||||
const initialAverageBpm = averageRaw(sortedSamples.slice(0, windowSize));
|
||||
const finalAverageBpm = averageRaw(sortedSamples.slice(-windowSize));
|
||||
const driftBpm = finalAverageBpm - initialAverageBpm;
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
driftBpm: Math.round(driftBpm),
|
||||
driftPercent:
|
||||
initialAverageBpm > 0
|
||||
? roundTo((driftBpm / initialAverageBpm) * 100, 1)
|
||||
: 0,
|
||||
initialAverageBpm: Math.round(initialAverageBpm),
|
||||
finalAverageBpm: Math.round(finalAverageBpm),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculatePostSessionRecovery(
|
||||
session: CompletedTrainingSession | null,
|
||||
continuousSamples: readonly HeartRateSample[],
|
||||
): PostSessionRecoverySummary {
|
||||
if (!session) {
|
||||
return emptyRecovery('no-data');
|
||||
}
|
||||
|
||||
const postSessionSamples = sortSamples(
|
||||
filterReliableHeartRateSamples(continuousSamples).filter(
|
||||
sample => sample.timestamp >= session.endedAt,
|
||||
),
|
||||
);
|
||||
|
||||
if (postSessionSamples.length === 0) {
|
||||
return emptyRecovery('no-data');
|
||||
}
|
||||
|
||||
const oneMinuteSample = nearestSample(
|
||||
postSessionSamples,
|
||||
session.endedAt + 60_000,
|
||||
20_000,
|
||||
);
|
||||
const twoMinuteSample = nearestSample(
|
||||
postSessionSamples,
|
||||
session.endedAt + 120_000,
|
||||
25_000,
|
||||
);
|
||||
|
||||
if (!oneMinuteSample && !twoMinuteSample) {
|
||||
return emptyRecovery('short-window');
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
oneMinuteDropBpm: oneMinuteSample
|
||||
? Math.max(0, session.peakBpm - oneMinuteSample.bpm)
|
||||
: null,
|
||||
twoMinuteDropBpm: twoMinuteSample
|
||||
? Math.max(0, session.peakBpm - twoMinuteSample.bpm)
|
||||
: null,
|
||||
oneMinuteBpm: oneMinuteSample?.bpm ?? null,
|
||||
twoMinuteBpm: twoMinuteSample?.bpm ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeRrAvailability(
|
||||
samples: readonly HeartRateSample[],
|
||||
): RrAvailabilitySummary {
|
||||
const samplesWithRr = filterReliableHeartRateSamples(samples).filter(
|
||||
sample => sample.rrIntervalsMs.length > 0,
|
||||
);
|
||||
const intervalCount = samplesWithRr.reduce(
|
||||
(sum, sample) => sum + sample.rrIntervalsMs.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
status: intervalCount > 0 ? 'ok' : 'needs-rr',
|
||||
intervalCount,
|
||||
sampleCount: samplesWithRr.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildInsightDashboard({
|
||||
liveSamples,
|
||||
continuousSamples,
|
||||
sessions,
|
||||
}: {
|
||||
readonly liveSamples: readonly HeartRateSample[];
|
||||
readonly continuousSamples?: readonly HeartRateSample[];
|
||||
readonly sessions: readonly CompletedTrainingSession[];
|
||||
}): InsightDashboard {
|
||||
const samples = sortSamples(
|
||||
filterReliableHeartRateSamples(
|
||||
continuousSamples && continuousSamples.length > 0
|
||||
? continuousSamples
|
||||
: liveSamples,
|
||||
),
|
||||
);
|
||||
const latestSession = sessions[0] ?? null;
|
||||
const metrics = calculateLiveMetrics(samples);
|
||||
const variability = calculateBpmVariability(samples);
|
||||
const drift = calculateHeartRateDrift(samples);
|
||||
const recovery = calculatePostSessionRecovery(latestSession, samples);
|
||||
const rrAvailability = summarizeRrAvailability(samples);
|
||||
const startedAt = samples[0]?.timestamp ?? null;
|
||||
const endedAt = samples[samples.length - 1]?.timestamp ?? null;
|
||||
const durationMs =
|
||||
startedAt !== null && endedAt !== null ? endedAt - startedAt : 0;
|
||||
|
||||
const rows: InsightMetricRow[] = [
|
||||
{
|
||||
key: 'current-bpm',
|
||||
label: 'FC atual',
|
||||
value: formatMetricValue(metrics.currentBpm),
|
||||
unit: 'bpm',
|
||||
status: metrics.currentBpm === null ? 'no-data' : 'ok',
|
||||
statusLabel: metrics.currentBpm === null ? 'sem dados' : 'ao vivo',
|
||||
summary: 'Última amostra real recebida do sensor.',
|
||||
detail: `${samples.length} amostras no fluxo analisado.`,
|
||||
tone:
|
||||
metrics.currentBpm !== null && metrics.currentBpm >= 170
|
||||
? 'danger'
|
||||
: 'neutral',
|
||||
},
|
||||
{
|
||||
key: 'average-bpm',
|
||||
label: 'FC média',
|
||||
value: formatMetricValue(metrics.averageBpm),
|
||||
unit: 'bpm',
|
||||
status: metrics.averageBpm === null ? 'no-data' : 'ok',
|
||||
statusLabel: 'BPM-only',
|
||||
summary: 'Média aritmética das amostras reais.',
|
||||
detail: `Duração observada ${formatDuration(durationMs)} · ${
|
||||
samples.length
|
||||
} amostras.`,
|
||||
tone: 'neutral',
|
||||
},
|
||||
{
|
||||
key: 'bpm-range',
|
||||
label: 'Amplitude',
|
||||
value: variability.status === 'ok' ? String(variability.rangeBpm) : '--',
|
||||
unit: 'bpm',
|
||||
status: variability.status,
|
||||
statusLabel: variability.status === 'ok' ? 'min↔máx' : 'sem dados',
|
||||
summary:
|
||||
variability.status === 'ok'
|
||||
? `${variability.minBpm}–${variability.maxBpm} bpm registrados.`
|
||||
: 'Aguardando amostras reais.',
|
||||
detail: 'Mostra o espaço entre mínima e pico, sem estimar FCmáx.',
|
||||
tone: variability.rangeBpm >= 45 ? 'warning' : 'neutral',
|
||||
},
|
||||
{
|
||||
key: 'bpm-variability',
|
||||
label: 'Oscilação BPM',
|
||||
value:
|
||||
variability.status === 'ok'
|
||||
? `${variability.standardDeviationBpm}`
|
||||
: '--',
|
||||
unit: 'DP',
|
||||
status: variability.status,
|
||||
statusLabel: variability.classification,
|
||||
summary:
|
||||
variability.status === 'ok'
|
||||
? `CV ${(variability.coefficientOfVariation * 100).toFixed(1)}% · ${
|
||||
variability.classification
|
||||
}.`
|
||||
: 'Sem base para variação.',
|
||||
detail: 'Não é VFC/HRV; mede dispersão de BPM agregado.',
|
||||
tone: variability.classification === 'oscilante' ? 'warning' : 'good',
|
||||
},
|
||||
{
|
||||
key: 'zones',
|
||||
label: 'Alta intensidade',
|
||||
value: `${metrics.highIntensityPercent}%`,
|
||||
unit: 'Z2+Z3',
|
||||
status: samples.length < 2 ? 'no-data' : 'ok',
|
||||
statusLabel: 'zonas fixas',
|
||||
summary: 'Percentual em pressão/redline pela zona operacional atual.',
|
||||
detail: metrics.zones
|
||||
.map(zone => `${zone.label}: ${zone.percent}%`)
|
||||
.join(' · '),
|
||||
tone: metrics.highIntensityPercent >= 50 ? 'danger' : 'neutral',
|
||||
},
|
||||
{
|
||||
key: 'load',
|
||||
label: 'Carga observada',
|
||||
value: metrics.loadScore > 0 ? String(metrics.loadScore) : '--',
|
||||
unit: 'u.a.',
|
||||
status: metrics.loadScore > 0 ? 'ok' : 'no-data',
|
||||
statusLabel: 'BPM+duração',
|
||||
summary: 'Carga interna aproximada com BPM real e tempo em amostra.',
|
||||
detail: 'TRIMP científico completo requer FCmáx e FCrepouso do perfil.',
|
||||
tone: metrics.loadScore >= 80 ? 'warning' : 'neutral',
|
||||
},
|
||||
{
|
||||
key: 'drift',
|
||||
label: 'Tendência FC',
|
||||
value: drift.status === 'ok' ? signedNumber(drift.driftBpm) : '--',
|
||||
unit: 'bpm',
|
||||
status: drift.status,
|
||||
statusLabel: drift.status === 'ok' ? 'deriva' : 'janela curta',
|
||||
summary:
|
||||
drift.status === 'ok'
|
||||
? `${signedNumber(drift.driftPercent)}% do início ao fim.`
|
||||
: 'Requer pelo menos 10 min de amostras contínuas.',
|
||||
detail:
|
||||
drift.status === 'ok'
|
||||
? `Início ${drift.initialAverageBpm} bpm · fim ${drift.finalAverageBpm} bpm.`
|
||||
: 'Evita chamar oscilação curta de deriva cardiovascular.',
|
||||
tone:
|
||||
drift.status === 'ok' && (drift.driftBpm ?? 0) > 8
|
||||
? 'warning'
|
||||
: 'neutral',
|
||||
},
|
||||
{
|
||||
key: 'recovery',
|
||||
label: 'Recuperação',
|
||||
value:
|
||||
recovery.oneMinuteDropBpm !== null
|
||||
? String(recovery.oneMinuteDropBpm)
|
||||
: '--',
|
||||
unit: 'bpm/1min',
|
||||
status: recovery.status,
|
||||
statusLabel: recovery.status === 'ok' ? 'pós-janela' : 'aguardando',
|
||||
summary:
|
||||
recovery.status === 'ok'
|
||||
? `Queda em 2min: ${recovery.twoMinuteDropBpm ?? '--'} bpm.`
|
||||
: 'Feche uma janela e mantenha o monitor ligado por 1–2 min.',
|
||||
detail: 'Leitura operacional baseada no pico da última janela salva.',
|
||||
tone:
|
||||
recovery.oneMinuteDropBpm !== null && recovery.oneMinuteDropBpm >= 18
|
||||
? 'good'
|
||||
: 'neutral',
|
||||
},
|
||||
{
|
||||
key: 'profile',
|
||||
label: 'FCmáx / HRR',
|
||||
value: 'perfil',
|
||||
unit: undefined,
|
||||
status: 'needs-profile',
|
||||
statusLabel: 'necessário',
|
||||
summary: 'Zonas por %FCmáx, Karvonen e TRIMP completo exigem perfil.',
|
||||
detail: 'Sem perfil, o app não inventa FCmáx ou FCrepouso.',
|
||||
tone: 'warning',
|
||||
},
|
||||
{
|
||||
key: 'hrv',
|
||||
label: 'VFC / HRV',
|
||||
value:
|
||||
rrAvailability.intervalCount > 0
|
||||
? String(rrAvailability.intervalCount)
|
||||
: 'RR',
|
||||
unit: rrAvailability.intervalCount > 0 ? 'intervalos' : undefined,
|
||||
status: rrAvailability.status,
|
||||
statusLabel: rrAvailability.status === 'ok' ? 'RR detectado' : 'sem RR',
|
||||
summary:
|
||||
rrAvailability.status === 'ok'
|
||||
? `${rrAvailability.intervalCount} intervalos RR em ${rrAvailability.sampleCount} amostras.`
|
||||
: 'RMSSD/SDNN/pNN50 só aparecem com RR intervals reais.',
|
||||
detail: 'VFC não é derivada de média de BPM.',
|
||||
tone: rrAvailability.status === 'ok' ? 'good' : 'warning',
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
sampleCount: samples.length,
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs,
|
||||
rows,
|
||||
zones: metrics.zones,
|
||||
narrative: buildTrainingInsights(samples, sessions),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateZones(
|
||||
samples: readonly HeartRateSample[],
|
||||
): readonly IntensityZone[] {
|
||||
const reliableSamples = filterReliableHeartRateSamples(samples);
|
||||
if (reliableSamples.length < 2) {
|
||||
return emptyZones();
|
||||
}
|
||||
|
||||
const secondsByZone: Record<IntensityZone['key'], number> = {
|
||||
base: 0,
|
||||
pressure: 0,
|
||||
redline: 0,
|
||||
};
|
||||
|
||||
for (let index = 1; index < reliableSamples.length; index += 1) {
|
||||
const previous = reliableSamples[index - 1];
|
||||
const current = reliableSamples[index];
|
||||
const seconds = Math.min(
|
||||
10,
|
||||
Math.max(0, (current.timestamp - previous.timestamp) / 1000),
|
||||
);
|
||||
secondsByZone[toZoneKey(current.bpm)] += seconds;
|
||||
}
|
||||
|
||||
const totalSeconds = Object.values(secondsByZone).reduce(
|
||||
(sum, seconds) => sum + seconds,
|
||||
0,
|
||||
);
|
||||
|
||||
return zoneDefinitions.map(zone => ({
|
||||
...zone,
|
||||
seconds: Math.round(secondsByZone[zone.key]),
|
||||
percent: totalSeconds
|
||||
? Math.round((secondsByZone[zone.key] / totalSeconds) * 100)
|
||||
: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildTrainingInsights(
|
||||
samples: readonly HeartRateSample[],
|
||||
sessions: readonly CompletedTrainingSession[],
|
||||
): readonly TrainingInsight[] {
|
||||
const reliableSamples = filterReliableHeartRateSamples(samples);
|
||||
const insights: TrainingInsight[] = [];
|
||||
const metrics = calculateLiveMetrics(reliableSamples);
|
||||
|
||||
if (
|
||||
reliableSamples.length >= 6 &&
|
||||
metrics.averageBpm !== null &&
|
||||
metrics.peakBpm !== null
|
||||
) {
|
||||
const firstWindow = average(
|
||||
reliableSamples.slice(0, Math.min(8, reliableSamples.length)),
|
||||
);
|
||||
const lastWindow = average(
|
||||
reliableSamples.slice(-Math.min(8, reliableSamples.length)),
|
||||
);
|
||||
const drift = Math.round(lastWindow - firstWindow);
|
||||
const peakGap = metrics.peakBpm - metrics.averageBpm;
|
||||
|
||||
insights.push({
|
||||
title:
|
||||
drift > 8
|
||||
? 'Carga subindo rápido'
|
||||
: drift < -8
|
||||
? 'Recuperação aparecendo'
|
||||
: 'Ritmo estável',
|
||||
why:
|
||||
drift > 8
|
||||
? `A média recente está ${drift} BPM acima do início desta captura.`
|
||||
: drift < -8
|
||||
? `A média recente caiu ${Math.abs(drift)} BPM frente ao início.`
|
||||
: 'A janela recente está próxima da primeira janela de leitura.',
|
||||
action:
|
||||
drift > 8
|
||||
? 'Use como alerta de controle: se a técnica cair, reduza o ritmo ou aumente a pausa.'
|
||||
: drift < -8
|
||||
? 'Bom sinal para blocos intervalados: registre quanto tempo levou para baixar.'
|
||||
: 'Mantenha o bloco atual e compare com a próxima sessão salva.',
|
||||
confidence: `${reliableSamples.length} amostras reais · taxa ${
|
||||
metrics.sampleRatePerMinute ?? '--'
|
||||
}/min`,
|
||||
});
|
||||
|
||||
insights.push({
|
||||
title:
|
||||
peakGap >= 28
|
||||
? 'Picos explosivos detectados'
|
||||
: 'Picos dentro da média do bloco',
|
||||
why:
|
||||
peakGap >= 28
|
||||
? `O pico ficou ${peakGap} BPM acima da média, típico de troca forte ou scramble.`
|
||||
: `Pico e média ficaram próximos (${peakGap} BPM de diferença).`,
|
||||
action:
|
||||
peakGap >= 28
|
||||
? 'Marque mentalmente o round/posição que gerou o pico e compare no histórico depois.'
|
||||
: 'Se o treino era explosivo, talvez falte intensidade ou o sensor esteja mal posicionado.',
|
||||
confidence:
|
||||
'Baseado somente na captura atual, sem estimar FC máxima pessoal.',
|
||||
});
|
||||
}
|
||||
|
||||
if (sessions.length >= 2) {
|
||||
const [latest, previous] = sessions;
|
||||
const averageDelta = latest.averageBpm - previous.averageBpm;
|
||||
insights.push({
|
||||
title:
|
||||
averageDelta >= 6
|
||||
? 'Sessão mais pesada que a anterior'
|
||||
: averageDelta <= -6
|
||||
? 'Sessão menos exigente'
|
||||
: 'Sessões parecidas',
|
||||
why: `Última média ${latest.averageBpm} BPM contra ${previous.averageBpm} BPM da anterior.`,
|
||||
action:
|
||||
'Use a comparação junto do RPE: frequência parecida com sensação maior pode indicar fadiga.',
|
||||
confidence: `${
|
||||
latest.sampleCount + previous.sampleCount
|
||||
} amostras reais em 2 sessões salvas.`,
|
||||
});
|
||||
}
|
||||
|
||||
return insights;
|
||||
}
|
||||
|
||||
export function formatDuration(durationMs: number) {
|
||||
const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function roundTo(value: number, precision: number) {
|
||||
const factor = Math.pow(10, precision);
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function averageRaw(samples: readonly HeartRateSample[]) {
|
||||
if (samples.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return samples.reduce((sum, sample) => sum + sample.bpm, 0) / samples.length;
|
||||
}
|
||||
|
||||
function classifyBpmVariation(
|
||||
coefficientOfVariation: number,
|
||||
): BpmVariabilitySummary['classification'] {
|
||||
if (coefficientOfVariation <= 0) {
|
||||
return 'sem-dados';
|
||||
}
|
||||
if (coefficientOfVariation < 0.06) {
|
||||
return 'estável';
|
||||
}
|
||||
if (coefficientOfVariation <= 0.12) {
|
||||
return 'progressivo';
|
||||
}
|
||||
return 'oscilante';
|
||||
}
|
||||
|
||||
function sortSamples(samples: readonly HeartRateSample[]) {
|
||||
return [...samples].sort((left, right) => left.timestamp - right.timestamp);
|
||||
}
|
||||
|
||||
function nearestSample(
|
||||
samples: readonly HeartRateSample[],
|
||||
targetTimestamp: number,
|
||||
toleranceMs: number,
|
||||
) {
|
||||
return samples.reduce<HeartRateSample | null>((nearest, sample) => {
|
||||
const distance = Math.abs(sample.timestamp - targetTimestamp);
|
||||
if (distance > toleranceMs) {
|
||||
return nearest;
|
||||
}
|
||||
if (!nearest) {
|
||||
return sample;
|
||||
}
|
||||
const nearestDistance = Math.abs(nearest.timestamp - targetTimestamp);
|
||||
return distance < nearestDistance ? sample : nearest;
|
||||
}, null);
|
||||
}
|
||||
|
||||
function emptyDrift(
|
||||
status: HeartRateDriftSummary['status'],
|
||||
): HeartRateDriftSummary {
|
||||
return {
|
||||
status,
|
||||
driftBpm: null,
|
||||
driftPercent: null,
|
||||
initialAverageBpm: null,
|
||||
finalAverageBpm: null,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyRecovery(
|
||||
status: PostSessionRecoverySummary['status'],
|
||||
): PostSessionRecoverySummary {
|
||||
return {
|
||||
status,
|
||||
oneMinuteDropBpm: null,
|
||||
twoMinuteDropBpm: null,
|
||||
oneMinuteBpm: null,
|
||||
twoMinuteBpm: null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatMetricValue(value: number | null) {
|
||||
return value === null ? '--' : String(value);
|
||||
}
|
||||
|
||||
function signedNumber(value: number | null) {
|
||||
if (value === null) {
|
||||
return '--';
|
||||
}
|
||||
return value > 0 ? `+${value}` : String(value);
|
||||
}
|
||||
|
||||
function calculateLoadScore(samples: readonly HeartRateSample[]) {
|
||||
if (samples.length < 2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let load = 0;
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
const previous = samples[index - 1];
|
||||
const current = samples[index];
|
||||
const minutes = Math.min(
|
||||
10 / 60,
|
||||
Math.max(0, (current.timestamp - previous.timestamp) / 60000),
|
||||
);
|
||||
const intensity = current.bpm >= 170 ? 3 : current.bpm >= 150 ? 2 : 1;
|
||||
load += minutes * intensity * Math.max(0, current.bpm - 80);
|
||||
}
|
||||
|
||||
return Math.round(load / 10);
|
||||
}
|
||||
|
||||
function average(samples: readonly HeartRateSample[]) {
|
||||
if (samples.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round(
|
||||
samples.reduce((sum, sample) => sum + sample.bpm, 0) / samples.length,
|
||||
);
|
||||
}
|
||||
|
||||
function toZoneKey(bpm: number): IntensityZone['key'] {
|
||||
if (bpm >= 170) {
|
||||
return 'redline';
|
||||
}
|
||||
if (bpm >= 150) {
|
||||
return 'pressure';
|
||||
}
|
||||
return 'base';
|
||||
}
|
||||
|
||||
function emptyZones(): readonly IntensityZone[] {
|
||||
return zoneDefinitions.map(zone => ({ ...zone, seconds: 0, percent: 0 }));
|
||||
}
|
||||
|
||||
const zoneDefinitions = [
|
||||
{ key: 'base', label: 'BASE <150' },
|
||||
{ key: 'pressure', label: 'PRESSÃO 150–169' },
|
||||
{ key: 'redline', label: 'REDLINE 170+' },
|
||||
] as const satisfies readonly Omit<IntensityZone, 'seconds' | 'percent'>[];
|
||||
@@ -0,0 +1,105 @@
|
||||
export type HeartRateSample = {
|
||||
readonly id?: number;
|
||||
readonly sessionId?: number;
|
||||
readonly bpm: number;
|
||||
readonly timestamp: number;
|
||||
readonly contactDetected: boolean | null;
|
||||
readonly rrIntervalsMs: readonly number[];
|
||||
readonly deviceName?: string | null;
|
||||
};
|
||||
|
||||
export type CompletedTrainingSession = {
|
||||
readonly id: string;
|
||||
readonly startedAt: number;
|
||||
readonly endedAt: number;
|
||||
readonly durationMs: number;
|
||||
readonly sampleCount: number;
|
||||
readonly averageBpm: number;
|
||||
readonly peakBpm: number;
|
||||
readonly minBpm: number;
|
||||
readonly deviceName?: string | null;
|
||||
};
|
||||
|
||||
export type IntensityZoneKey = 'base' | 'pressure' | 'redline';
|
||||
|
||||
export type IntensityZone = {
|
||||
readonly key: IntensityZoneKey;
|
||||
readonly label: string;
|
||||
readonly seconds: number;
|
||||
readonly percent: number;
|
||||
};
|
||||
|
||||
export type TrainingInsight = {
|
||||
readonly title: string;
|
||||
readonly why: string;
|
||||
readonly action: string;
|
||||
readonly confidence: string;
|
||||
};
|
||||
|
||||
export type InsightMetricStatus =
|
||||
| 'ok'
|
||||
| 'needs-profile'
|
||||
| 'needs-rr'
|
||||
| 'short-window'
|
||||
| 'no-data';
|
||||
|
||||
export type InsightMetricTone = 'neutral' | 'good' | 'warning' | 'danger';
|
||||
|
||||
export type InsightMetricRow = {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
readonly unit?: string;
|
||||
readonly status: InsightMetricStatus;
|
||||
readonly statusLabel: string;
|
||||
readonly summary: string;
|
||||
readonly detail: string;
|
||||
readonly tone: InsightMetricTone;
|
||||
};
|
||||
|
||||
export type InsightDashboard = {
|
||||
readonly sampleCount: number;
|
||||
readonly startedAt: number | null;
|
||||
readonly endedAt: number | null;
|
||||
readonly durationMs: number;
|
||||
readonly rows: readonly InsightMetricRow[];
|
||||
readonly zones: readonly IntensityZone[];
|
||||
readonly narrative: readonly TrainingInsight[];
|
||||
};
|
||||
|
||||
export type BpmVariabilitySummary = {
|
||||
readonly status: InsightMetricStatus;
|
||||
readonly averageBpm: number | null;
|
||||
readonly minBpm: number | null;
|
||||
readonly maxBpm: number | null;
|
||||
readonly rangeBpm: number;
|
||||
readonly standardDeviationBpm: number;
|
||||
readonly coefficientOfVariation: number;
|
||||
readonly classification:
|
||||
| 'estável'
|
||||
| 'progressivo'
|
||||
| 'oscilante'
|
||||
| 'sem-dados';
|
||||
};
|
||||
|
||||
export type HeartRateDriftSummary = {
|
||||
readonly status: InsightMetricStatus;
|
||||
readonly driftBpm: number | null;
|
||||
readonly driftPercent: number | null;
|
||||
readonly initialAverageBpm: number | null;
|
||||
readonly finalAverageBpm: number | null;
|
||||
};
|
||||
|
||||
export type PostSessionRecoverySummary = {
|
||||
readonly status: InsightMetricStatus;
|
||||
readonly oneMinuteDropBpm: number | null;
|
||||
readonly twoMinuteDropBpm: number | null;
|
||||
readonly oneMinuteBpm: number | null;
|
||||
readonly twoMinuteBpm: number | null;
|
||||
};
|
||||
|
||||
export type RrAvailabilitySummary = {
|
||||
readonly status: InsightMetricStatus;
|
||||
readonly intervalCount: number;
|
||||
readonly sampleCount: number;
|
||||
};
|
||||