# Real-Time Streaming Architecture for Roomfit

**Status:** Research complete, architecture proposed, implementation pending user decision.
**Owner:** App team.
**Last updated:** 2026-04-08.

---

## 1. Problem statement

The Roomfit MCU pushes a 36-byte `ReportResponse` every **50 ms (20 Hz)** containing
position, speed, acceleration, current command/feedback, calculated load, voltage,
weight mode, region, and weight setpoint for both sides. Downstream the app must:

- **Parse and validate** the frame (already done well in `roomfit_protocol`)
- **Feed a rep-detection FSM** with every sample (no drops allowed — missing a sample
  at the peak of a fast rep can lose a rep boundary)
- **Update a state dashboard** with coarse numeric readouts (kg, region, firmware)
- **Eventually render a live line chart** of position and load history (up to ~5000 points,
  4 series). *This was the single biggest bottleneck in the previous version of the app
  and the reason for this research.*

The current pipeline works for points 1–3 but was never load-tested for the chart.
Per our audit (see `CLAUDE.md` "50 ms 데이터 처리" notes in the session log) the data path
carries several items of **unnecessary allocation pressure** and has **no decoupling between
the 20 Hz producer and what will become a 60 Hz chart consumer**.

This document is the synthesis of the research conducted on 2026-04-08 (three parallel
subagents covering stream primitives, real-time charting, and reference architectures)
and the architecture decision for Roomfit's real-time path.

---

## 2. Research findings (convergent)

Three independent research agents surveyed Flutter/Dart perf patterns, real-time charting
libraries, and production reference apps. Every major finding converged across all three.

### 2.1 The core pattern — "producer/consumer rate decoupling via ring buffer + vsync pull"

**All three agents independently identified this as the single most important pattern.**
Producers (20 Hz BLE) write to a shared buffer. Consumers read from that buffer at their own
rates:

- **FSM detector** reads synchronously per sample (20 Hz, event-driven, never drops)
- **Dashboard** reads at human rate or on change (5–10 Hz effective via `ValueNotifier` +
  `ListenableBuilder`)
- **Chart** reads at 60 Hz vsync via a `Ticker` pulling the latest N samples from the ring
  buffer, repainting a single leaf via `CustomPaint(repaint: listenable)`

**Evidence:**
- Flame game engine ([source](https://github.com/flame-engine/flame/blob/main/packages/flame/lib/src/game/game_loop.dart))
  uses exactly this structure: `GameLoop` wraps a `Ticker`, polls component state on each frame,
  does not drive off data arrival
- `audio_waveforms` ([SimformSolutions](https://github.com/SimformSolutionsPvtLtd/audio_waveforms)):
  canonical Flutter audio pattern. `RecorderController` owns the buffer; widget subscribes to the
  controller, not to raw audio. 16–48 kHz producer, 60 fps consumer.
- PolarMon ([alkatrazstudio/polarmon](https://github.com/alkatrazstudio/polarmon)): real shipping
  Flutter app with **130 Hz ECG** over BLE. Proves Flutter can handle our 20 Hz × 6.5 comfortably.
- Flutter docs ([`SchedulerBinding.addPersistentFrameCallback`](https://api.flutter.dev/flutter/scheduler/SchedulerBinding/addPersistentFrameCallback.html)):
  canonical "run me every vsync" hook.

### 2.2 Typed data (`Float32List`) is the only real allocation win

All three agents converged: **don't premature-optimize small object pooling**. Dart's generational
GC handles 100-200 short-lived allocations/sec without noticeable impact. The one exception is the
**chart history buffer**. `List<double>` boxes every value with object header + pointer + payload
(~24 bytes/double on 64-bit). `Float32List` is raw 4 bytes/element.

For a 1000-sample × 4-series window:
- `List<double>`: ~96 KB with overhead, boxed reads
- `Float32List`: 16 KB, direct memory read

**This is a 6x memory reduction and allows `Canvas.drawRawPoints` to consume the buffer with zero
Dart-side allocation per frame.**

**Evidence:**
- [Flutter Float32List docs](https://api.flutter.dev/flutter/dart-typed_data/Float32List-class.html):
  *"For long lists, this implementation can be considerably more space- and time-efficient than
  the default List implementation."*
- [Dart numeric computation article](https://dart.cn/articles/archive/numeric-computation):
  *"Using an object list with doubles is always slower than typed lists."*

### 2.3 Isolates are NET NEGATIVE at 20 Hz

All three agents independently concluded: **do not offload BLE parsing to an isolate**. The cost
of sending 36 bytes across an isolate boundary (even with `SendPort`) exceeds the cost of parsing
them on the main isolate (<1 µs each). `TransferableTypedData` is designed for megabytes, not 36B.

Real evidence:
- PolarMon handles 130 Hz ECG without isolate offload
- `audio_waveforms` does parsing on the native side, not Dart isolate
- No published Flutter app uses an isolate for BLE parsing at sub-100 Hz rates
- [Dart SDK #31960](https://github.com/dart-lang/sdk/issues/31960) confirms message passing
  has measurable overhead for small messages

**Use isolates only if you add CPU-heavy transforms** (FFT, ML inference) — which we don't, yet.

### 2.4 fl_chart is a rejection, not a candidate

All three agents agreed fl_chart is an active liability at our workload. The library's immutable-
widget architecture forces full chart rebuilds on every data update, and side titles cause an fps
cliff.

**Specific GitHub evidence** (cited by the charting agent):
- [#322 Performance problem with LineCharts](https://github.com/imaNNeo/fl_chart/issues/322) —
  maintainer's "fix" is to **reduce your Y-axis range below 100**. Not a real-time fix.
- [#357 Line Chart bad performance with sideTitle](https://github.com/imaNNeo/fl_chart/issues/357) —
  **still open**. On OnePlus 3: 40→30→20 fps as title interval tightens.
- [#684 Cannot get smooth realtime line](https://github.com/imaNNeo/fl_chart/issues/684) —
  workaround: `swapAnimationDuration: Duration.zero`. Still allocates per update.
- [#782 Real time dynamic chart support?](https://github.com/imaNNeo/fl_chart/issues/782) —
  closed without implementation, maintainer cited insufficient capacity.
- [#1118 Real-time data](https://github.com/imaNNeo/fl_chart/issues/1118) — community workaround
  is "rebuild with StreamBuilder", which is the exact jank pattern we're trying to avoid.

**This is the library that took down the previous version of this app. Do not use.**

### 2.5 `syncfusion_flutter_charts` works but has license tax

Evidence that syncfusion scales:
- [Official real-time sample](https://github.com/syncfusion/flutter-examples/blob/master/lib/samples/chart/cartesian_charts/real_time_charts/live_update/real_time_line_chart.dart) —
  `FastLineSeries` + `ChartSeriesController.updateDataSource(addedDataIndexes, removedDataIndexes)` +
  `animationDuration: 0`. A **mutating delta API**, not a widget rebuild.
- [flutter-examples #810](https://github.com/syncfusion/flutter-examples/issues/810) confirms
  **3000 points/sec, 124k total** working.

License reality ([syncfusion_license.pdf](https://www.syncfusion.com/content/downloads/syncfusion_license.pdf)):
- Community License: free if **< $1M USD annual gross revenue AND < 5 developers**
- Otherwise: commercial license purchase required

**Decision signal:** if Roomfit is small-team / sub-$1M revenue today, syncfusion is a fine backup
plan. But it creates a hard licensing cliff at growth time, and the library is closed-source. We
should not architecturally depend on it if a cleaner path exists.

### 2.6 Hand-rolled `CustomPainter` is the cleanest win

A hand-rolled real-time line chart for our specific constraints (fixed rate, polylines only, small
number of series, known viewport width) is ~200–400 lines of code and outperforms every off-the-shelf
library we found. It avoids the fl_chart trap, avoids syncfusion's license tax, and gives us direct
control over the hot path.

Key primitives:
- `Canvas.drawRawPoints(PointMode.polygon, Float32List, Paint)` — draws a connected polyline with
  **zero Dart-side allocation** per frame (assumes the Float32List is pre-allocated and reused).
  [API docs](https://api.flutter.dev/flutter/dart-ui/Canvas/drawRawPoints.html).
- `CustomPaint(repaint: Listenable, painter: ...)` — avoids widget tree rebuilds. Only the painter
  repaints when the `Listenable` fires. [Flutter CustomPainter docs](https://api.flutter.dev/flutter/rendering/CustomPainter-class.html).
- `RepaintBoundary` — isolates the chart's layer from the rest of the tree so ancestor rebuilds
  don't force a chart repaint.
- Static layer cached via `PictureRecorder` — draw axes/grid once, blit the recorded `Picture`
  each frame, overlay the dynamic polyline on top.
- `Ticker` (via `SingleTickerProviderStateMixin`) or `SchedulerBinding.addPersistentFrameCallback` —
  vsync-driven redraw loop.

See the charting agent's skeleton code in section 2.7 of their report; it compiles with minor
import adjustments.

### 2.7 Android BLE is bursty, not uniform

Critical, often-overlooked finding from [flutter_blue_plus #842](https://github.com/chipweinberger/flutter_blue_plus/issues/842):

- iPhone 13 @ 20 Hz: ~2 ms RTT, uniform
- Samsung S9 @ 20 Hz: **20 ms average, 4000 ms spikes**, not uniform

Maintainer's quote: *"There's nothing FBP can do about this. We just call the native android BLE
functions."* The Android BLE stack itself introduces the latency.

**Implication:** a push-based `StreamBuilder` chart will hiccup visibly on Android during BLE stack
stalls. A frame-clock-pulled chart reads "whatever's in the buffer right now" each frame and
visually glides through the gap. **Pull-based is the only robust answer on Android.**

### 2.8 Riverpod 3.0: `Notifier` + `.select` for coarse state, bypass for hot path

- `StreamProvider` in Riverpod 3.0 filters events with `==`. For our freezed `DeviceState` with
  15 fields at 20 Hz, that's a structural equality per update. Non-trivial.
- `.select` is mandatory, not optional, to narrow widget rebuilds. Per
  [riverpod.dev/docs/how_to/select](https://riverpod.dev/docs/how_to/select):
  *"Using select slightly slows down individual read operations and increases complexity — but
  rebuild fan-out cost dominates at our rates."*
- The chart does NOT go through Riverpod. It binds directly to a `Listenable` owned by the gateway.
  Riverpod is a coarse-state router, not a high-frequency render driver.

### 2.9 `AppLogger._buffer.toList()` on every log call — real hot path waste

Our own code audit (pre-research) found that `AppLogger._log` copies the full 1000-entry buffer
to a fresh `List<LogEntry>` on every `.debug/.info/.warn/.error` call. `BleTransport` calls
`_log.debug(...)` on every BLE RX, producing 16KB/sec of throwaway lists plus an eagerly-formatted
hex string. Fix: change the stream to `Stream<LogEntry>` (single entry per event); viewer lazily
reads `AppLogger.entries` on-demand.

---

## 3. Proposed Roomfit architecture

Given the research above, Roomfit's real-time path should be:

```
┌─────────────────────┐
│ BleTransport        │  flutter_blue_plus notify
│   incoming: Uint8List │
└──────────┬──────────┘
           ↓
┌─────────────────────┐
│ FrameCodec          │  ring buffer + framing (already correct)
│   frames: RoomfitFrame│
└──────────┬──────────┘
           ↓
┌─────────────────────┐
│ DeviceLink          │  ResponseRegistry.parse → Response
│   responses: Response │
└──────────┬──────────┘
           ↓
┌─────────────────────────────────────────────────────────────┐
│ DeviceGateway (rewritten to be a proper controller)         │
│                                                             │
│  ┌──────────────────────────────────────────────────────┐   │
│  │ On each ReportResponse:                              │   │
│  │                                                      │   │
│  │  1. SampleRingBuffer<MotionSample>.push(sample)      │   │
│  │     — Float32List backed, reused, zero-alloc         │   │
│  │                                                      │   │
│  │  2. Per-field ValueNotifier set:                     │   │
│  │     - latestPositionL.value = sample.positionL       │   │
│  │     - latestFLoadL.value = sample.fLoadL             │   │
│  │     - latestWeightMode.value = sample.weightMode     │   │
│  │     (… each dashboard cell gets its own)             │   │
│  │                                                      │   │
│  │  3. FSM detector sync call:                          │   │
│  │     repEventStream.addAll(pipeline.feed(sample))     │   │
│  │                                                      │   │
│  │  4. Coarse DeviceState update (downsampled to ~5 Hz):│   │
│  │     if (_shouldUpdateCoarseState(sample)) {          │   │
│  │       state = state.copyWith(...);                   │   │
│  │     }                                                │   │
│  └──────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘
       │                │                │                │
       ↓                ↓                ↓                ↓
┌─────────────┐ ┌───────────────┐ ┌───────────────┐ ┌──────────┐
│ Chart       │ │ Dashboard     │ │ Rep counter   │ │ Packet   │
│ (Ticker @60│ │ scalar cells  │ │ widget        │ │ log      │
│  Hz, reads │ │ (Listenable  │ │ (StreamBuilder│ │ viewer   │
│  ring buf) │ │  Builder per │ │  on RepEvent) │ │ (opt-in) │
│             │ │  field)      │ │                │ │          │
└─────────────┘ └───────────────┘ └───────────────┘ └──────────┘
```

### 3.1 Key components

**`SampleRingBuffer` (new, in `roomfit_exercise` or a new `roomfit_realtime` package)**

Pure Dart. Float32List-backed. Single-producer, single-consumer-per-reader. Interleaved field
layout: `[time, posL, posR, spdL, spdR, accL, accR, icmdL, icmdR, ifbL, ifbR, fLoadL, fLoadR,
voltage, weightModeL, weightModeR, regionL, regionR, weightSetL, weightSetR]` per sample slot.

For a 10-second window at 20 Hz: 200 samples × 20 fields × 4 bytes = **16 KB total**. Negligible.

API:
```dart
class SampleRingBuffer {
  SampleRingBuffer({required int capacitySamples});
  void push(MotionSample sample);       // parses into underlying Float32List slots
  int snapshotLast(int n, Float32List out);  // copy last n samples into caller's buffer
  int get writeCount;                    // monotonic, wraps at int64
  int get fillCount;                     // clamped to capacity
}
```

Reader pulls by providing a pre-allocated output buffer. Zero allocation in the hot path for
both producer and consumer.

**`DeviceGateway` (refactor of existing `DeviceGatewayImpl`)**

Adds:
- An internal `SampleRingBuffer` (owned by the gateway, exposed for chart consumption)
- Per-field `ValueNotifier<T>` for each dashboard cell (lazily created, live only if watched)
- A downsampled "coarse state" stream for the existing Riverpod consumers (emit every 5th sample
  or every field-changed sample, whichever is stricter — roughly 5 Hz effective)

Keeps:
- Existing `state: Stream<DeviceState>` for backwards compat but recommends migration to
  per-field notifiers
- Existing `packetLog: Stream<PacketLog>` — but `PacketBuilder.toHex` now runs only when a
  subscriber is actually attached (lazy)

**`LiveLineChart` (new widget, in `lib/features/exercise/presentation/widgets/`)**

`CustomPaint` + `RepaintBoundary` + vsync-driven `Ticker`. Binds directly to the gateway's
`SampleRingBuffer`. Renders via `Canvas.drawRawPoints(PointMode.polygon, Float32List, Paint)`.
Supports 1-4 concurrent series.

Chart state (axes, y-range snapping, viewport width) lives in a `LiveChartController` class
that the widget owns. Static layer (axes, gridlines) cached as a `ui.Picture` and blitted per
frame.

**`AppLogger` fix (quick win, PR-sized)**

Change `_controller` type from `Stream<List<LogEntry>>` to `Stream<LogEntry>`. Viewer lazily
calls `AppLogger.entries` when it rebuilds. Eliminates the 16 KB/sec list-copy waste on every
log call. Independently valuable even if the rest of this architecture is delayed.

**`BleTransport` lazy log (quick win)**

`if (AppLogger.minLevel.index <= LogLevel.debug.index) _log.debug('RX: ${...}');` guard, or
a `_log.debugFn(() => 'RX: ${...}')` lazy-closure API. Eliminates eager hex stringification
when debug level is off.

### 3.2 What stays the same

- `roomfit_protocol` — already pure Dart, frame codec, response parsing. Solid.
- `roomfit_ble` — BLE transport, scanner. Only the `_log.debug` hex eager eval needs fixing.
- `roomfit_exercise` — FSM detector, metrics, scenarios. Already consumes `SideSample`, so
  feeding it from the ring buffer requires only a small adapter (or direct consumption of
  `MotionSample` if we add a convenience method).

### 3.3 What's removed / deprecated

- `StreamProvider<DeviceState>` for hot path — replaced by Notifier + per-field ValueNotifier
- Chart binding to `deviceStateProvider` (which never existed yet anyway) — chart binds to
  the gateway's ring buffer directly, bypassing Riverpod
- fl_chart as a candidate — never adopted

---

## 4. Options comparison

| Dimension | Recommended (hand-rolled) | Alternative A (syncfusion) | Alternative B (throttled fl_chart) | Rejected |
|---|---|---|---|---|
| **Chart library** | Pure `CustomPainter` + `drawRawPoints` | `FastLineSeries` + `ChartSeriesController.updateDataSource` | `LineChart` with `swapAnimationDuration: Duration.zero`, heavy throttling | fl_chart without throttling |
| **Evidence at 20 Hz** | Strong (Flame, audio_waveforms, PolarMon 130 Hz) | Strong (official sample, 3k pts/sec) | Weak (community workarounds, issue #1118) | None |
| **Data path** | Ring buffer + vsync pull | Timer-driven `updateDataSource` every ~50ms | Throttled `StreamBuilder` rebuilds | Direct `StreamBuilder` on LineChart |
| **License** | Ours | Community License (<$1M rev, <5 devs) or paid | MIT | — |
| **Implementation cost** | ~400 lines (chart) + ~200 lines (ring buffer) | ~80 lines (integration) | ~50 lines (integration) | — |
| **Runtime cost** | Lowest — single leaf repaint per frame | Low — but opaque black box | Moderate — widget rebuild per update | High — jank |
| **Future-proof** | Fully ours | License cliff on growth, closed source | Still fights the architecture | — |
| **Maintenance** | Ours to own | Vendor-dependent | Fighting an immutable-data chart lib | — |

### 4.1 Recommendation: hand-rolled

All three research agents, independently, arrived at the same primary recommendation: **for a
narrowly-scoped use case like Roomfit's (fixed producer rate, fixed series count, polylines only,
controlled viewport), hand-rolling the chart via `CustomPainter` + `drawRawPoints` + ring buffer
+ vsync `Ticker` is the highest-leverage path.** It's ~400 lines of painter code, pure Dart, no
dependencies, no license risk, and empirically outperforms every off-the-shelf Flutter charting
library we found evidence for.

### 4.2 When to pick syncfusion instead

- If implementation time is the critical constraint and we qualify for the Community License today
- If we need non-line chart types (candlestick, heatmap, area-stacked) we don't want to hand-roll
- If we want vendor support as insurance

### 4.3 Fallback policy

If the hand-rolled path hits unforeseen Flutter engine limits (unlikely based on research, but
possible), the syncfusion fallback is already architecturally compatible: the ring buffer stays,
the chart widget swaps. The investment in the ring buffer + decoupled architecture is not wasted.

---

## 5. Implementation phases

### Phase P1 — Quick wins (small PR, ~2h, no architecture change)

Goal: eliminate the measurable allocation waste found in the pre-research audit without changing
any architecture. Independently valuable; can ship before deciding on chart strategy.

1. `AppLogger`: stream becomes `Stream<LogEntry>` (single entry); viewer reads `AppLogger.entries`
   lazily.
2. `BleTransport`: guard debug logs with `minLevel` check or convert to lazy closure API.
3. `DeviceGatewayImpl`: `PacketBuilder.toHex` invoked only when a `packetLog` subscriber exists,
   or make `PacketLog.hex` a lazy getter.
4. `PacketLogNotifier`: `state = _queue.toList()` → `state = UnmodifiableListView(_queue)` (no
   copy, references the underlying queue).
5. `repEventControllerProvider`: dedup before `motionStateToSample()` allocation.

**Delivers:** ~20 KB/sec less garbage, zero behavior change, ~50-line diff.

### Phase P2 — `SampleRingBuffer` + gateway refactor (medium PR, ~1 day)

Goal: introduce the ring buffer as the single source of truth for motion samples. Refactor
`DeviceGatewayImpl` to own it. Add per-field `ValueNotifier`s for dashboard cells.

1. New file: `packages/roomfit_exercise/lib/src/realtime/sample_ring_buffer.dart` (pure Dart,
   Float32List-backed, interleaved field layout, fully unit-tested)
2. `DeviceGatewayImpl` additions:
   - `SampleRingBuffer get motionBuffer`
   - `ValueListenable<T> latestX` for each motion field
   - `Stream<RepEvent> repEvents` (from the internal FSM pipeline, fed per sample)
3. Keep the existing `Stream<DeviceState>` interface for backwards compat, but downsample the
   emissions to ~5 Hz (emit on field change above an epsilon, otherwise every 5th sample)
4. Adapter in `motion_sample_mapper.dart` or inline: parse `ReportResponse` directly into the
   ring buffer + notifier updates, skipping the intermediate `MotionState` freezed object for
   the hot path. Keep `MotionState` updates only for the coarse `Stream<DeviceState>`.
5. Unit tests for `SampleRingBuffer` (happy path + wrap-around + concurrent reader)
6. Integration test: feed 1000 synthetic samples through the gateway, assert ring buffer
   snapshot matches input

**Delivers:** pulled-based data source for the chart, per-field scalar notifiers for the
dashboard, existing tests still pass.

### Phase P3 — `LiveLineChart` widget (medium PR, ~1 day)

Goal: real-time line chart widget binding to `SampleRingBuffer`.

1. New file: `lib/features/exercise/presentation/widgets/live_line_chart.dart`
2. `LiveChartController extends ChangeNotifier` — owns the viewport state, Y-range snapping,
   the static layer `ui.Picture`, and the scratch `Float32List`. Drives redraw via a `Ticker`.
3. `LiveLinePainter extends CustomPainter` — paints the static layer via `Canvas.drawPicture`,
   then the dynamic polylines via `Canvas.drawRawPoints(PointMode.polygon, scratchBuffer, paint)`.
4. `LiveLineChart extends StatefulWidget` — wraps everything in `RepaintBoundary`, creates the
   `Ticker` in `initState`, disposes in `dispose`.
5. Widget tests: mount with a fake ring buffer + time source, assert painter is invoked once
   per vsync tick, assert no widget rebuilds occur outside the `LiveLinePainter`.
6. Integration test: feed 600 samples through the ring buffer, pump widget tree, assert the
   painter was called the expected number of times.

**Delivers:** production-ready real-time line chart, benchmarked in unit tests.

### Phase P4 — Dashboard migration to per-field notifiers (small-medium PR, ~0.5 day)

Goal: migrate `state_dashboard.dart` from watching `deviceStateProvider` (full rebuild on every
update) to per-field `ListenableBuilder`s.

1. Each card in `state_dashboard.dart` becomes a `ListenableBuilder` binding to the gateway's
   corresponding `ValueListenable`.
2. The `Stream<DeviceState>` / `deviceStateProvider` continues to exist for use cases that
   legitimately need the full snapshot (recording/replay metadata, etc.).
3. Widget test: feed a motion sample, assert only the relevant card rebuilds (verify via
   `WidgetTester.tester.widget.renderObject.debugLayer.canvas` inspection or a mounted counter).

**Delivers:** dashboard no longer rebuilds 20 times per second for a single field change.

### Phase P5 — `WorkoutScreen` integration (small PR, ~0.5 day)

Goal: wire up the `LiveLineChart` into `WorkoutScreen` alongside the existing rep counter.

1. Add `LiveLineChart` as a child of `WorkoutScreen`, bound to the gateway's motion buffer.
2. Decide which 2-4 series to show (recommendation: positionL, positionR on one chart;
   fLoadL, fLoadR on another, stacked).
3. Update `dev_lab_screen.dart` to also show the live chart, useful for tuning.
4. Manual smoke test on simulator / real device (this is the Phase 8 trigger).

**Delivers:** end-to-end live chart in production UI, ready for real-hardware smoke test.

### Phase P6 — (Optional, Phase 8 dependent) profile + optimize

After real-hardware testing, profile with Flutter DevTools:
- Timeline view: any frames > 16ms?
- Memory view: any GC spikes?
- CPU profiler: any hot functions > 1% self-time?

Optimize **only** what the profiler shows. Do not preemptively add isolates, object pools, or
any other pattern the research explicitly rejected.

---

## 6. Critical files inventory

### New files (Phase P2-P3)
- `packages/roomfit_exercise/lib/src/realtime/sample_ring_buffer.dart`
- `packages/roomfit_exercise/test/realtime/sample_ring_buffer_test.dart`
- `lib/features/exercise/presentation/widgets/live_line_chart.dart`
- `lib/features/exercise/presentation/widgets/live_chart_controller.dart` (split for testability)
- `test/features/exercise/presentation/widgets/live_line_chart_test.dart`

### Refactored files
- `packages/roomfit_device/lib/src/device/device_gateway.dart` — interface additions for
  `motionBuffer`, per-field listenables, `repEvents`
- `packages/roomfit_device/lib/src/device/device_gateway_impl.dart` — ring buffer allocation,
  notifier updates, downsampled coarse state emission
- `lib/core/logger/app_logger.dart` — stream type change + lazy viewer API (P1)
- `packages/roomfit_ble/lib/src/transport/ble_transport.dart` — lazy log guards (P1)
- `lib/features/dev_remote/domain/providers/packet_log_providers.dart` — `UnmodifiableListView`
  instead of `toList()` (P1)
- `lib/features/dev_remote/presentation/widgets/state_dashboard.dart` — per-field
  `ListenableBuilder`s (P4)
- `lib/features/exercise/presentation/screens/workout_screen.dart` — embed `LiveLineChart` (P5)
- `lib/features/exercise/domain/providers/exercise_providers.dart` — dedup before alloc
  (P1), then eventually replaced by direct gateway binding (P2+)

### Unchanged files
- `packages/roomfit_protocol/` — the framing and parsing are already correct
- `packages/roomfit_exercise/lib/src/segmentation/` — FSM detector is unchanged
- `packages/roomfit_exercise/lib/src/metrics/` — metrics calculation unchanged
- `packages/roomfit_exercise/test/support/motion_profile.dart`,
  `scenarios.dart` — hostile test infrastructure stays as-is

---

## 7. Open questions for user decision

1. **Which chart strategy?** Hand-rolled (recommended) vs syncfusion (if license fits and
   implementation time is critical).
2. **Ship P1 quick wins as a separate PR immediately?** They're independently valuable and
   don't require the architecture decision.
3. **How large is the chart viewport target?** 5 seconds? 10 seconds? 30 seconds? 1 minute?
   Determines ring buffer capacity and scratch buffer size but not architecture.
4. **Which series to plot first?** Recommendation: position L/R on one chart, fLoad L/R on
   a stacked second chart. Other combinations are possible.
5. **Should the `Stream<DeviceState>` path survive at all after P4?** It's a single source of
   truth that some non-UI consumers (session recorder, replay) may still want. Recommendation:
   keep but downsample to ~5 Hz.

---

## 8. References

### Convergent consensus (cited by 2+ agents)
- [Flutter `SchedulerBinding.addPersistentFrameCallback`](https://api.flutter.dev/flutter/scheduler/SchedulerBinding/addPersistentFrameCallback.html)
- [Flutter `Canvas.drawRawPoints`](https://api.flutter.dev/flutter/dart-ui/Canvas/drawRawPoints.html)
- [Flutter `Float32List`](https://api.flutter.dev/flutter/dart-typed_data/Float32List-class.html)
- [Flutter `CustomPainter`](https://api.flutter.dev/flutter/rendering/CustomPainter-class.html)
- [Flutter `RepaintBoundary`](https://api.flutter.dev/flutter/widgets/RepaintBoundary-class.html)
- [PolarMon Flutter 130Hz ECG](https://github.com/alkatrazstudio/polarmon)
- [audio_waveforms (SimformSolutions)](https://github.com/SimformSolutionsPvtLtd/audio_waveforms)
- [Flame GameLoop source](https://github.com/flame-engine/flame/blob/main/packages/flame/lib/src/game/game_loop.dart)
- [Syncfusion real-time line chart sample](https://github.com/syncfusion/flutter-examples/blob/master/lib/samples/chart/cartesian_charts/real_time_charts/live_update/real_time_line_chart.dart)
- [flutter_blue_plus Android latency #842](https://github.com/chipweinberger/flutter_blue_plus/issues/842)

### fl_chart evidence (rejection basis)
- [#322 Performance with LineCharts](https://github.com/imaNNeo/fl_chart/issues/322)
- [#357 sideTitle perf](https://github.com/imaNNeo/fl_chart/issues/357) (still open)
- [#684 Smooth realtime](https://github.com/imaNNeo/fl_chart/issues/684)
- [#782 Real-time support](https://github.com/imaNNeo/fl_chart/issues/782)
- [#1118 Real-time data](https://github.com/imaNNeo/fl_chart/issues/1118)

### Riverpod patterns
- [Riverpod 3.0 what's new](https://riverpod.dev/docs/whats_new)
- [Riverpod how-to: reduce rebuilds with select](https://riverpod.dev/docs/how_to/select)

### Dart perf
- [Dart VM GC notes](https://dart.googlesource.com/sdk/+/refs/tags/2.15.0-99.0.dev/runtime/docs/gc.md)
- [Dart numeric computation (older but still canonical)](https://dart.cn/articles/archive/numeric-computation)
- [Flutter concurrency & isolates](https://docs.flutter.dev/perf/isolates)
- [Dart SDK #31960 isolate send overhead](https://github.com/dart-lang/sdk/issues/31960)

### Audio / waveform patterns
- [LogRocket audio waveforms tutorial](https://blog.logrocket.com/audio-waveforms-flutter-app/)
- [waveform_visualizer pub.dev](https://pub.dev/packages/waveform_visualizer)

### Syncfusion evidence
- [flutter-examples #810 — 3k pts/sec benchmark](https://github.com/syncfusion/flutter-examples/issues/810)
- [Syncfusion license terms](https://www.syncfusion.com/content/downloads/syncfusion_license.pdf)

### Reference apps / case studies
- [Polar BLE SDK](https://github.com/polarofficial/polar-ble-sdk)
- [polar Flutter plugin](https://pub.dev/packages/polar)
- [plugfox — High-Performance Canvas Rendering](https://plugfox.dev/high-performance-canvas-rendering/)
- [Gurzu — Flutter BLE Deep Dive](https://gurzu.com/blog/flutter-ble-deep-dive/)

---

## 9. Decision pending

This document is the research output + architecture proposal. Next step is user review and a
go/no-go decision on:
1. Overall architecture direction (hand-rolled vs syncfusion vs both-tracks)
2. Phase ordering (P1 quick wins first? Or bundled with P2?)
3. Chart viewport width and series selection

Implementation begins after decision.
