移动端安装
- 作者仓库星标 0
- 作者更新于 实时读取
- 作者仓库 skills-registry
- 领域
- 工程开发
- 兼容 Agent
-
- Claude Code
- Cursor
- Cline
- Codex
- Windsurf
- Gemini CLI
- +20
- 信任分
- 88 / 100 · 社区维护
- 作者 / 版本 / 许可
- @tomevault-io · 未声明 license
- Token 消耗评级
- 低消耗
- 接入复杂程度
- 需简单配置
- 是否需要外部 API Key
- 不需要
- 兼容的系统
- 未声明(默认跨平台)
- 底层运行要求
- 无特殊要求
- 文件与系统权限
-
- 只读
- 允许写入 / 修改
- 网络行为
- 仅限本地
- 安装命令数
- 26 条
档案由构建时根据 SKILL.md 与安装命令自动衍生,可能与作者实际意图存在差异。
需要注意: 未限定 allowed-tools,默认拥有全部工具权限。
---
name: flutter-bloc-stream-tracking
description: Subscribes to a real-time stream (WebSocket, Pusher, Firestore) inside a Bloc via `emit.forEach`…
category: 工程开发
runtime: 无特殊运行时
---
# flutter-bloc-stream-tracking 输出预览
## PART A: 任务判断
- 适用问题:代码实现、重构、调试或代码审查。
- 输入要求:目标材料、限制条件、期望输出和验收方式。
- 证据边界:围绕“Contents / Stream subscription pattern / Connection lifecycle”读取原文规则,不把推断写成作者承诺。
## PART B: 执行结果
- **01** 任务判断:确认你的需求是否属于代码实现、重构、调试或代码审查,并标出输入、限制和预期结果。
- **02** 执行计划:优先按“Contents / Stream subscription pattern / Connection lifecycle”拆成步骤,说明每一步会读取什么、修改什么、产出什么。
- **03** 交付结果:给出可复制的命令、文件改动、检查清单或内容草稿,并说明如何继续迭代。
- **04** 风险边界:结合 读取文件、写入/修改文件、主要在本地完成、通常不需要额外 API Key 给出执行前确认项。
## Running Rules
- 读取文件、写入/修改文件;主要在本地完成;通常不需要额外 API Key。
- 先小样例验证,再放大到真实任务。
- 交付时同时给结果、检查口径和下一步迭代建议。 原文没有稳定的斜杠命令要求。安装验证后通常全局生效,直接在对话里点名这个 Skill 并描述任务即可。
告诉 Agent 目标文件或材料、期望结果、不可改范围、是否允许联网或执行命令。本 Skill 的权限画像是:读取文件、写入/修改文件。
先用一个小任务确认它会围绕“Contents / Stream subscription pattern / Connection lifecycle”工作;涉及文件或命令时,先看 diff、日志、预览或测试结果。
检查最终产物是否包含明确结果、必要证据和下一步动作;如果输出泛泛而谈,就补充输入、边界和验收标准后重跑。
---
name: flutter-bloc-stream-tracking
description: Subscribes to a real-time stream (WebSocket, Pusher, Firestore) inside a Bloc via `emit.forEach`…
category: 工程开发
source: tomevault-io/skills-registry
---
# flutter-bloc-stream-tracking
## 什么时候使用
- 把工程方向的常用动作沉淀成 Agent 可调用的技能 适合处理工程开发场景下的代码实现、调试、重构、测试或代码审查,核心价值是把输入、判断、执行、验证和交付边界固定下来,避免 Agent 泛泛回答。 把任务拆成可执行、可检查、可继续迭代…
- 面向代码实现、重构、调试或代码审查,优先处理能明确输入、步骤和验收标准的工作。
## 需要提供什么
- 目标材料、目录范围、期望结果和不可改动内容。
- 是否允许联网、执行命令、读写文件或调用外部服务。
## 执行规则
- 围绕「Contents / Stream subscription pattern / Connection lifecycle」组织步骤,不把推断写成作者事实。
- 读取文件、写入/修改文件;主要在本地完成;通常不需要额外 API Key。
- 先跑小样例,确认结果可检查后再扩大任务范围。
## 输出要求
- 给出最终产物、关键证据、验证方式和下一步动作。
- 信息不足时标记 unknown,不编造命令、平台或依赖。 作者原文负责流程事实;仓库文件负责来源和命令;流狐只补充适用场景、限制和质量判断。
skill "flutter-bloc-stream-tracking" {
输入层 -> 用户目标 + 目标文件 + 禁止范围 + 验收标准
上下文层 -> Contents / Stream subscription pattern / Connection lifecycle
规则层 -> SKILL.md 触发条件 / 执行顺序 / 输出格式
运行层 -> 无特殊运行时 | 读取文件、写入/修改文件 | 主要在本地完成
安全层 -> 通常不需要额外 API Key + 小任务验证 + diff / 日志复核
输出层 -> 可复制结果 + 检查清单 + 下一步迭代
} 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.
先判断是否适合
作者设计意图
作者的方法与取舍
边界和复核