Skip to content

Commit cd50bc4

Browse files
renefloorclaude
andauthored
feat(ai): theme charts, and describe them to a screen reader (#10)
* feat(ai): theme charts, and describe them to a screen reader Adds AITheme — a ThemeExtension a host registers on ThemeData.extensions — with ChartThemeData as its first component theme, plus a ChartTheme InheritedTheme for subtree overrides and a `theme` argument on both chart widgets. Thirteen nullable fields cover the palette, the plot height, the scatter and bubble radii, the histogram's bucket count, the axis, pie-label and title styles, the grid line colour and the heatmap's three ramp stops. An unset field derives from the ambient ColorScheme, so overriding the palette leaves everything else following the app in both brightnesses. There was nothing to hang a chart theme on: no ThemeExtension and no InheritedWidget existed here outside 2.3's translations scope. The shape mirrors stream_core_flutter's StreamTheme (<Component>ThemeData plus an InheritedTheme, defaults resolved in the widget's build) so a host of both SDKs finds what it expects, but AITheme carries no brightness, colour scheme or typography of its own — every widget here already resolves from the ambient ColorScheme, and a second brightness would be a second source of truth able to disagree with Theme.of. copyWith/merge/lerp/== are hand-written: one component theme does not justify a codegen dependency in a package whose premise is a short dependency list. In lerp, a field set on only one side swaps at t == 0.5 instead of interpolating. MaterialApp animates its ThemeData, so lerp runs on every light/dark switch, and null here means "derive from the theme" rather than "transparent" or "zero" — Color.lerp(null, c, t) would fade a grid line through transparency mid-transition and lerpDouble(null, 220, t) would grow a chart up from zero height. Palettes of different lengths interpolate by wrapping the shorter one, the way it is cycled at paint time, so no series loses its colour partway through. Neither AIMarkdownBody nor StreamingMessageView gained a parameter, so the fence widget cache's key is untouched: a chart resolves the theme from its own BuildContext and the inherited-dependency mechanism marks the cached element dirty regardless of widget identity. ai_markdown_body_test asserts it — one identical widget instance, two palettes. A chartTheme constructor argument left out of that global key would instead let two differently-themed bodies share one palette. Charts also had no Semantics at all, fl_chart painting to a canvas, so a ChartView was an empty box to a screen reader. Each now carries one node summarising the kind, title, axes, series, counts and value range, with a sentence per USpecKind. ChartSemantics.fromSpec gathers the facts and formats the numbers for speech; AITranslations.chartSemanticsLabel composes the sentence, so it is translatable like every other string — one method rather than a dozen phrase-sized ones, because a sentence's word order varies far more between languages than a tooltip's does. The node excludes its subtree: the tick labels and a heatmap's row, column and legend labels are real Text widgets, and walking them yields a run of bare numbers with nothing saying which axis they belong to. This is also the first code here to read USpec.xLabel and USpec.yLabel, which the parsers had been filling in for nobody. Two visible changes. Pie slice labels pick black or white per slice instead of always white, which disappears on any light slice a host palette might carry; under the default palette that flips every label dark. And USpecParser leaves an unnamed series' name empty instead of substituting 'Series' or 'Pie' — 2.3 deferred those here, since a parser has no BuildContext — with the name resolved at render time from AITranslations.unnamedChartSeries. The 'Pie' variant is dropped rather than translated: it reached the screen nowhere. All ten committed CI goldens are unchanged, defaults having been preserved verbatim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(ai): describe only the chart that is drawn, and format its numbers Two defects in the chart accessibility summary, both reaching the screen reader as the only description a chart has — its Semantics node excludes the painted axis labels, so there is nothing else to fall back on. The trailing-zero strip in `_formatValue` ran on the whole formatted number instead of the fraction, so anything rounding to `x.00` kept a dangling point: 99.999 became '100.', read aloud as "one hundred point". Values below 0.005 collapsed to '0.' at both ends of the range, losing the range entirely. `ChartSemantics.fromSpec` also flattened every series, while `ChartView` plots only the first for a pie and a histogram — so a two-series pie showing four slices announced eight, and computed the largest slice's share against a total it never draws. `USpecParser` accepts a multi-series pie from model output, so nothing unusual was needed to hit it. Deriving the described series list up front also removes an unguarded `reduce` over an empty first series, which threw `StateError` inside `build` and took the whole `AIMarkdownBody` down with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(ai): cover the chart theme's dark half, and correct the docs Adds the coverage the theming work was missing, each case checked by breaking the code it guards and watching it fail: - No test set a dark Brightness, so `_kDarkScale` and the three ramp fallbacks never ran. Delete the brightness check and the suite stayed green while every dark-mode heatmap inverted the intensity it encodes. - The ramp test matched with `contains` over an unordered bag of fills, so its own `reason` strings ("the minimum cell sits at the low stop") were unchecked and a reversed ramp passed. Now positional, and the mid stop is asserted too. - `copyWith`/`merge`/`==`/`hashCode` are four hand-written 13-line tables, spot-checked on two fields. A loop over all thirteen asserts each one round-trips and that nothing else moves, which is what catches a crossed line such as `scatterRadius: bubbleMinRadius ?? ...`. - `ChartTheme.wrap` had no coverage at all, though carrying the scope across a route is the reason the class is an InheritedTheme. Mirrors the AITranslationsScope route test. Doc corrections: - `USeries.name` said "shown in legends". This package draws no legend, and the field is now `''` for an unnamed series, which the doc never mentioned. - The README described `ChartView` as "line, bar, or pie ... powered by fl_chart" (it handles eight kinds, and the heatmap is a widget grid), listed two parser formats of seven, and said unset theme fields derive from the ambient ColorScheme — five of them fall back to constants. - The README's Dutch `chartSemanticsLabel` example labelled every chart a bar chart; it now switches on `chart.kind` and handles `isEmpty`. - The example's pie comment pointed at an amber slice the four-slice chart never reached, and the ROADMAP claimed the example showed the unnamed-row fallback while every series was named. Both are now true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(ai): add goldens for the themed and dark chart The eight chart goldens all render the default theme, so they show that defaults didn't move and nothing else — a feature whose whole point is visual had no visual evidence of what it does, which is why running update_goldens produced no new files: there were no new cases to render. Four cases covering the two things a host actually changes: - `chart_view_themed_bar` — a host palette and grid colour reaching the paint. - `chart_view_themed_heatmap` — a host three-stop ramp. - `chart_view_dark_line` — the default line chart under a dark theme. - `chart_view_dark_heatmap` — the ramp inversion `_kDarkScale` exists for, which until now had no coverage of any kind and which the luminance test can only describe. Scope, noted at the group: CI goldens obscure text with an opaque paint, so these pin fills, grid lines and the ramp. Label colours — including the per-slice pie contrast — are invisible to every golden here and stay covered by the widget tests. The committed `goldens/ci/` PNGs have to be rendered on Linux, so these four fail locally until the update_goldens workflow generates them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: Update Goldens * docs(ai): compact the chart theming docs The ChartThemeData docs argued their design at length where a caller only needs to know what a field does and what it falls back to. Every field doc is now one or two lines, and the class doc keeps the three things a host acts on — null means derive, how the three levels compose, copyWith can't unset — while the essay on why layout isn't themeable is down to the one consequence a host can see. Rationale comments survive only where they stop a wrong change: _swap's reason not to interpolate a null, _lerpPalette's wrapping, and AITheme's hand-written == that ThemeData compares with mapEquals. The rest of the argument lives in the ROADMAP entry, which is where it belongs. Three docs were also wrong, not just long: - `ChartView.semanticsLabel` said `''` adds "no semantics at all". It removes this widget's node and so puts the excluded subtree labels back in reach — the opposite of hiding the chart. - `ChartSemantics.seriesNames` claimed axis order; it is series order. - `rowCount`/`columnCount` read as heatmap-only but are populated for every kind, which a translator reading them could not have known. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(ai): describe the chart that is drawn, and pick a readable slice label Five review findings on roadmap 2.4, all confirmed against the code. Pie slice labels no longer go through ThemeData.estimateBrightnessForColor, which compares its 0.15 threshold to (luminance + 0.05)² and so switches at 0.34 — leaving white on mid blue (3.34:1), orange (2.85:1) and red (3.82:1), under the 4.5:1 an 11px label needs. The label is now whichever of white and black87 has the better WCAG contrast against the slice, composited first. Five of the six default colors flip dark; purple keeps white, which reads better on it. The widget test asserts the ratio over kDefaultChartSeriesColors rather than a navy/yellow pair any threshold would pass — the CI goldens can't cover this, since alchemist blocks fl_chart's canvas-painted slice titles. ChartSemantics._formatValue rounds before it decides how to render. It caught exact -0.0 only, so every value in (-0.005, 0) formatted as '-0.00' and was stripped to '-0', voiced as "minus zero". The guarding test used y: -0, the one input that hit the short-circuit. A size range is gathered only for a bubble chart. Every other kind draws its points at one flat radius, but the parsers fill UPoint.size in regardless of the mark, so a Vega-Lite {"mark": "point", "encoding": {"size": …}} — a scatter — announced "sizes 1000000 to 9000000" over identical dots. The gate is in fromSpec, so no translation can reintroduce it. A heatmap names its rows and columns instead of only counting them. It rarely carries axis labels, the row and column are what identify a cell, and the painted labels are excluded from the tree — so "2 rows by 4 columns" was the whole description. Rows come from seriesNames, columns from the new ChartSemantics.columnLabels. Counts are singularised too ("1 slice"). ChartTheme.merge layers onto an enclosing scope, since `of` reads the nearest one only and nesting otherwise shadows the outer palette. Kept IconTheme's behaviour and added its escape hatch; the README's "the three compose" claim now says which three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(ai): correct three claims in the new chart comments Self-audit of the comments added in the previous commit, since adding prose to fix a prose-accuracy problem is the same failure mode. - ChartSemantics.columnLabels said a heatmap draws them "down the side and along the bottom". It draws them along the bottom; the side is the rows, from seriesNames (heatmap_chart_view.dart:61-132). - ChartView._onSlice punctuated a paraphrase as a quotation. Flutter's comment reads "appears to bias more towards", not "biases more towards". - ChartTheme.merge claimed folding AITheme in would leave a widget outliving a change to it. It wouldn't — the Builder depends on AITheme through 'of' and rebuilds. The reason that holds is that it keeps 'data' the overrides its own doc describes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(ai): cut the chart comments back to their point The previous two commits added 64 lines of comment against 20 of code. Trimmed to what a reader needs and can't infer: that estimateBrightnessForColor switches at 0.34 rather than the 0.15 it names, that black87 is composited before measuring, that only a bubble draws UPoint.size, that _formatValue rounds before deciding, and that nesting a ChartTheme replaces. Dropped the measured contrast tables, the ChartTheme.merge sample and the restated rationale — the ROADMAP entry carries all of it at length. Net comment lines across the review round: +64 -> +30. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(ai): tighten the chart theming and accessibility docs A pass over the doc comments 2.4 added, not only the ones this review round touched. Cut the bug archaeology, which git blame holds better than a doc comment does: how the scatter axis used to index per series, how a Chart.js null gap shifted points left, how clamping raw bubble sizes flattened the encoding. Each is now one clause naming the trap rather than a paragraph recounting it. Also cut the restated rationale in AITheme, ChartThemeData, HeatmapChartView and ResolvedChartTheme — the ROADMAP entry carries all of it. Kept every 'don't simplify this back' note: why lerp swaps a one-sided field instead of interpolating, why the row-label gutter isn't themeable, why the pie label style may carry no colour, why the heatmap grid keys rows by label. Comment density across 2.4's lib additions: 38% -> 32%. AITheme's doc-only import of ai_translations.dart went with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(ai): make ChartSemantics compare by value Follows #11, which gave USpec, USeries and UPoint value equality. ChartSemantics is derived from them and exported alongside them, so a host memoizing on one or asserting against one in a test was the odd case out. Matches uspec.dart's shape: identical-first, ListEquality for the two string lists, Object.hashAll for their deep hashes. The test pairs two specs per field, each differing in exactly one derived fact, and asserts both != and a different hashCode. A set of distinct variants would not do: a field dropped from == alone still hashes apart and lands in another bucket, so the collision never happens. Verified by removing three fields from == and three from hashCode and watching the matching test fail each time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: renefloor <15101411+renefloor@users.noreply.github.com>
1 parent a5d5cd7 commit cd50bc4

23 files changed

Lines changed: 3470 additions & 281 deletions

‎packages/stream_chat_flutter_ai/CHANGELOG.md‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,23 @@ First release of `stream_chat_flutter_ai`.
1010
1111
🔄 Changed
1212

13+
- **Pie slice labels now pick black or white per slice** instead of always being white. White
14+
disappears on a light slice, and a host palette is free to contain one — so the colour is the one
15+
of the two with the better WCAG contrast against that slice's own fill. Under the default palette
16+
five of the six flip dark; purple keeps white, which reads better on it. Setting a colour on
17+
`ChartThemeData.pieLabelStyle` takes the choice back.
18+
19+
- **`USpecParser` leaves an unnamed series' name empty** instead of substituting `'Series'` or
20+
`'Pie'`. Those were English strings assigned in a parser that has no `BuildContext`, and
21+
`'Series'` reached the screen as a heatmap row label; the name is now resolved at render time
22+
from `AITranslations.unnamedChartSeries`. Read `USeries.name` from a parse result and you will
23+
see `''` where you used to see one of those two words.
24+
25+
- **`AITranslations` gained two members**, `unnamedChartSeries` and `chartSemanticsLabel`. A host
26+
subclassing `DefaultAITranslations` — which is what the docs tell you to do — picks up the
27+
English defaults and needs no change; one implementing `AITranslations` directly gets a compile
28+
error until it adds them.
29+
1330
- `SpeechToTextController.instance` now asserts rather than allowing `dispose()`. It is owned by the
1431
process; a host disposing its controllers reflexively in `State.dispose` would otherwise brick
1532
dictation app-wide, surfacing much later on an unrelated screen.
@@ -69,6 +86,62 @@ First release of `stream_chat_flutter_ai`.
6986
(override `buildLeading` — still nullable — for no picker at all). Nothing is compared against a
7087
sentinel either, so the `SizedBox.shrink()` hazard that made the other two nullable can't recur
7188
here.
89+
- **Charts are themeable, through a new `AITheme` extension.** `AITheme` is a `ThemeExtension` a
90+
host registers on `ThemeData.extensions`; `ChartThemeData` is its first component theme, carrying
91+
the categorical palette (`kDefaultChartSeriesColors` is the old six-colour set, exported so it
92+
can be extended rather than replaced), the plot height, the scatter and bubble radii, the
93+
histogram's bucket count, the axis, pie-label and title text styles, the grid line colour, and
94+
the heatmap's three ramp stops. Every field is nullable and an unset one derives from the ambient
95+
`ColorScheme`, so overriding the palette leaves everything else following the app in both
96+
brightnesses. `ChartTheme` overrides it for one subtree and `ChartView.theme` /
97+
`HeatmapChartView.theme` for one chart; those three compose, most specific first. Nesting one
98+
`ChartTheme` inside another replaces rather than layers, the way `IconTheme` does — use
99+
`ChartTheme.merge` to add to an enclosing scope instead of shadowing it.
100+
101+
Note that `ThemeData.copyWith(extensions:)` replaces the whole extension set, so an app that
102+
already registers others has to re-list them.
103+
104+
`AITheme` deliberately carries no brightness, colour scheme or typography of its own. Every
105+
widget here already resolves from the ambient `ColorScheme`, and a second brightness would be a
106+
second source of truth able to disagree with `Theme.of` — so this is a bag of overrides over
107+
Material, not a parallel design system. `copyWith`/`merge`/`lerp`/`==` are hand-written rather
108+
than generated: one component theme does not justify a codegen dependency in a package whose
109+
premise is a short dependency list. In `lerp`, an unset field *swaps* at the halfway point
110+
instead of interpolating, because `null` means "derive from the theme" and not "transparent" —
111+
interpolating one would fade a grid line through transparency, or grow a chart up from zero
112+
height, partway through a light/dark transition.
113+
114+
Neither `AIMarkdownBody` nor `StreamingMessageView` gained a parameter for this. Both lookups
115+
reach a chart in the markdown path through its own `BuildContext`, so the fence widget cache's
116+
key is untouched — the same call `AITranslations` made, and for the same reason.
117+
118+
- **`ChartSemantics` compares by value**, like the `USpec` types it is derived from. Two summaries
119+
of the same chart are `==` and hash alike, so a host can memoize on one or assert against one in
120+
a test.
121+
122+
- **Charts describe themselves to a screen reader.** `fl_chart` paints to a canvas and contributes
123+
no accessibility nodes, so a `ChartView` used to be an empty box. Each chart now carries one
124+
semantics node summarising the kind, the title, the axes, the series, the counts and the value
125+
range — "Bar chart, Messages per day, 5 categories, values 8 to 24" — with a sentence per
126+
`USpecKind`. `ChartSemantics.fromSpec` gathers the facts and formats the numbers for speech
127+
(`10.0` reads as "10"); `AITranslations.chartSemanticsLabel` composes the sentence, so it is
128+
translatable like everything else. Pass `ChartView.semanticsLabel` to replace it, or an empty
129+
string to add no node and describe the chart yourself.
130+
131+
The node excludes the subtree beneath it. The axis tick labels and a heatmap's row, column and
132+
legend labels are real `Text` widgets, and letting a reader walk them yields a run of bare
133+
numbers with nothing saying which axis they belong to or how they pair up; the summary already
134+
carries the range, the counts and — where the data named them — the axis labels.
135+
136+
A heatmap is the one kind whose summary also names things rather than only counting them. It
137+
rarely has axis labels, and what identifies a cell is the row and column it sits in, so it reads
138+
"Heatmap, 2 rows by 4 columns, rows: Mon, Tue, columns: 9am, 12pm, 3pm, 6pm, values 2 to 14".
139+
The row names come from `ChartSemantics.seriesNames` and the column names from its new
140+
`columnLabels`.
141+
142+
This is also the first thing in the package to read `USpec.xLabel` and `USpec.yLabel`, which the
143+
parsers have been filling in for nobody.
144+
72145
- **Every string the package renders itself is now translatable.** `AITranslations` (abstract) and
73146
`DefaultAITranslations` (the English it has always rendered) replace the fourteen literals that
74147
were hardcoded across `ChatComposer`, `ChatComposerInput`, the attachment sheet, the voice-input
@@ -285,6 +358,29 @@ First release of `stream_chat_flutter_ai`.
285358

286359
🐞 Fixed
287360

361+
- **A chart's spoken value range dropped its fraction and kept the decimal point.** The trailing-zero
362+
strip in `ChartSemantics` ran on the whole formatted number rather than on the fraction, so a value
363+
rounding to `x.00` — `99.999`, `3.999`, `1.001` — came out as `100.`, `4.` and `1.`, which a screen
364+
reader voices as "one hundred point". Charts whose values all sit below `0.005` fared worst: both
365+
ends of the range collapsed to `0.`, so the range clause said nothing at all. Since the chart's
366+
`Semantics` node excludes the painted axis labels, that sentence is the only thing a screen-reader
367+
user gets. The same rewrite fixed the other end of the rounding: a value in `(-0.005, 0)` is
368+
neither zero nor whole, so it formatted as `-0.00` and was stripped to `-0` — voiced as "minus
369+
zero". A chart of small negative deltas announced "values -0 to -0".
370+
- **A line, area or scatter chart announced a size range it never drew.** Only a bubble chart maps
371+
`UPoint.size` onto anything; every other kind draws its points at one flat radius. The parsers
372+
fill `size` in regardless of the mark, so a Vega-Lite `{"mark": "point", "encoding": {"size":
373+
…}}` — a scatter — described "sizes 1000000 to 9000000" over a dozen identical dots.
374+
- **A pie or histogram described series it doesn't draw.** `ChartView` plots the first series and
375+
ignores the rest for both kinds, but `ChartSemantics.fromSpec` flattened every series — so a
376+
two-series pie showing four slices was announced as eight, with the largest slice's share computed
377+
against a total the chart never draws. `USpecParser` accepts a multi-series pie from model output,
378+
so this needed nothing unusual to hit. The summary now covers exactly what is on screen.
379+
- **A pie whose first series was empty crashed the frame.** `ChartSemantics.fromSpec` reduced over
380+
that series without a guard, throwing `StateError` inside `build` and replacing the whole
381+
`AIMarkdownBody` with an error widget, where `ChartView` renders the same spec as an empty pie.
382+
Reachable through the public `USpec` constructor, which validates nothing.
383+
288384
- `ChatComposerInput` now rebuilds on its own listenables. As the private `_InputContainer` it was only ever constructed inside `ChatComposer`'s `ListenableBuilder`; exported, it read `ChatComposerController` state and the process-wide `SpeechToTextController.instance` while subscribing to neither, so standalone use rendered once and then froze — typing never enabled send, a dismissed chip stayed on screen, and with `enableSpeechToText` a dictation session could run with no control able to stop it.
289385
- `ChatComposerInputProps.hintText` is now `String?`, and `null` where the host passed no `ChatComposer.hintText` — **breaking change** for a custom input reading it. It was non-nullable, defaulting to a constant, which left an input no way to tell a hint the host chose from the package's own default, and put the English literal on the composer rather than in the translations. The slot rendering the field now owns what an absent hint means: `ChatComposerInput` falls back to `AITranslations.composerHint`, and a custom input wanting the same writes `props.hintText ?? AITranslations.of(context).composerHint`.
290386
- `ChatComposerInputProps.onSend`/`onStop` now check `mounted`. They are handed to host code that may call them across an async gap — a confirmation dialog, a debounce — and doing so after the composer left the tree sent a message from an abandoned screen and then notified a disposed controller, which only asserts in debug.

‎packages/stream_chat_flutter_ai/README.md‎

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,14 +170,59 @@ The block stays dark regardless of the ambient `Theme` — code reads as code.
170170

171171
### `ChartView` + `USpec`
172172

173-
Renders a `USpec` chart as a line, bar, or pie chart powered by `fl_chart`. Parse AI
174-
responses that contain JSON chart data with `USpecParser`:
173+
Renders a `USpec` as a line, bar, area, scatter, bubble, pie, histogram or heatmap chart. All but
174+
the heatmap are painted by `fl_chart`; the heatmap is a plain widget grid. Parse AI responses that
175+
contain JSON chart data with `USpecParser`:
175176

176177
```dart
177-
final spec = USpecParser.tryParse(jsonString); // supports USpec and Chart.js formats
178+
// Reads USpec, Chart.js, Plotly, ECharts, Highcharts, Vega-Lite and a flat pie schema.
179+
final spec = USpecParser.tryParse(jsonString);
178180
if (spec != null) ChartView(spec: spec);
179181
```
180182

183+
**Theming.** Colors and sizes come from `ChartThemeData`, registered app-wide as part of the
184+
package's `AITheme` extension:
185+
186+
```dart
187+
MaterialApp(
188+
theme: ThemeData(
189+
colorSchemeSeed: brandBlue,
190+
extensions: const [
191+
AITheme(chartTheme: ChartThemeData(seriesColors: [Color(0xFF005FFF), Color(0xFF00C1FF)])),
192+
],
193+
),
194+
...
195+
)
196+
```
197+
198+
Every field is nullable, and leaving one unset means "derive it from the ambient `ThemeData`" — so
199+
overriding the palette leaves the grid lines, axis labels and heatmap ramp following your app, in
200+
light and dark alike. The sizing fields (`height`, the scatter and bubble radii, `histogramBinCount`)
201+
have no theme to derive from, so they fall back to fixed defaults instead. Override it for a subtree with `ChartTheme(data: ..., child: ...)`, or for
202+
one chart with `ChartView(theme: ...)`; those three compose, most specific first.
203+
204+
Two things worth knowing:
205+
206+
- **`ThemeData.copyWith(extensions:)` replaces the whole extension set.** If your app already
207+
registers others, re-list them:
208+
`theme.copyWith(extensions: [...theme.extensions.values, const AITheme()])`.
209+
- **Pie slice labels pick black or white per slice** — whichever has the better WCAG contrast
210+
against that slice's own fill. Set a color on `ChartThemeData.pieLabelStyle` to take that choice
211+
back.
212+
- **Nesting `ChartTheme` replaces rather than layers**, the way `IconTheme` does. Use
213+
`ChartTheme.merge` to add to an enclosing scope instead of shadowing it.
214+
215+
**Accessibility.** `fl_chart` paints to a canvas and exposes nothing, so each chart describes
216+
itself: one semantics node whose label summarises the kind, the title, the axes, the series and the
217+
value range — "Bar chart, Messages per day, 5 categories, values 8 to 24". The node deliberately
218+
excludes the subtree, because letting a screen reader walk the tick labels gives a run of bare
219+
numbers with nothing saying which axis they belong to. Because of that exclusion a heatmap names
220+
its rows and columns rather than only counting them — "Heatmap, 2 rows by 4 columns, rows: Mon,
221+
Tue, columns: 9am, 12pm, 3pm, 6pm, values 2 to 14" — since a heatmap rarely has axis labels and
222+
the row and column are what identify a cell. The sentence is composed by
223+
`AITranslations.chartSemanticsLabel` (see [Localization](#localization)); pass
224+
`ChartView(semanticsLabel: ...)` to replace it, or an empty string to describe the chart yourself.
225+
181226
---
182227

183228
### `ChatComposer`
@@ -610,6 +655,38 @@ Three things worth knowing:
610655
while such a route is open doesn't reach the open route, only the next one. The delegate has no
611656
such seam.
612657

658+
`chartSemanticsLabel` is the odd one out: it takes a `ChartSemantics` — the facts about a chart,
659+
with their numbers already formatted — and composes a whole sentence, rather than being one short
660+
label. A sentence's word order varies far more between languages than a tooltip's does, so
661+
composing it is the point:
662+
663+
```dart
664+
@override
665+
String chartSemanticsLabel(ChartSemantics chart) {
666+
final kind = switch (chart.kind) {
667+
USpecKind.bar => 'Staafdiagram',
668+
USpecKind.pie => 'Cirkeldiagram',
669+
USpecKind.heatmap => 'Heatmap',
670+
_ => 'Grafiek',
671+
};
672+
if (chart.isEmpty) return '$kind, geen gegevens';
673+
final count = switch (chart.kind) {
674+
USpecKind.bar => '${chart.categoryCount} categorieën',
675+
USpecKind.pie => '${chart.pointCount} segmenten',
676+
USpecKind.heatmap => '${chart.rowCount} rijen bij ${chart.columnCount} kolommen',
677+
_ => '${chart.pointCount} punten',
678+
};
679+
return '$kind, $count, waarden ${chart.valueMin} tot ${chart.valueMax}';
680+
}
681+
```
682+
683+
Switch on `chart.kind` — it covers all eight `USpecKind`s, and a label that names the wrong kind is
684+
worse than a generic one. The count fields are not interchangeable either: `categoryCount` is the x
685+
axis a bar chart draws, `pointCount` counts plotted points (a pie's slices, a histogram's samples),
686+
and `rowCount`/`columnLabels` are a heatmap's grid. Check `chart.isEmpty` before reading
687+
`valueMin`/`valueMax`: they are `null` for a chart with no points. `sizeMin`/`sizeMax` are non-null
688+
only for a bubble chart, the one kind that draws `UPoint.size`.
689+
613690
Subclassing `DefaultAITranslations` rather than implementing `AITranslations` directly means a
614691
string added in a later version arrives as an untranslated default rather than a compile error.
615692

0 commit comments

Comments
 (0)