Release 2.4.0+66 - #1270
Release 2.4.0+66#1270
Conversation
…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.
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
…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/
chore(l10n): update translations
…-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/
chore(l10n): update translations
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(l10n): ship Spanish
* 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/
chore(l10n): update translations
…#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.
There was a problem hiding this comment.
💡 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( |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| final translationRows = rankAndTruncateTranslationRows( | ||
| [...unrankedRows], | ||
| searchString, | ||
| forResolution: forResolution, | ||
| ); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
) 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)
Release 2.4.0+66. Candidate frozen at
680eafaa; this branch is that tree withmainmerged in, resolving todevelop's side.git diff origin/develop HEADis empty — the merge brings nothing down frommain, it only givesmainan ancestry link so the PR can merge. That corroborates #1242, which found nomain-only linedeveloplacks.Charted, tested and ruled through Map: a verified go/no-go for 2.4.0 — 13 tickets, all closed.
What ships
vitamin-pp_100g(fix(off): read niacin from vitamin-pp_100g, not niacin_100g #1168); a startup migration rewrites rows older builds saved up to a million times too small (fix(data): bring Open Food Facts micronutrients logged before #775 into app units #1193). This changes numbers users have already seen, which is whychangelogs/66.txtsays so plainlyEvidence
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/onresearch/release-2.4.0-pixel-test. No step hit a blocker. Highlights:langs=de,en→es,en→hu,enas the app language changed while the device stayedde-DEsodium100: 552.0(a regressed write path would store0.552), and the two repaired rows still read 720.0 and 0.0 through further writes and an export round-trip. Product screen, diary panel, JSON and CSV exports all agreePre-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.xcprivacystand with no console edit. Anddevelopis missing nothingmainhas; the ARB diffs are Weblate key reordering, with zeromain-only keys.Known, and shipping deliberately
SPConst.translationLocaleOfreturns null for both on purpose:food_translationhas no rows yet. Their Open Food Facts names are localizedNot verified on device
After merging
docs/RELEASING.mdapplies.release-shippauses for approval — that is the irreversible point, since it spends a PlayversionCodeand a TestFlight build number. Afterwards: readandroid-deploy's annotation rather than its colour, submit the TestFlight build, and pastechangelogs/66.txtinto 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/*→mainshape (and5a582111 Merge main into release/2.3.0before it) is how 2.2.0 and 2.3.0 landed.