API安装
- 作者仓库星标 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-async-api
description: Implements the canonical async API call pattern in Bloc — dispatch event, emit loading, call Rep…
category: 工程开发
runtime: 无特殊运行时
---
# flutter-bloc-async-api 输出预览
## PART A: 任务判断
- 适用问题:代码实现、重构、调试或代码审查。
- 输入要求:目标材料、限制条件、期望输出和验收方式。
- 证据边界:围绕“Contents / Repository contract / Failure taxonomy”读取原文规则,不把推断写成作者承诺。
## PART B: 执行结果
- **01** 任务判断:确认你的需求是否属于代码实现、重构、调试或代码审查,并标出输入、限制和预期结果。
- **02** 执行计划:优先按“Contents / Repository contract / Failure taxonomy”拆成步骤,说明每一步会读取什么、修改什么、产出什么。
- **03** 交付结果:给出可复制的命令、文件改动、检查清单或内容草稿,并说明如何继续迭代。
- **04** 风险边界:结合 读取文件、写入/修改文件、会按任务需要访问外部网络、通常不需要额外 API Key 给出执行前确认项。
## Running Rules
- 读取文件、写入/修改文件;会按任务需要访问外部网络;通常不需要额外 API Key。
- 先小样例验证,再放大到真实任务。
- 交付时同时给结果、检查口径和下一步迭代建议。 原文出现了 `/api` 这类斜杠命令;如果你的 Agent 支持命令触发,优先用命令开场,再补充目标和边界。
告诉 Agent 目标文件或材料、期望结果、不可改范围、是否允许联网或执行命令。本 Skill 的权限画像是:读取文件、写入/修改文件。
先用一个小任务确认它会围绕“Contents / Repository contract / Failure taxonomy”工作;涉及文件或命令时,先看 diff、日志、预览或测试结果。
检查最终产物是否包含明确结果、必要证据和下一步动作;如果输出泛泛而谈,就补充输入、边界和验收标准后重跑。
---
name: flutter-bloc-async-api
description: Implements the canonical async API call pattern in Bloc — dispatch event, emit loading, call Rep…
category: 工程开发
source: tomevault-io/skills-registry
---
# flutter-bloc-async-api
## 什么时候使用
- 把工程方向的常用动作沉淀成 Agent 可调用的技能 适合处理工程开发场景下的代码实现、调试、重构、测试或代码审查,核心价值是把输入、判断、执行、验证和交付边界固定下来,避免 Agent 泛泛回答。 把任务拆成可执行、可检查、可继续迭代…
- 面向代码实现、重构、调试或代码审查,优先处理能明确输入、步骤和验收标准的工作。
## 需要提供什么
- 目标材料、目录范围、期望结果和不可改动内容。
- 是否允许联网、执行命令、读写文件或调用外部服务。
## 执行规则
- 围绕「Contents / Repository contract / Failure taxonomy」组织步骤,不把推断写成作者事实。
- 读取文件、写入/修改文件;会按任务需要访问外部网络;通常不需要额外 API Key。
- 先跑小样例,确认结果可检查后再扩大任务范围。
## 输出要求
- 给出最终产物、关键证据、验证方式和下一步动作。
- 信息不足时标记 unknown,不编造命令、平台或依赖。 作者原文负责流程事实;仓库文件负责来源和命令;流狐只补充适用场景、限制和质量判断。
skill "flutter-bloc-async-api" {
输入层 -> 用户目标 + 目标文件 + 禁止范围 + 验收标准
上下文层 -> Contents / Repository contract / Failure taxonomy
规则层 -> SKILL.md 触发条件 / 执行顺序 / 输出格式
运行层 -> 无特殊运行时 | 读取文件、写入/修改文件 | 会按任务需要访问外部网络
安全层 -> 通常不需要额外 API Key + 小任务验证 + diff / 日志复核
输出层 -> 可复制结果 + 检查清单 + 下一步迭代
} Async API Calls in Bloc
Defines the contract between a Bloc and the data layer: every Repository method returns Future<Result<T>>, never throws. The Result<T> union carries either the successful T or a Failure — Failure is fixed (not a second generic parameter on Result), so the union is parameterised by the success type alone. The Bloc handler emits Loading, awaits the repository, and emits Loaded or Failure by pattern-matching on the Result. Failure shapes mirror Laravel's HTTP response taxonomy (especially the 422 validation envelope), so backend errors round-trip cleanly into the UI. Builds on flutter-bloc-feature-pattern; the HTTP transport itself is covered by flutter-use-http-package.
Contents
- Repository contract
- Failure taxonomy
- Event handler shape
- Refresh and request cancellation
- Workflow: Wire a feature to the API
- Applied to Talabat-clone
- Examples
Repository contract
Every Repository method returns Future<Result<T>>. Repositories never throw. Errors that escape from the HTTP layer (network, JSON, status code) are caught at the Repository boundary and converted into a Failure variant. A Bloc handler that calls a Repository never needs try/catch — the type system already accounts for failure.
Result is a sealed freezed union with two variants:
// lib/core/result/result.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'failure.dart';
part 'result.freezed.dart';
@freezed
sealed class Result<T> with _$Result<T> {
const factory Result.success(T data) = ResultSuccess<T>;
const factory Result.failure(Failure failure) = ResultFailure<T>;
}
Why a typed Result instead of throwing: in Dart, throw is invisible at the call site. The Bloc author has no compile-time signal that repo.fetch() might fail. With Result, the consumer must pattern-match — an unhandled failure becomes a non-exhaustive switch and a compile error.
Failure taxonomy
Failure is a sealed union covering every error category that can come back from a Laravel API. Map each HTTP outcome to exactly one variant.
// lib/core/result/failure.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'failure.freezed.dart';
@freezed
sealed class Failure with _$Failure {
const factory Failure.network() = NetworkFailure; // connection refused, DNS, no internet
const factory Failure.timeout() = TimeoutFailure; // server unreachable past deadline
const factory Failure.unauthorized() = UnauthorizedFailure; // 401 — trigger logout flow
const factory Failure.forbidden() = ForbiddenFailure; // 403
const factory Failure.notFound() = NotFoundFailure; // 404
const factory Failure.validation(Map<String, List<String>> errors) // 422 — Laravel field errors
= ValidationFailure;
const factory Failure.server(int statusCode) = ServerFailure; // 500..599
const factory Failure.unknown(String message) = UnknownFailure; // fallback; log + report
}
Laravel's 422 response envelope:
{
"message": "The given data was invalid.",
"errors": {
"email": ["The email has already been taken."],
"phone": ["The phone field is required.", "The phone must be at least 10 digits."]
}
}
A direct cast json['errors'] as Map<String, List<String>> throws at runtime — jsonDecode returns Map<String, dynamic> with List<dynamic> values. Map carefully:
final raw = json['errors'] as Map<String, dynamic>;
final errors = raw.map(
(k, v) => MapEntry(k, (v as List).cast<String>()),
);
return Failure.validation(errors);
Forms surface per-field errors via flutter-bloc-forms. For non-form features, render the first message of the first key as a snackbar.
Event handler shape
The canonical async handler is five lines: emit loading, await the repository, pattern-match the result, emit the next state.
Future<void> _onLoadRequested(
RestaurantListLoadRequested event,
Emitter<RestaurantListState> emit,
) async {
emit(const RestaurantListState.loading());
final result = await _repo.fetchNearbyStores(event.location);
emit(switch (result) {
ResultSuccess(:final data) => RestaurantListState.loaded(data),
ResultFailure(:final failure) => RestaurantListState.failure(_messageFor(failure)),
});
}
_messageFor(Failure) is a private presentation-layer mapper — it converts a typed Failure into a human-readable string. Keep this mapper in the Bloc, not the View. The View should never know that Failure.network() even exists; it just renders state.message. The full mapper for every Failure variant appears in the Examples section below.
For 401 responses specifically, prefer a BlocListener<AuthBloc, AuthState> higher in the tree that triggers a global logout when Failure.unauthorized() is observed, rather than rendering an inline error.
Failure.unknown(message) is the catch-all branch and message is e.toString() from the inner exception — useful in logs and crash reports, but not user-facing copy. The presentation mapper should map it to a generic fallback like "Something went wrong. Try again." rather than rendering the raw text.
Refresh and request cancellation
A pull-to-refresh that arrives while an initial load is still in flight should cancel the in-flight request, not be silently ignored. Use bloc_concurrency's restartable() for refresh handlers.
on<RestaurantListLoadRequested>(_onLoadRequested, transformer: restartable());
on<RestaurantListRefreshRequested>(_onRefreshRequested, transformer: restartable());
restartable() makes the second event abort the handler running for the first. The HTTP request itself is not cancelled (unless the underlying client supports CancellationTokens) — but the Bloc stops listening, so any state the obsolete handler tries to emit is dropped. This prevents the "stale data flicker" where the cancelled request resolves after the new one and overwrites it.
Do not use droppable() for refresh — that would silently ignore the user's pull, leaving them stuck staring at stale data while a slow request finishes.
Workflow: Wire a feature to the API
Task Progress
- Step 1 — Define the DTO.
freezed+json_serializabledata class matching the API response shape exactly. Lives inlib/data/models/. - Step 2 — Define the domain model. Cleaner shape consumed by UI. Lives in
lib/domain/models/. The Repository transforms DTO → domain. - Step 3 — Add
ResultandFailure. Droplib/core/result/result.dartandlib/core/result/failure.dartif they don't already exist (one-time per project). - Step 4 — Implement the Service. HTTP client wrapper using
package:http. Returns the raw DTO or throws. Lives inlib/data/services/. Setup followsflutter-use-http-package. - Step 5 — Implement the Repository. Wraps the Service in
try/catch. Maps DTO → domain on success. Maps each thrown error to aFailurevariant. ReturnsResult<T>. Lives inlib/data/repositories/. - Step 6 — Implement the Bloc handler. Emit
Loading,await repo.fetch(...), pattern-match theResult, emitLoadedorFailure. Userestartable()for queries and refreshes. - Step 7 — Register the Repository. Add it to the root
MultiRepositoryProviderinmain.dart(introduced byflutter-bloc-setup). - Step 8 — Wire the View.
BlocBuilderwith a sealed-stateswitchrendering Loading / Loaded / Failure (and Empty if relevant). Runflutter analyze→ review compile errors → fix non-exhaustive switches → re-run.
Applied to Talabat-clone
A representative feature: the list of nearby stores on the home screen.
Endpoint: GET /api/stores/nearby?lat=<lat>&lng=<lng> on the Laravel backend.
Inputs: LocationDto(lat, lng) derived from device GPS or a saved address. Saved-address selection is prd.md §2.1; the "this address is far from your current location" guard is prd.md §2.2. The guard check happens in the View before dispatching the load event, not in the Bloc.
Output: List<StoreDto> on success. The Repository maps DTOs to Store domain models and filters out stores that fail operational gating (PRD_states.md §4):
paused_by_merchant == true→ drop.suspension_reason != null→ drop.- Outside working hours → drop (computed at read time using
working_hoursconfig; not a stored state).
// inside StoreRepository.fetchNearbyStores
final dtos = await _api.fetchNearbyStores(loc);
final now = DateTime.now();
final available = dtos
.where((d) => !d.pausedByMerchant)
.where((d) => d.suspensionReason == null)
.where((d) => d.workingHours.contains(now))
.map((d) => d.toDomain())
.toList();
return Result.success(available);
The home screen calls RestaurantListBloc.add(RestaurantListLoadRequested(location)) when a saved address is selected. Pull-to-refresh dispatches RestaurantListRefreshRequested(location) with the same location. Both handlers use restartable().
When a Laravel form-related call (e.g., placing an order, submitting an address) returns 422, the Failure.validation payload is forwarded to the relevant form Bloc per flutter-bloc-forms (deferred to wave 3). For non-form 422 cases, render the first message via _messageFor.
Examples
End-to-end implementation of RestaurantListBloc consuming GET /api/stores/nearby. The Service uses a project-defined ApiException that carries the raw status code and parsed body — no regex round-tripping through http.ClientException.message.
lib/core/api/api_exception.dart
/// Thrown by Service implementations on any non-2xx HTTP response.
/// Repositories catch this and map to a `Failure`.
class ApiException implements Exception {
const ApiException({required this.statusCode, this.body});
final int statusCode;
final Map<String, dynamic>? body; // null when body wasn't JSON
/// Laravel-style validation errors when statusCode == 422, else null.
Map<String, List<String>>? get validationErrors {
if (statusCode != 422) return null;
final raw = body?['errors'];
if (raw is! Map<String, dynamic>) return null;
return raw.map(
(k, v) => MapEntry(k, (v as List).cast<String>()),
);
}
@override
String toString() => 'ApiException($statusCode)';
}
lib/data/models/store_dto.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'store_dto.freezed.dart';
part 'store_dto.g.dart';
@freezed
class StoreDto with _$StoreDto {
const factory StoreDto({
required String id,
required String name,
required String logoUrl,
required double rating,
required int ratingsCount,
required int deliveryMinutes,
@JsonKey(name: 'paused_by_merchant') required bool pausedByMerchant,
@JsonKey(name: 'suspension_reason') String? suspensionReason,
}) = _StoreDto;
factory StoreDto.fromJson(Map<String, dynamic> json) => _$StoreDtoFromJson(json);
}
lib/data/services/store_api_client.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../core/api/api_exception.dart';
import '../models/store_dto.dart';
class StoreApiClient {
StoreApiClient({required http.Client client, required Uri baseUrl})
: _client = client,
_baseUrl = baseUrl;
final http.Client _client;
final Uri _baseUrl;
Future<List<StoreDto>> fetchNearbyStores(double lat, double lng) async {
final uri = _baseUrl.resolve('/api/stores/nearby?lat=$lat&lng=$lng');
final res = await _client.get(uri, headers: const {'Accept': 'application/json'});
if (res.statusCode == 200) {
final body = jsonDecode(res.body) as Map<String, dynamic>;
// This assumes Laravel's `AnonymousResourceCollection` envelope —
// `JsonResource::collection($stores)` returns `{"data": [...]}`.
// A controller that does `return response()->json($stores)` returns
// a bare array; in that case decode `as List` directly.
final list = (body['data'] as List).cast<Map<String, dynamic>>();
return list.map(StoreDto.fromJson).toList();
}
// Parse the body if it's JSON (Laravel error responses usually are);
// fall back to null when the server returned plain text or HTML.
Map<String, dynamic>? parsedBody;
try {
parsedBody = jsonDecode(res.body) as Map<String, dynamic>;
} catch (_) {/* keep parsedBody == null */}
throw ApiException(statusCode: res.statusCode, body: parsedBody);
}
}
lib/data/repositories/store_repository.dart
import 'dart:async';
import 'dart:io';
import '../../core/api/api_exception.dart';
import '../../core/result/failure.dart';
import '../../core/result/result.dart';
import '../../domain/models/location.dart';
import '../../domain/models/store.dart';
import '../services/store_api_client.dart';
class StoreRepository {
StoreRepository({required StoreApiClient api}) : _api = api;
final StoreApiClient _api;
Future<Result<List<Store>>> fetchNearbyStores(Location loc) async {
try {
final dtos = await _api.fetchNearbyStores(loc.lat, loc.lng);
final available = dtos
.where((d) => !d.pausedByMerchant && d.suspensionReason == null)
// .where((d) => d.workingHours.contains(now)) // omitted for brevity
.map(Store.fromDto)
.toList();
return Result.success(available);
} on SocketException {
return const Result.failure(Failure.network());
} on TimeoutException {
return const Result.failure(Failure.timeout());
} on ApiException catch (e) {
return Result.failure(_mapApiException(e));
} catch (e) {
return Result.failure(Failure.unknown(e.toString()));
}
}
Failure _mapApiException(ApiException e) => switch (e.statusCode) {
401 => const Failure.unauthorized(),
403 => const Failure.forbidden(),
404 => const Failure.notFound(),
422 => Failure.validation(e.validationErrors ?? const <String, List<String>>{}),
int s when s >= 500 && s < 600 => Failure.server(s),
_ => Failure.unknown('HTTP ${e.statusCode}'),
};
}
lib/ui/features/restaurant_list/bloc/restaurant_list_state.dart
The view below switches on this sealed state. See flutter-bloc-feature-pattern for the full event/state/bloc scaffolding pattern.
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../../domain/models/store.dart';
part 'restaurant_list_state.freezed.dart';
@freezed
sealed class RestaurantListState with _$RestaurantListState {
const factory RestaurantListState.initial() = RestaurantListInitial;
const factory RestaurantListState.loading() = RestaurantListLoading;
const factory RestaurantListState.empty() = RestaurantListEmpty;
const factory RestaurantListState.loaded(List<Store> stores) = RestaurantListLoaded;
const factory RestaurantListState.failure(String message) = RestaurantListFailure;
}
lib/ui/features/restaurant_list/bloc/restaurant_list_bloc.dart
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/result/failure.dart';
import '../../../../core/result/result.dart';
import '../../../../data/repositories/store_repository.dart';
import '../../../../domain/models/location.dart';
import 'restaurant_list_event.dart';
import 'restaurant_list_state.dart';
class RestaurantListBloc
extends Bloc<RestaurantListEvent, RestaurantListState> {
RestaurantListBloc({required StoreRepository repo})
: _repo = repo,
super(const RestaurantListState.initial()) {
on<RestaurantListLoadRequested>(_onLoad, transformer: restartable());
on<RestaurantListRefreshRequested>(_onLoad, transformer: restartable());
}
final StoreRepository _repo;
Future<void> _onLoad(
RestaurantListEvent event,
Emitter<RestaurantListState> emit,
) async {
final loc = switch (event) {
RestaurantListLoadRequested(:final location) => location,
RestaurantListRefreshRequested(:final location) => location,
_ => null,
};
if (loc == null) return;
emit(const RestaurantListState.loading());
final result = await _repo.fetchNearbyStores(loc);
emit(switch (result) {
ResultSuccess(:final data) when data.isEmpty =>
const RestaurantListState.empty(),
ResultSuccess(:final data) => RestaurantListState.loaded(data),
ResultFailure(:final failure) =>
RestaurantListState.failure(_messageFor(failure)),
});
}
String _messageFor(Failure f) => switch (f) {
NetworkFailure() => 'No internet connection',
TimeoutFailure() => 'Server is taking too long. Try again.',
UnauthorizedFailure() => 'Please sign in again',
NotFoundFailure() => 'No restaurants near this location',
ValidationFailure(:final errors) => errors.values.first.first,
ServerFailure(:final statusCode) => 'Server error ($statusCode). Try again.',
ForbiddenFailure() => 'Access denied',
// UnknownFailure.message is from `e.toString()` — useful for logs/crashlytics,
// not user-facing. Render a generic fallback and report the raw message elsewhere.
UnknownFailure() => 'Something went wrong. Try again.',
};
}
lib/ui/features/restaurant_list/view/restaurant_list_view.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/restaurant_list_bloc.dart';
import '../bloc/restaurant_list_state.dart';
class RestaurantListView extends StatelessWidget {
const RestaurantListView({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<RestaurantListBloc, RestaurantListState>(
builder: (context, state) => switch (state) {
RestaurantListInitial() => const SizedBox.shrink(),
RestaurantListLoading() => const Center(child: CircularProgressIndicator()),
RestaurantListEmpty() => const Center(child: Text('No restaurants near you')),
RestaurantListLoaded(:final stores) => ListView.builder(
itemCount: stores.length,
itemBuilder: (_, i) => ListTile(
title: Text(stores[i].name),
subtitle: Text('${stores[i].deliveryMinutes} min'),
),
),
RestaurantListFailure(:final message) => Center(child: Text(message)),
},
);
}
}
Source: abdallhMoukdad/flutter-bloc-skills — distributed by TomeVault.
先判断是否适合
作者设计意图
作者的方法与取舍
边界和复核