Most operations return a single value: UseCase<Output, Param> with
Future<Output> call(Param). Some operations emit many values over time —
server-sent events (SSE), web sockets, LLM token streams. For those, use
StreamUseCase and consume it in the BLoC with emit.forEach (or a managed
subscription when you need to cancel).
The apps/ai_chat reference app implements this end to end.
| Use case shape | Base class | Returns |
|---|---|---|
| One request → one result | UseCase<Output, Param> |
Future<Output> |
| One request → many chunks over time | StreamUseCase<Output, Param> |
Stream<Output> |
Both live in packages/core/lib/core/usecase/usecase.dart. The output is
typically Either<Failure, T> so success and error both flow through the
stream as values — no exception ever crosses a layer boundary.
// repository/chat_repository.dart
abstract interface class ChatRepository {
Stream<Either<Failure, String>> sendMessage(String prompt); // token deltas
}
// usecase/send_message_usecase.dart
class SendMessageUseCase
extends StreamUseCase<Either<Failure, String>, SendMessageParams> {
final ChatRepository _repository;
const SendMessageUseCase(this._repository);
@override
Stream<Either<Failure, String>> call(SendMessageParams params) =>
_repository.sendMessage(params.prompt);
}The data source returns a raw transport Stream (here, mocked token deltas; a
real impl would yield each SSE data: payload). The repository wraps it with
BaseRepository.handleStream, the streaming counterpart to handleRequest
— it yields each value as right(value) and maps any error to a typed
Failure:
class ChatRepositoryImpl with BaseRepository implements ChatRepository {
final ChatRemoteDataSource _dataSource;
const ChatRepositoryImpl(this._dataSource);
@override
Stream<Either<Failure, String>> sendMessage(String prompt) =>
handleStream(() => _dataSource.sendMessage(prompt));
}
ai_chatships two data sources behind this interface:MockChatRemoteDataSourceImpl(the default — emits a canned reply with delays, so the app runs offline with no key) andGroqChatRemoteDataSourceImpl(a real model). AChatDataSourceDispatcherroutes per call: the user pastes a Groq key via the top-bar key action (stored on-device throughSharedPreferenceServiceand managed withGet/SaveApiKeyUseCase), and from then on sends go to Groq instead of the mock.
Direct LLM APIs (Groq, OpenAI, Anthropic, Gemini) do not use a separate
"send" call + persistent socket. It's a single POST to the
chat-completions endpoint; with "stream": true the response body of that same
request is the SSE stream. So a reusing dev needs exactly one endpoint and
one API key.
GroqChatRemoteDataSourceImpl (OpenAI-compatible) implements both modes the
user can toggle:
- Streaming (
ChatMode.streaming,"stream": true) —HttpService.postStreamreturns the raw byte stream; the data source parses SSEdata:frames and yields eachchoices[0].delta.content, stopping atdata: [DONE]. - One-shot (
ChatMode.oneShot,"stream": false) — a normalHttpService.post; yields the singlechoices[0].message.contentonce.
Both flow through the same Stream<Either<Failure, String>> contract, so the
repository, use case, and BLoC are identical regardless of mode or backend —
only the data source differs. (No eventflux/SSE package is needed: eventflux
is built for persistent server-push connections and would wrongly auto-reconnect
a one-shot completion; dio's ResponseType.stream is the right tool.)
The simple case — emit.forEach. When you don't need to cancel, this is the
whole pattern: the handler stays alive while the stream drains, emitting a new
state per chunk.
await emit.forEach(
_sendMessage(SendMessageParams(prompt: prompt)),
onData: (either) => either.fold(
(failure) => _withAssistantError(state),
(delta) => _appendDelta(state, delta), // immutable list update
),
);With Stop/cancel. A chat needs to halt a reply mid-stream, so ai_chat
holds the StreamSubscription and a Completer the handler awaits. A
StopPressed event cancels the subscription and completes the completer, so the
send handler finishes cleanly. See chat_bloc.dart for the full implementation
(_streamReply, _onStop). Always cancel the subscription in close().
Chat is a single, continuously evolving state — existing messages, the
in-flight reply, and any error all coexist. So ChatState is one Freezed data
class with copyWith, and per-message lifecycle lives on
ChatMessageEntity.status (streaming / done / error):
@freezed
abstract class ChatState with _$ChatState {
const factory ChatState({
@Default(<ChatMessageEntity>[]) List<ChatMessageEntity> messages,
@Default(false) bool isResponding,
}) = _ChatState;
}This is a deliberate exception to the "sealed states + exhaustive switch"
default, which fits mutually exclusive phases (loading / loaded / error). A
stream that mutates a list in place is not phase-based — forcing it into a union
would mean rebuilding the whole list on every token. Retry context comes from
the conversation itself (the last user message), never from inspecting a prior
BLoC state.
apps/ai_chat/test/unit/feature/home/presentation/chat_bloc_test.dart covers
delta accumulation → done, error → error status, and retry re-streaming.
Run cd apps/ai_chat && flutter test, or make run-ai-chat to watch a reply
stream in token by token and hit Stop mid-stream.