Skip to content

fix(ai): improvements on existing features - #2

Open
renefloor wants to merge 30 commits into
mainfrom
fix/improvements-on-existing-features
Open

fix(ai): improvements on existing features#2
renefloor wants to merge 30 commits into
mainfrom
fix/improvements-on-existing-features

Conversation

@renefloor

Copy link
Copy Markdown
Collaborator

This has quite a bit of fixes on the existing features before we continue adding new ones.
The commit messages should explain all the fixes that are in here.

@VelikovPetar VelikovPetar left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks pretty good from my testing! I left some new findings/recommendations, but as I don't think any of them is a blocker.

/// Ends the current session, keeping what has been recognised so far.
Future<void> stop() async {
if (!_isListening) return;
await _speech.stop();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was able to reproduce a small bug on my Samsung A05s device:

  1. Tap the mic and start speaking
  2. Tap Stop within ~2s, before the first partial result has appeared
  3. Nothing ever lands in the text field

I believe it is because the plugin always delivers the final transcript after stop() — its docs even say "Stopping a listen session will cause a final result to be sent." But by then _setListening(false) has already cleared _onWords, so that result is thrown away.

I believe it can be fixed along the lines of: keep _onWords attached until the final result (or the done status) arrives; only cancel() should discard it. (isListening can still flip immediately for the UI.)

/// its "All Photos" picker don't know about each other, so the same image
/// arriving twice is ordinary rather than exceptional, and it used to produce
/// two identical thumbnails.
List<XFile> addAttachments(Iterable<XFile> files) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we still have the issue of duplicates when picking the same attachment once from the inline picker, and once from "App photos" picker. But I don't consider this a blocker, as usually a customer will not be using both paths when selecting an image. Maybe something for the polishing phase.

final cached = _fenceWidgetCache[key];
if (cached != null) return cached;

if (_fenceWidgetCache.length >= _kFenceCacheCapacity) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should refresh an entry's recency on cache hits (a small LRU touch), because the insertion-order eviction can work against us while a fence is still streaming.

What I'm seeing: a fence that is still arriving inserts a new cache entry on every typewriter tick (its content grows each tick), and eviction removes the oldest entries — which at that point are the completed charts and code blocks above it, still on screen. The stale partial snapshots are the newest entries, so they stay. So while a later fence streams in, the visible fences above it can get evicted, re-parsed and rebuilt repeatedly.

Again, a small optimisation for an edge case, but perhaps one it makes sense to do.

Base automatically changed from feat/init-package to main August 19, 2026 09:57
renefloor and others added 15 commits August 19, 2026 11:57
`TypewriterController` tracked the target text and the reveal position as two
independent pieces of state, and `updateText` only replaced the former. When the
new text was shorter than what was already on screen, the char index was left
pointing past the end of it, so `startTyping` saw nothing left to reveal and
returned immediately — leaving the *previous* text displayed indefinitely. Any
replacement hit this: a regenerated or edited reply, or an error message
swapped in for a partial one.

Rather than patch that one path, every mutating method now goes through
`_reveal` and maintains a single invariant:

    value.text == _targetText.take(_currentCharIndex).string

`updateText` treats text that starts with what is already displayed as a
continuation and types on from where it was (the streaming case, where each
chunk appends to the last); anything else is a replacement and is revealed from
the start. `stopTyping` now clears the revealed text along with the index, so a
following `startTyping` no longer jumps from the fully-revealed text back to its
first character. The constructor's off-by-one (`length - 1`, which cost a
duplicate frame on the first delta) is gone too.

Separately, `StreamingMessageView` never reported anything through
`onTypewriterStateChanged` for a view built with its complete text: it starts
fully revealed, so the controller never transitions and never notifies. A host
that clears a "generating" flag on `TypewriterState.idle` would wait forever.
The initial state is now reported once from a post-frame callback — deferred so
the host is free to rebuild in response without doing so during our build.

Also drops `test/stream_chat_flutter_ai_test.dart`, a placeholder asserting
`expect(true, isTrue)`, now that the controller has real coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The example app's own chart fence didn't parse. It was written as

    {"type": "bar", "series": [{"label": "…", "points": [{"x": …, "y": …}]}]}

which matches no adapter: `_tryUSpec` requires `kind`, `_tryChartJs` requires
`data.datasets`, and ECharts/Highcharts require `xAxis`. So the example's "### A
chart" section rendered as a raw JSON code block. That is worth more than an
example fix — a model asked for a USpec will reach for the Chart.js vocabulary
it has seen far more of during training, exactly as the author of the example
did from memory. `kind`/`name` now accept `type`/`label` as aliases.

Widening the adapter's entry condition made an existing latent bug reachable:
`_tryUSpec` added a `USeries` even when it extracted no points from it, so a
payload that merely *looked* like a USpec was claimed here and rendered as an
empty chart instead of falling through. It now skips empty series, which is what
lets a `{type, xAxis, series}` payload still reach the ECharts adapter that
understands it. `y`/`size`/`z` also go through `_asDouble` like every other
adapter, so a quoted number (`"y": "12"`) is accepted.

Finally, the Vega-Lite adapter coerced a missing `y` to zero for every row, so a
spec whose `encoding` field names didn't match its data produced a chart of flat
zeroes that read as real measurements. It now skips unusable rows, which leaves
`groups` empty and falls through to a plain code block — the honest outcome, and
consistent with every other adapter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four separate ways a chart could misrepresent its `USpec`:

- **Bar charts dropped and invented data.** The category count came from the
  *first* series, so any later series' extra points were silently discarded; and
  where a series had no point at a category, a rod was still emitted with
  `toY: 0`. A fabricated zero reads as a real measurement of nothing rather than
  as absent data. Categories now come from the longest series, and missing
  points produce no rod.

- **Categorical scatter/bubble series were offset from each other.** The
  fallback x for a non-numeric label was `spots.length` — the running total
  across *all* series — so each series was pushed to the right of the one before
  it instead of sharing the same categories. It is now the point's index within
  its own series. These charts also passed an empty label list, reserving 28px
  for bottom-axis labels that never rendered; they now show category names, or
  the raw numeric values when every x parses as a number.

- **Bubble sizes were clamped, not scaled.** `UPoint.size` was treated as though
  it were already a pixel radius and clamped to 6–40. Chart.js's `r` is pixels,
  but a USpec `size` is just as likely to be a population or a revenue figure —
  so every bubble past 40 came out identical, flattening the one encoding a
  bubble chart exists for. Sizes are now normalized across the chart before
  being mapped into the pixel range.

- **A histogram of identical values rendered blank**, because `_makeBins`
  returned no bins when `max <= min`. It now returns a single bin.

Dark theme, in the same files: the grid line and heatmap cell border were
hardcoded translucent *black*, invisible on a dark surface, and now come from
`colorScheme.outlineVariant`, with axis/row/column labels from
`onSurfaceVariant`. The heatmap's sequential ramp also inverts in a dark theme —
a light-to-dark ramp on a dark background makes the highest-value cells recede
into it, inverting the intensity the color is meant to encode.

Also replaces `List.contains` with a set when collecting heatmap columns, which
was quadratic in the number of cells.

The bottom-axis label callback now matches whole positions with a tolerance
rather than truncating with `toInt()`, so fl_chart's fractional intermediate
values no longer draw duplicate labels.

Note these change rendered output, so `goldens/ci/` must be generated *after*
this lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`AIMarkdownBody.build` called `_parse(data)` — a regex scan of the whole message
— and then `USpecParser.tryParse` for every chart fence, which is a `jsonDecode`
plus a walk through seven schema adapters. `StreamingMessageView` rebuilds once
per typewriter tick, so a streaming reply containing a chart re-decoded that
JSON roughly a hundred times a second, and paid for a throw-and-catch each time
for any fence that wasn't chart data at all.

Two changes, matched to how the two costs actually behave:

- The widget keeps its segment list in `State` and re-scans only when `data`
  changes, so rebuilds from a theme change, a scroll, or an ancestor cost
  nothing. (This does mean the widget is now stateful.)

- Chart-fence parses are memoized on the fence's exact content, which stops
  changing the moment its closing fence arrives — so the steady state during
  streaming is a map hit. Failures are cached too, since those are the expensive
  ones. The cache is bounded at 32 entries and evicts in insertion order; a
  streaming message appends fences, so the oldest is the least likely to still be
  on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`SpeechToTextButton` called `_speech.initialize()` from `initState`, and that is
what triggers the microphone and speech-recognition permission prompts. So
merely rendering a composer with `enableSpeechToText: true` asked the user for
the microphone before they had shown any interest in dictating — and, because
the composer swaps this widget out as soon as the field is non-empty, re-ran that
initialization every time the field went from empty to typed and back.
Initialization now happens on the first tap.

The button also returned `SizedBox.shrink()` until initialization reported
success. Since it occupies the composer's *trailing* slot, that left the slot
completely empty on first frame — no mic, and no send button either — and
permanently empty on any platform without a recognizer. It now always renders,
and renders disabled if the recognizer turns out to be unavailable.

The existing test asserted only `findsNothing` for the send icon, which passed
precisely *because* nothing rendered at all. It now asserts the mic is present,
plus a new test that spies on the plugin's method channel to pin down the part
that actually matters: mounting the composer must not call `initialize`, and
tapping the mic must.

Two smaller fixes in the same widget:

- `_onResult` assigned `textEditingController.text = words`, discarding whatever
  was already in the field. Harmless in the composer's own slot (only shown when
  the field is empty) but not when mounted via a factory, which the class docs
  recommend. Recognized words are now appended to the text present when
  listening began.

- The pulse `AnimationController` was `repeat()`ing from `initState`, ticking
  every frame for the button's whole lifetime even though the animation is only
  used while listening. It now starts and stops with the listening state. The
  `ListenableBuilder` wrapper is gone as well: `build` reads nothing from the
  controller, so it was rebuilding on every keystroke for no reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two unrelated problems in the composer's image and button rendering.

**Thumbnail futures were created inside `build`.** `_AttachmentThumbnail` called
`file.readAsBytes()` there, and `ChatComposerController` notifies on every
keystroke — so each character typed re-read every pending attachment off disk in
full and re-decoded it, at full camera resolution, to fill a 64px box. The future
now lives in `State` (re-created only when the file path changes) and
`Image.memory` gets a `cacheWidth`, so decoding happens at thumbnail size.
`_RecentPhotoTile` in the attachment sheet had the same shape: selecting one
photo rebuilds the sheet, which re-requested all thirty visible thumbnails from
the platform.

**The circular buttons had no press feedback.** Each wrapped an `InkWell` around
a `Container`/`AnimatedContainer` carrying an opaque decoration. Ink splashes
paint on the nearest `Material` *below* the widget, so the decoration covered
them and taps did nothing visible — on the "+" button, send/stop/mic, the
thumbnail remove button, the option-chip dismiss button and the sheet's camera
tile. Each now has a transparent `Material` *above* the fill for the ink to paint
on. (`_SuggestionChip` already did this correctly and is unchanged.)

The remove, dismiss and camera controls are icon-only and had no accessible name
at all, so they gained tooltips, which is also what a screen reader announces.
Those strings are hardcoded English like the rest of the package — they are
listed in the roadmap's localization entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`AnimatedDots.spacing` was a public, documented parameter that did nothing — the
`Row` was hardcoded to `spacing: 4`, which happened to match the default, so
setting it had no effect.

`_AnimatedDot.build` also constructed a `CurvedAnimation` and two `Tween`s on
every call. Since these dots animate continuously that is once per frame, and a
`CurvedAnimation` registers a listener on its parent and needs disposing, so it
leaked one per frame as well. They are now built in `initState` and the curve is
disposed.

While here: the class doc referenced `[AI_STATE_THINKING]` and
`[AI_STATE_CHECKING_SOURCES]`, constants that exist nowhere in this package —
leftovers from `stream_chat_flutter`, where the AI state constants live. They
rendered as dead links in the generated docs. The widget takes an arbitrary
string, so the doc now says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_measureContentWidth` binary-searches the narrowest width that still fits a
label in two lines, laying out a `TextPainter` up to ~8 times per chip per build,
and never disposed it — a `TextPainter` holds a native paragraph. It is now
disposed in a `finally`, since the method has several early returns.

The chip then rendered its label with `textAlign: TextAlign.left` despite
threading the ambient `textDirection` all the way into the measurement above it,
so in a right-to-left locale the text was measured correctly and then aligned to
the wrong edge. Changed to `TextAlign.start`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`XFile` appears in this package's own public API — `ChatComposerSendCallback`'s
third parameter and `ChatComposerController.attachments` — and is re-exported
from the barrel file so callers don't have to reach for the picker themselves.

It was being exported via `package:image_picker`, which only re-exports it from
`cross_file`. Since the type is part of our API regardless of which picker it
happens to arrive through, `cross_file` is now a direct dependency and the export
comes from where the type is actually defined. Added under
`melos.command.bootstrap` in the root pubspec as well, per the repo convention
for dependency versions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`public_member_api_docs` makes a doc comment mandatory on every public member,
but nothing checked that the `[links]` inside those comments resolve. Several
didn't, and an unresolved reference doesn't fail anything — it just renders as
plain text in the published dartdoc, so the rot is invisible until someone reads
the docs on pub.dev.

Enabling `comment_references` catches them, and with `--fatal-infos` it also
guards the doc-only imports that some of those links depend on: this package's
libraries are laid out so that the widget imports its controller, not the other
way round, so referencing `[ChatComposer]` from `chat_composer_controller.dart`
means importing it back. Dart is perfectly happy with the resulting cycle, and a
doc-comment reference counts as a use so `unused_import` stays quiet — but an
over-eager "organize imports" would otherwise silently break the link. Now it
fails the build instead. Each such import carries a comment saying it exists for
the docs.

The individual reference fixes are in the preceding commits, alongside the code
they document. This one adds the lint plus the last remaining file, whose only
change is documentation: `chat_composer_controller.dart` gains the
`chat_composer` import for `[ChatComposer]`, and imports `material` rather than
`widgets` so `[TextField]` — the thing you actually pass its
`textEditingController` to — resolves as well.

Verified with `dart doc`: 0 warnings, 0 errors, and the references come out as
real anchors, including to `api.flutter.dev` for `[TextField]`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The example handed `StreamingMessageView` the entire canned reply in one go. A
view built with its complete text is already fully revealed, so the reply
appeared instantly and the typewriter — the feature the example exists to show —
never ran at all. Worse, the controller never transitioned, so
`onTypewriterStateChanged` never fired, so the `isGenerating = false` wired to it
never ran either: after the first reply the composer was stuck showing the stop
button and the "Thinking" indicator stayed up forever.

The fake backend now delivers the reply in chunks on a timer, which is both what
a real streaming backend does and what the widget is designed around. Because the
typewriter goes briefly idle whenever it catches up with the chunks received so
far, "the reply is finished" is now the conjunction of two conditions — the
backend has stopped sending *and* the typewriter has caught up — which is the
same distinction a real integration has to make, so the example is a better
model of one.

The chart fence is also rewritten to a shape the parser accepts, with categorical
x labels so the bar chart is labelled Mon–Fri rather than 1–5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Changelog: adds 🐞 Fixed and 🚀 Performance sections covering the preceding
commits. Also adds a note under the 0.0.1 heading explaining what the 🔄 Changed
entries are relative to — they describe renames and behaviour changes against
unreleased code that lived on a branch in the `stream-chat-flutter` repo, which
reads on pub.dev as though there were a previous release to migrate from. There
isn't, and a first-time reader shouldn't have to work that out.

Roadmap: several known issues belong to work already scheduled there, so they are
recorded against it rather than patched in isolation.

- **2.1 (code highlighting)** rewrites `CodeBlockView` wholesale, so the findings
  in that file are folded in as things to handle while it's open: `fontFamily:
  'monospace'` doesn't resolve on iOS/macOS/web (only Android maps that generic
  name, so code blocks aren't monospaced at all elsewhere — and
  `test/flutter_test_config.dart` registers a system font purely to work around
  it in goldens, which should be revisited then), a `setState` after an `await`
  with no `mounted` guard, two racing reset timers on repeated copy taps, and no
  test coverage. Also corrects a stale `melos.yaml` reference — this repo is on
  Melos 8 and configures bootstrap from the root `pubspec.yaml`.

- **2.3 (localization)** gets the complete table of hardcoded strings and their
  files, so it can be done in one pass instead of discovered piecemeal. Several
  are tooltips doubling as the only accessible label for an icon-only button,
  which makes that entry an accessibility fix too.

- **2.4 (new)** — chart theming and accessibility. The dark-theme chrome that was
  outright broken is fixed, but the palette, chart height, bubble radius range and
  bin count are all still hardcoded constants with no way for a host to intervene,
  and a `ChartView` is an empty box to a screen reader despite having everything
  needed for a summary label sitting in the `USpec`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@renefloor
renefloor force-pushed the fix/improvements-on-existing-features branch from cc2bd54 to 8cc83b3 Compare August 19, 2026 09:57
renefloor and others added 12 commits August 19, 2026 09:59
`MathBlockSyntax.parse` read the opening line, kept what sat between the
delimiters and dropped everything after the closer. So
`\[E = mc^2\] where m is mass.` rendered the expression and silently lost the
sentence — content loss in an AI reply, not a layout glitch.

It bites hardest outside of maths. `\[` at the start of a line is also
CommonMark's escape for a literal `[`, so a citation list reaches this syntax:
`\[1\] First source` collapsed to a bare `1`. A whole reference section turned
into a column of numbers.

The tail is now emitted as `UnparsedContent`, which the block parser's inline
pass then walks — so it comes back as ordinary markdown, and a second
expression on the same line (`\[a\] and \[b\]`) typesets too. An empty `\[\]`
hands its source back rather than dropping the line, matching what
`MathInlineSyntax` already did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… axes

Two problems, one symptom.

A Chart.js dataset of plain numbers with no `labels` key — a shape models emit
constantly — was only decoded in the branch that expects `{x, y, r}` objects,
so it yielded no points at all. Unlabelled numbers now take their position in
the array as the category, which is what Chart.js itself does.

And the empty series was still appended, so `series.isEmpty` was false and a
non-null `USpec` came back: `ChartView` drew an empty chart and the user's
numbers appeared nowhere on screen. `_tryUSpec` already guarded against exactly
this, with a comment explaining why — a payload an adapter cannot decode has to
fall through to one that can, or to a readable `CodeBlockView`. The Chart.js,
ECharts and Highcharts adapters now do the same.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Vega-Lite heatmap is `mark: "rect"` with `x` and `y` as the two axes and
`color` carrying the cell value — the opposite of every other mark, where
`color` names the series. Running it through the shared path used `color` as
the series key and `y` as the value, and never set `UPoint.z`, so
`HeatmapChartView` fell back to `y` for intensity.

Given hours on one axis and a count in `color`, that produced one row per
distinct count, columns of days, and the hour painted as the shade. Every axis
was wrong, and the existing test only asserted `kind == heatmap`, so it passed.

`rect` now gets its own mapping. A `rect` without a `color` encoding is
declined rather than guessed at — there is no cell value to shade by, and
inventing one draws a plausible grid out of data that says nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… series

`null` in a Chart.js data array is the documented way to write a gap. The
parser skips it, which leaves a series that is simply shorter — and points were
then plotted at their position within their own list. So `[1, null, 3]` against
labels Jan/Feb/Mar drew March's value above the February tick, out of step with
the axis and with every other series in the chart.

Rendering now keys off `UPoint.x` instead: `_categoryLabels` merges an axis
across all series (longest first, so the fullest series sets the order and the
rest only contribute what it is missing), and line, bar and scatter all look up
each point's position on it.

Labels aren't always usable as keys — a histogram's raw samples all carry the
same empty `x` — so `_hasCategoryKeys` detects repeats and keeps the positional
path for those. The previous fix in this area covered scatter only; this
generalises it and fixes the gap case the parser can produce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ChatComposer.dispose` cancelled the recognition session only when
`enableSpeechToText` was true, but disposed its internally-owned controller
unconditionally on the very next line.

Those two conditions come apart in the arrangement `SpeechToTextButton`'s own
class documentation recommends: placing the button through a
`ChatComposerFactory`, which leaves `enableSpeechToText` at its default false.
Navigating away mid-dictation then left a live session holding `_onWords`, and
when its final transcript arrived — up to `finalTimeout + 500ms` later, by
design, so that stopping early doesn't throw the dictation away — it wrote into
a `TextEditingController` that was gone:

    A TextEditingController was used after being disposed.

The cancel is now unconditional, and `_onWords` bails when the button is no
longer mounted. Either alone would close the crash; both, because the session
deliberately outlives the widget and a host can own the controller too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four faults in the recognition controller, all of them about a failure going
somewhere the user can't see or come back from.

`ensureInitialized` cached `false` as permanently as `true`. The usual reason
initialization fails is the user declining the permission prompt, so the mic
stayed disabled for the life of the process even after they granted access in
Settings — stricter than `speech_to_text` itself, which retries after a
failure. Only success is remembered now. That alone isn't enough, because
`isAvailable == false` still renders the button disabled, so
`invalidateAvailability()` is added and `SpeechToTextButton` calls it when the
app returns to the foreground: coming back from Settings is exactly the moment
to ask again.

`start` set `isListening` only after awaiting `ensureInitialized`, and on first
use that await spans the permission prompt — seconds, during which the button
still renders as an enabled mic. Tapping twice reached `listen()` twice, which
a real engine rejects as busy while the UI shows a session that isn't there.
Concurrent callers now share one in-flight `initialize`, and `start` holds a
guard across its awaits.

Failures from `listen`, `stop` and `cancel` were rethrown into a tap handler,
making them unhandled async errors — a console line in debug, silence in
release — while `SpeechToTextConfig.onError`, added for precisely this, never
fired. They are reported through it now. `SpeechRecognitionError` is
re-exported so reacting to it doesn't mean depending on `speech_to_text` just
to name the parameter's type, the same treatment `XFile` already gets.

And `dispose()` asserts instead of running. It is inherited from
`ChangeNotifier` and public, so a host disposing its controllers reflexively
would brick dictation app-wide — surfacing much later, on an unrelated screen,
as "used after being disposed".

The permission-prompt paths need a recognizer that has never initialized
successfully, which `SpeechToText` latches process-wide, so they get their own
test file and their own isolate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Listenable.merge` returns a fresh `_MergingListenable` on every call and
doesn't define `==`, so building it inside `build` made `ListenableBuilder`
detach from and re-attach to both the composer controller and the speech
singleton on every parent rebuild.

Not a leak, and narrower than it first looks — `ListenableBuilder` is an
`AnimatedWidget`, so notifications rebuild it internally without re-running the
composer's `build` — but it is pointless churn. Held in state now, rebuilt only
when the controller or `enableSpeechToText` actually changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…to access"

The `catch` around `_loadRecentPhotos` spanned the permission request and both
`photo_manager` calls, and mapped all of it to the same "Allow photo access"
state. Only one kind of failure actually means that — no platform gallery
implementation, on web or desktop or in a test. Anything else sent the user to
Settings, where access was already granted, and back to the same empty strip,
with no diagnostic anywhere to say what really happened. Genuine failures now
go through `FlutterError.reportError`; the fallback UI is unchanged.

`pickImage`/`pickMultiImage` had no handling at all. They throw
`PlatformException` for an unavailable camera or a permission revoked
mid-flight, and both call sites are tap handlers, so the error escaped into the
zone and the tap simply did nothing. Both are guarded, and both now check the
sheet is still mounted before adding to the controller — the picker is a
separate activity on Android, so the host can be recreated behind it.

Also adds `LruCache.peek`, and uses it for `_isSelected`. That runs from
`build`, twice per tile, and `get` marks an entry most-recently-used — so
painting was reordering the cache and recency tracked paint order rather than
access, which is the opposite of what the eviction policy is for.

While here, `XFile` is imported from `cross_file` rather than `image_picker`,
matching the reasoning the barrel and the pubspec already give for depending on
it directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`MarkdownWidget` parses in `initState` and re-parses only when `data` or
`styleSheet` changes; it caches the built children and `build` just hands them
back. So rebuilding the syntax lists in `didUpdateWidget` changed nothing that
was ever consulted again, and flipping `useDollarDelimitersForMath` at runtime
did nothing at all until the text happened to change. Streaming hid it — `data`
changes every tick — but a finished message was stuck for good. The
`MarkdownBody` is now re-keyed when the configuration changes.

The comment claiming a changed `mathBuilder` is "picked up lazily" said the
opposite of what happens: the closure is read during a *parse*, not a build.
Reading it lazily is still right — hosts pass inline closures whose identity
differs every build, and rebuilding the map would recompile
`MathInlineSyntax`'s RegExp once per typewriter tick — so the behaviour stands
and the comment now describes it.

Adds `AIMarkdownBody.chartLanguages`, exporting the previously private default
set. `json` is in it because models label chart data that way constantly, which
also meant a plain ```json fence shaped like a chart spec was silently swallowed
into a `ChartView` with no way to read the source, and no seam to opt out. The
ROADMAP claimed that hook already existed; now it does.

The `RepaintBoundary` assertion was tautological — `ModalRoute` already wraps
every page in one, so `findsWidgets` over all ancestors passed whether or not
the fence was wrapped. Since that boundary is what the streaming performance
claim rests on, it now checks the immediate parent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`updateText` decided whether a chunk continues the text on screen with
`String.startsWith`, a UTF-16 test, while `_currentCharIndex` counts grapheme
clusters. The two disagree when a streaming chunk boundary lands inside a
cluster: "👨" is a code-unit prefix of "👨‍👩‍👦" but not a grapheme one, so the
chunk was taken for a continuation and the index was left past the end of a
target one cluster long — `startTyping` returned immediately and the view
froze until more text arrived.

Also adds `finishTyping()`. `stopTyping()` resets, which empties the view, and
`pauseTyping()` freezes it half-written — so a host wiring up a "stop
generating" control had neither of the things that button wants, and the only
way to reveal everything received so far was the `text` setter, which nobody
finds from the name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
renefloor and others added 3 commits August 21, 2026 16:44
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dency

The speech tests substitute their own `SpeechToTextPlatform` to drive the
plugin, so they import `speech_to_text_platform_interface` directly. It only
arrived transitively via `speech_to_text`, and `flutter pub publish -n` rejects
an import the pubspec doesn't declare — which is what `melos run lint:pub`
runs, and why the analyze job's Pub Check step was failing.

Declared in both places, per the workspace convention: the version in the root
`melos.command.bootstrap` list, the entry itself in the package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…diff

The format job runs `melos bootstrap`, then `melos run format`, then asserts
`git ls-files --modified` is empty. `dart format` was already clean — the diff
came from bootstrap itself: `flutter pub get` injects an `analyzer.exclude`
block for the platform and build directories into `analysis_options.yaml`, and
re-resolves four SDK-constrained transitive packages that the committed
lockfile predates.

Neither is something a contributor edits, so the answer is to commit what the
tool writes. Both hunks here are byte-identical to what CI produced (the run's
own diff shows the same `1ba8b64..0f2585d` and `ddf0ae1..b70e94b` blobs).

The root `analysis_options.yaml` block is included even though CI's Flutter
3.47.1 doesn't add it — 3.47.0 does, so checking it in keeps the tree clean on
both rather than only on the runner.

This failure predates the PR; it is on `main` too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants