Skip to content

Release 2.4.0+66 - #1270

Merged
simonoppowa merged 85 commits into
mainfrom
release/2.4.0
Sep 26, 2026
Merged

simonoppowa merged 85 commits into
mainfrom
release/2.4.0

Conversation

@simonoppowa

@simonoppowa simonoppowa commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Release 2.4.0+66. Candidate frozen at 680eafaa; this branch is that tree with main merged in, resolving to develop's side.

git diff origin/develop HEAD is empty — the merge brings nothing down from main, it only gives main an ancestry link so the PR can merge. That corroborates #1242, which found no main-only line develop lacks.

Charted, tested and ruled through Map: a verified go/no-go for 2.4.0 — 13 tickets, all closed.

What ships

Evidence

Device: driven on a Pixel 6 (Android 17) over adb, upgrading in place from a 2.3.0 box. Record: docs/release-tests/2.4.0/ on research/release-2.4.0-pixel-test. No step hit a blocker. Highlights:

Pre-PR checks (#1241, #1242): no outbound destination added, removed or redirected — same hosts, same RPCs, same payloads — so the Play data-safety record and PrivacyInfo.xcprivacy stand with no console edit. And develop is missing nothing main has; the ARB diffs are Weblate key reordering, with zero main-only keys.

Known, and shipping deliberately

Not verified on device

After merging

docs/RELEASING.md applies. release-ship pauses for approval — that is the irreversible point, since it spends a Play versionCode and a TestFlight build number. Afterwards: read android-deploy's annotation rather than its colour, submit the TestFlight build, and paste changelogs/66.txt into both consoles (the pipeline uploads no metadata).


Merge with a merge commit, not squash. A squash here re-strands the merge base and makes the next release conflict across everything — the release/* → main shape (and 5a582111 Merge main into release/2.3.0 before it) is how 2.2.0 and 2.3.0 landed.

simonoppowa and others added 30 commits September 8, 2026 14:58
…Free forever" (#1120)

Retires the "Free forever. No account." hero caption, which restated the copy
rendered directly above the carousel on both stores and made an unfalsifiable
forward promise under Play's Misleading Claims policy. It is now
"No sign-up. No paywall.", and the App Store promotional text drops the same
phrase for claims that hold in the present tense.

Captions the Play phone set, which had been committed as raw captures — the
second pass described in compose.py's own docstring had never been run, so
#1072's decision was undone on Play. Same captures, no re-shoot.

Adds the App Store set: six at 1290x2796 and six at 2064x2752, captured with
`xcrun simctl io booted screenshot` because the CI lane still cannot get an
image off the simulator (#1076). Nine of twelve are final; the README records
the two owed re-shoots, the #1080 answer that a released version is read-only
so these need a build to ship, and the open question about the 6.9" slot.

Also, from review:

- rejects a too-wide word that arrives after a wrap (pre-existing)
- preserves the raw captures under tools/screenshots/raw/ and refuses to
  composite an output, or a directory onto itself — the in-place Play pass had
  left the raws reachable only through git history
- clears the committed sets before capture, so a single-device run cannot
  present the other device's committed set as its own output
- runs the Apple spec check on !cancelled(), so a partial run's assets are
  validated rather than skipped and uploaded unchecked
The lane never produced a screenshot. It drove the app with `flutter test`,
which uninstalls the app on exit, so iOS deleted the data container the
captures had been written into — confirmed across three dispatches, with the
app absent from `simctl listapps` while 131 unrelated containers survived and
every PNG left on the device belonging to the GeoServices cache.

Reviving it means a `flutter drive` + `onScreenshot` conversion. Against a cap
of five concurrent macOS runners, and map #1016's effort that took macOS queue
p90 from 35.4 min to 1.7, that is not worth it for a set re-shot about once a
release. The twelve committed assets came from `xcrun simctl io booted
screenshot`, which writes host-side and never meets the problem.

`integration_test/store_screenshots_test.dart` is kept for what it encodes —
the shot order, the finders, the demo fixture, and the assertions that refuse
a loading or banner-covered frame — not as a working capture route. It writes
to `getApplicationDocumentsDirectory()`, so the container deletion takes its
output too; that is a property of the test rather than of CI, and a hand-run
hits it on an Android emulator as readily as on an iOS simulator. Its
docstring and the screenshots README say so, after review caught the README
documenting a `flutter test` recipe that returns no files for exactly the
reason the paragraph above it explained.

`tools/screenshots/compose.py` and `captions.en-US.json` are untouched — the
Play path still uses them.

Closes #1076.
Play's Health Connect permissions policy rejected the update for
"excessive data access for declared functionality", naming BodyFat,
Distance and StepsCadence/Steps. Enforced 8 Sept 2026, with 2.2.0 left
live.

READ_DISTANCE and READ_STEPS were never wanted. The health plugin's
workout read issues a DistanceRecord and a StepsRecord query for every
session it returns, and Health Connect fails the whole read without
them, so the permissions existed to satisfy a library rather than a
feature -- the values themselves were fetched and thrown away, since
energy is attributed from the raw calorie records instead. Unchanged in
13.3.2, so no version bump escapes it.

So the session read moves out of the plugin: a small method channel over
androidx.health.connect reads ExerciseSessionRecord directly, paged to
exhaustion, and everything the import consumes -- id, start, end,
activity type, writing app -- is on that record. The calorie read stays
with the plugin, which needs only READ_TOTAL_CALORIES_BURNED. Exercise
type is mapped type-to-name rather than the plugin's name-to-type, so
the three types several names share (dancing, skiing, wheelchair) resolve
deterministically instead of by hash order.

READ_BODY_FAT personalised the workout calorie-credit suggestion, which
already fell back to a BMI-derived percentile for anyone whose health
store held no body fat. Android now always takes that path; HealthKit is
not subject to this policy and still reads it.

The platform is injected into HealthPackageService rather than read from
Platform, so both branches are exercisable from a host test.

health_connect_permissions_test asserts the manifest holds those two
permissions and no others. Nothing else would catch a regression:
android-deploy tolerates a failed upload by design (#942), and the next
thing to inspect the declaration is a human reviewer, days later.

docs/play-data-safety.md records the Data safety answers separately --
that form is Console-only and unrelated to this fix.
Build 64 was submitted and rejected under Play's Health Connect
permissions policy, so its version code is spent -- Play refuses a
reused one, and release-gate refuses a build number that does not
increase.

The note carries build 64's four lines unchanged, because 64 never
reached anyone, plus a line for the permission change users will see:
the health sync grant sheet now offers two rows instead of five.

496 characters, inside Play's 500-character cap.
* fix(l10n): disclose body fat only where it is read

Play's Health Connect permissions policy refused this app READ_BODY_FAT
as excessive, so Android will read workouts alone. The disclosure and
the permission-denied message still promised body fat on both
platforms, which leaves an Android user reading about a permission the
Health Connect sheet never offers them, and being told to grant
something that was never asked for.

healthSyncDisclosureBody is split three ways: the part that holds on
every platform, an addendum shown only where body fat is actually read,
and a footer carrying the closing guarantees. The addendum then lands
between what is collected and those guarantees, which is where the
removed sentences sat — a paragraph about what is read would be an
afterthought if it followed the line about turning the feature off.

The nine translations are the existing ones re-cut along that seam
rather than new copy; only the addendum's opening sentence is
assembled, from each locale's own words.

healthSyncPermissionDeniedLabel drops body fat on both platforms.
Body fat is not what an import needs, so it is not what a failed
import should ask the user to grant.

This must not ship ahead of the manifest change that drops the
permission: while Android still asks for body fat, a disclosure that
omits it is an under-disclosure.

* docs(readme): body fat is read on iOS only

Play's Health Connect permissions policy refused this app READ_BODY_FAT
as excessive, so the Android build reads workouts alone and derives the
calorie-credit suggestion from height and weight instead.

Same hold as the disclosure strings: this must not ship ahead of the
manifest change that drops the permission.

* docs(health): the sequencing note is spent

The manifest change landed in #1123, so the warning not to ship ahead of
it no longer describes anything. Point at the other half of the rule
instead: `readLatestBodyFatPercent` returns null on Android for the same
reason, and the two have to move together.
Copilot and an internal review converged on the same set. Three are
defects in what #1123 shipped, two are the #1132 nits.

The sixth, the permission guard that could not see a plugin merging its
own uses-permission in, is deliberately not here: #1131 fixes it on this
branch by reading each plugin's manifest from
.flutter-plugins-dependencies, which needs no Android build and so has
teeth in linux-checks. That is the better guard. This commit leaves the
file untouched so the two do not collide.

docs/play-data-safety.md told the maintainer to flip Play's "data can't
be deleted" answer because Settings offers a delete-all. That was wrong
twice over: deleteAll() clears the ACTIVE PROFILE's boxes and
deliberately leaves the shared libraries and settings, and it sends no
deletion request to anyone. Play's question is about collected --
off-device -- data, and nothing in the app can delete what already
reached Sentry, an AI provider, Open Food Facts or the Supabase backend.
Acting on the old advice would have put a claim on the form the app
cannot honour, so the section now says leave the answer alone, with the
recipients tabulated and the reasoning kept rather than quietly deleted.

MainActivity caught Exception around the Health Connect read, which also
catches CancellationException. Destroying the activity mid-read cancels
the scope, and the handler turned that into result.error() on a channel
whose engine is gone. Cancellation is rethrown first.

HealthConnectWorkoutReader's KDoc said an unmapped exercise type is
reported by its Health Connect name; the else branch returns "OTHER".

The disclosure dialog's composition had no test: the assertions
re-implemented _body rather than observing it, so deleting the
healthStoreReadsBodyFat gate -- telling every Android user their body fat
is read -- left the suite green, as did dropping the footer. A widget
test now asserts the rendered text on the host, which is the Android
branch and the compliance-critical one. Both mutations fail against it.

The English addendum said "also reads" against the body's "will read", in
a dialog shown before consent; English is the template a Play reviewer
reads.
The release summary read `deployed/<n>` as "a store consumed this build" and
told the operator to bump the version. That holds for iOS and does not hold
for Android.

When Play refuses the bundle with the known health-declaration defect (#942),
`android-deploy` tolerates it, sets `play_uploaded=false` and goes green — and
the ledger step is the unconditional last step of that job, so the tag is
written regardless. Play never took the bundle. If `ios-deploy` then failed
before TestFlight, no store consumed the number, yet the summary said it was
spent and to ship afresh. That is a wasted version code every time, on the
exact defect this pipeline already tolerates by design, so it is the likely
path rather than a corner case.

`play_uploaded` is already passed into this step, so the tag is now read
alongside it: when Play rejected, the advice says the tag proves only that a
deploy job reached its final step, that the answer turns on whether iOS
reached TestFlight, and to check that before deciding.

Unset `play_uploaded` — android-deploy skipped entirely — keeps the old
conservative wording, since a skipped Android job cannot have written the tag
on a rejection path.

Verified by exercising all three ls-remote outcomes against
`play_uploaded` true, false and empty, and by re-parsing the step with
`bash -n`.
* fix: three review findings from the 2.3.0 release PR

**The Play listing claimed weight logs are not exported.** They are:
export_data_usecase.dart writes weight_log.json, and a device export run
for #1042 produced it at 568 bytes alongside the five other JSON members.
A reader deciding whether their weight history is backed up would have
got the wrong answer. The sentence now names the weight log among what
the JSON zip carries and drops it from what stays out; profile, custom
meals, water and fasting genuinely do stay out and are unchanged.

The edit is length-neutral by construction. That listing sits at 3991 of
Play's 4000 characters — nine to spare — and the cap test counts UTF-16
units, not bytes, so `wc -c` reads 4049 and looks over when it is not.

**check_agents_md measured only the root AGENTS.md** while the budget it
guards is cumulative: Codex reads every AGENTS.md that applies to a path.
A scoped subdir/AGENTS.md would have kept the check green while the real
total blew the 32768 limit, silently dropping the rules the guard exists
to protect. It now sums every tracked AGENTS.md and lists them when over.

**Eight AI docs linked a file that does not exist.** The OpenRouter path
lives in openai_compatible_meal_items_api.dart as OpenAiCompatibleMealItemsApi;
the docs still pointed at openrouter_meal_items_api.dart and a class name
that appears nowhere in the tree. 36 references across seven files.
test/unit_test/openrouter_meal_items_api_test.dart kept its name and its
links, which is why those were left alone.

Verified: every lib/ and test/ link in docs/ now resolves, the guard runs
green, store_declarations_test passes all 27, flutter analyze clean.

* fix(store): the export line promised CSVs it does not write

The Play description offered "diary entries, activities, tracked days,
recipes and weight log as a JSON zip ... or as flat CSVs". Only the first
three are written in both formats. `ExportDataUsecase.assembleArchive` adds
recipes, photos, the weight log and activity templates inside
`if (format == ExportFormat.json)`, and its own comment says so: "Recipes,
photos, weight log and Custom activity templates — JSON only."

So a reader taking a CSV backup to preserve their weight history would lose
it, which is a Misleading Claims exposure on the description #1077 is about
to publish — the same class of false capability claim #1049 removed from
this file.

The recipe half of the sentence was wrong before this branch touched it and
is corrected here too, rather than left standing in a line being rewritten
for the identical reason.

"for a spreadsheet" is dropped to pay for the new clause: the file was at
3990 of Play's 4000-character cap, so every accurate phrasing that kept it
went over. Now 3989, with the headroom preserved rather than spent.

* fix(justfile): charge each AGENTS.md chain, not the whole tree

The guard summed every tracked AGENTS.md. Sibling scoped files never apply
to the same reviewed path — a file under `a/` gets the root instructions
plus `a/AGENTS.md`, never `b/AGENTS.md` — so the sum could fail the
31,000-byte limit while every chain Codex actually reads stayed under it.
Measure the worst root-to-leaf chain instead: each file plus the AGENTS.md
in its ancestor directories, maximised over all of them. The failure
message now lists that one chain rather than every tracked file.

Also drop the `*AGENTS.md` suffix glob, which matched a tracked
`NOTAGENTS.md` and charged its bytes to a file Codex never reads. Two
pathspecs, `AGENTS.md` and `*/AGENTS.md`, match the exact basename at the
root and in subdirectories.

* docs(ai): attribute two source quotes to the code that holds them

Renaming the cited client left both notes pointing at a file that
contradicts them.

`ai-openai-wire-format.md` said the comment in
`openai_compatible_meal_items_api.dart` reasons that `required` would
oblige the model to estimate. It says the opposite: lines 194-212 record
that as the former rationale, note that a nullable union invalidates it,
and give the reason #683 settled on. That rewrite was #701, acting on this
very note — so quote the revision the note was written against
(`openrouter_meal_items_api.dart` at 7481bff) and say plainly that the
current comment no longer makes the argument.

`ai-openai-key-transfer.md` quoted `'Bearer ${_apiKey()}'` against the
generic client, which calls a nullable callback and emits `'Bearer $key'`;
the quoted expression is `openai_meal_items_api.dart:105`. Both are now
named with the code each one contains. While checking, the same bullet's
"only two sites in lib/" turned out to be four: the direct OpenAI client,
the generic client, the Anthropic client, and `ai_model_list_api.dart`,
which sends the key to a configured `/v1/models` route.

* fix(just): keep check_agents_md runnable on macOS bash 3.2

`declare -A` is bash 4. macOS still ships bash 3.2 as /bin/bash, so the
recipe died at the declaration on the machine most likely to run it by hand —
the same trap .github/scripts/pod_install_with_targeted_fallback.sh already
documents.

The sizes are carried as `size<TAB>path` lines and read with IFS instead,
which needs nothing newer than bash 3. Logic is unchanged: on a scratch tree
with root 19979, a 9000, a/deep 3000, a/deep/deeper 100 and a sibling b 9000,
it still reports the worst chain as 32079 over four files and does not charge
the sibling.

The reviewer's other point — that `*/AGENTS.md` matches only one level — is
wrong, and no change is made for it. Git pathspecs are matched without
FNM_PATHNAME, so `*` crosses `/`: on that same tree the pathspec returns
a/deep/AGENTS.md and a/deep/deeper/AGENTS.md alongside the shallow ones.
* fix(test): let the permission guard see plugin manifests

The guard read only android/app/src/main/AndroidManifest.xml, while its own
docstring named "a Flutter plugin that merges its own uses-permission into
the manifest" as one of two regressions it covers and said "Both show up
here." Gradle merges plugin manifests into the application manifest, so such
a permission reaches the APK and Play's reviewer without ever appearing in
the app manifest. That half was claimed, not implemented.

Plugin manifests are now read too, via the paths in
.flutter-plugins-dependencies, and any android.permission.health.* found in
one fails with the plugin named. `flutter test` regenerates that file before
running, so the guard always sees the real dependency set rather than a stale
one; its absence fails a test of its own instead of passing quietly.

Verified by adding READ_DISTANCE to a real cached plugin manifest: the guard
fails with {'android_file_picker': ['READ_DISTANCE']}, and passes again once
removed. An earlier attempt to prove it by appending a fake plugin to
.flutter-plugins-dependencies proved nothing — the regeneration wipes the
edit before the test reads it.

This matters here rather than in the abstract: build 64 was rejected under
Play's Health Connect permissions policy for exactly these permissions, and
the health plugin needing READ_DISTANCE and READ_STEPS for its own workout
path is why this app reads ExerciseSessionRecord itself. A plugin bump is a
live route back to that rejection.

The comment on the "beyond those two" test claimed the same plugin coverage
and is corrected to point at the new test.

* fix(test): read every plugin source set, and prove one was read

Two review findings on the previous commit, both correct and both about the
guard being weaker than its name.

Gradle merges variant overlays such as `android/src/release/
AndroidManifest.xml` into the production manifest, so a permission declared
only there would ship while a `src/main`-only check stayed green. Every
source set under a plugin's `android/src` is now read. Verified by creating
such an overlay on a real cached plugin: the guard fails with
{'android_file_picker': ['READ_STEPS']} and passes once removed.

The other test asserted only that `.flutter-plugins-dependencies` exists,
while its name claimed the plugin half had "checked something". If the paths
in it resolved to nothing — a pub cache that had never been populated — the
plugin test would pass having read no files at all. It now counts the
manifests actually read and requires at least one, and is renamed to say what
it checks.

Still forward-looking, deliberately: no plugin here has a non-`main` source
set, and none declares a health permission. That is when a guard is worth
adding rather than after the next rejection.

* fix(test): scan only release source sets, and match any attribute order

Two more review findings, both correct.

Iterating every directory under a plugin's `android/src` also read `debug`,
`profile` and `androidTest`, which Gradle never merges into the uploaded
artifact. A permission a plugin declares for instrumentation would have
failed linux-checks over a release manifest that was clean. Only `main` and
`release` are read now — the same rule already applied to the app's own
manifests, which is why its debug and profile ones were never in scope.

The pattern also insisted on `android:name` being the first attribute and
double-quoted. Both are free in XML and both occur:
`<uses-permission android:maxSdkVersion="32" android:name='…'/>` merges
exactly like the canonical spelling, and would have left the guard green
while the release acquired the permission. Attribute order and quote style
are now free.

Not handled, and documented rather than pretended: a manifest binding the
Android namespace to an alias other than `android:`. The only robust fix is
structural XML parsing, and `xml` is a transitive dependency here — promoting
it to a declared one for this is not worth doing on a release branch.

Verified against a real cached plugin: a `src/release` overlay with the
attributes reversed and single-quoted fails with
{'android_file_picker': ['READ_BODY_FAT']}, while the same permission in
`src/debug` is correctly ignored.
…1135)

Develop twin of two fixes on the 2.3.0 release PR (#1117).

**Play description.** "AI meal assistance sends only the line you type or the
photo you take" was not the whole payload: both interpreters append the app
language to the system prompt, and every request names the selected model.
The exclusive "only" made a listing claim the code does not keep. Paid for by
trimming "no paid tier, no advertising", which restated its own bullet and the
opening line — the file was at 3989 of Play's 4000-character cap, and is now
3969.

**iOS privacy manifest.** The two AI types were unlinked on the stated grounds
that the app "generates no user or device identifier and sends none". The
first half is true, the second is not: those requests travel under the user's
own provider credential, which names the account the content belongs to. This
file already states the tie-breaker — over-declaring costs a less flattering
row, under-declaring costs a rejected submission. Both AI types are now
linked; the three diagnostic types are untouched.

The test asserted every type was unlinked, encoding the same mistake, and now
pins linking per type. Its comment said so too, and is corrected.
develop's copy had fallen well behind. main now carries three overlapping
checks; develop had two, and the two it had were the older implementation.
Missing here: reading the app's own release overlays, honouring
`tools:node="remove"`, and the merged-manifest check that inspects what
Gradle actually produced.

Taken wholesale from the shipped version rather than ported hunk by hand.
Piecemeal porting is what let the two branches drift in the first place —
each fix landed on the release branch as review found it, and the pairing
PRs kept chasing a file that had already moved again.

Nothing develop-only is lost. Every test it had is still here and one is
added; the only lines that disappear are the older single-manifest read and
the regex the shared matcher replaces.

Verified on develop by making each capability fail and pass:

  * a merged manifest carrying READ_STEPS with the attributes reversed and
    single-quoted fails, naming the full set;
  * READ_BODY_FAT in android/app/src/release fails the refused-permissions
    test — a source set develop's copy never read;
  * a plugin declaring READ_DISTANCE alongside a tools:node="remove" for it
    in the app manifest passes, so the standard remedy is not blocked.

Supersedes the pairing PRs: #1136 ports an older version of this same group,
and #1137's content is included here.
`manifest` is the joined String of the app's own manifests, bound for the
whole of main(); the loop rebound the same name to a File. The code was
correct — `manifest.readAsStringSync()` resolved to the File, which is what
was meant — but the reader has to work that out.

Carried over from #1136, which caught it on its copy of this group and is
being closed in favour of #1144. Closing it without this would have thrown
the fix away. The `reason` string is reflowed to stay inside 80 columns.
Only line 6 exceeds it, as it already did.
The block sat two spaces deeper than everything around it — the enclosing
if closes at six, so the loop belongs at six. Introduced when the loop was
spliced in to rename the shadowed variable; nothing about it is deliberate.

No behaviour change: the guard still fails on a merged manifest carrying
READ_STEPS with the attributes reversed and single-quoted.
…uard-develop

test(health): converge the permission guard on the shipped version
…1146)

* docs(releasing): the Android upload worked, without calling it fixed

Build 65 uploaded through the Play API on 2026-09-10 -- fastlane logged
"Successfully finished the upload to Google Play", android-deploy emitted
no ::warning::, and its only annotation was the ledger notice. The
runbook still told the releaser to expect a rejection and walked through
a hand-upload that did not happen.

It would be easy to write this up as "#942 is fixed". Two things changed
at once and this repo cannot separate them: Google may have fixed the
Publishing API, or the bundle may have stopped provoking it, since build
65 is the first to declare two android.permission.health.* permissions
rather than five after Play's Health Connect enforcement on 8 Sept forced
the reduction (#1122). Nobody has run the release that would tell them
apart, so the section says so rather than picking one.

The tolerance and the hand-upload steps stay. They cost nothing when the
upload works, and what they guard against is silent.

Also gives the releaser a cheaper tell than reading the step summary: a
successful upload leaves a notice annotation and nothing else, a
tolerated rejection leaves a warning.

* docs(releasing): keep the Play upload conditional in the summary table

The row said android-deploy uploads to the internal track. It attempts
to. default_workflow.yml sets play_uploaded=false and emits a
::warning:: on the #942 rejection, then lets the job succeed -- so it
can finish green with nothing on Play, which is the whole reason the
section below it exists.

Recording build 65's success by dropping the hedge put the unconditional
promise back into the one line most people read. Restores "attempt",
keeps the build-65 fact, and names the failure mode in the row itself.

Caught by Codex on #1146.
…ver answers (#1149)

The own-server provider is given 120 s to answer (#776, measured in
#774: a cold Ollama model took 22-24 s on an M4 Mac mini before it
replied at all, and Ollama unloads after five idle minutes, so the
first request of a meal is the ordinary case). The bulk-add screen
spent all of that on a bare CircularProgressIndicator: no movement,
no words, no way out. Twenty-plus seconds of that reads as a hang,
and #774 already documented where that sends people - to debug a
network that is fine.

The loading state on the own-server path now counts the seconds up,
says after ten of them that the server is probably loading its model
and that this is normal, and offers Cancel. Hosted providers answer in
seconds and keep the plain spinner; the screen asks the keystore which
provider is active, the same way and at the same moments it already
resolves the photo destination, because the bloc emits the loading
state before the use case decides which provider it will call.

Cancel does not abort the request. Neither interpreter can: package:http
has no per-request cancel, and closing the shared client would take
every other request with it. It leaves the loading state and discards
the result when it lands, via an attempt counter the bloc compares
after every await. The counter also tells one loading state from the
next, so a Search tapped mid-wait restarts the elapsed count for the
request actually in flight instead of carrying on from the one it
replaced. The server finishes the work either way, and for a model
that was loading that is no loss - the next request finds it warm.

ownServerTimeout is unchanged; its own comment says a shorter ceiling
is the wrong fix, and this is the progress state it asked for.

Fixes #1148
ExportImportBloc caught every exception on the export, import and
sample-download paths and threw the reason away: ten `catch (e)` sites,
six emitting a bare ExportImportError, none logging. A wrong file, a
picker that returned no usable path, a DBO whose JSON no longer parsed
and the ExportWriteVerifier's StateError all rendered as the same
"Export / Import error", and a bug report could say nothing more.

The use cases now throw ExportImportFailure with a reason at the
boundaries they understand — the file picker, reading and decoding the
archive, saving an export — and the bloc logs every failure with its
exception and stack trace through the app's Logger before putting the
reason on ExportImportError. Anything unclassified is filed as
`unexpected`, so it still surfaces instead of vanishing.

A dismissed picker was conflated with a pick that returned a null path;
both threw Exception('No file selected'). They are separated now: a
cancel returns the dialog to its initial state (as the other importers
already did), a null path is reported as unreadable. The picker is
injectable on ImportDataUsecase so the tests can drive both.

The dialogs show a different sentence for unreadable / wrong format and
for a failed write, and keep the generic label for the unexpected case.
The two new strings are translated in all nine locales.

Fixes #1103
…units (#775)

Open Food Facts normalises every `<nutrient>_100g` field to grams, whatever
unit the packaging used. This entity carries each micronutrient in the unit
the app displays it in: milligrams for the minerals, micrograms for vitamins
A, D and B12. `fromOffNutriments` copied the values across untouched, so
every mineral was stored a thousand times too small and those three vitamins
a million times too small.

That is what Genfood saw in #716. Vitamin D read 0.00µg on every Open Food
Facts product, in search results and in the Today's nutrients sheet alike,
because 5µg had been stored as 0.000005. The daily micronutrient panel sums
these same fields and compares them against reference intakes in mg and µg,
so a food scanned from Open Food Facts was contributing almost nothing to
any micronutrient goal, quietly, without ever looking broken.

The other two sources were already right, which is what made the mismatch
easy to see once you looked: the Supabase view pivots into the app's units,
and FDC publishes these nutrients in mg and µg natively. The same field
therefore meant milligrams from one database and grams from another.

Two small helpers on the factory now do the conversion, and the tests read
it in both directions: the fixtures carry real values from live API
responses, and one case asserts that the same food mapped from Open Food
Facts and from FDC comes out identical.

Meals logged before this keep the values they were saved with. Repairing
that history means rewriting someone's intake records on upgrade, which
deserves its own change and its own review, so it is filed separately.
* fix(icons): give the launcher artwork room inside the icon mask

The two logo PNGs are a tight crop (~5% margin top and bottom) that also
serves as the in-app logo, so both launcher pipelines shipped artwork that
sat ~3dp from the Android mask edge and 5% from the iOS canvas edge (#1151).

Android: set `adaptive_icon_foreground_inset` to 23. The 16% that shipped
since v1.4.0 is flutter_launcher_icons' default, which replaced the earlier
hand-written 25% when #447 regenerated the icons. At 23% the composition is
52.7dp tall — on the 52dp keyline and 6.7dp inside the 66dp safe zone.

iOS: the tool has no inset option and copies the PNG into the asset catalog
as-is, so add pre-padded copies of the two logo PNGs (composition at 80% of
the canvas height on a transparent canvas, so the dark and tinted variants
keep their alpha) and the script that regenerates them. The in-app logo PNGs
are untouched.

* chore(icons): regenerate the launcher icon sets

`dart run flutter_launcher_icons` with the new inset and iOS sources. Only
the adaptive-icon XML and the iOS PNGs change; the Android PNGs, colors.xml
and Contents.json come out byte-identical.

* fix(icons): keep white under the padded icons' transparency

Pillow's resize leaves (0,0,0,0) in the padded sources' transparent area,
and flutter_launcher_icons downscales with a straight box average, so the
black bled into every anti-aliased edge of the dark and tinted iOS icons:
the 40px dark icon's spoon tip came out (152,152,152) instead of white.
The logo PNGs carry white under their transparency; make the padded copies
do the same. Only the dark and tinted sets change — the light set is
flattened at 1024 before resizing and comes out byte-identical.

* chore(icons): let the padding script run from any directory

Anchor the source and output paths on the repo root the way
tools/screenshots/compose.py does, take the bounding box from the alpha
channel explicitly, and mirror compose.py's Pillow import guard and
`from __future__ import annotations`. Output is byte-identical, so the
padded PNGs and the icon sets stay as they are.
…s search state (#995)

When a Products or Food search finds nothing, the empty state now offers Scan barcode and Create custom food, translated in all nine locales, and scrolls on small screens. Part of #578.
Dependabot, android/Gemfile and Gemfile.lock only.
Dependabot, ios/Gemfile and Gemfile.lock only. The iOS build ran green on this head.
Dependabot: archive 4.0.9→4.2.0, cached_network_image 3.4.1→4.0.0, intl 0.20.2→0.20.3, meta 1.18.0→1.19.0, sentry_flutter 9.26.0→9.29.0, stream_transform 2.1.1→2.1.2. cached_network_image 4.0.0's only breaking change is a Flutter ≥3.44 / Dart ^3.12 floor the pin already meets; no widget parameter changed. Lockfile SDK floor matches the pinned SDK.
…st, stop failing CI on incomplete locales (#1188)

French, Hungarian and Russian (896 of 1044 keys), Spanish (224) and
Swedish (10) have been translated on Hosted Weblate since July, but the
component had no push URL, so they never reached the repository. Taken
from Weblate's public git export at afbad294 and normalised to the repo's
conventions (2-space indent, template key order; two keys the template no
longer has were dropped, no value changed). Two further keys whose English
has changed since Weblate's base are dropped from the four files, so the
current wording shows in English until Weblate re-translates them - one of
them is the profile-reset warning that now names the credential deletion.
Weblate's empty pt_BR file is not landed. The files are here so Weblate
can rebase onto them and keep translating.

None of the five is shipped, and shipping is now a deliberate act with one
source of truth: lib/core/l10n/shipped_locales.dart maps the shipped
language codes to their picker names. main.dart and main_dev.dart resolve
the device locale against gen-l10n's list narrowed to that map
(appLocales), matching the whole locale tag, so an ARB that is present but
unshipped - including a regional variant of a shipped language - is
compiled and inert. A device set to Swedish still gets English. The
Settings picker reads its names from the map instead of owning a list.
tool/check_locales.dart checks Info.plist and locales_config.xml against
the map (and --fix rewrites them), and test/unit_test/shipped_locales_test
runs that check, replacing the iOS drift test that assumed every ARB is a
shipped language. Shipping a language: add its line, run
`dart run tool/check_locales.dart --fix`, commit.

`check_l10n` no longer fails when a locale is missing a key. Translations
now arrive through Weblate as pull requests and a code PR adds English
only, so every locale is incomplete for a while by design. It still fails
on what the English fallback cannot absorb - a gen-l10n error or an empty
or whitespace-only translation - and reports per-locale untranslated
counts to the job summary. Empty fields inside "@key" metadata are not
translations and do not count.

The documentation follows the policy rather than trailing it: CONTRIBUTING
describes English-only PRs, the shipped-language list and its recipe, and
that Weblate's pull requests are merged with a merge commit, never
squashed; AGENTS.md's review rules now report a code PR that hand-edits a
non-English ARB instead of one that leaves English in place, and say
nothing about key counts; the README and the PR template follow. Leaving
that to a later PR would have had AGENTS.md instructing AI reviewers to
flag exactly what this change makes policy.

Two tests asserted every ARB has every key (the bulk-add hint test and the
AI consent own-server-note test); they now skip an absent key and still
validate a present one.

Refs #1184, #1189, #1186, #1182, #1180, #1181
…y in the source (#1201)

Two activity names were ambiguous in English and so in every language:
paAmericanFootballGeneral read "football" (in most languages that word
is soccer, a separate activity at a different MET) and
paDivingSpringboardPlatform read "diving", identical to paDivingGeneral
(scuba, 7.0 MET vs 3.0). A Hungarian translator rendered both faithfully
and produced two pairs of activities that look the same and burn very
different calories.

"American football" and "springboard or platform diving" now. English
only, as CONTRIBUTING prescribes: Weblate sees the changed sources and
the existing translations of these two keys wait for a translator; the
commit policy keeps the stale ones out of the files meanwhile.

Fixes #1200
…ched_network_image, sentry_flutter, stream_transform) (#1166)

* chore(deps): bump fastlane from 2.238.0 to 2.239.0 in /android

Supersedes #1141.

* chore(deps): bump fastlane from 2.238.0 to 2.239.0 in /ios

Supersedes #1142.

* chore(deps): bump archive, cached_network_image, sentry_flutter and stream_transform

archive 4.0.9 -> 4.2.0, cached_network_image 3.4.1 -> 4.0.0,
sentry_flutter 9.26.0 -> 9.29.0, stream_transform 2.1.1 -> 2.1.2.

Dependabot's lockfile also moved intl and meta; flutter pub get on
3.44.6 pins both back (the SDK vendors them), so the lock here is the
one CI actually resolves. Podfile.lock is the one CI generated on the
bot branch (sentry_flutter pod 9.29.0, Sentry/HybridSDK unchanged).

Supersedes #1143.
… foods by their full description (#1170)

* fix(add-meal): show backend foods by their full description

SpFoodDTO.displayName preferred the view's short_title over the full
description, so a survey record's siblings reached the screen as one
word: "Egg, whole, raw", "Egg, yolk only, raw" and "Egg, creamed" were
all "Egg". 555 short titles cover 4,215 of the 5,432 FDC survey records,
so this hid the one thing a reader picking between siblings needs to
see, and it is also what made same-named records look like duplicates
to the search ranker (#1164).

displayName is now the localized name when there is one, else the full
English description. A localized name is already a full translated
description, so it follows the same rule by construction. short_title
stays parsed, because it is a view column, but is not displayed.

Refs #1164

* fix(add-meal): stop collapsing FDC records and pick the canonical sibling by its portions

The app-side half of #1164, in two parts.

The near-duplicate collapse in mergeAndRankMeals grouped every non-own
meal by normalized name. It was built for Open Food Facts, where one
product turns up under several barcodes, but it also ran over backend
records, and FDC survey records that share a name are distinct foods,
not copies: 555 short_title groups cover 4,215 of the 5,432 survey
records, the largest holds 140, and "Egg, yolk only, raw" folded into
"Egg" beside "Egg, whole, raw" and was gone from the candidate list.
The collapse now groups OFF products only; a backend record is keyed
on its own identity and is never merged with anything.

With siblings shown by their full description, a family like "Apple,
raw" / "Apple, dried" / "Apple, baked" ties on the resolver's text
score, and the auto-select logged whichever the pool listed first.
rankForResolution now breaks equal scores by the number of labelled
portions, then by the shorter name, then stably. Most labelled portions
is the data-driven proxy for the canonical FNDDS record: measured
against the backend's deliverable portions, Apple, raw carries 7 to 2,
Banana, raw 5 to 2, and Milk, NFS ties Milk, whole at 3 and wins on
its name. Bread, white (7) beats the shorter Bread, rye (5), the one
real family where the two keys disagree.

scoreMealForResolution also takes 0.15 off a backend record with no
labelled portion, and nowhere else: the Food tab's plain search keeps
the BLS records where they are, and OFF products, which never carry
portions, are not penalised. This is the decided size. It was reckoned
against the shared ranker's 1.0-vs-0.9, and the resolver's own scorer
puts the survey's nearest real record, "Orange juice, 100%, NFS", at
0.667, so at 0.85 the BLS "Orange juice" still leads it; the test pins
the measured order so a change to the size is deliberate.

The tests are built over real backend rows (description, source, and
the portions portions_by_food_ids delivers) and pin what is measured,
including where that differs from the decision's reckoning: egg
resolves to "Egg, creamed" on text score before any tie-break, and
rice to "Rice, cooked, NFS" for the same reason.

Refs #1164

* test(add-meal): pin the three mutations that survived the #1164 review

Three mutations of the branch passed every test:

- the no-portions penalty applied after the clamp, so a non-match could
  go negative and a detailed exact title lost its bonus;
- `_nearDuplicateKey`'s codeless fallback replaced by an empty string,
  so two codeless backend records with different names folded into one;
- `mergeSort` swapped for `List.sort`. "Stable after that" was claimed
  but not pinned: Dart insertion-sorts lists under 32 elements, which
  happens to be stable, so a two-record tie cannot tell the two apart.

Each now has a test that fails under exactly that mutation and passes
otherwise. The earlier ten mutations were re-run against the current
tests and all still die.

Comment corrections, no behaviour change:

- `_sorted` no longer calls rice the decision's accepted miss and says
  the miss never forms in the same paragraph. The Puerto Rican miss does
  not form (three tokens against eight, one portion each); "Bread, rice"
  and "Chips, rice" outscoring the plain record on the live pool is a
  text-score miss the tie-break cannot reach; and "Pie, apple" (8
  portions) taking the tie from "Apple, raw" (7) on the live pool is a
  counterexample to the proxy. All three are recorded as what they are.
- `_noPortionsPenalty` records dark chocolate alongside orange juice
  (survey "Dark chocolate candy" 0.8 against BLS at 0.85), and that a
  backend record read back from the search cache carries no portions
  (`MealDBO` does not persist them), so on the resolver's real path the
  penalty and the portions key see less than a fresh page does.
- `kResolutionConfidenceFloor` was documented against short titles
  (`eggs` -> `Egg` is 0.75). With full descriptions the same match is
  0.5 against "Egg, creamed" and 0.375 against "Egg, whole, raw", one
  on each side of the floor. The value is untouched; the comment says
  so.
- The rice sibling test is named for the two records it actually ranks.

Mutations, each run over the five #1164 test files and restored:

  N1 penalty after clamp             1 fail: the penalty comes off before
                                     the clamp, not after it
  N2 single key `code ?? ''`         1 fail: never collapses two codeless
                                     backend records with different names
  N3 mergeSort -> List.sort          1 fail: the input order survives a
                                     pool too large for insertion sort
  R1 penalty removed                 6 fails
  R2 penalty for every source        3 fails
  R3 penalty in the shared ranker    4 fails
  R4 portions key dropped            2 fails
  R5 name length before portions     2 fails
  R6 longest name first              2 fails
  R7 fewest portions first           7 fails
  R8 backend records collapsed again 5 fails
  R9 name-length key returns 0       2 fails
  R10 displayName back to shortTitle 3 fails

* fix(add-meal): score backend records on their short title, show the description

MealEntity.name is both what a row shows and what the scorers read, so
showing backend records by their full description (#1164) changed their
scoring with it. The resolver's soft Dice charges every token past the
one that matched, and same-titled siblings stopped tying: on `egg`,
"Egg, creamed" (two tokens) beat "Egg, whole, raw" (three) and "Egg,
whole, boiled or poached" (five), so the portions tie-break that was to
pick among the family was never reached; on `rice`, "Bread, rice" and
"Chips, rice" outscored "Rice, cooked, NFS"; `orange juice` left the
survey record at 0.667, below the penalised BLS record's 0.85; and
`eggs` -> "Egg, whole, raw" fell to 0.375, under the 0.45 confidence
floor, so the #601 case was flagged as a guess. The branch's tests
pinned those as measured.

Add MealEntity.searchTitle, not persisted like `portions`: the short
title for an English backend row, null for a translated one (the query
that found it is in its language; the English title would score `Eier`
against "Egg" at nothing) and for everything else. Both scorers read
`searchTitle ?? name`; the resolver's name-length tie-break keeps
measuring `name`, since siblings that reach it share a title and the
description is where they differ.

With every "Egg" scoring 1.0 the keys are reached: `egg` -> "Egg, whole,
boiled or poached" (3 portions); `rice` -> "Rice, cooked, NFS" (Bread and
Chips score nothing on their titles); `orange juice` -> the survey record,
the -0.15 now being the whole difference; `eggs` -> 0.75, above the
floor; `chicken breast` -> the baked record on 9 portions, where the
rotisserie record used to win on having the fewest tokens. Fixtures carry
the real `food.short_title` for every row.

* fix(add-meal): keep the short title through the search cache and hand the resolver the fresh page

`SearchProductsUseCase` writes the remote page to the cache before
`_buildResult` reads the cache back, and the cache-first dedup keeps
the cached copy of every record the page returned -- from the first
search, not the second. A copy read back through `MealDBO` had no
`searchTitle` and no `portions`, so on the resolver's real path a
backend record was scored on its description and penalised for
carrying no portion, and nothing c3aa4eb pinned held there: `egg` ->
"Egg, creamed" at 0.517; `rice` -> "Bread, rice" ("Rice, cooked, NFS"
at 0.350 and flagged, with the pair alone); `chicken breast` -> the
SR Legacy roll at 0.421, under the floor; `orange juice` -> the BLS
record ahead of the survey one, the order that commit said it fixed;
`bread` -> "Bread, rye". A sibling the user had logged, held in the
cache and not in this search's page, scored 0.183 beside fresh
siblings at 1.0 -- last; offline, the whole pool did.

Two changes. `MealDBO.searchTitle` (HiveField 17, nullable) persists
the title the way `machineTranslatedName` is persisted, so a cached
copy is scored on the text the record it was written from is scored
on; a row cached before the column existed reads back null and is
scored on its name, as it is today. And `_buildResult` puts the fresh
entity in the cached copy's slot for a record this search's page
returned: every persisted field agrees between the two -- the cache was
just written from the page -- and the fresh one also carries the
portions and the verified-label flag the cache cannot hold, which the
resolver scores (#1164) and offers for picking (#968). The position,
and the list stability it is there for, stays the cache's. The one
fresh copy not taken is the one `cacheFromSearch` refuses to write: a
thin search result over a hydrated product.

Measured over the real use case and a real Hive box with only the
network faked: `egg`, `orange juice`, `rice`, `chicken breast` and
`bread` resolve on the first search and the next as the cold-cache
tests say, portions included. A logged sibling held only in the cache
scores 0.85 beside fresh siblings at 1.0 -- the no-portions penalty,
which the cache still cannot answer for, and nothing else -- and
offline every cached "Egg" scores 0.85, above the floor. The
`_noPortionsPenalty`, `_sorted` and `MealEntity.searchTitle` comments
now say which half of the cache gap is closed and which remains; the
"scored by its name as before" claim is gone, since before #1164 the
cached name was the short title.

Mutations, each run over the four test files touched and restored:

  M1 _freshest returns the cached copy    6 fails: egg resolves the same
     (return fresh -> return cached)      on the first search and the
                                          next; orange juice keeps the
                                          survey record ahead of BLS;
                                          rice stays above the floor on
                                          a warm cache; chicken breast
                                          resolves to the baked record
                                          on a warm cache; bread
                                          resolves to Bread, white on a
                                          warm cache; a backend record
                                          the fresh page returned
                                          surfaces as the fresh entity,
                                          in the cached entry's position
  M2 downgrade guard dropped              1 fail: a hydrated OFF product
                                          is not replaced by its thin
                                          search copy
  M3 fromMealDBO drops searchTitle        4 fails: a record held only in
                                          the cache is scored on its
                                          title beside fresh siblings;
                                          ... when the remote is down;
                                          a backend record held only in
                                          the cache keeps its title and
                                          has no portions; the title is
                                          persisted
  M4 fromMealEntity drops searchTitle     the same 4
  M5 adapter write() without field 17     1 fail: a backend record keeps
     (the HiveField added, .g.dart not    its short title across a
     regenerated)                         reopen

Refs #1164

* fix(add-meal): derive the scoring title from the name instead of persisting it

c3aa4eb scored backend records on a `MealEntity.searchTitle` carried
from the backend's `short_title` column, and 52c6196 persisted it as
`MealDBO` HiveField 17 (with an export key) because a copy read back
from the search cache lost it. Measured against the backend, the
column is the description up to its first comma: `short_title` equals
`split_part(description, ',', 1)`, trimmed and compared
case-insensitively, on 5,432 of 5,432 survey rows, 7,793 of 7,793 SR
Legacy rows, 469 of 469 Foundation rows and 7,135 of 7,140 BLS rows.
Nothing needs carrying or persisting: the title is derivable from the
name, which every copy keeps.

Derivation is also the better title for a translated row. The
persisted column was English, so it was set to null for a localized
name and the row was scored on its whole translated description; a
German reader's "Milch, menschliche" now derives "Milch", which is
what a German query is matched against.

`MealEntity.scoringName` replaces the stored field: for a backend
record (source `fdc`) the name up to its first comma, trimmed, the
whole name when nothing precedes the comma; for every other source
the name itself. Both scorers read it; the resolver's tie-break keeps
measuring the full name and the -0.15 no-portions penalty is
unchanged. The `SpFoodDTO.searchTitle` getter and the
`withServingLabel`/`withPortions` carry-through go with the field.

The Hive and export change is withdrawn: HiveField 17 and the
`searchTitle` JSON key are removed, `meal_dbo.g.dart` regenerated, and
both files are identical to develop again. A row cached before this
branch has the short title as its name and derives the same title, so
nothing on disk changes meaning.

`SearchProductsUseCase._freshest` stays: a cached copy still has no
portions, and a record this search's page returned is scored as the
fresh entity regardless of where the title comes from. The
warm-cache expectations hold as before -- a record held only in the
cache scores its title minus the no-portions penalty, 0.85.

Tests: the fixtures keep the backend's real short titles beside each
row and the derivation is held to them on all 27; a localized name
derives a localized title and scores as one; a name without a comma
is its own title; an OFF product scores on its full name; a cached
row scores as its fresh twin on the title through the `MealDBO` and
export-JSON round trips, with no title key in either. The persistence
and DTO-plumbing tests are removed.

* fix(add-meal): score the qualifiers a query names, not the title alone

495a03a scored a backend record on its title and nothing else, and
every query the branch exercised was a bare title. On a qualified
query that discards the one thing the user typed to choose a sibling:
every "Apple" ties at 0.667 on `dried apple`, the `_sorted` tie-break
picks the most-portioned record, and `dried apple` -> "Apple, raw"
(conf 0.667, not flagged), `rye bread` -> "Bread, white", `egg yolk`
-> "Egg, whole, boiled or poached", `baked banana` -> "Banana, raw",
`whole milk` -> "Milk, NFS", `chicken breast rotisserie` -> the baked
record, measured through the real use case, the real
`SearchProductsUseCase` and a real Hive cache. Develop got the first
two right by luck -- the near-duplicate collapse kept the first-seen
record, which on a cold cache was the description-relevance leader --
so the branch turned a lucky answer into a deterministic wrong one
above the confidence floor, in the path whose worst failure is a
silently wrong food in the diary.

`MealEntity.scoringQualifiers` is the rest of the name past the
title -- "whole, raw" for "Egg, whole, raw"; null for any other
source, a name with no comma, or nothing after it -- derived like the
title, so a cached copy has it too. Each scorer reads it one way
only: a qualifier the query does not name is never read, so a title
that scores 1.0 still scores 1.0 whatever follows it and a family
still ties on `egg`; a qualifier the query names joins the scored
text, so `dried apple` scores "Apple, dried" as a record called
"Apple dried" (1.0) and its siblings as "Apple" (0.667), and the
tie-break is never reached. The resolver names a qualifier by its
soft rule -- `egg yolks` reads `yolk` -- and only where the query
token agrees with it better than with any title token, so "Puerto
Rican" (`ric`, 0.6) stays out on `rice` and the variant still ties
the plain record on the title. The shared ranker names one by exact
token, as it matches everything: `whole milk` puts "Milk, whole" at
0.9 over "Milk, NFS" at 0.667 on the Food tab by score rather than by
list order.

The one pinned number that moves: "Bread, rice" and "Chips, rice" on
`rice` score 0.667 -- the named `rice` behind their titles, what an
OFF product "Bread rice" gets -- instead of 0.0, still under the plain
record's 1.0 and still tied with each other on every key. Every other
expectation holds unchanged: egg, eggs (0.75), rice, orange juice,
apple, milk, banana, bread, chicken breast, and the warm-cache tests.

Tests: the six measured queries through the scorers, `dried apple`
and `egg yolk` through the use case, `dried apple` and `rye bread`
through the real cache path cold and warm; the soft-match and
better-than-the-title rules; a qualifier alone (`yolk`); an OFF name
with a comma has no qualifiers; the Food tab order; the split is the
name put back together on every fixture row, and its edges.

* fix(add-meal): prefer the shortest description among siblings, and rank the 20 survivors the same way

07186f8 broke a family's tie by most labelled portions, then the
shortest description. Measured over the 39 survey families with more
than twenty members, that picks a dish over its ingredient in most of
them, because FDC counts a dish in more ways: Potato -> "Potato,
french fries, fast food" (12 deliverable portions to "Potato, NFS"'s
4), Beef -> "Beef, ground, patty", Bread -> "Bread, French or Vienna,
whole wheat", Pasta -> "Pasta, whole grain, with cream sauce,
ready-to-heat", Cheese -> Cheddar, Tea -> "Tea, iced, bottled, black".
Shortest description first, then most portions, lands on the generic
record there and in milk, apple, banana and orange juice, and misses
egg ("Egg, creamed", 12 characters to "Egg, whole, raw"'s 15), bread
("Bread, rye", 10 to 12), coffee ("Coffee, Latte") and tea ("Tea,
ginger"). `_sorted` now orders by length, then portions, then stable;
egg and bread are pinned as the known misses, with the siblings one
tap away on the review screen. The -0.15 penalty is unchanged, and it
is now the only thing keeping the portionless SR Legacy "Chicken
breast, roll, oven-roasted" -- the shortest description in that
family -- off the top.

The tie-break could not reach a record the data source had already
cut. `rankAndTruncateFoodsByName` and `rankAndTruncateTranslationRows`
scored the whole description with `textRelevanceScore` and kept twenty
before any MealEntity existed, so a family did not tie there as it
ties in the resolver: the twenty with the fewest tokens survived, in
backend order among equals, and the backend's order keeps the
canonical record inside the first twenty in only 29 of the 39 large
families. Both now score the derived title plus the qualifiers the
query names -- `deriveTitle` and `deriveQualifiers` in the new
`backend_title.dart`, which `MealEntity.scoringName` and
`scoringQualifiers` call behind their source guard -- and break ties
by description length, then backend order, stable. The survivors and
the resolver apply one rule, so the resolver's pick is inside the
twenty by construction. `textRelevanceScore` takes the qualifiers; no
portion fetch happens before the cut.

`SpFoodDTO.displayName` falls back to the short title when the name
is null (#1170 review). `food.description` is NOT NULL in the backend,
so this is defensive only, and the comment says so.

Tests: the real 106-row "Potato" survey family in the backend's pool
order, with the portions `portions_by_food_ids` delivers per row,
through the real cut and then the resolver's own path (`potato` ->
Potato, NFS out of the twenty and out of all 106, `french fries` ->
Potato, french fries, NFS); the real 100-row German `Milch` pool
through the translation cut (2705384's "Milch, NFS" first, all
nineteen "Milch"-titled rows kept); the survivors are the family's
twenty shortest and survive from the far end of the pool; the
re-pinned families; the helper held to the entity on every fixture
row; the fallback.

Mutation-tested, each restored before the next: tie-break back to
portions-first (12 failures: egg, bread, potato, chicken breast, the
warm cache); truncation scoring the full name (3); truncation without
the length key (10); the helper returning the whole name (55); the
fallback removed (1); `_freshest` returning the cached copy (5).

* test(add-meal): pin what the resolver is handed, not what the family would give it

c78b5a3 said the twenty survivors and the resolver apply one rule, so
the resolver's pick is inside the twenty by construction, and pinned
`potato` -> "Potato, NFS" and `bread` -> "Bread, rye" as what the app
does. Neither is what the app does (#1170 review).

The pool `rankAndTruncateFoodsByName` is handed is
`search_food_summary(term, null, 100)`, and that function cuts first:
`order by food_has_deliverable_portion(food_id) desc, food_id limit
100`, before any client code runs. Measured read-only on the backend
(2026-09-13): `potato` matches 712 rows, "Potato, NFS" is rank 128 and
the family 128..488, so the hundred are all dishes with not one row
titled "Potato"; through the branch's cut and resolver, `potato` ->
"Stewed, seasoned, ground beef with potatoes, Mexican style" at 0.500,
above the 0.45 floor, logged as settled. `bread` matches 540, "Bread,
rye" is rank 157 and never arrives, and the app lands on "Bread, pita"
(11 characters, 5 portions) over naan (11, 3) and white (12, 7). The
106-row fixture is real rows, but the whole family handed to the cut on
its own, which the app never does for `potato`. Of the eighteen families
the tie-break decision listed, sixteen resolve on their real pool as
listed (egg -> creamed, milk, apple, banana, orange juice, cheese, beef,
pasta, coffee -> Latte, tea -> ginger, pork, turkey, soup, crackers,
muffin, pretzels); potato and bread do not.

`BackendPoolFixtures.potatoSearch` and `breadSearch` are those two
hundred-row answers verbatim, with the portions `portions_by_food_ids`
delivers per row, and the tests pin the app's answers on them through
the cut, `mergeAndRankMeals`, `rankForResolution` and the real
`ResolveParsedMealsUseCase` over a Hive cache: the stew at 0.5 and not
flagged; pita, with rye absent. The family fixture stays for what the
cut does with a family when one reaches it, and its group and doc say
that. The bread two-row test stays as the key-order proof (length before
portions) and no longer calls rye the app's miss. `chicken breast`
pinned "rotisserie, skin eaten" (38) as the shortest description on a
fixture that left out "Chicken breast, stewed, skin eaten" (2705965,
rank 13 of the 98 rows the term matches, all inside the hundred, 34
characters, 8 portions); the row is added with its portions and stewed
is pinned on both paths. The SR Legacy roll ties it at 34 now, so the
penalty is what puts the roll last, not what keeps it off the top.

"One rule" is one derivation and one tie-break. The cut names a
qualifier by exact token (`textRelevanceScore`) where the resolver's
`_namedQualifiers` names it by prefix: on `cheesy potato` over the
family the resolver picks "Potato, french fries, with cheese" (0.917),
the cut scores every row 0.667 and keeps the twenty shortest (max 30),
the 33-character record is cut, and the resolver logs "Potato, NFS" at
0.667. And the cut runs before any portion is known where the resolver
takes 0.15 off a record with none: 25 portionless "Chicken breast, roll
NN" rows and one survey record with 9 portions resolve to the survey
record at 1.0 from the whole pool and to "roll 00" at 0.85 through the
cut. Both are pinned as they stand, and the doc comments on
`rankAndTruncateFoodsByName`, `_sorted`, `_candidatePoolSize`,
`backend_title.dart`, `MealEntity.scoringName` and `textRelevanceScore`
now say what the shared derivation guarantees and what it does not.
The -0.15 penalty, the tie-break, the cut's scoring and `_freshest` are
unchanged; nothing in lib/ but comments moved.

`_rankAndTruncate`'s "the sort is stable" was not pinned: `mergeSort`
-> `List.sort` survived the branch's tests (`+228: All tests passed!`),
because the one stability test used two rows, which Dart insertion-sorts
either way. Forty same-length "Milk, variant NN" rows now pin it, as
bf71427's forty-record case pins the resolver's sort.

Mutation-tested over the branch's ten test files, each restored before
the next:
- `mergeSort(decorated, ...)` -> `decorated.sort(...)` in
  `_rankAndTruncate`: `+259 -2`, "the backend's order survives a pool
  too large for insertion sort" and "bread keeps the twenty shortest
  Bread titles; rye was never sent" (naan/pita order).
- `_sorted` tie-break back to portions-first: `+247 -14`, among them
  "bread resolves to Bread, pita" on both paths and "chicken breast
  resolves to the stewed record on a warm cache".
- `_rankAndTruncate` without the length key: `+247 -14`.
- `_noPortionsPenalty` = 0.0: `+245 -16`, among them "a family whose
  shortest members carry no portions".
- `_namedQualifiers` by exact token only: `+256 -5`, among them "potato
  resolves to a beef stew at 0.5" on both paths and "a qualifier the
  query names only by prefix: cheesy potato".
- `_backendScore` on the whole description: `+254 -7`, among them "the
  survivors are ten titles holding the word, then the shortest".

* fix(add-meal): one qualifier rule for the cut and the resolver, pinned on the title-first pools

Depends on Backend#10, applied 2026-09-13: search_food_summary and
search_food_translation now order their rows by
food_has_deliverable_portion desc, title = term desc,
length(description), food_id, so a family the query names by its title
arrives whole and shortest-first. The order before it (portion, then id)
handed `potato` a hundred beef stews with the "Potato" family at ranks
128 to 488, and `bread` a hundred without "Bread, rye" (rank 157).

The unified rule: soft_text_score.dart, beside backend_title.dart, holds
the soft token agreement, `namedQualifiers` (which qualifier tokens a
query names, by prefix) and `scoreText` (title plus the named qualifiers,
soft Dice). `scoreMealForResolution` and the data source's
`rankAndTruncateFoodsByName` / `rankAndTruncateTranslationRows` both
score with it, so the twenty survivors and the resolver's pick among
them are chosen by one rule and the pick is inside the twenty by
construction — up to the one thing the cut cannot see, the portions,
which are fetched after it; the comment at the cut says exactly what
that leaves open, and a test pins it on a synthetic family and its
absence on the real pools. The cut used to name a qualifier by the exact
token through `textRelevanceScore`, so `cheesy potato` named `cheese` in
the resolver and not at the cut; `textRelevanceScore` is removed, and
the Food tab's `scoreMealRelevance` keeps its exact-token rule for the
list the user reads.

The stability test: a forty-row tie on (score, length) through
`rankAndTruncateTranslationRows` keeps the backend's order, beside the
existing forty-row test through `rankAndTruncateFoodsByName` — above
List.sort's insertion-sort threshold, where a `List.sort` would move
equal rows.

Re-fetched pools (test/fixture/backend_pool_fixtures.dart, 2026-09-13):
search_food_summary for potato, bread, chicken breast, egg, eggs, milk,
apple, orange juice, rice, carrots, and search_food_translation for
Milch and Kartoffel, each row with the deliverable portions
portions_by_food_ids delivers for it. Re-pinned on them, through the
cut and the resolver, from the page and from the whole pool alike:
potato → Potato, NFS; bread → Bread, rye; rice → Rice, fried, NFS (known
miss: the shortest sibling is a dish, as egg → Egg, creamed is); chicken
breast → Chicken breast, stewed, skin eaten in every input order; egg →
Egg, creamed; eggs → Egg, creamed at 0.75, not flagged; milk → Milk,
NFS; apple → Apple, raw; orange juice → Orange juice, 100%, NFS over
BLS; carrots → Carrots, raw; dried apple, whole milk, egg yolk, rye
bread and french fries by their named qualifiers; cheesy potato and
fried potato to the same record at the cut and in the resolver; Milch
→ Milch, NFS and Kartoffel → Kartoffel, NFS through the translation cut.

* test(add-meal): say exactly what the cut does not read, and pin it where it bites

The comment at rankAndTruncateFoodsByName said the portions hole needs
fewer than twenty portion-bearing rows in the pool and twenty
portionless rows that score as well and are no longer than the pick,
and that no real pool has both. Neither is the condition, and the live
`muffins` pool refutes both: 79 rows, forty with a portion, and the
twenty SR Legacy rows titled "Muffins" — none with a portion — are an
exact match at 1.0 where the survey's "Muffin, NFS" is a soft one at
0.857, so the cut keeps exactly the twenty, the survey record is
twenty-first, and the resolver logs "Muffins, oat bran" at 0.85 where
the whole pool gives "Muffin, NFS". `puddings`, `ice creams` and
`McDONALD'S` (German path) go the same way. The condition, now stated:
a portion-bearing record the resolver would pick is lost when twenty
rows rank ahead of it at the cut and behind it in the resolver —
portionless rows scoring at least what it scores and less than 0.15
above it, whatever their family, or rows tying it on score and length
with fewer portions. The muffins pool is a fixture
(search_food_summary('muffins', null, 100), 2026-09-13) and the miss is
pinned on it, twenty-first and all; the orange-juice non-bite is
restated on the same condition.

"The only thing the cut cannot see" was also inexact: the translation
cut is handed each row's source and does not read it, where the
resolver takes 0.03 off a machine translation. A native row behind
twenty machine rows that tie it and are no longer is lost the same way.
On the live German translations this changes no pick — every native row
is a BLS row, none of the 7,140 carries a deliverable portion, and
`cracker`, the one title with a native row and twenty machine rows, has
forty-nine portion-bearing ones — so the four comments and the test
header now name both things, and a synthetic pool pins the second.

soft_text_score_test's "one rule" group compared scoreText with
scoreMealForResolution and never called the cut, so a cut naming a
qualifier by the exact token left it green. It now goes through
rankAndTruncateFoodsByName and rankAndTruncateTranslationRows: with a
portion on every entity and no machine translation, the twenty the cut
keeps are the resolver's first twenty in the resolver's order, on the
potato pool for `cheesy potato`, `fried potato`, `fries` and `french
fries`, and on the Milch and Kartoffel pools for `fettarme Milch`,
`Kartoffeln` and `gebratene Kartoffel`.

* fix(add-meal): an unavailable portion lookup is not a missing portion, and codeless backend rows never collapse
Translation: OpenNutriTracker/OpenNutriTracker App
Language: German
Progress: 99.9% (1043 of 1044 strings)
Translate-URL: https://hosted.weblate.org/projects/opennutritracker/app/de/

Co-authored-by: Anonymous <noreply@weblate.org>
…to app units (#1193)

* fix(data): bring Open Food Facts micronutrients logged before #775 into app units (#1152)

#775 made `fromOffNutriments` convert the grams Open Food Facts sends into
the app's units, but only for new writes. Every intake logged from an OFF
product by a released build (2.0.2 through 2.3.0, the whole life of the
micronutrient fields) still holds minerals a thousand times too small and
vitamins A, D and B12 a million times too small, and so does every OFF
product in the remote-search cache and every recipe ingredient snapshotted
from one. Vitamin D reads 0.00 µg across the history, and every daily
micronutrient total is off by the same factor.

The values alone cannot say which convention they were written under — a
400 mg sodium food stored as 0.4 is indistinguishable from a genuinely
low-sodium one — so a range heuristic would only ever be safe for the three
microgram fields. This adds a marker instead: `MealDBO.dataVersion`, stamped
by `fromMealEntity` on every row a build carrying #775 writes. An OFF row
without it was written by the old mapping. That inference holds because no
build with #775 and without the stamp ever reached anyone: #775 is on
develop only, no tag contains it, the deploy jobs are gated on main, and the
one `deployed/*` ledger tag points at a main commit from before it.

`OffMicronutrientRepair` scales the unstamped OFF rows by #775's own two
factors and stamps them, row by row, each row in the same write as its
stamp. A pass that stops part-way therefore leaves nothing scaled twice; the
next pass picks up exactly the rows still unstamped, and a box with nothing
left to do costs one in-memory scan and no write. Rows from any other source
are never rewritten. It runs on every profile activation — startup and a
profile switch, next to `ensureConfigInitialized`, the existing per-profile
migration hook — over the active intake log, the shared OFF cache and the
recipe library, whose affected aggregates are recomputed from the repaired
ingredients the way `SaveRecipeUseCase` computes them.

Tests seed mixed boxes (old OFF, post-#775 OFF, custom, FDC, recipe), run
the pass twice and fingerprint every untouched row, simulate a pass that
stopped after one row, and cover the cache and recipe boxes.

* fix(settings): scale pre-#775 Open Food Facts rows on import and carry the marker through exports (#1152)

A backup exported by a build before #775 holds the same unconverted Open
Food Facts rows the on-device repair fixes, and importing one after the
upgrade would put them straight back. The importer now maps every intake
and recipe through the same per-row repair before writing it: an `off` row
with no `dataVersion` (JSON) or no `meal_data_version` (CSV) is scaled, a
stamped one passes through as-is.

The exports carry the marker so this cannot fire twice — `dataVersion` was
already in the JSON via `toJson`, and the CSV gains a `meal_data_version`
column appended last, the one place the format allows a new column without
a schema bump. docs/export-format.md documents both, including the one
consequence for external tooling: an `off` row written in app units must
carry the version, or the importer will scale it.

The test drives `ImportDataUsecase` end to end with a 2.3.0-shaped JSON
bundle (an OFF intake, a custom intake and a recipe with an OFF
ingredient), a stamped bundle, and a CSV without the new column.

* test(data): observe the no-write guard and pin the recipe rows the repair leaves alone (#1152)

Review of the #1152 repair turned up two gaps in what the tests and the
class doc say.

"A box with nothing to repair is not written to" never observed a write:
it asserted the return value and that every row's JSON was unchanged,
both of which also hold if the pass re-puts identical content. The test
now subscribes to `box.watch()` before the pass — every put reaches a
watcher, an identical re-put included — and asserts the event list stays
empty, with a real put afterwards to show the watcher is live. Re-putting
every unchanged row makes it fail with two BoxEvents; the old assertions
passed that mutation.

The class doc explained why FDC and custom rows are skipped but said
nothing about recipe-sourced rows, the one non-OFF source that can hold
pre-#775 magnitudes: a logged recipe, or a recipe picked off the builder's
recent tab (which lists recipe intakes, no source filter) as an
ingredient of another recipe, carries a blended aggregate no single
factor applies to. #1152 keeps recipe rows untouched; the doc now says so
and why, and the recipe-box test seeds the nested shape and fingerprints
it. The same note records the one input the marker cannot separate: an
OFF row whose micronutrient a user re-typed in app units on an old build.

For the record, the first commit on this branch dates the affected rows
to "2.0.2 through 2.3.0". The 24-field DBO and the unconverted
`fromOffNutriments` mapping landed in #237 (cd82bc1) and first shipped in
v.1.3.0 (1.3.0+47, 2026-05-10; `git tag --contains cd82bc1` starts
there), so the range is 1.3.0 through 2.3.0. docs/export-format.md's
"Builds up to 2.3.0" is right as written.

* fix(data): repair the saved custom meals box too, not just the cache (#1152)

`ensureOffMicronutrientsRepaired` ran over the intake log, the Open Food
Facts cache and the recipe library, and skipped `customMealBox`.
`EditMealBloc.saveCustomMeal` builds the saved row with
`source: oldMealEntity.source` (edit_meal_bloc.dart:145), so an Open Food
Facts product saved for reuse on a build before #775 sits in that box as
an `off` row in raw grams with no stamp. Selecting it showed the wrong
numbers, the barcode lookup reads that box before the repaired cache, and
logging it went through `MealDBO.fromMealEntity`, which stamped the new
intake `dataVersion 1` with the unconverted values so no later pass could
tell it apart.

The box now gets the same `repairMealBox` pass as the cache. Every other
place a `MealDBO` can be persisted was checked and needs nothing: the two
nested shapes (`IntakeDBO.meal`, `RecipeIngredientDBO.snapshotMeal`) were
already covered; the pasted-JSON, sample-CSV and share-payload importers
hard-code source `custom` (or `fdc`) and carry no micronutrients beyond
fibre, sugars and saturated fat; the demo seeder writes `custom` rows
through `fromMealEntity`; the backup bundle has no custom-meals file, so
there is no import leg to route (`ExportDataUsecase` only reads the box
for photo paths). The doc comments name all four stores and
`docs/export-format.md` says the saved meals are repaired on device only.

Test: a seeded saved-meals box with a pre-#775 `off` row, a stamped `off`
row, a `custom` row and an `fdc` row — one rewritten, the other three
byte-identical, the second run returns 0 and puts nothing (watched on the
box stream), and a row logged from the repaired product carries app units
and is identity under a further repair. The startup helper test seeds all
four boxes; dropping `customMealBox` from the helper fails it (cholesterol
left at 0.055). Analyze clean; 41 targeted tests pass.

* docs(data): correct why the saved custom meals box is repaired (#1152)

The previous commit added `customMealBox` to the pass and justified it with
a user-visible bug: an Open Food Facts product saved for reuse from the edit
form sitting there as an `off` row, because `EditMealBloc.saveCustomMeal`
builds the row with `source: oldMealEntity.source`. The bloc does keep the
source, but it is never reached with a non-custom one. `EditMealScreen` only
calls it behind `newMealEntity.source == MealSourceEntity.custom`
(edit_meal_screen.dart:902), and that guard arrived in the same commit as
the box and the data source, a039933 (#313), so no shipped build ever wrote
an `off` row there — v2.3.0 included, where the guard reads identically. The
other three writers hard-code the source at their only call sites
(csv_meal_importer.dart:158, json_meal_importer.dart:273 and, via the share
payload's 'custom'/'fdc' parse, import_meal_scanner_screen.dart:266), each
unchanged since it was introduced.

Covering the box is still right: it is typed `Box<MealDBO>`, its data source
takes any `MealDBO`, and one screen-level check is the only thing keeping an
`off` row out — the repair should not be the second place that assumption
has to hold, and the scan costs nothing on a box with no such row. So the
code is unchanged and only the reasoning is: the helper doc no longer claims
the box holds pre-#775 Open Food Facts products, and says instead that the
pass does not depend on that check; the class doc drops the saved-meals
clause from the list of stores the old mapping corrupted;
docs/export-format.md no longer tells users their saved meals may have been
affected. The box test keeps its seeded row and its assertions, renamed and
recommented to say it pins a shape the pass must handle rather than one the
app produces.

Analyze clean; the repair tests pass (19 in
off_micronutrient_repair_test.dart and import_data_usecase_off_repair_test.
dart, including the renamed saved-meals test and the four-box helper test);
full suite 2259 passed, 1 skipped.
…vied (#1203)

* chore(deps): bump mobile_scanner, sentry_flutter, synchronized and envied

The bumps from Dependabot's pub rollup (#1192) that resolve under the
pinned Flutter 3.44.6: mobile_scanner ^7.4.1 (lands 7.4.2), sentry_flutter
^9.30.0, synchronized ^3.4.2, and envied/envied_generator 1.3.9 in the lock.
build_runner 2.16.1, mockito 5.8.1 and hive_ce_generator 1.11.3 need
analyzer >=13, whose meta ^1.18.3 the SDK's flutter_test cannot satisfy,
so they wait for the next Flutter bump.

Regenerating the lock locally also returns intl, matcher, meta, test_api
and vector_math to the versions 3.44.6 actually resolves; the bot lock
merged in #1143 had carried newer pins that every `pub get` reverted.

* chore(ios): refresh Podfile.lock for sentry_flutter 9.30.0
simonoppowa and others added 22 commits September 18, 2026 17:41
…cover from a squash

CONTRIBUTING ("Translating") and AGENTS.md rule 1: the Merge Weblate pull
requests workflow lands Weblate's PRs with a merge commit; by hand, only
ever a merge commit; after a squash, Weblate's "Reset and reapply" (not
"Reset and discard", which drops the strings the commit policy holds back).
ci(l10n): merge Weblate's pull requests with a merge commit once CI is green
Translation: OpenNutriTracker/OpenNutriTracker App
Language: Hungarian
Progress: 99.9% (1043 of 1044 strings)
Translate-URL: https://hosted.weblate.org/projects/opennutritracker/app/hu/
…-1216

fix(meal-detail): keep the hydrated serving option when the unit menu opens (#1216)
Translation: OpenNutriTracker/OpenNutriTracker App
Language: Spanish
Progress: 100.0% (1044 of 1044 strings)
Translate-URL: https://hosted.weblate.org/projects/opennutritracker/app/es/
Juan Vega (@jdvr) translated all 1044 strings in #791. That pull request
predates the Weblate pipeline, so its ARB went in through Weblate instead
(translation upload, credited to him) and landed as #1228; this is the
one-line ship recipe from shipped_locales.dart for it: the line, and
`dart run tool/check_locales.dart --fix` for Info.plist and
locales_config.xml. README counts eleven languages now.
Same wiring as Hungarian (#1213): SupportedLanguage.es, product_name_es in
the DTO and in both OFF field lists, and a Spanish UI now reads the Spanish
name from Search-a-licious and the v2 product endpoint. The food backend
has no Spanish rows in food_translation yet, so translationLocaleOf maps
es to null like hu, and the two notes that listed hu among the locales
that query translations now say what actually happens.
* feat(meal-detail): quick-serving chips (0.5x/1x/2x + 100 g)

Replaces the fixed 50-250 g quick-quantity chip row on the meal-detail
sheet with chips derived from the food itself: 0.5x, 1x, 2x when the
product carries a scalable serving, and a 100 g shortcut when the food
is measured by mass. Foods with neither hide the row entirely instead
of showing a chip that rounds to nothing meaningful.

Each chip writes both the quantity and the corresponding unit through
the existing onQuantityOrUnitChanged callback, so the nutriment preview
recalculates through the same path a dropdown change would take.
Manual entry keeps working — the callback already fires on text change.

The chip data is built by quickServingOptionsFor in a new util file so
the mapping is unit-testable without spinning up the sheet's widget
tree, and covered by a matching test.

Fixes part of #577

* review: stable ASCII id for chip Semantics, revert formatter drift, broaden gate

- QuickServingOption gets an ASCII `id` field (`half-serving`,
  `one-serving`, `double-serving`, `100g`); the sheet interpolates
  that into `meal-detail-chip-<id>` instead of the locale-dependent
  label, so identifiers stay stable across the nine locales (avoiding
  `meal-detail-chip-100 г` on uk, `×` and spaces on the multiplier
  chips, etc.).
- Restore `Navigator.of(context,).popUntil(...)` at
  meal_detail_bottom_sheet.dart:352-353 to develop's shape — that
  wrapping was formatter drift between SDK versions, not part of the
  chip change.
- Broaden the 100 g gate to `isSolid || (!isLiquid && !isSolid)`,
  mirroring the unit dropdown's own gate at :167-170, so quick-add
  custom meals (mealUnit == null / 'g/ml') and liquids without a
  scalable serving get the 100 g shortcut instead of an empty row.
  Purely-liquid foods with no serving still get nothing, matching the
  dropdown.
- Tests updated: id parity added to every case, new case for the gml
  quick-add path, new case for the Cyrillic `г` label to lock in
  that ASCII id survives localised label, purely-liquid case kept
  separate as the still-empty branch.

* test(meal-detail): drive MealDetailBottomSheet chips end-to-end

Pumps the real widget with a scalable-serving solid product and
asserts, for each of the four chip identifiers, that:

  - the Semantics identifier is exactly `meal-detail-chip-<id>`
    (locked in as ASCII per #1101 review round);
  - a tap writes the paired quantity into the shared text controller
    (`0.5`, `1`, `100` — not `0`, not the label with symbols);
  - the same tap fires `onQuantityOrUnitChanged` with the paired unit
    (`serving` for the multipliers, `g` for the 100 g shortcut).

Uses Fake-implementing stubs for the seven MealDetailBloc dependencies
so the sheet gets a real bloc without any usecase actually running —
enough setup for the chip-tap path without dragging in the full add-
intake flow the existing edit_meal_unwind_test covers.

* refactor(meal-detail): fold quick-serving chip row into a collection-if and stop the stale-unit event

Follow-up to Simon's review round on #1101:

- meal_detail_bottom_sheet.dart:220 — detach _onQuantityChanged around
  the programmatic controller write inside the chip tap, so setting
  the field's text no longer fires the listener with the old unit
  before onQuantityOrUnitChanged sends the new one. Keeps :220/:222
  order — the write still happens before the callback so the final
  UpdateKcalEvent carries the chip's unit — but the intermediate
  100 g / 100 servings event is gone.
- meal_detail_bottom_sheet.dart:188 — compute quickServingOptionsFor
  once at the top of the builder and fold the empty case into the
  collection-if at :187 (`if (quickOptions.isNotEmpty)`). Drops the
  Builder and the SizedBox.shrink branch; single expression instead
  of a spread + inner conditional.
- quick_serving_option.dart:40 — use `½×` for the 0.5x label. Same
  reading in every locale that writes 0,5 instead of 0.5, without
  needing an ARB key (the field itself normalises comma / period).
- quick_serving_chips_widget_test.dart:117 — drop the
  `find.bySemanticsLabel(RegExp('.*'))` assertion; it matched any
  labelled node so it could not fail. The identifier check at :122
  is the load-bearing one.

Also adds test/features/meal_detail/quick_serving_chips_screen_test.dart
— the screen-level cover Simon asked for. Pumps the real MealDetailScreen
on `mealDetailRoute` with a 30 g-serving solid product, taps chips through
their Semantics identifier, and asserts against MealDetailBloc.state:

  * baseline 1 × serving lands 30.0 in totalQuantityConverted,
  * ½× lands 15.0 with the unit still `serving`,
  * typing `3` after the ½× chip keeps `serving` and recomputes to 90.0,
  * 100 g from a serving unit flips selectedUnit to `g` and totalQuantityConverted to 100.0.

Deleting `totalWeightOverridden:`-style regressions on this row would now
surface here instead of only in the callback-pair widget test.

---------

Co-authored-by: ibrahim-iqbal <ibrahim-iqbal@users.noreply.github.com>
* fix(off): read niacin from vitamin-pp_100g, not niacin_100g

Open Food Facts exposes niacin under the id `vitamin-pp`, so the API
emits `vitamin-pp_100g`; the previous DTO carried `niacin_100g` with no
`@JsonKey`, so the generated deserializer looked for a key that never
appears in an OFF response. Every OFF food dropped its niacin value
silently.

Aligns with the sibling vitamins immediately above (vitamin-a, -c, -d,
-b6, -b12) that already carry the mapping. Regenerated .g.dart via
build_runner. Three test cases lock the mapping in — read succeeds
from `vitamin-pp_100g`, is null when the key is absent, and does NOT
read the historical `niacin_100g` (guarding against a future 'clean up
the JsonKey' revert).

Fixes #1153

* test(off): add a JSON to entity boundary case for niacin

Simon flagged that the three earlier tests stopped at
`nutriments.niacin_100g` and never called
`MealNutrimentsEntity.fromOffNutriments`, so the last hop between the
DTO field and the entity had no cover on this branch (#775 pins the
x1000 scaling but builds the DTO through its constructor, not from
JSON). Add one case that goes through both hops with a live value —
`0.069` g / 100 g is Marmite (50184453) as the OFF v2 API returns
it today — and asserts the entity's `niacin100` lands at 69 mg / 100 g
(the number the label prints). Fails against the merge-base with
`Actual: <null>`; passes on this branch.

---------

Co-authored-by: ibrahim-iqbal <ibrahim-iqbal@users.noreply.github.com>
…1169)

* fix(recipes): thread totalWeightOverridden through JSON import save

JsonRecipeImporter read `totalWeight` and passed it to compute() as
`totalWeightOverride`, but ImportRecipesJsonUseCase called
SaveRecipeUseCase.save(recipe) without setting the override flag, so
the save-time recompute fell back to the ingredient sum and the
imported total was discarded.

Wrap each parsed recipe in a new `ImportedRecipe` that carries the
override flag alongside the entity, and hand the flag to save() in
the use case. The parse-time compute is untouched, so cases without
`totalWeight` still land the ingredient sum; cases with it now keep
that number end-to-end.

Fixes #1139

* fix(recipes): guard bad totalWeight and cover save-side flag with tests

Simon's review flagged two gaps that survived the first pass:

- lib/core/utils/json_recipe_importer.dart:249 accepted 0, negatives,
  and NaN as a totalWeight override. With the new save-side wiring the
  override now reaches persistence, so `"totalWeight": 0` was a new way
  to land a broken recipe (0 g and null nutrition) from input the
  importer already surfaces as valid elsewhere. The importer's own
  contract is that malformed numeric fields become per-entry errors —
  matching the ingredient amount check at :178 — so add the same guard
  next to the totalWeight read.

- lib/features/settings/domain/usecase/import_recipes_json_usecase.dart
  was untestable because `FilePicker.pickFile` is a static call, which
  meant the save call that fixes #1139 had no test coverage: dropping
  the `totalWeightOverridden:` argument left the whole suite green.
  Add a `pickFile` constructor seam mirroring
  ImportDataUsecase:36-49, then drive `importFromPickedFile` end to
  end against `SaveRecipeUseCase` and a fake `RecipeRepository`.
  The happy-path test uses the exact numbers from the issue
  (110 g at 360 kcal/100 g, totalWeight 300) and asserts the saved
  recipe keeps 300 g and 132 kcal/100 g. Dropping the flag from the
  save call now fails this test.

* test(recipes): address review nits on the JSON import test

- Drop the `cross_file` import + `depend_on_referenced_packages`
  ignore in favour of `Never get xFile => throw UnimplementedError()`,
  matching the shape used by every other PlatformFile stub in this
  repo so the test file adds no transitive dependency.
- Register `addTearDown` inside `_writeTempJson` so each test's
  `ont_json_recipes_*` directory under systemTemp is removed at the
  end of the test — matches the neighbouring tests' pattern and
  stops the scratch directories from accumulating on every run.

Behaviour unchanged. All four cases still pass.

---------

Co-authored-by: ibrahim-iqbal <ibrahim-iqbal@users.noreply.github.com>
) (#1195)

* feat(meal-detail): opt-in Settings toggle for grams-first default (#1126)

Meal detail defaults to the serving unit whenever a food has a scalable
serving, which is the wrong default for users who log by weight and
have to change the dropdown every entry. Adds a Settings toggle —
"Default to grams instead of servings" — that steers _applyInitialSelection
to grams/oz (or ml/fl oz) for scalable-serving foods too. Off preserves
the pre-existing behaviour byte-for-byte.

Full chain:

- ConfigDBO gains `@HiveField(39) bool? defaultToRawFoodUnits`. Null
  reads as false, so every install that predates the field keeps the
  serving-first default without a migration.
- ConfigEntity carries the field (default false), reads it from
  ConfigDBO with the same `?? false` fallback the rest of this file
  uses for nullable bools, and folds it into equatable props.
- ConfigDataSource / ConfigRepository / AddConfigUsecase get a
  `setConfigDefaultToRawFoodUnits(bool)` setter, matching the
  showMicronutrients / usesKilojoules setter shape.
- SettingsLoadedState carries the flag; SettingsBloc emits it and
  exposes `setDefaultToRawFoodUnits`.
- settings_screen.dart gets a `_SettingsSwitchTile` next to the
  micronutrients tile, with an English label plus subtitle in intl_en.arb.
- meal_detail_screen.dart loads the flag alongside `_showMicronutrients`
  and gates `_applyInitialSelection`'s scalable-serving branch on it.
  Because the config read is async and `_applyInitialSelection` may
  already have run with the fallback (false), the load callback resets
  the initial selection and re-picks — but only while the user hasn't
  touched the dropdown or the quantity field.

Race the re-pick surfaced: `_applyInitialSelection`'s quantity block
writes `quantityTextController.text` between the two UpdateKcalEvents
it dispatches. On the *second* call the bottom sheet's text listener
is already attached, so the write fires
`onQuantityOrUnitChanged(text, widget.selectedUnit)` before the
sheet has rebuilt with the just-emitted new unit — dispatching a
stale-unit event that lands last. Fix: the quantity event now
passes `selectedUnit: _initialUnit` as well, so the final event
always carries the picked unit regardless of who won the race.

Tests:

- test/unit_test/config_default_to_raw_food_units_test.dart pins the
  fromConfigDBO mapping: null-in reads as false (no migration on
  upgrade), a stored true / false round-trips unchanged.
- test/features/meal_detail/default_to_raw_food_units_screen_test.dart
  drives the real MealDetailScreen on `mealDetailRoute` with a
  scalable-serving product and asserts against MealDetailBloc.state:
    * off (default): 30 g serving still selects serving, 30.0 total,
    * on + solid: solid with a scalable serving lands on `g`, 100.0,
    * on + liquid: liquid with a scalable serving lands on `ml`, 100.0.

Closes #1126.

* meal-detail: honour the raw-units preference on first paint, without collapsing the app bar.

Guard `onQuantityOrUnitChanged` against the text-controller echo that
`_applyInitialSelection` itself causes. The bottom sheet's listener
forwards every controller.text write back to the parent, including the
one this method makes; treating that as a user edit dispatched an extra
UpdateKcalEvent with the last-rendered unit and called
`_scrollToCalorieText`, which collapsed the app bar and pushed the
product image out of view the moment the screen opened.

Also feed the hydration path through the same picker (drop the
`_userChangedSelection` gate on `_onMealHydrated`), so a thin OFF search
result that reveals a serving on the v2 barcode fetch still respects
the raw-units preference.

While in the settings screen, move the toggle to the Units & Energy
group where it belongs, and reword the label so it reads honestly for
liquid foods too — "weight or volume, not servings".

The default-to-raw-units screen tests now cover the hydration path and
assert the initial scroll offset is 0, so the collapse regression can't
sneak back.

Fixes: #1195

* l10n: leave the non-English ARBs to Weblate for the new keys.

Post-#1188 the non-English ARBs are Weblate-owned, so code PRs add
English-only keys and Weblate sends the translations back in its own
PR. Revert the eight placeholder rows I added earlier in this branch.

The one remaining parity-test failure is tracked separately as #1199.

* meal-detail: tighten the raw-units test viewport and correct the settings tile comment.

The scroll-offset assertion in default_to_raw_food_units_screen_test.dart
sat behind a 1080x2400 viewport, so the CustomScrollView's
maxScrollExtent was 0 and any `animateTo(300)` clamped to 0 — the
assertion could not fail. Shrink the viewport to 600x800 so the
assertion catches the race regression the guard exists to prevent
(three cases fail with `Actual: <300.0>` if the guard is removed).

Also correct the tile comment in settings_screen.dart: the tile now
sits after Energy unit in the Units & Energy group, not next to Food
units.

---------

Co-authored-by: ibrahim-iqbal <ibrahim-iqbal@users.noreply.github.com>
Co-authored-by: jamtechdev <jamtechba@gmail.com>
Co-authored-by: Simon Oppowa <24407484+simonoppowa@users.noreply.github.com>
…rd the column (#1194) (#1196)

CSV had the identical defect the JSON path in #1169 just fixed:
`CsvRecipeImporter` read `recipe_total_weight_g` into
`_RecipeGroup.totalWeightOverride` and computed against it, but
`ImportRecipesCsvUsecase` then called `save(recipe)` without the
`totalWeightOverridden:` flag, so the save-time recompute fell back
to the ingredient sum and threw the override away.

Reproduced with the issue's numbers (110 g at 360 kcal/100 g,
`recipe_total_weight_g: 300`): saved 110 g / 360 kcal/100 g on develop,
saves 300 g / 132 kcal/100 g on this branch. QR share path is out of
scope — the wire format carries no override flag, so that half needs
a format decision (documented on #1194).

Same shape as the JSON fix, three parts:

- `CsvRecipeImportResult.recipes` becomes `List<ImportedCsvRecipe>`,
  each entry carrying `{ recipe, totalWeightOverridden }`.
  `totalWeightOverridden` is derived from the group's own
  `totalWeightOverride`, matching the "was this field supplied?"
  signal `CsvRecipeImporter.parse` already used.
- `CsvRecipeImporter.parse` guards the raw
  `recipe_total_weight_g` value: a row that supplies 0, negative,
  NaN or infinity is rejected with a per-row error, the group is
  marked as "totalWeight rejected", and the whole recipe is dropped
  at build time — same shape as `JsonRecipeImporter`'s guard at
  `json_recipe_importer.dart:249`.
- `ImportRecipesCsvUsecase` threads
  `imported.totalWeightOverridden` into `SaveRecipeUseCase.save`,
  and gains a named-optional `pickFile:` seam mirroring
  `ImportDataUsecase:36-49` / `ImportRecipesJsonUsecase` so the
  save-side wire can be tested against a fake `RecipeRepository`.

Tests:

- `test/unit_test/csv_recipe_importer_test.dart` — two new cases:
  the `totalWeightOverridden` flag round-trips per recipe on a
  mixed batch, and a batch with 0 / -100 / NaN / a good 300 g row
  drops the three bad recipes with a per-row error each while the
  good recipe still lands.
- `test/unit_test/import_recipes_csv_usecase_test.dart` (new) —
  drives `importFromPickedFile` end to end against
  `SaveRecipeUseCase` + a fake `RecipeRepository`. The happy-path
  test uses the exact issue numbers (110 g at 360 kcal/100 g,
  totalWeight 300) and asserts the saved recipe holds
  `totalWeightG == 300` and `energyKcal100 == 132`. Deleting
  `totalWeightOverridden:` from the save call fails this test —
  the wire is pinned.

Also covered: no-`recipe_total_weight_g` path (falls back to the
ingredient sum), picker-cancelled path (returns null, no save),
and a rejected-`recipe_total_weight_g` path
(skippedRows 1, error reported, save never reached).

Refs #1194.

Co-authored-by: ibrahim-iqbal <ibrahim-iqbal@users.noreply.github.com>
…#1119) (#1197)

* feat(profile): add a 7-day moving-average overlay to the weight-trend chart

The primary line lands on every recorded reading, so a daily weigh-in
that swings a kilogram either way with water alone is exactly what the
chart shows — hard to see the week-over-week direction the user actually
cares about (#1119).

Adds a trailing-window rolling mean as a second, dashed line on the same
chart. Default window is 7 calendar days: on any reading, the smoothed
point is the mean of every entry in the last week including the reading
itself. The line lags real changes by about half the window and hides
day-to-day noise, which is the whole point.

- `lib/core/utils/calc/weight_moving_average.dart` — a pure calc unit,
  same shape as the other `core/utils/calc/*` helpers. Two-pointer sweep
  so a long log stays O(n) rather than O(n·window). Anchors on entry
  dates so the smoothed line lines up with the raw dots, drops leading
  points that had fewer than `minSamples` readings in-window (the line
  starts once the smoothing is real), and counts every entry that falls
  in each window — including two on the same day.
- `weight_trend_chart.dart` gains an optional `movingAverageWindowDays`
  parameter (default 7, null hides the overlay). The MA is computed
  against the *full* log and then clipped to the visible window, so the
  leftmost visible point is anchored on entries predating the window
  instead of lagging one window-length behind. Rendered as a dashed
  1.5 px line at 55 % of the primary colour, no dots, two lines with
  ≥ 2 spots or none at all so a two-entry log does not sprout a stub.

Two callers pick up the overlay for free — `weight_history_screen.dart`
and `trends_page.dart` — because both build the shared chart widget with
no `movingAverageWindowDays` override.

Tests:

- `test/unit_test/weight_moving_average_test.dart` (7 cases): consecutive
  daily readings land the arithmetic mean; readings outside the window
  are excluded; two entries on the same day fold into every window that
  day is in; unsorted input is sorted before computing; empty log
  produces no points; `windowDays` honoured on a shorter window;
  `minSamples` gate keeps the line from starting on a single reading.
- `test/features/profile/presentation/widgets/weight_trend_chart_moving_average_test.dart`
  (3 cases): a 10-day log renders the primary line as before plus a
  dashed dot-less MA line that stays inside the raw envelope; passing
  `movingAverageWindowDays: null` renders only the primary line;
  a two-entry log a month apart never crosses the smoothing threshold
  and renders no MA line either.

Closes #1119.

* fix(profile): keep the MA overlay inside the chart plot and off wall-clock arithmetic

Follow-up on the #1197 review:

- The moving average is the one series whose leftmost points fold in
  readings that predate the visible window, so on a "last week high,
  this week flat" log its y can sit above the raw envelope — Simon's
  81.5–82.0 → 80.0–80.5 case. `LineChartData` keeps the default
  `FlClipData.none()`, so an MA spot above `maxY` painted outside the
  plot; a "dashed line running up through the card header" in the 7d
  Trends chip. Fold `maSpots` into the min/max reduce so the y-range
  covers every rendered spot. A new widget test seeds the exact shape
  Simon reproduced and pins `data.maxY >= max(maSpots)` and
  `data.minY <= min(maSpots)`.
- `Duration(days: windowDays - 1)` is 144 wall-clock hours, not six
  calendar days: on a fall-back DST day it lands the window boundary
  an hour after local midnight and drops that day's entry. Every
  entry in this log is `DateTime(y, m, d)` at local midnight (see the
  four write sites), so building the boundary through DateTime's
  year/month/day constructor keeps it aligned. A boundary test at the
  exact edge (Jan 3 out, Jan 4 in for a Jan 10 anchor with a 7-day
  window) pins the calendar-day intent.
- Comment fixes flagged in the review: `weight_trend_chart.dart:220`
  correctly says the MA is rendered second and paints on top of the
  raw line, so the test-file header now says the same; the calc's
  sweep comment names the O(n log n) sort separately from the O(n)
  sweep; and `weight_moving_average_test.dart`'s "9 days out of the
  anchor" comment is rewritten to describe the window geometry
  (Jan 4..Jan 10) directly.

Refs #1197.

---------

Co-authored-by: ibrahim-iqbal <ibrahim-iqbal@users.noreply.github.com>
Translation: OpenNutriTracker/OpenNutriTracker App
Language: Hungarian
Progress: 99.9% (1045 of 1046 strings)
Translate-URL: https://hosted.weblate.org/projects/opennutritracker/app/hu/
…#1259)

* fix(edit-meal): stop a zero base quantity from storing NaN nutriments

A custom meal saved in Advanced mode with a base quantity of 0 computed
its per-100 factor as 100 / 0 = infinity, so every nutriment typed as 0
was stored as NaN. The NaN flowed into the intake and the day's running
totals, and every card that rounds a total with toInt() threw, leaving a
grey box on Home, Diary and Trends (#1254).

- factorTo100gFromBase treats a zero, negative or non-finite base like
  unparseable input and returns the no-op factor of 1.
- MealNutrimentsEntity.fromMealNutrimentsDBO reads stored NaN or
  infinite values back as null, so meals and intakes already saved by
  the bug no longer poison the totals built from them.
- TrackedDayEntity.fromTrackedDayDBO reads a non-finite running total as
  nothing tracked, so days that already took in a NaN render again.

Fixes #1254

* fix(tracked-day): rebuild NaN day totals from their intakes

A day that took in a NaN intake kept NaN in its running totals, and every
later add or remove on it stayed NaN, so the previous commit could only
show that day as 0 tracked (#1254).

ensureTrackedDayTotalsFinite scans the tracked-day box and, for any day
with a NaN or infinite calorie or macro total, sums its four totals again
from the intakes logged on it, matched by the configured day-start
boundary like the diary lists them. The broken intake now reads back with
null nutriments, so it counts as 0 and every other entry that day counts
again.

It runs per profile before anything reads the box, next to
ensureOffMicronutrientsRepaired: at the end of initLocator and in
SwitchProfileUsecase. With no broken day it is one in-memory scan and no
write. The read-side guard in TrackedDayEntity stays as the fallback.

Refs #1254

---------

Co-authored-by: Claude <noreply@anthropic.com>
Keeps the portion-size line #1208 added, trimmed to one sentence, and
adds what else a user can see in 2.4.0: the two new languages and
localized food names, the micronutrient correction (which changes
numbers already logged, so it is stated plainly), the serving
shortcuts and grams-first setting, the weight-trend average, and full
descriptions for database foods.

462 characters, inside the 500-character Play cap that
store_declarations_test.dart pins. Plain 0.5x / 2x and a hyphen rather
than the typographic forms: this file is pasted into the consoles by
hand.
A feature release: Spanish and Hungarian ship, food search follows the
app's language, the meal screen gains serving shortcuts and a
grams-first setting, the weight chart gains a 7-day average, and Open
Food Facts micronutrients logged before #775 are corrected on first
launch.

The build number must increase or release-gate refuses to deploy, and
the name must move with it or the gate refuses that too. changelogs/66.txt
carries the store notes.
main carries the 2.3.0 release as a squashed commit develop will never
contain, plus the #1226 hotfix. Conflicts resolve to develop's side, as
docs/RELEASING.md records: develop is uniformly newer wherever the two
differ. #1242 checked this independently and found no main-only line
that develop lacks.
@simonoppowa simonoppowa mentioned this pull request Sep 26, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 31165d485e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// the Units & Energy group since it steers the same
// meal-detail dropdown. Off preserves the pre-existing
// behaviour — serving wins whenever the food has one.
_SettingsSwitchTile(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Add a semantics ID to the raw-units switch

The new interactive settings switch has no locale-independent Semantics(identifier: ...), so Appium/uiautomator feature tests cannot reliably locate or toggle this preference. Please expose a stable identifier such as settings-default-raw-food-units around this tile.

AGENTS.md reference: AGENTS.md:L29-L33

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 759b754b on this branch, with its develop twin in #1271. _SettingsSwitchTile now takes the same optional identifier as _SettingsTile and wraps itself in Semantics the same way; this switch passes settings-default-raw-food-units, and the four existing switch tiles are unchanged. Confirmed on device too: it was the one toggle the 2.4.0 adb run could find only by its English label.

Comment on lines +336 to +340
final translationRows = rankAndTruncateTranslationRows(
[...unrankedRows],
searchString,
forResolution: forResolution,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Filter translated candidates by source before truncation

When bulk resolution runs in a translated locale with only a portionless source such as BLS enabled, this source-agnostic list is truncated to 20 using the new portion penalty before food_summary_by_ids applies enabledSources at lines 368–370. Disabled FDC rows can therefore occupy the entire retained set and then be filtered out, while matching BLS translations outside the cutoff are lost; the subsequent English fallback cannot recover a German query. Apply the source selection before this truncation, or make the translation-search RPC source-aware.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed, and filed as #1272. The source-agnostic cut before food_summary_by_ids is pre-existing (v2.3.0 has the same order), but #1209 sharpened it: every BLS row is portionless, so on the resolver's page the new penalty pushes all of them below FDC rows that the source filter then discards when FDC is off. Shipping in 2.4.0 knowingly — it needs a translated locale, FDC disabled and AI bulk-add, and the effect is a flagged, unselected row rather than wrong numbers. #1272 carries both fix options and a regression test sketch.

The toggle #1195 added is the only tile in the Units & Energy group
without a locale-independent identifier, so UI automation can find it
only by its English label, which Weblate will translate away. AGENTS.md
asks new interactive widgets for one.

_SettingsSwitchTile gains the same optional identifier _SettingsTile
already takes and wraps itself in Semantics the same way; the four
existing switch tiles pass none and are unchanged.

Raised by the Codex review on #1270.
@simonoppowa
simonoppowa merged commit 8d2420f into main Sep 26, 2026
17 checks passed
simonoppowa added a commit that referenced this pull request Sep 26, 2026
)

The toggle #1195 added is the only tile in the Units & Energy group
without a locale-independent identifier, so UI automation can find it
only by its English label, which Weblate will translate away. AGENTS.md
asks new interactive widgets for one.

_SettingsSwitchTile gains the same optional identifier _SettingsTile
already takes and wraps itself in Semantics the same way; the four
existing switch tiles pass none and are unchanged.

Raised by the Codex review on #1270.

(cherry picked from commit 759b754)
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.

7 participants