flutter_fixtures_recorder 0.3.0
flutter_fixtures_recorder: ^0.3.0 copied to clipboard
Record and replay real request/response traffic for demos and offline use
Flutter Fixtures Recorder #
Record real request/response traffic and replay it later, in order — for product demonstrations, offline environments, and repeatable simulations.
The recorder is a self-contained module: nothing else in Flutter Fixtures depends on it, and adding it to an app changes nothing until you press record. It ships with built-in UI tools (a toolbar and a sessions sheet), but every control they offer is available on the public API, so you can build your own UI instead.
This package is the engine. It knows nothing about HTTP or SQL — one
recorder captures and replays traffic from any data source, and a single
session can hold traffic from several sources at once. The contract
between sources and the engine is the thin TrafficRecorder seam in
flutter_fixtures_core (just decide + record); each transport
package ships its own implementation of the capture/replay side:
| Adapter | Ships in | Source |
|---|---|---|
RecorderInterceptor |
flutter_fixtures_dio |
HTTP via a Dio interceptor |
RecorderDatabaseAdapter |
flutter_fixtures_sqflite |
sqflite via a DatabaseAdapter decorator |
| (your code) | anywhere | anything else — see Any source below |
Quick Start #
dependencies:
flutter_fixtures_recorder: ^0.3.0 # the engine + UI tools
flutter_fixtures_dio: ^0.3.0 # ships RecorderInterceptor
Create one recorder, hand it to the adapters for the sources you use, and drop the toolbar somewhere in your debug/demo UI:
import 'package:flutter_fixtures_dio/flutter_fixtures_dio.dart';
import 'package:flutter_fixtures_recorder/flutter_fixtures_recorder.dart';
// Files where possible, memory on web — the directory resolves lazily,
// so everything constructs synchronously.
final recorder = FixtureRecorder(
store: sessionStoreForDirectory(() async =>
'${(await getApplicationDocumentsDirectory()).path}/fixture_recordings'),
);
// HTTP:
final dio = Dio();
dio.interceptors.add(RecorderInterceptor(recorder: recorder));
// Database (RecorderDatabaseAdapter ships in flutter_fixtures_sqflite):
final db = RecorderDatabaseAdapter(
inner: RealDatabaseAdapter(await openDatabase('app.db')),
recorder: recorder,
);
// Anywhere in your widget tree:
RecorderToolbar(recorder: recorder)
Press record, exercise the app, stop and name the session. Later — with or without internet — pick the session from the sessions sheet and every wired source receives the same responses in the same order.
How replay works #
- Every source describes a request in the same shape (
RecordedRequest): a source name ('http','sqlite', ...), an operation (an HTTP method,query,insert, ...), and a normalized target (a path with sorted query, a SQL statement with its arguments). The default match key issource operation target, so sources never collide inside a session. - Repeated requests with the same key are served in recorded order: a
polling endpoint captured as
pending → pending → donereplays exactly that progression. Each recording serves exactly once — when a key's recordings run out, the session's scope has explicitly ended and further requests are misses (restartReplayrewinds for another pass). - Misses — requests never recorded, or replayed past their recordings —
follow the
ReplayMissBehaviorthe adapter passes in: forward to the real source (default) or reject/fail to guarantee the real source is never touched. The recorder interprets the policy — adapters only render the resultingReplayDecision. Composed withFixturesInterceptorandforward, the fixture picker reappears when a session runs out — and picks marked "Remember" answer silently.
The pieces #
| Class | Role |
|---|---|
FixtureRecorder |
The single entry point: mode transitions (idle / recording / replaying), capture, replay lookup, session management. A ChangeNotifier, so any widget can observe it. |
RecordedRequest |
The source-agnostic description of one request: source + operation + target (+ informational payload). |
RecordingSession / RecordedInteraction |
The saved artifact: a named, ordered capture of traffic, JSON-serializable. The response inside an interaction is opaque to the recorder — the adapter that wrote it reads it back. |
RecordingSessionStore |
The persistence seam: save/load full sessions, list lightweight summaries. Built-ins: FileRecordingSessionStore (JSON files, survives restarts) and MemoryRecordingSessionStore (runtime-only, web-friendly); sessionStoreForDirectory picks the right one per platform. |
SessionReplay |
The playback engine: per-request-key cursors over a session. |
ReplayDecision |
The recorder's sealed answer per request: Replayed, ForwardToSource, or RejectRequest — returned by decide, rendered by adapters. |
ReplayMissBehavior |
The miss policy adapters pass to decide: forward or reject. |
RecorderToolbar / stopRecordingWithPrompt / showRecordingSessionsSheet |
The built-in UI tools: start/stop recording with a save-or-discard prompt, list/select/delete sessions, stop or restart replay. The prompt and the sheet are plain functions, reusable from a custom control surface. |
Any source #
An adapter is code that (1) describes its requests as RecordedRequests,
(2) asks the recorder how each one should be handled, and (3) renders the
decision in its own types. For a call/return source — one call, one
result: an in-memory cache, a platform channel, a GraphQL client — the
whole choreography is owned by TrafficRecorder.run, so an adapter is a
description plus its live call:
Future<Object?> loadPreferences(FixtureRecorder recorder) {
return recorder.run(
describe: () => RecordedRequest(
source: 'memory',
operation: 'read',
target: 'user_preferences',
),
live: realPreferenceLookup,
// decode: (recorded) => ... // if the native type is not JSON-shaped
// onMiss: ReplayMissBehavior.reject,
);
}
run describes lazily (only when the recorder's mode needs it), serves a
replay through decode (identity by default), throws a rejection (a
StateError unless you pass reject:), and on forward runs the live call
and records its result — only while recording, so idle traffic costs
nothing. Transports that split request and response stages (Dio's
interceptor chain) render the sealed ReplayDecision by hand instead —
see RecorderInterceptor:
switch (recorder.decide(() => request)) {
case Replayed(:final interaction): // serve interaction.response
case RejectRequest(:final message): // fail with message
case ForwardToSource(): // continue, then recorder.record(() => ...)
}
Keep the target normalized (the same logical request must always produce the same target) and the response JSON-encodable — a persistent store refuses anything else loudly at save time.
Bring your own UI #
The built-in widgets are plain listeners on FixtureRecorder — they use no
private hooks. A custom control surface is just:
ListenableBuilder(
listenable: recorder,
builder: (context, _) => switch (recorder.mode) {
RecorderMode.idle => IconButton(
icon: const Icon(Icons.fiber_manual_record),
onPressed: recorder.startRecording,
),
RecorderMode.recording => IconButton(
icon: const Icon(Icons.stop),
// Or stopRecordingWithPrompt(context, recorder) for the built-in
// save-or-discard dialog.
onPressed: () => recorder.stopRecording(name: 'My demo'),
),
RecorderMode.replaying => IconButton(
icon: const Icon(Icons.stop),
onPressed: recorder.stopReplay,
),
},
)
Sessions are listed with recorder.sessions(), replayed with
recorder.startReplay(id) (or startReplayOf(session) for sessions built
in code or bundled with the app), and deleted with
recorder.deleteSession(id).
Custom storage #
Implement RecordingSessionStore to keep sessions anywhere — a database, a
remote service, shared preferences:
class MyStore implements RecordingSessionStore {
Future<void> save(RecordingSession session) async { ... }
Future<RecordingSession?> load(String id) async { ... }
Future<List<RecordingSessionSummary>> list() async { ... }
Future<void> delete(String id) async { ... }
}
Custom request matching #
If your requests carry volatile parts (timestamps, signatures in an HTTP query), provide a custom key builder so they still match during replay:
FixtureRecorder(
store: store,
keyOf: (request) => request.source == 'http'
? '${request.source} ${request.operation} ${request.target.split('?').first}'
: RecordedRequest.defaultKey(request),
)
Notes #
- Responses are stored as JSON. A response that cannot be JSON-encoded (a stream, raw bytes) is rejected loudly at save time — a record-time error beats a corrupted replay mid-demo.
FileRecordingSessionStoreis not available on web;sessionStoreForDirectoryfalls back toMemoryRecordingSessionStorethere, so recordings live for the app's lifetime.- Listing sessions (
recorder.sessions()) returns lightweightRecordingSessionSummaryvalues — recorded payloads are not retained by a listing, only by a replay. - Request payloads are informational and never block a save: the Dio
adapter records anything that is not plain JSON data (multipart
FormData, streams) as its string form.