flutter-bloc-stream-tracking
- Repo stars 0
- Author updated Live
- Author repo skills-registry
- Domain
- Engineering
- Compatible agents
-
- Claude Code
- Cursor
- Cline
- Codex
- Windsurf
- Gemini CLI
- +20
- Trust score
- 88 / 100 · community maintained
- Author / version / license
- @tomevault-io · no license declared
- Token usage
- Lean
- Setup complexity
- Guided setup
- External API key
- Not required
- Operating systems
- Unspecified (assume cross-platform)
- Runtime requirements
- No special requirements
- Permissions
-
- Read-only
- Write / modify
- Network behavior
- Local-only
- Install commands
- 26 variants
Profile is derived at build time from SKILL.md and install vectors. Subject to drift from author intent.
Heads up: 未限定 allowed-tools,默认拥有全部工具权限。
---
name: flutter-bloc-stream-tracking
description: Subscribes to a real-time stream (WebSocket, Pusher, Firestore) inside a Bloc via `emit.forEach`…
category: engineering
runtime: no special runtime
---
# flutter-bloc-stream-tracking output preview
## PART A: Task fit
- Use case: Subscribes to a real-time stream (WebSocket, Pusher, Firestore) inside a Bloc via `emit.forEach` and emits a new state on every frame. Use when implementing live order tracking, driver location updates, real-time chat, presence indicators, or any feature where the UI must reflect server-pushed events. Prerequisite: `flutter-bloc-setup` and `flutter-bloc-feature-pattern`. Use when this capability is needed..
- Inputs: target material, constraints, expected output, and acceptance criteria.
- Evidence boundary: follow “Contents / Stream subscription pattern / Connection lifecycle” and do not present inference as author intent.
## PART B: Execution result
- **01** The card summarizes the use case; runtime output centers on “Subscribes to a real-time stream (WebSocket, Pusher, Firestore) inside a Bloc via `emit.forEach` and emits a new state on every frame. Use when implementing live order tracking, driver location updates, real-time chat, presence indicators, or any feature where the UI must reflect server-pushed events. Prerequisite: `flutter-bloc-setup` and `flutter-bloc-feature-pattern`. Use when this capability is needed.”.
- **02** When the source has headings, the agent prioritizes “Contents / Stream subscription pattern / Connection lifecycle” so the result follows the author’s structure.
- **03** Typical output includes task judgment, concrete steps, required commands or file edits, validation, and follow-up options.
- **04** Risk context follows the fingerprint: read files, write/modify files; mostly runs locally; usually needs no extra API key.
## Running Rules
- read files, write/modify files; mostly runs locally; usually needs no extra API key.
- Validate with a small sample before expanding scope.
- Return the result, validation criteria, and next iteration options. The source does not require a stable slash command. After installation, invoke the skill by name and describe the task.
Name target files or source material, expected output, forbidden changes, and whether network or shell access is allowed. Permission fingerprint: read files, write/modify files.
Start with a small task and check whether the result follows “Contents / Stream subscription pattern / Connection lifecycle”. Inspect diffs, logs, previews, or tests before expanding scope.
Confirm the final output includes a concrete result, evidence, and next action. If it stays generic, tighten inputs, boundaries, and acceptance criteria.
---
name: flutter-bloc-stream-tracking
description: Subscribes to a real-time stream (WebSocket, Pusher, Firestore) inside a Bloc via `emit.forEach`…
category: engineering
source: tomevault-io/skills-registry
---
# flutter-bloc-stream-tracking
## When to use
- Subscribes to a real-time stream (WebSocket, Pusher, Firestore) inside a Bloc via `emit.forEach` and emits a new state…
- Use it when the task has clear inputs, repeatable steps, and validation criteria.
## What to provide
- Target material, scope, expected result, and forbidden changes.
- Whether network, commands, file writes, or external services are allowed.
## Execution rules
- Organize steps around “Contents / Stream subscription pattern / Connection lifecycle” and keep inference separate from source facts.
- read files, write/modify files; mostly runs locally; usually needs no extra API key.
- Validate with a small sample before expanding the task.
## Output requirements
- Return the deliverable, key evidence, validation method, and next action.
- Mark missing information as unknown; do not invent commands, platforms, or dependencies. The author source anchors workflow facts; repository files anchor sources and commands; Fluxly only adds fit, limitations, and quality judgment.
skill "flutter-bloc-stream-tracking" {
input -> user goal + target files + boundaries + acceptance criteria
context -> Contents / Stream subscription pattern / Connection lifecycle
rules -> SKILL.md triggers / order / output contract
runtime -> no special runtime | read files, write/modify files | mostly runs locally
guardrails -> usually needs no extra API key + small-sample validation + diff/log review
output -> copyable result + checklist + next iteration
} Real-Time Streams in Bloc
Couples a Stream<T> from the data layer to a Bloc's state machine. The Bloc consumes the stream inside an event handler using await emit.forEach(...), which ties the subscription's lifetime to the handler's lifetime and the Bloc's close() — no manual StreamSubscription to track, no leaks. Connection lifecycle (connect, disconnect, reconnect-with-backoff) is explicit events on the Bloc; the transport plumbing lives in the Repository. Builds on flutter-bloc-feature-pattern.
Contents
- Stream subscription pattern
- Connection lifecycle
- State design for streams
- Workflow: Wire a real-time stream into a Bloc
- Applied to Talabat-clone
- Examples
Stream subscription pattern
Inside an event handler, await emit.forEach(stream, onData: ..., onError: ...) keeps the handler alive for as long as the stream yields. When the Bloc closes — route pop, app background, hot restart — the subscription is cancelled automatically. There is no StreamSubscription field on the Bloc, and no cancel() call in close().
Future<void> _onConnect(
TrackingConnectRequested event,
Emitter<OrderTrackingState> emit,
) async {
emit(const OrderTrackingState.connecting());
await emit.forEach<DriverLocation>(
_repo.subscribeToDriver(event.orderId),
onData: (loc) => OrderTrackingState.connected(loc),
onError: (err, _) => OrderTrackingState.disconnected(reason: err.toString()),
);
}
Compare with the older stream.listen approach:
// AVOID — manual lifetime management
StreamSubscription? _sub;
Future<void> _onConnect(/* ... */) async {
_sub?.cancel();
_sub = _repo.subscribeToDriver(...).listen(
(loc) => emit(...), // emit-after-close hazard
onError: (e) => emit(...),
);
}
@override
Future<void> close() {
_sub?.cancel(); // easy to forget
return super.close();
}
emit.forEach eliminates two classes of bug: (1) emit-after-close (where .listen calls back after the Bloc is gone) and (2) leaked subscriptions in feature-with-route Blocs.
Connection lifecycle
A stream-driven Bloc has at least two explicit events:
| Event | Triggered by | Effect |
|---|---|---|
ConnectRequested(id) |
View initState after BlocProvider mounts; or after a RetryRequested |
Start emit.forEach on the stream |
DisconnectRequested |
View dispose; or business rule (e.g., Order moved past delivered) |
Calls _repo.disconnect(id) which closes the upstream stream. emit.forEach in _onConnect then completes naturally and emits Closed via its own epilogue. |
Crucial: _onDisconnect cannot terminate _onConnect's emit.forEach by just emitting Closed itself. Each event handler gets its own Emitter instance; marking one as done has no effect on the other's stream subscription. The Repository owns the stream's lifetime — the only correct way to stop tracking is to close the stream at the source.
Reconnection with backoff lives in the Repository, not the Bloc. The Repository wraps the raw stream so transient transport errors are caught, retried with exponential backoff, and capped at a maximum attempt count. From the Bloc's perspective, the stream stays "open" across transient failures. A Reconnecting state is reachable only if the Repository explicitly emits a reconnecting sentinel (e.g., a wrapper type like Either<Reconnecting, DriverLocation>); the simplest implementations just sleep silently between retries and never surface a Reconnecting state to the Bloc.
Use transformer: restartable() on the ConnectRequested handler so dispatching ConnectRequested again — say, after a navigation pop-then-push — cancels the previous handler cleanly. The if (!emit.isDone) emit(Closed) epilogue after emit.forEach exists so a restartable() cancellation (where the prior emitter is already done) doesn't leak a spurious Closed state during a normal restart.
State design for streams
Do not reuse Loading / Loaded from API-call states for streams. They mean different things: API Loaded is terminal, stream Connected is ongoing. Use distinct variants.
@freezed
sealed class OrderTrackingState with _$OrderTrackingState {
const factory OrderTrackingState.initial() = TrackingInitial;
const factory OrderTrackingState.connecting() = TrackingConnecting;
const factory OrderTrackingState.connected(DriverLocation location) = TrackingConnected;
const factory OrderTrackingState.reconnecting({required String reason}) = TrackingReconnecting;
const factory OrderTrackingState.disconnected({required String reason}) = TrackingDisconnected;
const factory OrderTrackingState.closed() = TrackingClosed; // terminal: stream is done
}
The View's switch over this state is exhaustive — adding a Reconnecting variant later forces every consumer to handle it explicitly.
Workflow: Wire a real-time stream into a Bloc
Task Progress
- Step 1 — Add Repository stream method.
Stream<T> subscribeTo<...>(args)returning a hot or coldStream. Lives inlib/data/repositories/. - Step 2 — Wrap transient errors. Inside the Repository, transform the raw transport stream so transient disconnects do not surface — emit a
Reconnectingsentinel and retry with backoff. Only fatal errors propagate. - Step 3 — Define stream events. Sealed
<Feature>EventwithConnectRequested(id),DisconnectRequested, and optionallyRetryRequested. - Step 4 — Define stream states. Sealed
<Feature>StatewithInitial,Connecting,Connected(data),Reconnecting(reason),Disconnected(reason),Closedvariants. Do not reuseLoaded/Failurefrom API skills. - Step 5 — Implement the handler with
emit.forEach. Usetransformer: restartable()so a secondConnectRequestedcancels the first. Never store aStreamSubscriptionon the Bloc. - Step 6 — Wire
DisconnectRequested. The handler callsawait _repo.disconnect(id)which closes the upstream stream._onConnect'semit.forEachthen completes naturally and emitsClosedvia its own epilogue. Do not just emitClosedfrom_onDisconnect— each handler has its ownEmitter, so it can't terminate another handler's stream subscription. - Step 7 — Dispatch from the View lifecycle.
BlocProvider.createshould addConnectRequested(id)immediately;BlocListenerforClosedcan pop the route or show a summary. - Step 8 — Feedback loop. Run on a device → background the app → foreground it → verify the Bloc resumes (
reconnecting → connected) rather than wedges inDisconnected. If it wedges, the issue is almost always the Repository swallowing a fatal error as transient or vice versa.
Applied to Talabat-clone
Two real-time features map to this pattern.
Order tracking (OrderTrackingBloc)
Per PRD_states.md §1, an Order's driver-tracking window is open only while the Order is in picked_up or in_transit. Before picked_up there is no driver assigned; after delivered / failed_delivery / cancelled the assignment is over.
Order state → OrderTrackingBloc behavior
─────────────────────────────────────────────────
awaiting_driver → not yet — show "looking for driver"
picked_up → add ConnectRequested(orderId)
in_transit → still subscribed; frames flow
delivered → add DisconnectRequested → emit Closed → show summary
failed_delivery → add DisconnectRequested → emit Closed → trigger refund flow
cancelled → add DisconnectRequested → emit Closed
The OrderBloc (separate, not in this skill) owns the Order state transitions. OrderTrackingBloc is a child route's Bloc; the parent screen reacts to OrderBloc's state changes via BlocListener and dispatches the tracking lifecycle events.
Chat thread (ChatThreadBloc)
PRD_states.md §15 makes the chat window time-computed, not state-stored: open iff Order.state ∈ {picked_up, in_transit} OR Order.state == delivered AND now() < delivered_at + 24h. The chat Bloc subscribes when this predicate is true and unsubscribes when it flips. Per-message translation status is the Message model's async job lifecycle — out of scope for this Bloc.
Examples
lib/data/repositories/order_repository.dart (excerpt)
import 'dart:async';
/// Real transport may be Pusher, WebSocket, or Firestore. Backoff/retry,
/// max-attempts, and per-orderId kill-switches are the Repository's
/// responsibility — the Bloc only consumes the resulting `Stream`.
class OrderRepository {
OrderRepository({required this.transport, this.maxRetries = 5});
final RealtimeTransport transport;
final int maxRetries;
// One StreamController per active subscription; closing the controller
// is the kill switch that propagates "stop tracking" to the consumer.
final Map<String, StreamController<DriverLocation>> _active = {};
Stream<DriverLocation> subscribeToDriver(String orderId) {
// If somehow a previous subscription is still around, close it first.
_active[orderId]?.close();
final controller = StreamController<DriverLocation>();
_active[orderId] = controller;
unawaited(_pumpWithRetry(orderId, controller));
return controller.stream;
}
/// Closes the upstream stream for `orderId`. `_onConnect` in the Bloc
/// sees `emit.forEach` complete and emits its own `Closed` state.
Future<void> disconnect(String orderId) async {
final controller = _active.remove(orderId);
await controller?.close();
}
Future<void> _pumpWithRetry(
String orderId,
StreamController<DriverLocation> sink,
) async {
var attempt = 0;
while (!sink.isClosed) {
try {
await for (final loc in transport.driverLocationStream(orderId)) {
if (sink.isClosed) return;
sink.add(loc);
attempt = 0; // reset on any successful frame
}
// Upstream ended normally — close the sink so `emit.forEach` exits.
if (!sink.isClosed) await sink.close();
return;
} on TransientTransportException catch (e) {
attempt++;
if (attempt > maxRetries) {
// Promote a transient failure to fatal after N attempts.
if (!sink.isClosed) sink.addError(FatalTransportException(e.toString()));
if (!sink.isClosed) await sink.close();
return;
}
final backoff = Duration(milliseconds: 500 * (1 << (attempt - 1).clamp(0, 5)));
await Future<void>.delayed(backoff);
}
}
}
}
lib/ui/features/order_tracking/bloc/order_tracking_bloc.dart
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../data/repositories/order_repository.dart';
import '../../../../domain/models/driver_location.dart';
import 'order_tracking_event.dart';
import 'order_tracking_state.dart';
class OrderTrackingBloc
extends Bloc<OrderTrackingEvent, OrderTrackingState> {
OrderTrackingBloc({required OrderRepository repo})
: _repo = repo,
super(const OrderTrackingState.initial()) {
on<TrackingConnectRequested>(_onConnect, transformer: restartable());
on<TrackingDisconnectRequested>(_onDisconnect);
}
final OrderRepository _repo;
Future<void> _onConnect(
TrackingConnectRequested event,
Emitter<OrderTrackingState> emit,
) async {
emit(const OrderTrackingState.connecting());
await emit.forEach<DriverLocation>(
_repo.subscribeToDriver(event.orderId),
onData: (loc) => OrderTrackingState.connected(loc),
onError: (err, _) =>
OrderTrackingState.disconnected(reason: err.toString()),
);
// Stream completed normally — driver delivered, etc.
if (!emit.isDone) emit(const OrderTrackingState.closed());
}
Future<void> _onDisconnect(
TrackingDisconnectRequested event,
Emitter<OrderTrackingState> emit,
) async {
// Route disconnection through the Repository: closing the upstream stream
// makes `_onConnect`'s `emit.forEach` complete naturally, at which point
// `_onConnect`'s `if (!emit.isDone) emit(Closed)` epilogue fires.
// Do NOT just emit `Closed` here — each handler has its own Emitter, so
// marking this one done has zero effect on the other's subscription.
await _repo.disconnect(event.orderId);
}
}
lib/ui/features/order_tracking/view/order_tracking_view.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/order_tracking_bloc.dart';
import '../bloc/order_tracking_event.dart';
import '../bloc/order_tracking_state.dart';
class OrderTrackingView extends StatelessWidget {
const OrderTrackingView({super.key, required this.orderId});
final String orderId;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (ctx) => OrderTrackingBloc(repo: ctx.read())
..add(TrackingConnectRequested(orderId)),
child: BlocConsumer<OrderTrackingBloc, OrderTrackingState>(
listenWhen: (_, n) => n is TrackingClosed,
listener: (ctx, _) => Navigator.of(ctx).pop(), // back to order summary
builder: (context, state) => switch (state) {
TrackingInitial() || TrackingConnecting() =>
const Center(child: CircularProgressIndicator()),
TrackingConnected(:final location) =>
_DriverMap(location: location),
TrackingReconnecting(:final reason) =>
_Banner(text: 'Reconnecting: $reason'),
TrackingDisconnected(:final reason) =>
_ErrorView(message: reason),
TrackingClosed() => const SizedBox.shrink(),
},
),
);
}
}
emit.forEach plus restartable() plus a single Closed sentinel: that is the whole pattern. Everything else — backoff timing, transport choice, frame format — lives one layer down in the Repository where Bloc-level skills have no business reaching.
Source: abdallhMoukdad/flutter-bloc-skills — distributed by TomeVault.
Decide Fit First
emit.forEachand emits a new state on every…Design Intent
How To Use It
Boundaries And Review