diff --git a/demo/lib/main.dart b/demo/lib/main.dart index 70cbfb5..3f5cd5b 100644 --- a/demo/lib/main.dart +++ b/demo/lib/main.dart @@ -881,7 +881,8 @@ class GalleryScreen extends StatelessWidget { style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, - color: Color(0xFF9893A8), + // #9893A8 on white was ~3:1 at 12px; this passes AA (~7:1). + color: Color(0xFF5B5570), letterSpacing: 0.2, ), ), diff --git a/demo/screenshots/element_branch.png b/demo/screenshots/element_branch.png index 174eb65..900a5bc 100644 Binary files a/demo/screenshots/element_branch.png and b/demo/screenshots/element_branch.png differ diff --git a/demo/screenshots/element_confirmation.png b/demo/screenshots/element_confirmation.png index 38aceba..ae94409 100644 Binary files a/demo/screenshots/element_confirmation.png and b/demo/screenshots/element_confirmation.png differ diff --git a/packages/flutter_ai_client/CHANGELOG.md b/packages/flutter_ai_client/CHANGELOG.md index c2cab4b..2af1d96 100644 --- a/packages/flutter_ai_client/CHANGELOG.md +++ b/packages/flutter_ai_client/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.3.1 + +- Fix: settle dangling tool calls before `submit`/`regenerate`/`editMessage`. A + turn interrupted mid agent-loop (`stop()`, a `maxSteps` cutoff, or a + transcript rehydrated from storage) left a `ToolCallPart` with no matching + `ToolResultPart`; providers reject such a history, so every later `submit` + failed with a request error. The controller now inserts a synthesized error + result immediately after each affected assistant message — in place, not + appended, since providers require the result in the following turn. + Thanks to @HeZeBang for the report and the fix. (#149) + ## 0.3.0 - Add `UseChatController.load(AiConversation)` — swaps the transcript in place diff --git a/packages/flutter_ai_client/lib/src/use_chat_controller.dart b/packages/flutter_ai_client/lib/src/use_chat_controller.dart index 523a91b..5925c32 100644 --- a/packages/flutter_ai_client/lib/src/use_chat_controller.dart +++ b/packages/flutter_ai_client/lib/src/use_chat_controller.dart @@ -244,7 +244,9 @@ class UseChatController extends ChangeNotifier { _capture = _Capture.reset; // a new user turn starts a fresh branch set _step = 0; _toolCallCounts.clear(); - _processor.reset(_processor.conversation.append(userMessage)); + _processor.reset( + _settleDanglingToolCalls(_processor.conversation).append(userMessage), + ); _status = ChatStatus.submitted; _scheduleNotify(); return _beginTurn(); @@ -263,7 +265,10 @@ class UseChatController extends ChangeNotifier { _step = 0; _toolCallCounts.clear(); _processor.reset( - _processor.conversation.copyWith(messages: all.sublist(0, lastUser + 1)), + _settleDanglingToolCalls( + _processor.conversation + .copyWith(messages: all.sublist(0, lastUser + 1)), + ), ); _status = ChatStatus.submitted; _scheduleNotify(); @@ -308,11 +313,13 @@ class UseChatController extends ChangeNotifier { _step = 0; _toolCallCounts.clear(); _processor.reset( - _processor.conversation.copyWith( - messages: [ - ...all.sublist(0, index), - original.copyWith(parts: parts, status: AiMessageStatus.complete), - ], + _settleDanglingToolCalls( + _processor.conversation.copyWith( + messages: [ + ...all.sublist(0, index), + original.copyWith(parts: parts, status: AiMessageStatus.complete), + ], + ), ), ); _status = ChatStatus.submitted; @@ -655,6 +662,51 @@ class UseChatController extends ChangeNotifier { _dispatch(); } + /// Settles tool calls left unanswered anywhere in the transcript — a turn + /// stopped/replaced mid agent-loop, `maxSteps` cutting a loop short, or a + /// dirty transcript rehydrated from storage — by inserting a synthesized + /// error [ToolResultPart] message directly after each affected assistant + /// message. Providers reject a history containing a tool call with no + /// result in the immediately-following turn (so the fix-up must be inserted + /// in place, not appended at the end), and without it every subsequent + /// [submit] on the conversation fails with a request error. + AiConversation _settleDanglingToolCalls(AiConversation conversation) { + final msgs = conversation.messages; + final answered = { + for (final m in msgs) + for (final p in m.parts) + if (p is ToolResultPart) p.toolCallId, + }; + var changed = false; + final out = []; + for (final m in msgs) { + out.add(m); + if (m.role != AiRole.assistant) continue; + final dangling = m.parts + .whereType() + .where((c) => !answered.contains(c.toolCallId)) + .toList(); + if (dangling.isEmpty) continue; + changed = true; + out.add( + AiMessage( + id: _newId(), + role: AiRole.tool, + parts: [ + for (final call in dangling) + ToolResultPart( + toolCallId: call.toolCallId, + isError: true, + result: 'Cancelled: the turn was interrupted before this tool ' + 'call produced a result.', + ), + ], + ), + ); + } + return changed ? conversation.copyWith(messages: out) : conversation; + } + /// Tool calls in the latest assistant message that have no matching /// [ToolResultPart] anywhere in the transcript yet. /// A stable identity for a tool call — name plus JSON-encoded args — used to diff --git a/packages/flutter_ai_client/pubspec.yaml b/packages/flutter_ai_client/pubspec.yaml index e11716f..bd3889d 100644 --- a/packages/flutter_ai_client/pubspec.yaml +++ b/packages/flutter_ai_client/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_ai_client description: "Provider-agnostic chat controller for flutter_ai: wraps any LlmProvider with optimistic send, cancellation, regeneration, and frame-batched streaming." -version: 0.3.0 +version: 0.3.1 repository: https://github.com/ananmouaz/flutter_ai/tree/main/packages/flutter_ai_client issue_tracker: https://github.com/ananmouaz/flutter_ai/issues homepage: https://github.com/ananmouaz/flutter_ai diff --git a/packages/flutter_ai_client/test/use_chat_controller_test.dart b/packages/flutter_ai_client/test/use_chat_controller_test.dart index 5ff4ec5..802b45b 100644 --- a/packages/flutter_ai_client/test/use_chat_controller_test.dart +++ b/packages/flutter_ai_client/test/use_chat_controller_test.dart @@ -953,6 +953,103 @@ void main() { hasLength(1), ); }); + + test('submit settles dangling tool calls with synthesized error results', + () async { + // Turn 1 ends with an unanswered tool call (manual mode, never + // executed). Submitting a new user message must first append error + // results for it — providers reject a history containing a tool call + // with no following result. + final provider = _ToolThenTextProvider(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + ); + addTearDown(controller.dispose); + + await controller.sendText('weather?'); + await controller.sendText('never mind'); + + final messages = controller.messages; + // user, assistant(tool call), tool(synthesized error), user, assistant. + expect(messages.map((m) => m.role).toList(), [ + AiRole.user, + AiRole.assistant, + AiRole.tool, + AiRole.user, + AiRole.assistant, + ]); + final settled = messages[2].parts.whereType().single; + expect(settled.toolCallId, 'c1'); + expect(settled.isError, isTrue); + }); + + test('submit settles dangling tool calls buried mid-history', () async { + // A rehydrated transcript where an interrupted agent loop left an + // unanswered tool call in the MIDDLE of the history (later turns + // completed normally). The settle must insert the synthesized result + // directly after the affected assistant message — providers require the + // result in the immediately-following turn, so appending at the end + // would not fix the request. + const dirty = AiConversation( + id: 'thread-dirty', + messages: [ + AiMessage(id: 'u1', role: AiRole.user, parts: [TextPart('q1')]), + AiMessage( + id: 'a1', + role: AiRole.assistant, + parts: [ + ToolCallPart( + toolCallId: 'c9', + toolName: 'get_week_schedule', + args: {}, + ), + ], + status: AiMessageStatus.complete, + ), + AiMessage(id: 'u2', role: AiRole.user, parts: [TextPart('q2')]), + AiMessage( + id: 'a2', + role: AiRole.assistant, + parts: [TextPart('answer 2')], + status: AiMessageStatus.complete, + ), + ], + ); + final provider = ScriptedProvider(const [ + MessageStarted(messageId: 'a3', role: AiRole.assistant), + TextDelta(messageId: 'a3', delta: 'answer 3'), + MessageFinished(messageId: 'a3', reason: FinishReason.stop), + ]); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + initial: dirty, + ); + addTearDown(controller.dispose); + + await controller.sendText('q3'); + + final messages = controller.messages; + // u1, a1(tool call), tool(synthesized), u2, a2, u3, a3. + expect(messages.map((m) => m.role).toList(), [ + AiRole.user, + AiRole.assistant, + AiRole.tool, + AiRole.user, + AiRole.assistant, + AiRole.user, + AiRole.assistant, + ]); + final settled = messages[2].parts.whereType().single; + expect(settled.toolCallId, 'c9'); + expect(settled.isError, isTrue); + // The provider must have been sent the settled history too. + final sent = provider.lastConversation!.messages; + expect(sent[2].role, AiRole.tool); + }); }); group('threads', () { diff --git a/packages/flutter_ai_elements/CHANGELOG.md b/packages/flutter_ai_elements/CHANGELOG.md index e2d970b..381ef29 100644 --- a/packages/flutter_ai_elements/CHANGELOG.md +++ b/packages/flutter_ai_elements/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## 0.3.0 + +Accessibility, internationalization, and cross-fork compile fixes. + +- Fix: `aiLightHaptic` compiles against both upstream Flutter and the OHOS + Flutter fork. The exhaustive `TargetPlatform` switch could not cover both + enums at once (the fork adds `TargetPlatform.ohos`), so it is now an + iOS/Android allowlist. Thanks to @HeZeBang. (#149) +- Fix: strings that bypassed `AiLocalizations` are now localizable — tool-call + section labels (`toolArguments`/`toolResult`/`toolError`), the context meter + (`contextLabel`), source expand/collapse (`moreSources`/`showLess`), live + voice statuses (`liveConnecting`/`liveListening`/`liveThinking`/ + `liveSpeaking`), the empty-state headline (`emptyStateTitle`), the inline + citation and model-selector semantics labels (`citation`/`selectModel`), and + the attachment remove badge (`removeAttachment`). (#128) +- Fix: touch targets below the 44px platform minimum — the attachment remove + badge and `AiBranch`'s navigation arrows now use `InkResponse` over a 44px + box, which also gives them focus traversal and keyboard activation. + `AiModelSelector`'s trigger moves from `GestureDetector` to `InkWell` for the + same reason. (#128) +- Fix: the caution-tone confirm button painted white on amber (~2.2:1). The + label color is now derived from the fill's brightness, and the buttons use a + `minHeight` instead of a fixed 40px height so labels are not clipped at large + text scales. (#128) +- Fix: RTL gaps — the streaming loader alignment, the "New chat" label, and the + Markdown blockquote's border/padding are now directional. (#128) +- **Potentially source-breaking:** `AiChainOfThought.title`, + `AiContextMeter.label`, `AiEmptyState.title`, and `AiComposer.hintText` are + now `String?` and default to the matching `AiLocalizations` value. Passing a + string still works; only reading one of these fields as a non-nullable + `String` needs a change. + ## 0.2.0 Extensibility release — driven by dogfooding a full Gemini-clone app on the diff --git a/packages/flutter_ai_elements/lib/src/l10n/ai_localizations.dart b/packages/flutter_ai_elements/lib/src/l10n/ai_localizations.dart index a7da073..8d49f32 100644 --- a/packages/flutter_ai_elements/lib/src/l10n/ai_localizations.dart +++ b/packages/flutter_ai_elements/lib/src/l10n/ai_localizations.dart @@ -49,6 +49,20 @@ class AiLocalizations { this.loading = 'Loading', this.you = 'You', this.assistant = 'Assistant', + this.toolArguments = 'Arguments', + this.toolResult = 'Result', + this.toolError = 'Error', + this.contextLabel = 'Context', + this.showLess = 'Show less', + this.moreSources = _defaultMoreSources, + this.removeAttachment = 'Remove attachment', + this.liveConnecting = 'Connecting…', + this.liveListening = 'Listening', + this.liveThinking = 'Thinking…', + this.liveSpeaking = 'Speaking', + this.emptyStateTitle = 'Start the conversation', + this.citation = _defaultCitation, + this.selectModel = _defaultSelectModel, }); /// Copy-to-clipboard action. @@ -138,6 +152,56 @@ class AiLocalizations { /// Avatar accessibility label for the assistant. final String assistant; + /// Section title for a tool call's arguments. + final String toolArguments; + + /// Section title for a tool call's successful result. + final String toolResult; + + /// Section title for a tool call's error result. + final String toolError; + + /// Leading label on the context-window meter. + final String contextLabel; + + /// Collapse action on a list that was expanded (e.g. sources). + final String showLess; + + /// Expand action on a collapsed source list, given the hidden chip count — + /// a callback rather than a string so translations can decline the number. + final String Function(int hiddenCount) moreSources; + + /// Accessibility label on the remove badge of a staged attachment. + final String removeAttachment; + + /// Live-voice status while the session is connecting. + final String liveConnecting; + + /// Live-voice status while the mic is open. + final String liveListening; + + /// Live-voice status while the model is generating. + final String liveThinking; + + /// Live-voice status while the model is talking. + final String liveSpeaking; + + /// Default headline on the empty conversation state. + final String emptyStateTitle; + + /// Accessibility label for an inline citation badge, given its number. + final String Function(int number) citation; + + /// Accessibility label for the model selector, given the current model. + final String Function(String modelLabel) selectModel; + + static String _defaultMoreSources(int hiddenCount) => '+$hiddenCount more'; + + static String _defaultCitation(int number) => 'Citation $number'; + + static String _defaultSelectModel(String modelLabel) => + 'Select model, $modelLabel'; + /// The nearest [AiLocalizations]. Resolution order: an [AiLocalizationsScope] /// in the tree (the simplest way to override — no delegate wiring), then a /// `Localizations` delegate, then the English defaults. diff --git a/packages/flutter_ai_elements/lib/src/widgets/ai_branch.dart b/packages/flutter_ai_elements/lib/src/widgets/ai_branch.dart index 50b948d..d7c11c1 100644 --- a/packages/flutter_ai_elements/lib/src/widgets/ai_branch.dart +++ b/packages/flutter_ai_elements/lib/src/widgets/ai_branch.dart @@ -83,14 +83,21 @@ class _Arrow extends StatelessWidget { button: true, enabled: onTap != null, label: label, - child: GestureDetector( + // InkResponse over a 44px box: the arrow paints at 18px, but branch + // navigation needs a platform-minimum touch target — plus focus + // traversal and keyboard activation, which a GestureDetector lacks. + child: InkResponse( onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(4), - child: Icon( - icon, - size: 18, - color: onTap == null ? color?.withValues(alpha: 0.3) : color, + radius: 22, + child: SizedBox( + width: 44, + height: 44, + child: Center( + child: Icon( + icon, + size: 18, + color: onTap == null ? color?.withValues(alpha: 0.3) : color, + ), ), ), ), diff --git a/packages/flutter_ai_elements/lib/src/widgets/ai_chain_of_thought.dart b/packages/flutter_ai_elements/lib/src/widgets/ai_chain_of_thought.dart index 4dae830..a36c333 100644 --- a/packages/flutter_ai_elements/lib/src/widgets/ai_chain_of_thought.dart +++ b/packages/flutter_ai_elements/lib/src/widgets/ai_chain_of_thought.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; /// One step in an [AiChainOfThought]. @@ -30,15 +31,16 @@ class AiChainOfThought extends StatefulWidget { const AiChainOfThought({ super.key, required this.steps, - this.title = 'Chain of thought', + this.title, this.initiallyExpanded = false, }); /// The ordered steps. final List steps; - /// The disclosure label. - final String title; + /// The disclosure label. Defaults to `AiLocalizations.chainOfThought`, so a + /// translated [AiLocalizations] reaches it without passing a title here. + final String? title; /// Whether the timeline starts expanded. final bool initiallyExpanded; @@ -71,7 +73,7 @@ class _AiChainOfThoughtState extends State { Icon(Icons.account_tree_outlined, size: 16, color: subdued), const SizedBox(width: 6), Text( - widget.title, + widget.title ?? AiLocalizations.of(context).chainOfThought, style: TextStyle( color: subdued, fontWeight: FontWeight.w600, diff --git a/packages/flutter_ai_elements/lib/src/widgets/ai_composer.dart b/packages/flutter_ai_elements/lib/src/widgets/ai_composer.dart index 68305dc..d65f221 100644 --- a/packages/flutter_ai_elements/lib/src/widgets/ai_composer.dart +++ b/packages/flutter_ai_elements/lib/src/widgets/ai_composer.dart @@ -20,7 +20,7 @@ class AiComposer extends StatefulWidget { required this.onSend, this.onStop, this.isBusy = false, - this.hintText = 'Message', + this.hintText, this.controller, this.enabled = true, this.onAttach, @@ -39,8 +39,8 @@ class AiComposer extends StatefulWidget { /// Whether a response is in flight; the main button shows Stop. final bool isBusy; - /// Placeholder text. - final String hintText; + /// Placeholder text. Defaults to `AiLocalizations.messageHint`. + final String? hintText; /// Optional external text controller. final TextEditingController? controller; @@ -201,7 +201,7 @@ class _AiComposerState extends State { textInputAction: TextInputAction.send, onSubmitted: widget.enabled ? (_) => _handleSend() : null, decoration: InputDecoration( - hintText: widget.hintText, + hintText: widget.hintText ?? l.messageHint, hintStyle: theme.textStyle.copyWith( color: theme.assistantTextColor.withValues(alpha: 0.45), ), @@ -419,27 +419,56 @@ class _AttachmentPreview extends StatelessWidget { } if (onRemove == null) return content; - return Stack( - clipBehavior: Clip.none, - children: [ - content, - PositionedDirectional( - top: -6, - end: -6, - child: GestureDetector( - onTap: onRemove, - child: Container( - decoration: BoxDecoration( - color: theme.accentColor, - shape: BoxShape.circle, - border: Border.all(color: theme.onAccentColor, width: 1.5), + // The remove badge paints at ~20px but needs a 44px touch target. The + // padding reserves that room around the thumbnail: a hit area that merely + // overhung the Stack would be clipped by the ancestor's bounds check, so + // the target has to sit inside the widget's own box to be tappable. + return Padding( + padding: const EdgeInsetsDirectional.only(top: 12, end: 12), + child: Stack( + clipBehavior: Clip.none, + children: [ + content, + PositionedDirectional( + top: -12, + end: -12, + child: Semantics( + button: true, + label: AiLocalizations.of(context).removeAttachment, + // InkResponse, not a bare GestureDetector: focus traversal, + // keyboard activation (Enter/Space), hover and a ripple. + child: InkResponse( + onTap: onRemove, + radius: 22, + containedInkWell: false, + child: SizedBox( + width: 44, + height: 44, + child: Align( + alignment: AlignmentDirectional.topEnd, + child: Container( + decoration: BoxDecoration( + color: theme.accentColor, + shape: BoxShape.circle, + border: Border.all( + color: theme.onAccentColor, + width: 1.5, + ), + ), + padding: const EdgeInsets.all(2), + child: Icon( + Icons.close, + size: 12, + color: theme.onAccentColor, + ), + ), + ), + ), ), - padding: const EdgeInsets.all(2), - child: Icon(Icons.close, size: 12, color: theme.onAccentColor), ), ), - ), - ], + ], + ), ); } } diff --git a/packages/flutter_ai_elements/lib/src/widgets/ai_confirmation.dart b/packages/flutter_ai_elements/lib/src/widgets/ai_confirmation.dart index aca8a65..8b66dd6 100644 --- a/packages/flutter_ai_elements/lib/src/widgets/ai_confirmation.dart +++ b/packages/flutter_ai_elements/lib/src/widgets/ai_confirmation.dart @@ -158,6 +158,15 @@ class _Button extends StatelessWidget { final Color fillColor; final AiThemeExtension theme; + /// The label color for a filled button: the theme's pairing when the fill is + /// the accent, otherwise black/white chosen by the fill's brightness. + Color _onColor(Color fill) { + if (fill == theme.accentColor) return theme.onAccentColor; + return ThemeData.estimateBrightnessForColor(fill) == Brightness.dark + ? Colors.white + : Colors.black; + } + @override Widget build(BuildContext context) { final radius = BorderRadius.circular(12); @@ -178,15 +187,23 @@ class _Button extends StatelessWidget { onTap: onTap, borderRadius: radius, child: Container( - height: 40, + // minHeight, not a fixed height: a hard 40 clips the label at + // larger text scales. + constraints: const BoxConstraints(minHeight: 40), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), alignment: Alignment.center, child: Text( label, + textAlign: TextAlign.center, style: theme.textStyle.copyWith( fontSize: 14, fontWeight: FontWeight.w600, + // The fill follows the tone (amber for caution, red for + // danger), so the "on" color has to be derived from it — + // painting onAccentColor (white) on amber is ~2.2:1, well + // under the 4.5:1 WCAG AA minimum. color: filled - ? theme.onAccentColor + ? _onColor(fillColor) : DefaultTextStyle.of(context).style.color, ), ), diff --git a/packages/flutter_ai_elements/lib/src/widgets/ai_context_meter.dart b/packages/flutter_ai_elements/lib/src/widgets/ai_context_meter.dart index af21eef..e12a34d 100644 --- a/packages/flutter_ai_elements/lib/src/widgets/ai_context_meter.dart +++ b/packages/flutter_ai_elements/lib/src/widgets/ai_context_meter.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; /// A compact context-window usage meter: a label, a `used / total` token @@ -9,7 +10,7 @@ class AiContextMeter extends StatelessWidget { super.key, required this.usedTokens, required this.totalTokens, - this.label = 'Context', + this.label, }); /// Tokens used so far. @@ -18,8 +19,9 @@ class AiContextMeter extends StatelessWidget { /// The context-window size. final int totalTokens; - /// Leading label. - final String label; + /// Leading label. Defaults to `AiLocalizations.contextLabel`, so a translated + /// [AiLocalizations] reaches it without passing a label here. + final String? label; double get _fraction => totalTokens <= 0 ? 0 : (usedTokens / totalTokens).clamp(0, 1); @@ -42,7 +44,7 @@ class AiContextMeter extends StatelessWidget { Row( children: [ Text( - label, + label ?? AiLocalizations.of(context).contextLabel, style: theme.textStyle.copyWith( fontSize: 12, fontWeight: FontWeight.w600, diff --git a/packages/flutter_ai_elements/lib/src/widgets/ai_conversation_list.dart b/packages/flutter_ai_elements/lib/src/widgets/ai_conversation_list.dart index 9fa383c..2c0ca77 100644 --- a/packages/flutter_ai_elements/lib/src/widgets/ai_conversation_list.dart +++ b/packages/flutter_ai_elements/lib/src/widgets/ai_conversation_list.dart @@ -71,7 +71,7 @@ class AiConversationList extends StatelessWidget { onPressed: onNew, icon: const Icon(Icons.add, size: 18), label: Align( - alignment: Alignment.centerLeft, + alignment: AlignmentDirectional.centerStart, child: Text(newChatLabel ?? l.newChat), ), ), diff --git a/packages/flutter_ai_elements/lib/src/widgets/ai_conversation_view.dart b/packages/flutter_ai_elements/lib/src/widgets/ai_conversation_view.dart index 3514a0e..f1fd5b2 100644 --- a/packages/flutter_ai_elements/lib/src/widgets/ai_conversation_view.dart +++ b/packages/flutter_ai_elements/lib/src/widgets/ai_conversation_view.dart @@ -135,7 +135,7 @@ class _AiConversationViewState extends State { } if (index == loaderIndex) { return Align( - alignment: Alignment.centerLeft, + alignment: AlignmentDirectional.centerStart, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: widget.loadingBuilder?.call(context) ?? const AiLoader(), diff --git a/packages/flutter_ai_elements/lib/src/widgets/ai_empty_state.dart b/packages/flutter_ai_elements/lib/src/widgets/ai_empty_state.dart index 0f749cc..790eeaa 100644 --- a/packages/flutter_ai_elements/lib/src/widgets/ai_empty_state.dart +++ b/packages/flutter_ai_elements/lib/src/widgets/ai_empty_state.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; import 'package:flutter_ai_elements/src/widgets/ai_haptics.dart'; @@ -12,7 +13,7 @@ class AiEmptyState extends StatelessWidget { /// Creates an empty state. const AiEmptyState({ super.key, - this.title = 'Start the conversation', + this.title, this.subtitle, this.icon = Icons.chat_bubble_outline, this.glyph, @@ -23,8 +24,8 @@ class AiEmptyState extends StatelessWidget { this.background, }); - /// The primary headline. - final String title; + /// The primary headline. Defaults to `AiLocalizations.emptyStateTitle`. + final String? title; /// Optional supporting line beneath the title. final String? subtitle; @@ -67,7 +68,7 @@ class AiEmptyState extends StatelessWidget { glyph ?? Icon(icon, size: 48, color: muted), const SizedBox(height: 12), Text( - title, + title ?? AiLocalizations.of(context).emptyStateTitle, textAlign: TextAlign.center, style: theme.textStyle .copyWith( diff --git a/packages/flutter_ai_elements/lib/src/widgets/ai_haptics.dart b/packages/flutter_ai_elements/lib/src/widgets/ai_haptics.dart index 4ab6aa5..474b4d6 100644 --- a/packages/flutter_ai_elements/lib/src/widgets/ai_haptics.dart +++ b/packages/flutter_ai_elements/lib/src/widgets/ai_haptics.dart @@ -13,14 +13,11 @@ import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; /// chatter. void aiLightHaptic(AiThemeExtension theme) { if (!theme.enableHaptics || kIsWeb) return; - switch (defaultTargetPlatform) { - case TargetPlatform.iOS: - case TargetPlatform.android: - unawaited(HapticFeedback.lightImpact()); - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.macOS: - case TargetPlatform.windows: - break; + // An allowlist `if` rather than an exhaustive switch: the OHOS Flutter fork + // adds TargetPlatform.ohos, so an exhaustive switch can't compile on both + // it and upstream Flutter at once. + final platform = defaultTargetPlatform; + if (platform == TargetPlatform.iOS || platform == TargetPlatform.android) { + unawaited(HapticFeedback.lightImpact()); } } diff --git a/packages/flutter_ai_elements/lib/src/widgets/ai_inline_citation.dart b/packages/flutter_ai_elements/lib/src/widgets/ai_inline_citation.dart index d5f7384..db9522f 100644 --- a/packages/flutter_ai_elements/lib/src/widgets/ai_inline_citation.dart +++ b/packages/flutter_ai_elements/lib/src/widgets/ai_inline_citation.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; /// A small numbered citation badge (e.g. `1`) shown inline with text or after a @@ -24,7 +25,7 @@ class AiInlineCitation extends StatelessWidget { // fill bounded parents — so this badge intentionally has none.) return Semantics( button: onTap != null, - label: 'Citation $number', + label: AiLocalizations.of(context).citation(number), child: GestureDetector( onTap: onTap, child: Container( diff --git a/packages/flutter_ai_elements/lib/src/widgets/ai_live_session.dart b/packages/flutter_ai_elements/lib/src/widgets/ai_live_session.dart index d4a9afa..2180a42 100644 --- a/packages/flutter_ai_elements/lib/src/widgets/ai_live_session.dart +++ b/packages/flutter_ai_elements/lib/src/widgets/ai_live_session.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'dart:ui' show lerpDouble; import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; /// The phase of a live voice session. @@ -123,11 +124,11 @@ class _AiLiveSessionState extends State super.dispose(); } - String get _label => switch (widget.status) { - AiLiveStatus.connecting => 'Connecting…', - AiLiveStatus.listening => 'Listening', - AiLiveStatus.thinking => 'Thinking…', - AiLiveStatus.speaking => 'Speaking', + String _statusLabel(AiLocalizations l) => switch (widget.status) { + AiLiveStatus.connecting => l.liveConnecting, + AiLiveStatus.listening => l.liveListening, + AiLiveStatus.thinking => l.liveThinking, + AiLiveStatus.speaking => l.liveSpeaking, AiLiveStatus.ended => '', }; @@ -226,7 +227,7 @@ class _AiLiveSessionState extends State child: Opacity( opacity: intro * (1 - dock), child: Text( - _label, + _statusLabel(AiLocalizations.of(context)), textAlign: TextAlign.center, style: theme.textStyle.copyWith( fontSize: 15, diff --git a/packages/flutter_ai_elements/lib/src/widgets/ai_model_selector.dart b/packages/flutter_ai_elements/lib/src/widgets/ai_model_selector.dart index 034edcc..b40179e 100644 --- a/packages/flutter_ai_elements/lib/src/widgets/ai_model_selector.dart +++ b/packages/flutter_ai_elements/lib/src/widgets/ai_model_selector.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; /// A selectable model option. @@ -83,8 +84,11 @@ class AiModelSelector extends StatelessWidget { final color = DefaultTextStyle.of(context).style.color; return Semantics( button: true, - label: 'Select model, ${selected.label}', - child: GestureDetector( + label: AiLocalizations.of(context).selectModel(selected.label), + // InkWell, not a bare GestureDetector: focus traversal and keyboard + // activation for desktop/web. + child: InkWell( + borderRadius: BorderRadius.circular(16), onTap: () => unawaited(_open(context)), child: Container( padding: padding, diff --git a/packages/flutter_ai_elements/lib/src/widgets/ai_response.dart b/packages/flutter_ai_elements/lib/src/widgets/ai_response.dart index 2320827..d291f56 100644 --- a/packages/flutter_ai_elements/lib/src/widgets/ai_response.dart +++ b/packages/flutter_ai_elements/lib/src/widgets/ai_response.dart @@ -194,10 +194,10 @@ class _AiResponseState extends State { ); case _BlockType.quote: return Container( - padding: const EdgeInsets.only(left: 12), + padding: const EdgeInsetsDirectional.only(start: 12), decoration: BoxDecoration( - border: Border( - left: BorderSide(color: theme.borderColor, width: 3), + border: BorderDirectional( + start: BorderSide(color: theme.borderColor, width: 3), ), ), child: Text.rich( diff --git a/packages/flutter_ai_elements/lib/src/widgets/ai_sources.dart b/packages/flutter_ai_elements/lib/src/widgets/ai_sources.dart index bf2843d..9ef8f84 100644 --- a/packages/flutter_ai_elements/lib/src/widgets/ai_sources.dart +++ b/packages/flutter_ai_elements/lib/src/widgets/ai_sources.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; import 'package:flutter_ai_elements/src/widgets/ai_haptics.dart'; @@ -77,7 +78,9 @@ class _AiSourcesState extends State { ), if (collapsible) _SourceChip( - label: _expanded ? 'Show less' : '+$hiddenCount more', + label: _expanded + ? AiLocalizations.of(context).showLess + : AiLocalizations.of(context).moreSources(hiddenCount), icon: _expanded ? Icons.expand_less_rounded : Icons.expand_more_rounded, diff --git a/packages/flutter_ai_elements/lib/src/widgets/ai_tool_invocation.dart b/packages/flutter_ai_elements/lib/src/widgets/ai_tool_invocation.dart index ce4357e..27d11ca 100644 --- a/packages/flutter_ai_elements/lib/src/widgets/ai_tool_invocation.dart +++ b/packages/flutter_ai_elements/lib/src/widgets/ai_tool_invocation.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; /// A collapsible card showing a single tool call: its name, lifecycle state, @@ -37,6 +38,7 @@ class _AiToolInvocationState extends State { @override Widget build(BuildContext context) { final theme = AiThemeExtension.of(context); + final l = AiLocalizations.of(context); final baseColor = DefaultTextStyle.of(context).style.color; final (icon, iconColor) = _statusVisual(context); @@ -99,14 +101,16 @@ class _AiToolInvocationState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ _Section( - label: 'Arguments', + label: l.toolArguments, body: _pretty(widget.call.args), style: theme.codeStyle.copyWith(color: baseColor), ), if (widget.result != null) ...[ const SizedBox(height: 8), _Section( - label: widget.result!.isError ? 'Error' : 'Result', + label: widget.result!.isError + ? l.toolError + : l.toolResult, body: _pretty(widget.result!.result), style: theme.codeStyle.copyWith(color: baseColor), ), diff --git a/packages/flutter_ai_elements/pubspec.yaml b/packages/flutter_ai_elements/pubspec.yaml index 0188e23..272ffe5 100644 --- a/packages/flutter_ai_elements/pubspec.yaml +++ b/packages/flutter_ai_elements/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_ai_elements description: "Composable, themeable Flutter UI for AI chat: conversation view, message bubbles, a streaming-aware composer, and a loader, styled through a mobile-first theme extension." -version: 0.2.0 +version: 0.3.0 homepage: https://github.com/ananmouaz/flutter_ai repository: https://github.com/ananmouaz/flutter_ai/tree/main/packages/flutter_ai_elements issue_tracker: https://github.com/ananmouaz/flutter_ai/issues diff --git a/packages/flutter_ai_elements/test/a11y_i18n_test.dart b/packages/flutter_ai_elements/test/a11y_i18n_test.dart new file mode 100644 index 0000000..fc08468 --- /dev/null +++ b/packages/flutter_ai_elements/test/a11y_i18n_test.dart @@ -0,0 +1,312 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _wrap(Widget child, {TextDirection? direction, double textScale = 1}) => + MaterialApp( + home: Directionality( + textDirection: direction ?? TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(textScale)), + child: Scaffold(body: child), + ), + ), + ); + +const _fr = AiLocalizations( + toolArguments: 'Arguments (fr)', + toolResult: 'Résultat', + toolError: 'Erreur', + contextLabel: 'Contexte', + chainOfThought: 'Fil de pensée', + showLess: 'Voir moins', + removeAttachment: 'Retirer la pièce jointe', +); + +void main() { + group('i18n — strings that used to be hardcoded English', () { + testWidgets('AiToolInvocation section labels come from AiLocalizations', + (tester) async { + Widget card(ToolResultPart? result) => AiToolInvocation( + call: const ToolCallPart( + toolCallId: 'c1', + toolName: 'get_weather', + args: {'city': 'Paris'}, + ), + result: result, + initiallyExpanded: true, + ); + + await tester.pumpWidget(_wrap(card(null))); + expect(find.text('Arguments'), findsOneWidget); + + await tester.pumpWidget( + _wrap( + AiLocalizationsScope( + strings: _fr, + child: card( + const ToolResultPart(toolCallId: 'c1', result: {'temp': 21}), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Arguments (fr)'), findsOneWidget); + expect(find.text('Résultat'), findsOneWidget); + expect(find.text('Result'), findsNothing); + }); + + testWidgets('AiToolInvocation uses the error label for an error result', + (tester) async { + await tester.pumpWidget( + _wrap( + const AiLocalizationsScope( + strings: _fr, + child: AiToolInvocation( + call: ToolCallPart(toolCallId: 'c1', toolName: 't', args: {}), + result: ToolResultPart( + toolCallId: 'c1', + result: 'boom', + isError: true, + ), + initiallyExpanded: true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Erreur'), findsOneWidget); + }); + + testWidgets('AiContextMeter label defaults to l10n and can be overridden', + (tester) async { + const meter = AiContextMeter(usedTokens: 10, totalTokens: 100); + + await tester.pumpWidget(_wrap(meter)); + expect(find.text('Context'), findsOneWidget); + + await tester.pumpWidget( + _wrap(const AiLocalizationsScope(strings: _fr, child: meter)), + ); + expect(find.text('Contexte'), findsOneWidget); + + // An explicit label still wins over the localization. + await tester.pumpWidget( + _wrap( + const AiLocalizationsScope( + strings: _fr, + child: AiContextMeter( + usedTokens: 10, + totalTokens: 100, + label: 'Window', + ), + ), + ), + ); + expect(find.text('Window'), findsOneWidget); + }); + + testWidgets('AiChainOfThought title falls back to l10n.chainOfThought', + (tester) async { + const steps = [AiThoughtStep(label: 'search')]; + + await tester.pumpWidget(_wrap(const AiChainOfThought(steps: steps))); + expect(find.text('Chain of thought'), findsOneWidget); + + await tester.pumpWidget( + _wrap( + const AiLocalizationsScope( + strings: _fr, + child: AiChainOfThought(steps: steps), + ), + ), + ); + expect(find.text('Fil de pensée'), findsOneWidget); + }); + + testWidgets('AiSources expand/collapse labels come from AiLocalizations', + (tester) async { + final sources = [ + for (var i = 0; i < 5; i++) + SourcePart(url: Uri.parse('https://example.com/$i')), + ]; + + await tester.pumpWidget( + _wrap(AiSources(sources: sources, maxVisible: 2)), + ); + expect(find.text('+3 more'), findsOneWidget); + + await tester.pumpWidget( + _wrap( + AiLocalizationsScope( + strings: AiLocalizations( + showLess: _fr.showLess, + moreSources: (n) => 'encore $n', + ), + child: AiSources(sources: sources, maxVisible: 2), + ), + ), + ); + expect(find.text('encore 3'), findsOneWidget); + + await tester.tap(find.text('encore 3')); + await tester.pumpAndSettle(); + expect(find.text('Voir moins'), findsOneWidget); + }); + }); + + group('a11y — touch targets and contrast', () { + testWidgets('attachment remove badge is a labelled 44px target', + (tester) async { + var removed = false; + await tester.pumpWidget( + _wrap( + AiLocalizationsScope( + strings: _fr, + child: AiComposer( + onSend: (_) {}, + attachments: const [ + FilePart(mediaType: 'text/plain', name: 'notes.txt'), + ], + onRemoveAttachment: (_) => removed = true, + ), + ), + ), + ); + + final badge = find.ancestor( + of: find.byIcon(Icons.close), + matching: find.byType(InkResponse), + ); + expect(tester.getSize(badge), const Size(44, 44)); + expect( + find.bySemanticsLabel('Retirer la pièce jointe'), + findsOneWidget, + ); + + await tester.tap(badge); + expect(removed, isTrue); + }); + + testWidgets('caution confirm button does not paint white on amber', + (tester) async { + final theme = AiThemeExtension.fallback(); + await tester.pumpWidget( + _wrap( + Theme( + data: ThemeData(extensions: [theme]), + child: AiConfirmation( + title: 'Delete the file?', + tone: AiConfirmationTone.caution, + onConfirm: () {}, + onDeny: () {}, + ), + ), + ), + ); + + final label = tester.widget(find.text('Allow')); + final color = label.style!.color!; + expect(color, isNot(theme.onAccentColor)); + // Dark text on the amber fill: contrast well above the 2.2:1 the white + // pairing produced. + expect( + ThemeData.estimateBrightnessForColor(color), + Brightness.dark, + ); + }); + + testWidgets('confirm buttons grow with the text scale instead of clipping', + (tester) async { + Widget card(double scale) => _wrap( + AiConfirmation( + title: 'Delete the file?', + onConfirm: () {}, + onDeny: () {}, + ), + textScale: scale, + ); + + await tester.pumpWidget(card(1)); + final normal = tester.getSize( + find.ancestor( + of: find.text('Allow'), + matching: find.byType(InkWell), + ), + ); + + await tester.pumpWidget(card(2)); + await tester.pumpAndSettle(); + final scaled = tester.getSize( + find.ancestor( + of: find.text('Allow'), + matching: find.byType(InkWell), + ), + ); + + expect(normal.height, greaterThanOrEqualTo(40)); + expect(scaled.height, greaterThan(normal.height)); + expect(tester.takeException(), isNull); + }); + }); + + group('RTL', () { + testWidgets('blockquote border and padding follow the text direction', + (tester) async { + Widget quote(TextDirection direction) => _wrap( + const AiResponse(text: '> quoted'), + direction: direction, + ); + + await tester.pumpWidget(quote(TextDirection.ltr)); + final ltrQuote = tester.widget( + find + .ancestor( + of: find.textContaining('quoted'), + matching: find.byType(Container), + ) + .first, + ); + expect(ltrQuote.padding, isA()); + expect( + (ltrQuote.decoration! as BoxDecoration).border, + isA(), + ); + + // Same widget in RTL must not throw and keeps the directional border. + await tester.pumpWidget(quote(TextDirection.rtl)); + expect(tester.takeException(), isNull); + }); + + testWidgets('streaming loader aligns to the start, not the left', + (tester) async { + await tester.pumpWidget( + _wrap( + const AiConversationView( + messages: [ + AiMessage( + id: 'u1', + role: AiRole.user, + parts: [TextPart('hi')], + ), + ], + showLoader: true, + ), + direction: TextDirection.rtl, + ), + ); + // The loader animates forever — pump a frame, never pumpAndSettle. + await tester.pump(const Duration(milliseconds: 50)); + + final align = tester.widget( + find + .ancestor( + of: find.byType(AiLoader), + matching: find.byType(Align), + ) + .first, + ); + expect(align.alignment, AlignmentDirectional.centerStart); + }); + }); +} diff --git a/packages/flutter_ai_elements/test/widgets_test.dart b/packages/flutter_ai_elements/test/widgets_test.dart index 7348bc2..09a89f4 100644 --- a/packages/flutter_ai_elements/test/widgets_test.dart +++ b/packages/flutter_ai_elements/test/widgets_test.dart @@ -914,6 +914,9 @@ void main() { matchesSemantics( isButton: true, hasTapAction: true, + // The trigger is an InkWell, so it is focusable too. + hasFocusAction: true, + isFocusable: true, label: 'Select model, Fast\nFast', ), );