flutter_stream_buffer 0.1.0
flutter_stream_buffer: ^0.1.0 copied to clipboard
Frame-batched text streaming for Flutter AI responses, live captions, and logs. Includes cancellation, bounded accumulation, and a lifecycle-aware builder.
flutter_stream_buffer #
Frame-batched text streaming for Flutter. Give it any Stream<String> of text
deltas, such as an AI response, live captions, or a log tail. It keeps every
chunk in order and hands your widgets one combined snapshot per display frame.
It does not render anything itself, call any network API, or need a state management package. Keep your own HTTP client, AI provider, and text or Markdown renderer. This package owns only text accumulation and the stream lifecycle.
Features #
- One notification per frame. A burst of 1,000 chunks that arrives between two frames produces a single rebuild.
- Partial output survives. Text accepted before an error, a cancellation, or a size-limit rejection stays readable.
- Safe restarts. Starting a new response cancels the old subscription. Late events from the old stream can't overwrite the new response.
- Bounded memory. A configurable limit in UTF-16 code units rejects the chunk that would exceed it, then stops the stream with a typed error.
- Lifecycle-aware widget.
StreamTextBuildersubscribes, reconnects when the stream changes, and cancels when unmounted. - No dependencies beyond Flutter. Works on every Flutter platform.
Install #
flutter pub add flutter_stream_buffer
Quick start #
Create the stream outside build, for example in a button handler, and pass it
to StreamTextBuilder:
import 'package:flutter/material.dart';
import 'package:flutter_stream_buffer/flutter_stream_buffer.dart';
class AnswerView extends StatelessWidget {
const AnswerView({super.key, required this.answer});
/// Text deltas from your backend, or null before the user asks.
final Stream<String>? answer;
@override
Widget build(BuildContext context) => StreamTextBuilder(
stream: answer,
builder: (context, snapshot) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(snapshot.text),
if (snapshot.isStreaming) const LinearProgressIndicator(),
if (snapshot.hasError) Text('Stopped: ${snapshot.error}'),
],
),
);
}
Passing a different stream instance starts a fresh response. Passing the same
instance on rebuild keeps the existing subscription, and passing null resets
the view to idle.
Controller #
Use StreamTextController when you need to cancel, restart, or read the text
from outside the widget tree. It is a ValueListenable<StreamTextSnapshot>, so
it works with ValueListenableBuilder, AnimatedBuilder, or addListener.
class ChatPage extends StatefulWidget {
const ChatPage({super.key});
@override
State<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
final _reply = StreamTextController(maxCodeUnits: 200000);
void _ask(String prompt) => _reply.start(myBackend.streamAnswer(prompt));
@override
void dispose() {
_reply.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) =>
ValueListenableBuilder<StreamTextSnapshot>(
valueListenable: _reply,
builder: (context, snapshot, _) => Column(
children: [
Text(snapshot.text),
if (snapshot.isStreaming)
TextButton(onPressed: _reply.cancel, child: const Text('Stop')),
],
),
);
}
| Method | Behavior |
|---|---|
start(stream) |
Cancels any previous subscription, clears the text, and listens to stream immediately. |
cancel() |
Stops the response and keeps the accepted text. The returned future completes after upstream cleanup. |
dispose() |
Cancels the subscription and pending frame. No further notifications are delivered. |
Snapshots #
Every update arrives as an immutable StreamTextSnapshot:
| Field | Meaning |
|---|---|
text |
All accepted deltas, in arrival order. |
status |
idle, streaming, done, cancelled, or error. |
receivedChunks |
Data events received, including empty chunks and a rejected oversized chunk. |
error / stackTrace |
The source failure, cleanup failure, or StreamBufferLimitException. |
isStreaming, hasError, isTerminal |
Convenience getters for the status. |
After the first error, the controller stops accepting chunks and cancels the source. Text accepted before the error is kept.
Connecting a backend #
Any Stream<String> works. With package:http, decode the byte stream with
utf8.decoder, which correctly handles multi-byte characters split across
network packets:
final request = http.Request('POST', Uri.parse('https://example.com/answer'))
..body = jsonEncode({'prompt': prompt});
final response = await client.send(request);
controller.start(response.stream.transform(utf8.decoder));
For server-sent events or JSON lines, map each event to its text delta before
passing the stream in. The package never parses Markdown, so you can render
snapshot.text with any Markdown widget.
Size limit #
maxCodeUnits defaults to 1,048,576 UTF-16 code units (Dart String.length).
That is a count of code units, not bytes or visible characters. When a chunk
would push the total past the limit:
- the whole chunk is rejected,
- the status becomes
errorwith aStreamBufferLimitException, - the source subscription is cancelled, and
- all previously accepted text is kept.
The limit applies to retained text only. It does not throttle the network or apply backpressure upstream.
Frame timing #
Snapshots are delivered from a scheduled frame callback, including the final
done, cancelled, or error state. Between frames, value is the last
delivered snapshot. When the app is in the background and Flutter pauses
frames, chunks keep accumulating (within the size limit) and appear on the next
frame.
The package batches text concatenation and listener notifications. It does not measure or guarantee any frame rate.
Testing your widgets #
In testWidgets, call await tester.pump() to deliver the next snapshot.
start is synchronous. Don't await controller.cancel() directly inside
testWidgets: when the source has no asynchronous cancel work, Dart completes
that future outside the test's fake-async zone and the test never finishes. Call
cancel() without awaiting and pump, or await it inside tester.runAsync.
Example #
The example app is an interactive playground with a simulated AI response, a 1,000-chunk burst, a mid-stream failure, a size-limit test, and live counters for source chunks and delivered snapshots. It ships with web support:
cd example
flutter run -d chrome
To run it on mobile or desktop, add platform folders first with
flutter create . --platforms=android,ios.
When to use something else #
- For a complete chat interface, try flutter_gen_ai_chat_ui.
- For Markdown rendering with typing animations, try flutter_streaming_text_markdown.
This package suits apps that already have a renderer and want a small, provider-independent buffer with explicit lifecycle and limits.
Contributing #
Issues and pull requests are welcome on GitHub. See CONTRIBUTING.md.
License #
MIT. See LICENSE.