From de729ee4d26f95e1b6325fa403f5e41fece56152 Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 16:26:30 +0800 Subject: [PATCH 1/4] fix(client): settle dangling tool calls before submit/regenerate/edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turn interrupted mid agent-loop (stop, replace, maxSteps, or a dirty rehydrated transcript) leaves tool calls with no ToolResultPart. Providers reject such a history — Anthropic 400s with 'tool_use ids were found without tool_result blocks' — permanently breaking the conversation. Before re-prompting, scan the transcript and insert a synthesized error result message directly after each affected assistant message (in place, since providers require the result in the immediately-following turn). Co-Authored-By: Claude Fable 5 --- .../lib/src/use_chat_controller.dart | 67 +++++++++++-- .../test/use_chat_controller_test.dart | 98 +++++++++++++++++++ 2 files changed, 158 insertions(+), 7 deletions(-) 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..588e38e 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,52 @@ 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/test/use_chat_controller_test.dart b/packages/flutter_ai_client/test/use_chat_controller_test.dart index 5ff4ec5..23839f2 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,104 @@ 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', () { From 937d5eb85927daa642fa3b1ca93b649d91a7f07a Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 16:26:30 +0800 Subject: [PATCH 2/4] fix(elements): compile on both upstream Flutter and the OHOS fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exhaustive TargetPlatform switch in aiLightHaptic can't satisfy both SDKs — the OHOS fork adds TargetPlatform.ohos, upstream lacks it. Use an iOS/Android allowlist if instead. Co-Authored-By: Claude Fable 5 --- .../lib/src/widgets/ai_haptics.dart | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) 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()); } } From e59013457881c7e00ed1c02c71699d250da7965c Mon Sep 17 00:00:00 2001 From: Mouaz Anan Date: Fri, 21 Aug 2026 21:56:28 +0200 Subject: [PATCH 3/4] fix(elements,demo): accessibility & i18n audit items (#128) Localizes the strings that bypassed AiLocalizations, fixes sub-44px touch targets, the caution-tone contrast failure, and the remaining RTL gaps. - AiLocalizations gains toolArguments/toolResult/toolError, contextLabel, showLess/moreSources(int), removeAttachment, liveConnecting/liveListening/ liveThinking/liveSpeaking, emptyStateTitle, citation(int) and selectModel(String). AiChainOfThought.title, AiContextMeter.label, AiEmptyState.title and AiComposer.hintText are now nullable and fall back to the localized value. - The attachment remove badge and AiBranch's arrows get 44px InkResponse targets (the badge's padding reserves the room, since a hit area that only overhung the Stack would be clipped by the ancestor bounds check). AiModelSelector's trigger moves to InkWell for focus traversal. - AiConfirmation derives the filled label color from the fill's brightness (white on the caution amber was ~2.2:1) and uses minHeight + padding instead of a fixed 40px height so labels survive large text scales. - RTL: directional alignment for the streaming loader and the "New chat" label, directional padding/border for Markdown blockquotes. - demo: darken the gallery section header (~3:1 on white) and refresh the two affected screenshots. Co-Authored-By: Claude Opus 5 (1M context) --- demo/lib/main.dart | 3 +- demo/screenshots/element_branch.png | Bin 1122 -> 1195 bytes demo/screenshots/element_confirmation.png | Bin 12014 -> 12027 bytes .../lib/src/use_chat_controller.dart | 3 +- .../test/use_chat_controller_test.dart | 3 +- .../lib/src/l10n/ai_localizations.dart | 64 ++++ .../lib/src/widgets/ai_branch.dart | 21 +- .../lib/src/widgets/ai_chain_of_thought.dart | 10 +- .../lib/src/widgets/ai_composer.dart | 73 ++-- .../lib/src/widgets/ai_confirmation.dart | 21 +- .../lib/src/widgets/ai_context_meter.dart | 10 +- .../lib/src/widgets/ai_conversation_list.dart | 2 +- .../lib/src/widgets/ai_conversation_view.dart | 2 +- .../lib/src/widgets/ai_empty_state.dart | 9 +- .../lib/src/widgets/ai_inline_citation.dart | 3 +- .../lib/src/widgets/ai_live_session.dart | 13 +- .../lib/src/widgets/ai_model_selector.dart | 8 +- .../lib/src/widgets/ai_response.dart | 6 +- .../lib/src/widgets/ai_sources.dart | 5 +- .../lib/src/widgets/ai_tool_invocation.dart | 8 +- .../test/a11y_i18n_test.dart | 312 ++++++++++++++++++ .../test/widgets_test.dart | 3 + 22 files changed, 514 insertions(+), 65 deletions(-) create mode 100644 packages/flutter_ai_elements/test/a11y_i18n_test.dart 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 174eb655163d4bb863ab7035c47775499118a001..900a5bce028cc7cdef325d4e1bb5ac2ddb4fa06b 100644 GIT binary patch delta 874 zcmV-w1C{*Z2&)N@7YbAe1^@s6uPUv#ks%;|RY^oaRCt{2+0Sp&bsWd>SLd=VqlY1U zrKgr`wik*7NKNX@#>LHlz~6czMn_1Dni-Irpkh3kv7v{BGt+?%6^{;K!R&`&%K}LD zBaE$;hGuE9t#0{#zg}<&+k{I?-?Puht>1o<_xtqBx1R=l-}eOoFsGu_3jhE%MxX(a z@;{UC16O})(*)XeT}@0($aURPudn!JxADvRYhR)XK_= zs;a6=w$IGW9c3~Z?TYVO_g!!AIgO5vDW8AOYwy0jMccu1J>8j^l_Z5iA=TE_7Ht-Q z(lh%8ElIIhRN+Wi3kwSx9vac|@^Z;`xq9_?H6Li&@J+kz(7WpF>`-fKi@G{b7iGTZ zdg|^zqk(||#bPnN|G|5zsi`T-EC4o`4z!F!+H*Z!{_V1!OC}$9J~=t1p}`?ZQpJB0 z6-qptP&%EK@B0OxPn?1BV?NMY6X{ec_1Nc+kB=)D3@RK6Kkz)4%c;Bjj53*weBal| z$RCPCA_c!IpPHT~&OjM5dk4K%EEd()t#xu7=dsVea%Dix2b$JB-?Qf#H8(fuqxK`x z{d?iIws#6XzoDUC?d>0`zM-Bt17$EI;1~M$`2UROdg|&rtvBC#Lv?j^8=i4pSNZ(k z+OmZ|?En-Gfd&AhHpLw@0F)z<(O{GC12h)uA2#SLd=VqlY0> zdTPmLd!a~x)TGXAT-^Kz{H-Tqbi~9c83DNoD#nu;8+uqcGacwq@#qj1%zhZQEP!M` z!q{4AXqG11>XvW6USN?iA>X;LeWTCE?fdQL`}ur&d8Yle0oQe10RYS?EA<8d0IMUD zJpmb$5CKM$5CIZ@Oa$6-9F2{Q$#I;2b={&<)WX8Tnx8*+?nh;_*>&9$0NhpbKs%13 z%a?~WF+QmqH>Ny4KQoh7_t&TO>9NlqIPQ2`yLx-iFJJe3dsch*zNvjJ@95CM!$3c)K@xsloM9oPHQdDYjamajK$dR(#C zle+c$EsYG1==<+}Q2n#@p4Zo%NWb{f3(Jwt<#KD92LM)jc-?#u2m}-i29>*=)BOCr zmX?-OQBmQ`T2oUumCa_gEwSys*Sfn;Yj}7>g+f7pJ9q6|)BHuJsGC#MlB94rtm^9O zf4pY^P$`c%;_;Xw(TL{e<}^4kq{YQWU)H&F=@&KbX;}4>cFVrE)!yEw=H@1Kw4d-i zchM=Tv-70-`uY@)$MxR(@2aY*YMpit;Qy|qKN9*K>7rBAg`Y3znN;eY`{Uyi8t5O8 zB$YjXR;J|BNo6t_xvuMJZsH8UPbL4G-CrY}PN&y(?$Oav1%p9FqLF*<=ks}WcAiu= zo0aRj8XEdl(P-4uIrzlnByk4dtCE~SuN05Rv~gpNLZQ&Q&U^7yd78Vfu2!wB`&C<4OPm2S_#)tX`Ck7BDLO@Ubez!ZZ@i|OnwnL| zIF6%2;SX)tzz=%>?D@|I8UP-3B|n1(0IZIa5dlS$5CJF_e**3LI_>qJgtY(w002ov JPDHLkV1no&%SZqK diff --git a/demo/screenshots/element_confirmation.png b/demo/screenshots/element_confirmation.png index 38aceba3e1c7419ecbf4868fd7ef912a88d9a5a3..ae9440912103a5a8b230a19d4438be04579760d6 100644 GIT binary patch literal 12027 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 literal 12014 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.single; + final settled = messages[2].parts.whereType().single; expect(settled.toolCallId, 'c1'); expect(settled.isError, isTrue); }); 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_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/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', ), ); From 2c238bffe94bf59d4e66c0eed24928e484603501 Mon Sep 17 00:00:00 2001 From: Mouaz Anan Date: Fri, 21 Aug 2026 21:56:37 +0200 Subject: [PATCH 4/4] chore(release): flutter_ai_client 0.3.1, flutter_ai_elements 0.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client 0.3.1 — dangling tool calls are settled before submit/regenerate/edit. elements 0.3.0 — OHOS compile fix plus the accessibility/i18n pass; minor rather than patch because AiChainOfThought.title, AiContextMeter.label, AiEmptyState.title and AiComposer.hintText widened to String?. Co-Authored-By: Claude Opus 5 (1M context) --- packages/flutter_ai_client/CHANGELOG.md | 11 ++++++++ packages/flutter_ai_client/pubspec.yaml | 2 +- packages/flutter_ai_elements/CHANGELOG.md | 32 +++++++++++++++++++++++ packages/flutter_ai_elements/pubspec.yaml | 2 +- 4 files changed, 45 insertions(+), 2 deletions(-) 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/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_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/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