feat(android): add JSON share intent for AI-assisted meal import - #587
feat(android): add JSON share intent for AI-assisted meal import#587Marc4k wants to merge 2 commits into
Conversation
|
@simonoppowa, any updates on this one? I use it daily and would love to switch back to the main app instead of running my fork. Happy to also implement iOS support if that's a blocker for merging. |
There was a problem hiding this comment.
Pull request overview
Adds Android JSON share-intent support for importing AI-generated meals through the existing import pipeline.
Changes:
- Adds Android share-intent capture and Flutter lifecycle handling.
- Adds localized confirmation and error messages.
- Fixes an unrelated Dart lint violation.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
android/app/src/main/AndroidManifest.xml |
Registers supported share MIME types. |
android/app/src/main/kotlin/.../MainActivity.kt |
Captures shared payloads through a method channel. |
lib/core/presentation/main_screen.dart |
Confirms, imports, and refreshes shared meals. |
lib/core/utils/share_intent_service.dart |
Provides the Flutter method-channel bridge. |
lib/features/add_meal/util/meal_relevance_ranker.dart |
Adds required flow-control braces. |
lib/l10n/intl_en.arb |
Adds English strings. |
lib/l10n/intl_de.arb |
Adds German strings. |
lib/l10n/intl_cs.arb |
Adds Czech strings. |
lib/l10n/intl_it.arb |
Adds Italian strings. |
lib/l10n/intl_pl.arb |
Adds Polish strings. |
lib/l10n/intl_sk.arb |
Adds Slovak strings. |
lib/l10n/intl_tr.arb |
Adds Turkish strings. |
lib/l10n/intl_uk.arb |
Adds Ukrainian strings. |
lib/l10n/intl_zh.arb |
Adds Chinese strings. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| try { | ||
| return await _channel.invokeMethod<String?>('getSharedText'); | ||
| } on PlatformException { | ||
| return null; | ||
| } |
|
|
||
| if (parsed.intakes.isEmpty) { | ||
| ScaffoldMessenger.of(context).showSnackBar( | ||
| SnackBar(content: Text(S.of(context).shareJsonImportErrorLabel)), |
| final result = await locator<ImportMealsJsonUsecase>().importFromJsonString(text); | ||
| if (!mounted) return; |
| if (intent?.action != Intent.ACTION_SEND) return null | ||
| val type = intent.type ?: return null | ||
| if (!type.startsWith("text/") && type != "application/json") return null | ||
| return intent.getStringExtra(Intent.EXTRA_TEXT) |
| final message = result.imported > 0 | ||
| ? S.of(context).csvImportSuccessLabel(result.imported) | ||
| : S.of(context).shareJsonImportErrorLabel; |
simonoppowa
left a comment
There was a problem hiding this comment.
Thanks for this, @Marc4k — and apologies for the wait. The delay was not about your PR. It sat behind the fork-workflow approval gate, so CI had never run on it at all; that was mine to notice and I didn't. I've approved it now and everything passes: analyzer, unit tests, intl check, and both platform builds. (One iOS integration run hung at 41 minutes and I re-ran it — flaky simulator, unrelated to your changes; the re-run finished in 16m54s.)
Reading the diff, this is more careful than most: launchMode="singleTop" really is already set so onNewIntent fires as your comment says, consume-and-clear on the native side genuinely does prevent a double import, mounted is checked after every await, the lifecycle listener is disposed, and the new string is translated into all nine locales with no parity drift. That is good work.
The open question is ours, not yours
JsonMealImporter assigns MealSourceEntity.custom, so macros an assistant estimated land in the diary indistinguishable from a meal the user typed in themselves — and flow from there into TDEE, goal progress and trends.
That is the same question #250 was closed over. The closing comment there explicitly pre-empted the bring-your-own-model route: it doesn't change the underlying claim, which is a model estimated this rather than this came from a database. Your design sidesteps the cost and privacy objections in that thread completely — there's no key, no network call, no dependency — but not the provenance one.
That is a project decision we owe you an answer on before this PR can be judged fairly, and it is being worked through on #599. Nothing in your code causes it and no change you could make here would settle it. I did not want to leave you guessing about what the actual blocker is.
Code review
Ordered by what I'd want addressed, if and when the above is resolved.
1. text/plain puts the app in every text share sheet. AndroidManifest.xml — with a text/plain filter, any text share on the device (a browser URL, a selected paragraph) now offers OpenNutriTracker, and choosing it launches to a "Could not read shared JSON" snackbar. That footprint reaches every user, including those who never use the skill. application/json alone is much narrower; the trade-off is that plenty of apps only share as text/plain. Worth a deliberate decision either way.
2. onNewIntent can silently discard a pending share. MainActivity.kt:
override fun onNewIntent(intent: Intent) { pendingSharedText = extractSharedText(intent) }A non-SEND intent arriving before Flutter reads the value — a notification tap, for instance — overwrites it with null and the share is lost with no error. Suggest only assigning when the extract is non-null. It also never calls setIntent(intent); harmless today because you use the parameter, but it leaves getIntent() stale for anything added later.
3. The dialog's count can exceed what actually imports. main_screen.dart shows parsed.intakes.length, then imports and reports result.imported. If the usecase drops entries, the user approved 5 and silently received 3. The JSON is also parsed twice — once to count, once to import.
4. No tests for the new plumbing. The importer itself is well covered (json_meal_importer_test.dart, import_meals_json_usecase_test.dart), but ShareIntentService and _handleSharedMeal have none. A widget test with a faked MethodChannel would cover confirm / cancel / malformed cheaply, and this path writes to the diary.
5. Unrelated change. The meal_relevance_ranker.dart hunk is a line wrapped in braces, presumably from dart format. Harmless, but it is noise in the diff.
One to think about rather than fix
The dialog says "These meals will be added to your diary" with a count, and never shows the food names or the numbers. That is consistent with the existing QR importer, so it is not something you introduced — but that path imports another user's database-sourced export, while this imports model estimates, and the same dialog carries a different amount of trust in the two cases.
For contrast, the multi-item review screen being built on #599 states in its own doc comment that its confirmation exists "so nothing reaches the diary that the user has not looked at". A count is not looking at it.
Not reviewed
I have not opened the linked food-tracker.skill.zip. It is a meaningful part of what this PR enables, so what I have reviewed is the transport rather than what travels over it.
Heads-up on overlap
There is a multi-item logging feature in flight on feature/ai-assisted-meal-logging (#599) that also touches meal_relevance_ranker.dart and the same intake-writing path. Neither branch knows about the other yet. Worth coordinating before either merges so we don't collide.
Summary
Adds Android share intent support so any app — including AI assistants
using the bundled food-tracker skill — can share meal JSON directly to
OpenNutriTracker. The skill accepts natural-language descriptions and
food photos; it generates the app's existing JSON export format and the
user taps Share → OpenNutriTracker, confirms the import dialog, and the
meals land in their diary.
iOS share intent is out of scope for this PR (no Share Extension target
exists yet). The Flutter service returns null on iOS so there is no
regression — tracked as a follow-up.
Ai Skill
food-tracker.skill.zip
Type of change
Related issues
Changes
AndroidManifest.xmlregistersACTION_SENDintent-filtersfor
text/plainandapplication/jsonso the app appears in the systemshare sheet
MainActivity.ktcaptures the shared text via aMethodChanneland holds it until Flutter reads it (handles both cold-startand singleTop
onNewIntent)ShareIntentServicebridges the channel; returnsnulloniOS (no-op fallback)
MainScreenchecks for a pending share on cold start and onevery foreground resume via
AppLifecycleListener, shows a confirmationdialog, and imports approved meals using the existing
ImportMealsJsonUsecaseshareJsonImportContent,shareJsonImportErrorLabel) added to all 9 supported locales (EN + DE, CS,IT, PL, SK, TR, UK, ZH)
curly_braces_in_flow_control_structureslint inmeal_relevance_ranker.dartthat blocked CIScreenshots / recordings
example.mp4
Test plan
Steps
fvm flutter run --flavor develop)ONT's export format
Checklist
just format/ 120-char line width)Semantics(identifier: '...')whereneeded (
share-json-import-confirmon the dialog confirm button)lib/generated/when user-facingstrings change
just build) — no codegen changes.envvalues committedfeat:,fix:,chore:)