flutter_stream_buffer 0.1.0 copy "flutter_stream_buffer: ^0.1.0" to clipboard
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 #

pub package CI License: MIT

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. StreamTextBuilder subscribes, 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:

  1. the whole chunk is rejected,
  2. the status becomes error with a StreamBufferLimitException,
  3. the source subscription is cancelled, and
  4. 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 #

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.

1
likes
160
points
--
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Frame-batched text streaming for Flutter AI responses, live captions, and logs. Includes cancellation, bounded accumulation, and a lifecycle-aware builder.

Repository (GitHub)
View/report issues
Contributing

Topics

#streaming #ai #performance #widget #chat

License

MIT (license)

Dependencies

flutter

More

Packages that depend on flutter_stream_buffer