diff --git a/docs/implemented.md b/docs/implemented.md index 14c7324..3b5d022 100644 --- a/docs/implemented.md +++ b/docs/implemented.md @@ -11,10 +11,11 @@ This file documents the patterns and code that are already implemented in the `l 1. [Core Patterns](#core-patterns) 2. [Dependency Injection](#dependency-injection) 3. [Network Layer](#network-layer) -4. [Connectivity Strategy](#connectivity-strategy) -5. [Offline Queue](#offline-queue) -6. [BLoC Patterns](#bloc-patterns) -7. [Testing Strategy](#testing-strategy) +4. [Streaming (SSE) Network Layer](#streaming-sse-network-layer) +5. [Connectivity Strategy](#connectivity-strategy) +6. [Offline Queue](#offline-queue) +7. [BLoC Patterns](#bloc-patterns) +8. [Testing Strategy](#testing-strategy) --- @@ -278,6 +279,111 @@ class AuthInterceptor extends Interceptor { --- +## Streaming (SSE) Network Layer + +**Location:** `lib/core/network/streaming_client.dart`, `lib/core/network/sse_parser.dart` + +The request/response path waits for a full HTTP body. Streaming is different: AI/chat +backends emit tokens over a Server-Sent Events (SSE) stream, and we want to render +text incrementally instead of blocking on the whole response. The template ships a +small, reusable streaming layer that composes with the existing `Result` and +connectivity-first patterns. + +### When to use it + +Use `StreamingClient` whenever a response is delivered incrementally as tokens +(SSE from an AI/chat proxy). Do **not** use it for ordinary JSON request/response — +that stays on `DioClient`/`ItemRepository` so the offline queue and cache apply. + +### `StreamingClient` — how to wire it + +```dart +// Registered in `lib/core/di/injection.dart`: +getIt.registerLazySingleton( + () => StreamingClient( + dioClient: getIt(), + connectivity: getIt(), + logger: getIt(), + ), +); + +final client = getIt(); +final cancelToken = CancelToken(); + +final stream = client.stream( + '/chat', + queryParameters: {'q': 'hello'}, + cancelToken: cancelToken, +); +``` + +`StreamingClient` deliberately reuses the injected `DioClient` (its configured `Dio`) +so `baseUrl`, the `AuthInterceptor`, and debug logging are preserved. Every streaming +request overrides the global defaults: + +```dart +final options = Options( + responseType: ResponseType.stream, + receiveTimeout: Duration.zero, // do NOT use null — that falls back to 30s + sendTimeout: Duration.zero, + headers: {'Accept': 'text/event-stream'}, +); +``` + +The two overrides are load-bearing: + +- **`receiveTimeout: Duration.zero`** disables Dio's receive-timeout timer so a sparse, + long-lived stream (tokens arriving >30s apart) is not aborted. `null` would silently + fall back to the base 30s timeout in Dio 5.x and break streaming. +- **`Accept: text/event-stream`** stops SSE backends from buffering the full body and + overrides the template default `Accept: application/json`. + +### `sse_parser.dart` — framing SSE into events + +The parser turns a raw byte/text stream into typed `SseEvent`s (data / done / error), +handling multi-line `data:` fields, the `[DONE]` sentinel, `event:` / `id:` fields, +comment lines, and CRLF normalization. Bytes are decoded with `utf8.decoder.bind` so a +multi-byte character split across chunk boundaries is decoded correctly. + +```dart +Stream events = parseSseBytes(responseBodyStream); +``` + +### Emission contract + +`StreamingClient.stream` returns a `Stream>` that is **always +well-terminated** — it never leaves a consumer hanging: + +1. `Result.loading()` is emitted exactly once as the leading item. +2. One `Result.success(token)` per SSE `data:` payload. +3. The `[DONE]` sentinel ends the stream normally and is **not** emitted as a token. +4. A mid-stream error / disconnect / cancellation is converted into a terminal + `Result.failure(message, err)` before the stream closes. + +### Connectivity composition + +A token stream cannot be replayed by the Hive-backed `OfflineQueue` (it only replays +request/response pairs). So when connectivity is offline or poor, `StreamingClient` +emits `Result.failure` immediately and **does not enqueue** anything; no cached replay +of streams is attempted (deferred). A `CancelToken` is cancelled so no orphaned stream +lingers. + +### Reference feature: `features/chat/` + +`lib/features/chat/` is a copy-paste starting point mirroring `features/home/`: + +- `data/repositories/chat_repository.dart` — wraps `StreamingClient.stream`. +- `presentation/bloc/chat_bloc.dart` — accumulates tokens into a message, bounded by + `kMaxMessageChars`, with a `stop` action wired to the `CancelToken`. An aborted + mid-stream becomes `ChatState.stopped` (partial text), never a success. +- `presentation/pages/chat_page.dart` + `widgets/chat_stream_view.dart` — render the + accumulating text so it appears incrementally before the full response completes. + +The block accumulation guard (`if (message.length >= kMaxMessageChars) cancel();`) +bounds memory, and `CancelToken` gives the user a real stop control. + +--- + ## Connectivity Strategy **Location:** `lib/core/connectivity/` diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index a79f8fd..5b916a9 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -6,6 +6,8 @@ import 'package:hive/hive.dart'; import 'package:logger/logger.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../features/chat/data/repositories/chat_repository.dart'; +import '../../features/chat/presentation/bloc/chat_bloc.dart'; import '../../features/home/data/repositories/item_repository.dart'; import '../../features/home/presentation/bloc/home_bloc.dart'; import '../analytics/analytics_service.dart'; @@ -17,6 +19,7 @@ import '../network/auth_token_manager.dart'; import '../network/dio_client.dart'; import '../network/offline_queue.dart'; import '../network/request_executor.dart'; +import '../network/streaming_client.dart'; final getIt = GetIt.instance; @@ -107,6 +110,15 @@ Future configureDependencies() async { ), ); + // Streaming (SSE) + getIt.registerLazySingleton( + () => StreamingClient( + dioClient: getIt(), + connectivity: getIt(), + logger: getIt(), + ), + ); + // Repositories getIt.registerLazySingleton( () => ItemRepository( @@ -117,6 +129,13 @@ Future configureDependencies() async { ), ); + getIt.registerLazySingleton( + () => ChatRepository( + streamingClient: getIt(), + logger: getIt(), + ), + ); + // BLoCs (factories for fresh instances) getIt.registerFactory( () => HomeBloc( @@ -125,6 +144,13 @@ Future configureDependencies() async { ), ); + getIt.registerFactory( + () => ChatBloc( + repository: getIt(), + connectivityBloc: getIt(), + ), + ); + // Auth (uncomment after implementing AuthRepository) // Import: import '../auth/auth_bloc.dart'; // Import: import '../auth/auth_repository.dart'; diff --git a/lib/core/network/sse_parser.dart b/lib/core/network/sse_parser.dart new file mode 100644 index 0000000..d18b829 --- /dev/null +++ b/lib/core/network/sse_parser.dart @@ -0,0 +1,151 @@ +import 'dart:async'; +import 'dart:convert'; + +/// The type of a parsed SSE event. +enum SseEventType { data, done, error } + +/// A single parsed Server-Sent Events (SSE) event. +/// +/// The raw `data:`/`event:`/`id:` fields from one SSE block are collapsed into +/// a typed [SseEvent] so downstream consumers (e.g. [StreamingClient]) can +/// react to data tokens, the terminal `[DONE]` sentinel, and error events +/// without re-parsing text. +class SseEvent { + const SseEvent.data(this.data, {this.event, this.id}) + : type = SseEventType.data; + const SseEvent.done() + : type = SseEventType.done, + data = '', + event = null, + id = null; + const SseEvent.error(this.data) + : type = SseEventType.error, + event = null, + id = null; + + final SseEventType type; + + /// The payload. For a `data:` event this is the accumulated `data:` value + /// (multi-line values joined with `\n`). For an error event it is the error + /// payload. + final String data; + + /// The value of the `event:` field, if present. + final String? event; + + /// The value of the `id:` field, if present. + final String? id; + + bool get isData => type == SseEventType.data; + bool get isDone => type == SseEventType.done; + bool get isError => type == SseEventType.error; + + @override + String toString() => 'SseEvent($type, data: $data, event: $event, id: $id)'; +} + +/// The sentinel payload that marks the end of a stream. +/// +/// Most chat/AI backends emit `data: [DONE]` as the final event. +const String kSseDoneSentinel = '[DONE]'; + +/// Parses a stream of raw UTF-8 byte chunks into typed SSE events. +/// +/// Decoding is performed with [utf8.decoder] so a multi-byte character split +/// across chunk boundaries is still decoded correctly. +Stream parseSseBytes(Stream> chunks) { + return parseSse(utf8.decoder.bind(chunks)); +} + +/// Parses a stream of decoded text chunks into typed SSE events. +/// +/// The parser buffers partial events across chunk boundaries and only emits a +/// complete [SseEvent] once the terminating blank line (`\n\n`) has been +/// received (or the source stream closes with trailing data). +/// +/// Grammar handled (subset of the SSE spec): +/// * `data:` fields — multiple `data:` lines are joined with `\n`. +/// * `[DONE]` sentinel — surfaced as [SseEvent.done]. +/// * comments (lines starting with `:`) — ignored, never dispatched. +/// * `event:` / `id:` fields — carried on the emitted [SseEvent]. +/// * `event: error` — surfaced as [SseEvent.error]. +Stream parseSse(Stream chunks) async* { + var buffer = ''; + + await for (final chunk in chunks) { + buffer += chunk; + + // Normalize CRLF / CR to LF so event boundaries are predictable. + buffer = buffer.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); + + while (true) { + final separator = buffer.indexOf('\n\n'); + if (separator < 0) break; + + final block = buffer.substring(0, separator); + buffer = buffer.substring(separator + 2); + + final event = _parseBlock(block); + if (event != null) yield event; + } + } + + // A trailing block with no terminating blank line is still an event. + if (buffer.trim().isNotEmpty) { + final event = _parseBlock(buffer); + if (event != null) yield event; + } +} + +SseEvent? _parseBlock(String block) { + final dataLines = []; + String? eventName; + String? id; + var sawField = false; + + for (final line in block.split('\n')) { + if (line.isEmpty) continue; + + // Comment lines are keep-alive markers and never dispatch an event. + if (line.startsWith(':')) continue; + + sawField = true; + + final colon = line.indexOf(':'); + String field; + String value; + if (colon == -1) { + field = line; + value = ''; + } else { + field = line.substring(0, colon); + value = line.substring(colon + 1); + if (value.startsWith(' ')) value = value.substring(1); + } + + switch (field) { + case 'data': + dataLines.add(value); + case 'event': + eventName = value; + case 'id': + id = value; + } + } + + // A block of only comments produces no event. + if (!sawField) return null; + + final data = dataLines.join('\n'); + + // `[DONE]` and `event: done` are the terminal marker. + if (eventName == 'done' || data == kSseDoneSentinel) { + return const SseEvent.done(); + } + + if (eventName == 'error') { + return SseEvent.error(data); + } + + return SseEvent.data(data, event: eventName, id: id); +} diff --git a/lib/core/network/streaming_client.dart b/lib/core/network/streaming_client.dart new file mode 100644 index 0000000..78be610 --- /dev/null +++ b/lib/core/network/streaming_client.dart @@ -0,0 +1,122 @@ +import 'package:dio/dio.dart'; +import 'package:logger/logger.dart'; + +import '../connectivity/connectivity_service.dart'; +import '../utils/result.dart'; +import 'dio_client.dart'; +import 'sse_parser.dart'; + +/// A thin streaming wrapper over the existing [DioClient]. +/// +/// Unlike the request/response path, [StreamingClient] emits a +/// `Stream>` of decoded tokens so AI/chat features can render +/// text as it arrives instead of waiting for a full response. +/// +/// It deliberately reuses the injected [DioClient] (and its configured `Dio`) +/// rather than building a fresh one, so baseUrl, `AuthInterceptor`, and debug +/// logging are preserved. Only the per-request streaming options are overridden. +/// +/// ## Emission contract +/// +/// The returned stream is **always well-terminated** — it never leaves a +/// consumer hanging: +/// +/// 1. `Result.loading()` is emitted exactly once as the leading item. +/// 2. One `Result.success(token)` is emitted per SSE `data:` payload. +/// 3. The `[DONE]` sentinel ends the stream normally and is **not** emitted as +/// a token. +/// 4. A mid-stream error / disconnect / cancellation is converted into a +/// terminal `Result.failure(message, err)` before the stream closes. +/// +/// ## Connectivity +/// +/// A token stream cannot be replayed by the Hive-backed `OfflineQueue`, so no +/// enqueueing or cached replay is attempted. When connectivity is offline or +/// poor, [stream] emits `Result.failure` immediately (no request is issued and +/// no stream is left orphaned). +class StreamingClient { + StreamingClient({ + required DioClient dioClient, + required ConnectivityService connectivity, + required Logger logger, + }) : _dioClient = dioClient, + _connectivity = connectivity, + _logger = logger; + + final DioClient _dioClient; + final ConnectivityService _connectivity; + final Logger _logger; + + /// Streams decoded tokens from an SSE endpoint at [path]. + /// + /// The [path] is resolved against the configured `Dio` baseUrl. Pass + /// [queryParameters] for request query strings and [cancelToken] to allow the + /// consumer to abort the stream (e.g. a user "stop" action). + Stream> stream( + String path, { + CancelToken? cancelToken, + Map? queryParameters, + }) async* { + // Connectivity gate: streaming cannot be replayed from the offline queue, + // so offline/poor is a terminal failure — nothing is enqueued. + if (_connectivity.isOffline || _connectivity.isPoor) { + yield const Result.failure( + 'No connection — streaming requires a stable connection', + ); + return; + } + + yield const Result.loading(); + + // Per-request options override the global defaults (BLOCKER B-1). + // + // - `receiveTimeout: Duration.zero` disables Dio's receive-timeout timer so + // a sparse, long-lived stream (tokens >30s apart) is not aborted. + // (`null` would silently fall back to the base 30s in Dio 5.x.) + // - `Accept: text/event-stream` prevents the backend from buffering the + // full body and overrides the template's default `Accept: application/json`. + final options = Options( + responseType: ResponseType.stream, + receiveTimeout: Duration.zero, + sendTimeout: Duration.zero, + headers: const {'Accept': 'text/event-stream'}, + ); + + try { + final response = await _dioClient.get( + path, + options: options, + queryParameters: queryParameters, + cancelToken: cancelToken, + ); + + final body = response.data; + if (body == null) { + yield const Result.failure('Empty streaming response'); + return; + } + + await for (final event in parseSseBytes(body.stream)) { + if (event.isDone) { + // `[DONE]` — normal terminal; not emitted as a token. + return; + } + if (event.isError) { + yield Result.failure(event.data); + return; + } + yield Result.success(event.data); + } + } on DioException catch (e) { + if (CancelToken.isCancel(e)) { + yield Result.failure('Stream cancelled', e); + return; + } + _logger.e('Streaming request failed for $path: ${e.message}'); + yield Result.failure('Streaming failed: ${e.message}', e); + } catch (e) { + _logger.e('Streaming request failed for $path: $e'); + yield Result.failure('Streaming failed: $e', e); + } + } +} diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 2b7cd69..2c05fc5 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import '../../features/chat/presentation/pages/chat_page.dart'; import '../../features/home/presentation/pages/home_page.dart'; // Auth imports (uncomment when using authentication): // import '../auth/auth_bloc.dart'; @@ -28,6 +29,12 @@ final appRouter = GoRouter( name: 'home', builder: (context, state) => const HomePage(), ), + // Reference streaming feature + GoRoute( + path: '/chat', + name: 'chat', + builder: (context, state) => const ChatPage(), + ), // Add more routes here as you add features // Example: // GoRoute( diff --git a/lib/features/chat/data/repositories/chat_repository.dart b/lib/features/chat/data/repositories/chat_repository.dart new file mode 100644 index 0000000..7e88a27 --- /dev/null +++ b/lib/features/chat/data/repositories/chat_repository.dart @@ -0,0 +1,40 @@ +import 'package:dio/dio.dart'; +import 'package:logger/logger.dart'; + +import '../../../../core/network/streaming_client.dart'; +import '../../../../core/utils/result.dart'; + +/// Repository for the reference streaming-chat feature. +/// +/// Mirrors `features/home`'s repository pattern: the feature talks to a +/// repository, never to the network core directly. This one simply forwards +/// onto [StreamingClient] and exposes a `Stream>` of tokens. +class ChatRepository { + ChatRepository({ + required StreamingClient streamingClient, + required Logger logger, + }) : _streamingClient = streamingClient, + _logger = logger; + + final StreamingClient _streamingClient; + final Logger _logger; + + /// The chat endpoint resolved against the configured `Dio` baseUrl. + static const chatPath = '/chat'; + + /// Streams assistant tokens for the user's [message]. + /// + /// Pass [cancelToken] to allow the caller to abort the stream (e.g. a user + /// "stop" action). + Stream> stream( + String message, { + CancelToken? cancelToken, + }) { + _logger.i('Streaming chat request: $message'); + return _streamingClient.stream( + chatPath, + queryParameters: {'q': message}, + cancelToken: cancelToken, + ); + } +} diff --git a/lib/features/chat/presentation/bloc/chat_bloc.dart b/lib/features/chat/presentation/bloc/chat_bloc.dart new file mode 100644 index 0000000..602ab41 --- /dev/null +++ b/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -0,0 +1,113 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../core/connectivity/connectivity_bloc.dart'; +import '../../../../core/connectivity/connectivity_state.dart'; +import '../../../../core/utils/connectivity_aware_mixin.dart'; +import '../../../../core/utils/result.dart'; +import '../../data/repositories/chat_repository.dart'; + +part 'chat_event.dart'; +part 'chat_state.dart'; +part 'chat_bloc.freezed.dart'; + +/// Maximum number of characters accumulated before the stream is cancelled. +/// Guards against a runaway stream growing memory unbounded (C-2). +const int kMaxMessageChars = 2000; + +/// Reference BLoC for the streaming-chat feature. +/// +/// Accumulates streamed tokens into a message, bounded by [kMaxMessageChars], +/// and exposes a user-facing `stop` action wired to a `CancelToken`. +class ChatBloc extends Bloc + with ConnectivityAwareBlocMixin { + ChatBloc({ + required ChatRepository repository, + required this.connectivityBloc, + }) : _repository = repository, + super(const ChatState.initial()) { + initConnectivityListener(); + + on(_onEvent); + } + + final ChatRepository _repository; + + @override + final ConnectivityBloc connectivityBloc; + + CancelToken? _cancelToken; + String _accumulated = ''; + + Future _onEvent(ChatEvent event, Emitter emit) async { + await event.when( + send: (message) => _onSend(message, emit), + stop: () => _onStop(emit), + ); + } + + Future _onSend(String userMessage, Emitter emit) async { + // Cancel any previously in-flight stream and start fresh. + _cancelToken?.cancel(); + final cancelToken = CancelToken(); + _cancelToken = cancelToken; + + _accumulated = ''; + emit(const ChatState.loading()); + + await emit.onEach>( + _repository.stream(userMessage, cancelToken: cancelToken), + onData: (result) { + if (result.isFailure) { + // A failure caused by our own cancellation is handled below; do not + // surface it as an error. + if (cancelToken.isCancelled) return; + emit(ChatState.error(result.errorOrNull ?? 'Stream failed')); + return; + } + + if (result.isSuccess) { + if (cancelToken.isCancelled) return; + _accumulated += result.dataOrNull ?? ''; + + if (_accumulated.length >= kMaxMessageChars) { + cancelToken.cancel(); + } + + emit(ChatState.streaming(_accumulated)); + } + }, + onError: (error, _) { + if (!cancelToken.isCancelled) { + emit(ChatState.error('Stream failed: $error')); + } + }, + ); + + // The stream has ended. Decide the terminal state. + if (cancelToken.isCancelled) { + // User stop or max-length bound — partial message, never marked success. + emit(ChatState.stopped(_accumulated)); + } else if (_lastStateIsStreaming) { + // Stream reached [DONE] cleanly. + emit(ChatState.completed(_accumulated)); + } + // Otherwise an error state was already emitted. + } + + bool get _lastStateIsStreaming => state is ChatStreaming; + + Future _onStop(Emitter emit) async { + _cancelToken?.cancel(); + } + + @override + void onConnectivityChanged(ConnectivityState state) { + // Streaming cannot be replayed offline; abort an in-flight stream when we + // drop off so the UI is not left mid-token. + if (state is ConnectivityOffline) { + _cancelToken?.cancel(); + } + } +} diff --git a/lib/features/chat/presentation/bloc/chat_event.dart b/lib/features/chat/presentation/bloc/chat_event.dart new file mode 100644 index 0000000..b65634c --- /dev/null +++ b/lib/features/chat/presentation/bloc/chat_event.dart @@ -0,0 +1,10 @@ +part of 'chat_bloc.dart'; + +@freezed +abstract class ChatEvent with _$ChatEvent { + /// Send a user message and begin streaming the assistant's response. + const factory ChatEvent.send(String message) = _Send; + + /// Abort the in-flight stream (wires to the `CancelToken`). + const factory ChatEvent.stop() = _Stop; +} diff --git a/lib/features/chat/presentation/bloc/chat_state.dart b/lib/features/chat/presentation/bloc/chat_state.dart new file mode 100644 index 0000000..83902c5 --- /dev/null +++ b/lib/features/chat/presentation/bloc/chat_state.dart @@ -0,0 +1,23 @@ +part of 'chat_bloc.dart'; + +@freezed +abstract class ChatState with _$ChatState { + /// No message sent yet. + const factory ChatState.initial() = ChatInitial; + + /// Establishing the stream / waiting for the first token. + const factory ChatState.loading() = ChatLoading; + + /// Streaming in progress; [text] is the accumulated message so far. + const factory ChatState.streaming(String text) = ChatStreaming; + + /// The stream reached `[DONE]` — [text] is the complete message. + const factory ChatState.completed(String text) = ChatCompleted; + + /// The stream was aborted (user stop or max-length bound) — [text] is the + /// partial message. Never treated as a successful completion. + const factory ChatState.stopped(String text) = ChatStopped; + + /// The stream failed mid-flight. + const factory ChatState.error(String message) = ChatError; +} diff --git a/lib/features/chat/presentation/pages/chat_page.dart b/lib/features/chat/presentation/pages/chat_page.dart new file mode 100644 index 0000000..d55b26c --- /dev/null +++ b/lib/features/chat/presentation/pages/chat_page.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/di/injection.dart'; +import '../../../../shared/widgets/connectivity_banner.dart'; +import '../../../../shared/widgets/empty_state.dart'; +import '../../../../shared/widgets/error_view.dart'; +import '../../../../shared/widgets/loading_indicator.dart'; +import '../bloc/chat_bloc.dart'; +import '../widgets/chat_stream_view.dart'; + +/// Reference streaming-chat page. +/// +/// Renders assistant tokens incrementally via [ChatStreamView] and exposes a +/// send / stop pair wired to [ChatBloc]. +class ChatPage extends StatelessWidget { + const ChatPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => getIt(), + child: const ChatView(), + ); + } +} + +class ChatView extends StatefulWidget { + const ChatView({super.key}); + + @override + State createState() => _ChatViewState(); +} + +class _ChatViewState extends State { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _send() { + final message = _controller.text.trim(); + if (message.isEmpty) return; + _controller.clear(); + context.read().add(ChatEvent.send(message)); + } + + void _stop() { + context.read().add(const ChatEvent.stop()); + } + + @override + Widget build(BuildContext context) { + return ConnectivityBanner( + child: Scaffold( + appBar: AppBar(title: const Text('Streaming Chat')), + body: Column( + children: [ + Expanded( + child: BlocBuilder( + builder: (context, state) { + return state.when( + initial: () => const EmptyState( + title: 'Send a message', + subtitle: + 'Assistant tokens will appear as they stream in', + icon: Icons.chat_bubble_outline, + ), + loading: () => const Center( + child: LoadingIndicator(message: 'Connecting…'), + ), + streaming: (text) => + ChatStreamView(text: text, isStreaming: true), + completed: (text) => ChatStreamView(text: text), + stopped: (text) => ChatStreamView( + text: text.isEmpty ? 'Stopped' : '$text (stopped)', + ), + error: (message) => ErrorView( + message: message, + onRetry: _send, + ), + ); + }, + ), + ), + _Composer( + controller: _controller, + onSend: _send, + onStop: _stop, + ), + ], + ), + ), + ); + } +} + +class _Composer extends StatelessWidget { + const _Composer({ + required this.controller, + required this.onSend, + required this.onStop, + }); + + final TextEditingController controller; + final VoidCallback onSend; + final VoidCallback onStop; + + @override + Widget build(BuildContext context) { + final isStreaming = context.select( + (ChatBloc bloc) => + bloc.state is ChatStreaming || bloc.state is ChatLoading, + ); + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + onSubmitted: (_) => isStreaming ? onStop() : onSend(), + textInputAction: TextInputAction.send, + decoration: InputDecoration( + hintText: 'Ask the assistant…', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + ), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + tooltip: isStreaming ? 'Stop' : 'Send', + icon: Icon(isStreaming ? Icons.stop : Icons.send), + onPressed: isStreaming ? onStop : onSend, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/chat/presentation/widgets/chat_stream_view.dart b/lib/features/chat/presentation/widgets/chat_stream_view.dart new file mode 100644 index 0000000..e3a8afb --- /dev/null +++ b/lib/features/chat/presentation/widgets/chat_stream_view.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +/// Renders the accumulating assistant message as tokens stream in. +/// +/// Shows a subtle progress indicator while [isStreaming] is true and renders +/// [text] incrementally, so text appears before the full response completes. +class ChatStreamView extends StatelessWidget { + const ChatStreamView({ + required this.text, + this.isStreaming = false, + super.key, + }); + + /// The accumulated message text. + final String text; + + /// Whether tokens are still streaming in. + final bool isStreaming; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isEmpty = text.isEmpty; + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (isStreaming) + Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 8), + Text( + 'Streaming…', + style: theme.textTheme.labelMedium, + ), + ], + ) + else + Text( + 'Assistant', + style: theme.textTheme.labelLarge, + ), + const SizedBox(height: 12), + Text( + isEmptyHint ? 'Waiting for response…' : text, + style: theme.textTheme.bodyLarge, + ), + ], + ), + ), + ), + ); + } + + bool get isEmptyHint => text.isEmpty && !isStreaming; +} diff --git a/test/core/network/sse_parser_test.dart b/test/core/network/sse_parser_test.dart new file mode 100644 index 0000000..b81a7c1 --- /dev/null +++ b/test/core/network/sse_parser_test.dart @@ -0,0 +1,129 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_template/core/network/sse_parser.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Stream _parse(String raw) { + return parseSse(Stream.fromIterable([raw])); +} + +Future> _collect(Stream stream) { + return stream.toList(); +} + +void main() { + group('SseParser', () { + test('parses a simple data event', () async { + final events = await _collect(_parse('data: hello\n\n')); + expect(events, hasLength(1)); + expect(events.single.isData, isTrue); + expect(events.single.data, 'hello'); + }); + + test('joins multi-line data fields with newline', () async { + final events = await _collect(_parse('data: line1\ndata: line2\n\n')); + expect(events.single.data, 'line1\nline2'); + }); + + test('strips a single leading space after the field name', () async { + final events = await _collect(_parse('data: hello world\n\n')); + expect(events.single.data, 'hello world'); + }); + + test('emits an empty data event for a bare data: line', () async { + final events = await _collect(_parse('data:\n\n')); + expect(events.single.isData, isTrue); + expect(events.single.data, ''); + }); + + test('surfaces the [DONE] sentinel as a done event, not data', () async { + final events = await _collect(_parse('data: hello\n\ndata: [DONE]\n\n')); + expect(events, hasLength(2)); + expect(events[0].isData, isTrue); + expect(events[0].data, 'hello'); + expect(events[1].isDone, isTrue); + expect(events[1].data, isEmpty); + }); + + test('surfaces event: done as a done event', () async { + final events = await _collect(_parse('event: done\n\n')); + expect(events.single.isDone, isTrue); + }); + + test('surfaces event: error as an error event', () async { + final events = await _collect( + _parse('event: error\ndata: upstream failure\n\n'), + ); + expect(events.single.isError, isTrue); + expect(events.single.data, 'upstream failure'); + }); + + test('ignores comment lines', () async { + final events = await _collect(_parse(': keepalive\n: ping\n\n')); + expect(events, isEmpty); + }); + + test('ignores comments but still parses a following data event', () async { + final events = await _collect(_parse(': keepalive\ndata: token\n\n')); + expect(events, hasLength(1)); + expect(events.single.data, 'token'); + }); + + test('carries event: and id: fields on the emitted event', () async { + final events = await _collect( + _parse('id: 42\nevent: message\ndata: hello\n\n'), + ); + expect(events.single.data, 'hello'); + expect(events.single.event, 'message'); + expect(events.single.id, '42'); + }); + + test('parses multiple events in one stream', () async { + final events = await _collect( + _parse('data: one\n\ndata: two\n\ndata: three\n\n'), + ); + expect(events.map((e) => e.data), ['one', 'two', 'three']); + }); + + test('buffers an event split across chunk boundaries', () async { + // The event is delivered in pieces, including the \n\n boundary. + final stream = Stream.fromIterable([ + 'data: Hel', + 'lo Wo', + 'rld\n\ndata: next\n', + '\n', + ]); + final events = await _collect(parseSse(stream)); + expect(events, hasLength(2)); + expect(events[0].data, 'Hello World'); + expect(events[1].data, 'next'); + }); + + test('handles a trailing event with no terminating blank line', () async { + final events = await _collect(_parse('data: trailing')); + expect(events, hasLength(1)); + expect(events.single.data, 'trailing'); + }); + + test('normalizes CRLF line endings', () async { + final events = await _collect(parseSse(Stream.fromIterable([ + 'data: hello\r\n\r\n', + 'data: world\r\n\r\n', + ]))); + expect(events.map((e) => e.data), ['hello', 'world']); + }); + + test('decodes UTF-8 split across chunk boundaries', () async { + final smiley = utf8.encode('data: \u{1F600}\n\n'); + // Split the multi-byte emoji so the first chunk ends mid-code-point. + final splitPoint = smiley.indexOf(utf8.encode('😀').first) + 2; + final stream = Stream.fromIterable([ + smiley.sublist(0, splitPoint), + smiley.sublist(splitPoint), + ]); + final events = await _collect(parseSseBytes(stream)); + expect(events.single.data, '😀'); + }); + }); +} diff --git a/test/core/network/streaming_client_test.dart b/test/core/network/streaming_client_test.dart new file mode 100644 index 0000000..8096659 --- /dev/null +++ b/test/core/network/streaming_client_test.dart @@ -0,0 +1,239 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_template/core/connectivity/connectivity_service.dart'; +import 'package:flutter_template/core/network/dio_client.dart'; +import 'package:flutter_template/core/network/sse_parser.dart'; +import 'package:flutter_template/core/network/streaming_client.dart'; +import 'package:flutter_template/core/utils/result.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../helpers/auth_helpers.dart'; + +/// A fake Dio `HttpClientAdapter` that captures the outgoing [RequestOptions] +/// and replays a scripted SSE body (data chunks and/or an injected error). +class _FakeAdapter implements HttpClientAdapter { + _FakeAdapter(List<_StreamEvent> events) : _events = events; + + final List<_StreamEvent> _events; + RequestOptions? lastRequestOptions; + bool called = false; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + called = true; + lastRequestOptions = options; + return ResponseBody( + _buildBodyStream(), + 200, + headers: const { + Headers.contentTypeHeader: ['text/event-stream'], + }, + ); + } + + Stream _buildBodyStream() async* { + for (final event in _events) { + if (event.delay != null) await Future.delayed(event.delay!); + if (event.error != null) throw event.error!; + yield Uint8List.fromList(event.bytes); + } + } + + @override + void close({bool force = false}) {} +} + +class _StreamEvent { + _StreamEvent.data(String text, {this.delay}) + : bytes = utf8.encode(text), + error = null; + _StreamEvent.throwError(this.error) + : bytes = const [], + delay = null; + + final List bytes; + final Duration? delay; + final Error? error; +} + +class MockConnectivityService extends Mock implements ConnectivityService {} + +Logger get _logger => Logger(printer: SimplePrinter(colors: false)); + +StreamingClient _buildClient( + _FakeAdapter adapter, { + bool isOffline = false, + bool isPoor = false, +}) { + final authManager = MockAuthTokenManager(); + when(() => authManager.isTokenExpired()).thenAnswer((_) async => false); + when(() => authManager.getAccessToken()).thenAnswer((_) async => null); + + final dio = Dio()..httpClientAdapter = adapter; + final dioClient = DioClient( + dio: dio, + logger: _logger, + authManager: authManager, + ); + + final connectivity = MockConnectivityService(); + when(() => connectivity.isOffline).thenReturn(isOffline); + when(() => connectivity.isPoor).thenReturn(isPoor); + when(() => connectivity.isOnline).thenReturn(!isOffline && !isPoor); + + return StreamingClient( + dioClient: dioClient, + connectivity: connectivity, + logger: _logger, + ); +} + +void main() { + group('StreamingClient', () { + test( + 'overrides the global receiveTimeout and Accept header (BLOCKER B-1)', + () async { + final adapter = _FakeAdapter([ + _StreamEvent.data('data: hello\n\ndata: [DONE]\n\n'), + ]); + final client = _buildClient(adapter); + + final tokens = await client + .stream('/chat') + .where((r) => r.isSuccess) + .map((r) => r.dataOrNull) + .toList(); + + expect(tokens, ['hello']); + + final opts = adapter.lastRequestOptions!; + // The two pieces of the BLOCKER fix must be on the actual request. + expect(opts.responseType, ResponseType.stream); + expect(opts.receiveTimeout, Duration.zero, + reason: 'null would silently fall back to the base 30s in Dio 5.x'); + expect(opts.sendTimeout, Duration.zero); + + final acceptKey = + opts.headers.keys.firstWhere((k) => k.toLowerCase() == 'accept'); + expect(opts.headers[acceptKey], 'text/event-stream'); + }, + ); + + test( + 'a sparse stream with gaps between tokens does not throw a ' + 'receiveTimeout error', () async { + final adapter = _FakeAdapter([ + _StreamEvent.data('data: one\n\n', + delay: const Duration(milliseconds: 30)), + _StreamEvent.data('data: two\n\n', + delay: const Duration(milliseconds: 30)), + _StreamEvent.data('data: [DONE]\n\n'), + ]); + final client = _buildClient(adapter); + + final tokens = await client + .stream('/chat') + .where((r) => r.isSuccess) + .map((r) => r.dataOrNull) + .toList(); + + expect(tokens, ['one', 'two']); + }); + + test( + 'reuses the injected DioClient (baseUrl applied, no fresh Dio) ' + '(C-3)', () async { + final adapter = _FakeAdapter([ + _StreamEvent.data('data: hi\n\ndata: [DONE]\n\n'), + ]); + final client = _buildClient(adapter); + + await client.stream('/chat').toList(); + + // The request was routed through the configured DioClient's Dio. + expect(adapter.called, isTrue); + final opts = adapter.lastRequestOptions!; + expect(opts.uri.path, contains('/chat')); + expect(opts.uri.scheme, 'https'); + expect(opts.uri.host, 'api.example.com', + reason: 'baseUrl from DioClient must be preserved'); + }); + + test( + 'emits loading first, then one success per token, [DONE] not a token ' + '(C-1)', () async { + final adapter = _FakeAdapter([ + _StreamEvent.data('data: a\n\ndata: b\n\ndata: [DONE]\n\n'), + ]); + final client = _buildClient(adapter); + + final results = await client.stream('/chat').toList(); + + expect(results.first.isLoading, isTrue); + final tokens = + results.where((r) => r.isSuccess).map((r) => r.dataOrNull).toList(); + expect(tokens, ['a', 'b']); + // [DONE] is a distinct terminal marker, never emitted as a token. + expect( + results.any((r) => r.isSuccess && r.dataOrNull == kSseDoneSentinel), + isFalse); + // Stream ended normally after [DONE]. + expect(results.last.isSuccess, isTrue); + }); + + test('a mid-stream error becomes a terminal Result.failure (C-1)', + () async { + final adapter = _FakeAdapter([ + _StreamEvent.data('data: partial\n\n'), + _StreamEvent.throwError(StateError('connection reset')), + ]); + final client = _buildClient(adapter); + + final results = await client.stream('/chat').toList(); + + final tokens = + results.where((r) => r.isSuccess).map((r) => r.dataOrNull).toList(); + expect(tokens, ['partial']); + expect(results.last.isFailure, isTrue); + expect(results.last.errorOrNull, contains('Streaming failed')); + // The stream terminated — nothing is left hanging. + }); + + test('emits an immediate failure when offline and issues no request (C-4)', + () async { + final adapter = _FakeAdapter([ + _StreamEvent.data('data: x\n\n'), + ]); + final client = _buildClient(adapter, isOffline: true); + + final results = await client.stream('/chat').toList(); + + expect(results, hasLength(1)); + expect(results.single.isFailure, isTrue); + expect(adapter.called, isFalse, + reason: 'no request may be enqueued or issued offline'); + }); + + test('emits an immediate failure when connectivity is poor (C-4)', + () async { + final adapter = _FakeAdapter([ + _StreamEvent.data('data: x\n\n'), + ]); + final client = _buildClient(adapter, isPoor: true); + + final results = await client.stream('/chat').toList(); + + expect(results.single.isFailure, isTrue); + expect(adapter.called, isFalse); + }); + }); +} diff --git a/test/features/chat/chat_bloc_test.dart b/test/features/chat/chat_bloc_test.dart new file mode 100644 index 0000000..d61359c --- /dev/null +++ b/test/features/chat/chat_bloc_test.dart @@ -0,0 +1,170 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_template/core/connectivity/connectivity_bloc.dart'; +import 'package:flutter_template/core/connectivity/connectivity_state.dart'; +import 'package:flutter_template/core/utils/result.dart'; +import 'package:flutter_template/features/chat/data/repositories/chat_repository.dart'; +import 'package:flutter_template/features/chat/presentation/bloc/chat_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockChatRepository extends Mock implements ChatRepository {} + +class MockConnectivityBloc + extends MockBloc + implements ConnectivityBloc {} + +void main() { + late ChatBloc bloc; + late MockChatRepository repository; + late MockConnectivityBloc connectivityBloc; + + setUp(() { + repository = MockChatRepository(); + connectivityBloc = MockConnectivityBloc(); + + when(() => connectivityBloc.state).thenReturn( + const ConnectivityState.online(), + ); + when(() => connectivityBloc.stream).thenAnswer( + (_) => const Stream.empty(), + ); + + bloc = ChatBloc( + repository: repository, + connectivityBloc: connectivityBloc, + ); + + streamController = null; + capturedToken = null; + }); + + tearDown(() { + bloc.close(); + }); + + group('ChatBloc', () { + test('initial state is ChatState.initial', () { + expect(bloc.state, const ChatState.initial()); + }); + + group('send', () { + blocTest( + 'accumulates tokens across stream events before completion (N-1)', + build: () { + when( + () => repository.stream( + any(), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) => Stream>.fromIterable(const [ + Result.success('Hel'), + Result.success('lo'), + Result.success('!'), + ]), + ); + return bloc; + }, + act: (bloc) => bloc.add(const ChatEvent.send('hi')), + expect: () => [ + const ChatState.loading(), + const ChatState.streaming('Hel'), + const ChatState.streaming('Hello'), + const ChatState.streaming('Hello!'), + const ChatState.completed('Hello!'), + ], + ); + + blocTest( + 'does not mark a half-accumulated message success on mid-stream ' + 'failure (C-1)', + build: () { + when( + () => repository.stream( + any(), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) => Stream>.fromIterable(const [ + Result.success('partial'), + Result.failure('Network error'), + ]), + ); + return bloc; + }, + act: (bloc) => bloc.add(const ChatEvent.send('hello')), + expect: () => [ + const ChatState.loading(), + const ChatState.streaming('partial'), + const ChatState.error('Network error'), + ], + ); + + blocTest( + 'halts accumulation and stops at the max-length bound (C-2)', + build: () { + final big = 'a' * kMaxMessageChars; + when( + () => repository.stream( + any(), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) => Stream>.fromIterable( + >[ + Result.success(big), + const Result.success('bbbb'), + ], + ), + ); + return bloc; + }, + act: (bloc) => bloc.add(const ChatEvent.send('hello')), + expect: () => [ + const ChatState.loading(), + ChatState.streaming('a' * kMaxMessageChars), + // Accumulation halted at the bound: the trailing 'bbbb' was ignored + // and the stream was stopped, not completed. + ChatState.stopped('a' * kMaxMessageChars), + ], + ); + }); + + group('stop', () { + blocTest( + 'cancels the in-flight CancelToken', + build: () { + streamController = StreamController>(); + when( + () => repository.stream( + any(), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer((invocation) { + capturedToken = + invocation.namedArguments[#cancelToken] as CancelToken?; + return streamController!.stream; + }); + return bloc; + }, + act: (bloc) async { + bloc.add(const ChatEvent.send('hello')); + await Future.delayed(const Duration(milliseconds: 20)); + bloc.add(const ChatEvent.stop()); + await Future.delayed(const Duration(milliseconds: 20)); + await streamController!.close(); + }, + verify: (_) { + expect(capturedToken, isNotNull); + expect(capturedToken!.isCancelled, isTrue); + }, + ); + }); + }); +} + +StreamController>? streamController; +CancelToken? capturedToken; diff --git a/test/helpers/auth_helpers.dart b/test/helpers/auth_helpers.dart new file mode 100644 index 0000000..7be4dcc --- /dev/null +++ b/test/helpers/auth_helpers.dart @@ -0,0 +1,6 @@ +import 'package:flutter_template/core/network/auth_token_manager.dart'; +import 'package:mocktail/mocktail.dart'; + +/// Mock [AuthTokenManager] so network tests can run the `AuthInterceptor` +/// without touching secure storage. +class MockAuthTokenManager extends Mock implements AuthTokenManager {}