feat: dish recognition UX, background mode, and backend bug fixes

Flutter client:
- Progress dialog: redesigned with pulsing animated icon, info hint about
  background mode, full-width Minimize button; dismiss signal via ValueNotifier
  so the dialog always closes regardless of widget lifecycle
- Background recognition: when user taps Minimize, wasMinimizedByUser flag is
  set; on completion a snackbar is shown instead of opening DishResultSheet
  directly; snackbar action opens the sheet on demand
- Fix dialog spinning forever: finally block guarantees dismissSignal=true on
  all exit paths including early returns from context.mounted checks
- Fix DishResultSheet not appearing: add ValueKey to _DailyMealsSection and
  meal card Padding so Flutter reuses elements when _TodayJobsWidget is
  inserted/removed from the SliverChildListDelegate list
- todayJobsProvider refresh: added refresh() method; called after job submit
  and on DishJobDone; all ref.read() calls guarded with context.mounted checks
- food_search_sheet: scan buttons replaced with full-width stacked OutlinedButtons
- app.dart: WidgetsBindingObserver refreshes scan providers on app resume
- L10n: added dishRecognitionHint and minimize keys to all 12 locales

Backend:
- migrations/003: ALTER TYPE recipe_source ADD VALUE 'recommendation' to fix
  22P02 error in GET /home/summary -> getRecommendations()
- item_enricher: normalizeProductCategory() validates AI-returned category
  against known slugs, falls back to "other" — fixes products_category_fkey
  FK violation during receipt recognition
- recognition prompt: enumerate valid categories so AI returns correct values

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-28 00:03:17 +02:00
parent 5c5ed25e5b
commit 180c741424
32 changed files with 416 additions and 64 deletions

View File

@@ -202,7 +202,8 @@ Return ONLY valid JSON without markdown:
"fat_per_100g": 1, "fat_per_100g": 1,
"carbs_per_100g": 0, "carbs_per_100g": 0,
"storage_days": 3 "storage_days": 3
}`, name) }
"category" must be exactly one of: dairy, meat, produce, bakery, frozen, beverages, other`, name)
messages := []map[string]string{ messages := []map[string]string{
{"role": "user", "content": prompt}, {"role": "user", "content": prompt},

View File

@@ -3,11 +3,30 @@ package recognition
import ( import (
"context" "context"
"log/slog" "log/slog"
"strings"
"github.com/food-ai/backend/internal/adapters/ai" "github.com/food-ai/backend/internal/adapters/ai"
"github.com/food-ai/backend/internal/domain/product" "github.com/food-ai/backend/internal/domain/product"
) )
// validProductCategories mirrors the product_categories slugs seeded in the DB.
var validProductCategories = map[string]struct{}{
"dairy": {}, "meat": {}, "produce": {}, "bakery": {},
"frozen": {}, "beverages": {}, "other": {},
}
// normalizeProductCategory returns a pointer to a valid product_categories slug.
// It lowercases and trims the AI-returned value; if it is not recognised it falls
// back to "other" rather than letting an invalid string reach the FK constraint.
func normalizeProductCategory(category string) *string {
normalized := strings.ToLower(strings.TrimSpace(category))
if _, ok := validProductCategories[normalized]; ok {
return &normalized
}
fallback := "other"
return &fallback
}
// itemEnricher matches recognized items against the product catalog, // itemEnricher matches recognized items against the product catalog,
// triggering AI classification for unknown items. // triggering AI classification for unknown items.
// Extracted from Handler so both the HTTP handler and the product worker pool can use it. // Extracted from Handler so both the HTTP handler and the product worker pool can use it.
@@ -80,7 +99,7 @@ func (enricher *itemEnricher) saveClassification(enrichContext context.Context,
catalogProduct := &product.Product{ catalogProduct := &product.Product{
CanonicalName: classification.CanonicalName, CanonicalName: classification.CanonicalName,
Category: strPtr(classification.Category), Category: normalizeProductCategory(classification.Category),
DefaultUnit: strPtr(classification.DefaultUnit), DefaultUnit: strPtr(classification.DefaultUnit),
CaloriesPer100g: classification.CaloriesPer100g, CaloriesPer100g: classification.CaloriesPer100g,
ProteinPer100g: classification.ProteinPer100g, ProteinPer100g: classification.ProteinPer100g,

View File

@@ -0,0 +1,5 @@
-- +goose Up
ALTER TYPE recipe_source ADD VALUE IF NOT EXISTS 'recommendation';
-- +goose Down
-- Postgres does not support removing enum values; intentional no-op.

View File

@@ -32,7 +32,7 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
void didChangeAppLifecycleState(AppLifecycleState lifecycleState) { void didChangeAppLifecycleState(AppLifecycleState lifecycleState) {
if (lifecycleState == AppLifecycleState.resumed) { if (lifecycleState == AppLifecycleState.resumed) {
ref.read(recentProductJobsProvider.notifier).refresh(); ref.read(recentProductJobsProvider.notifier).refresh();
ref.read(todayJobsProvider.notifier).load(); ref.read(todayJobsProvider.notifier).refresh();
} }
} }

View File

@@ -220,24 +220,24 @@ class _FoodSearchSheetState extends ConsumerState<FoodSearchSheet> {
), ),
), ),
// Quick action chips // Scan action buttons — full-width, stacked vertically
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Wrap( child: Column(
spacing: 8, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (widget.onScanDish != null) if (widget.onScanDish != null)
ActionChip( OutlinedButton.icon(
avatar: icon: const Icon(Icons.camera_alt_outlined, size: 18),
const Icon(Icons.camera_alt_outlined, size: 18),
label: Text(l10n.scanDishPhoto), label: Text(l10n.scanDishPhoto),
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
widget.onScanDish!(); widget.onScanDish!();
}, },
), ),
ActionChip( if (widget.onScanDish != null) const SizedBox(height: 8),
avatar: const Icon(Icons.qr_code_scanner, size: 18), OutlinedButton.icon(
icon: const Icon(Icons.qr_code_scanner, size: 18),
label: Text(l10n.scanBarcode), label: Text(l10n.scanBarcode),
onPressed: _openBarcodeScanner, onPressed: _openBarcodeScanner,
), ),

View File

@@ -52,6 +52,8 @@ class TodayJobsNotifier
state = state =
await AsyncValue.guard(() => _service.listTodayUnlinkedJobs()); await AsyncValue.guard(() => _service.listTodayUnlinkedJobs());
} }
Future<void> refresh() => load();
} }
final todayJobsProvider = final todayJobsProvider =

View File

@@ -120,6 +120,7 @@ class HomeScreen extends ConsumerWidget {
], ],
const SizedBox(height: 16), const SizedBox(height: 16),
_DailyMealsSection( _DailyMealsSection(
key: const ValueKey('daily_meals_section'),
mealTypeIds: userMealTypes, mealTypeIds: userMealTypes,
entries: entries, entries: entries,
dateString: dateString, dateString: dateString,
@@ -777,6 +778,7 @@ class _DailyMealsSection extends ConsumerWidget {
final String dateString; final String dateString;
const _DailyMealsSection({ const _DailyMealsSection({
super.key,
required this.mealTypeIds, required this.mealTypeIds,
required this.entries, required this.entries,
required this.dateString, required this.dateString,
@@ -803,6 +805,7 @@ class _DailyMealsSection extends ConsumerWidget {
.where((slot) => slot.mealType == mealTypeId) .where((slot) => slot.mealType == mealTypeId)
.toList(); .toList();
return Padding( return Padding(
key: ValueKey(mealTypeId),
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 8),
child: _MealCard( child: _MealCard(
mealTypeOption: mealTypeOption, mealTypeOption: mealTypeOption,
@@ -857,13 +860,19 @@ Future<void> _pickAndShowDishResult(
if (image == null || !context.mounted) return; if (image == null || !context.mounted) return;
// 3. Show progress dialog. // 3. Show progress dialog.
// Capture root navigator before await to avoid GoRouter inner-navigator issues. // Use a dismiss signal so the dialog can close itself from within its own
final rootNavigator = Navigator.of(context, rootNavigator: true); // context, avoiding GoRouter's inner-navigator pop() issues.
final progressNotifier = _DishProgressNotifier(initialMessage: l10n.analyzingPhoto); final progressNotifier = _DishProgressNotifier(initialMessage: l10n.analyzingPhoto);
final dismissSignal = ValueNotifier<bool>(false);
bool wasMinimizedByUser = false;
showDialog( showDialog(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (_) => _DishProgressDialog(notifier: progressNotifier), builder: (_) => _DishProgressDialog(
notifier: progressNotifier,
dismissSignal: dismissSignal,
onMinimize: () { wasMinimizedByUser = true; },
),
); );
// 4. Determine target date and meal type for context. // 4. Determine target date and meal type for context.
@@ -882,7 +891,9 @@ Future<void> _pickAndShowDishResult(
targetDate: targetDate, targetDate: targetDate,
targetMealType: resolvedMealType, targetMealType: resolvedMealType,
); );
// Refresh immediately so the new queued job appears in the home screen list.
if (!context.mounted) return; if (!context.mounted) return;
ref.read(todayJobsProvider.notifier).refresh();
await for (final event in service.streamJobEvents(jobCreated.jobId)) { await for (final event in service.streamJobEvents(jobCreated.jobId)) {
if (!context.mounted) break; if (!context.mounted) break;
@@ -896,23 +907,52 @@ Future<void> _pickAndShowDishResult(
case DishJobProcessing(): case DishJobProcessing():
progressNotifier.update(message: l10n.processing); progressNotifier.update(message: l10n.processing);
case DishJobDone(): case DishJobDone():
rootNavigator.pop(); // close dialog dismissSignal.value = true;
if (!context.mounted) return; if (!context.mounted) return;
showModalBottomSheet( ref.read(todayJobsProvider.notifier).refresh();
context: context, if (wasMinimizedByUser) {
isScrollControlled: true, // Recognition finished in background — notify without opening the sheet.
useSafeArea: true, ScaffoldMessenger.of(context).showSnackBar(
builder: (sheetContext) => DishResultSheet( SnackBar(
dish: event.result, content: Text(l10n.dishRecognized),
preselectedMealType: mealTypeId.isNotEmpty ? mealTypeId : null, action: SnackBarAction(
jobId: jobCreated.jobId, label: l10n.addToJournal,
targetDate: targetDate, onPressed: () {
onAdded: () => Navigator.pop(sheetContext), if (!context.mounted) return;
), showModalBottomSheet(
); context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (sheetContext) => DishResultSheet(
dish: event.result,
preselectedMealType:
mealTypeId.isNotEmpty ? mealTypeId : null,
jobId: jobCreated.jobId,
targetDate: targetDate,
onAdded: () => Navigator.pop(sheetContext),
),
);
},
),
),
);
} else {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (sheetContext) => DishResultSheet(
dish: event.result,
preselectedMealType: mealTypeId.isNotEmpty ? mealTypeId : null,
jobId: jobCreated.jobId,
targetDate: targetDate,
onAdded: () => Navigator.pop(sheetContext),
),
);
}
return; return;
case DishJobFailed(): case DishJobFailed():
rootNavigator.pop(); // close dialog dismissSignal.value = true;
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -929,11 +969,15 @@ Future<void> _pickAndShowDishResult(
} catch (recognitionError) { } catch (recognitionError) {
debugPrint('Dish recognition error: $recognitionError'); debugPrint('Dish recognition error: $recognitionError');
if (context.mounted) { if (context.mounted) {
rootNavigator.pop(); // close dialog
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.recognitionFailed)), SnackBar(content: Text(l10n.recognitionFailed)),
); );
} }
} finally {
// Guarantee the dialog is always dismissed — covers early returns due to
// context.mounted being false, stream ending without a terminal event, or
// any unhandled exception. ValueNotifier deduplicates so double-setting is safe.
dismissSignal.value = true;
} }
} }
@@ -965,39 +1009,203 @@ class _DishProgressNotifier extends ChangeNotifier {
} }
} }
class _DishProgressDialog extends StatelessWidget { class _DishProgressDialog extends StatefulWidget {
final _DishProgressNotifier notifier; final _DishProgressNotifier notifier;
final ValueNotifier<bool> dismissSignal;
const _DishProgressDialog({required this.notifier}); /// Called when the user explicitly closes the dialog via the Minimize button
/// (i.e., before recognition has finished).
final VoidCallback? onMinimize;
const _DishProgressDialog({
required this.notifier,
required this.dismissSignal,
this.onMinimize,
});
@override
State<_DishProgressDialog> createState() => _DishProgressDialogState();
}
class _DishProgressDialogState extends State<_DishProgressDialog> {
@override
void initState() {
super.initState();
widget.dismissSignal.addListener(_onDismissSignal);
}
@override
void dispose() {
widget.dismissSignal.removeListener(_onDismissSignal);
super.dispose();
}
void _onDismissSignal() {
if (widget.dismissSignal.value && mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context, rootNavigator: true).pop();
});
}
}
void _minimize() {
widget.onMinimize?.call();
Navigator.of(context, rootNavigator: true).pop();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return ListenableBuilder( final theme = Theme.of(context);
listenable: notifier, final colorScheme = theme.colorScheme;
builder: (context, _) {
final state = notifier.state; return Dialog(
return AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
content: Column( child: Padding(
mainAxisSize: MainAxisSize.min, padding: const EdgeInsets.fromLTRB(24, 32, 24, 24),
children: [ child: ListenableBuilder(
const CircularProgressIndicator(), listenable: widget.notifier,
const SizedBox(height: 16), builder: (_, __) {
Text(state.message, textAlign: TextAlign.center), final state = widget.notifier.state;
if (state.showUpgrade) ...[ return Column(
const SizedBox(height: 12), mainAxisSize: MainAxisSize.min,
children: [
const _PulsingRecognitionIcon(),
const SizedBox(height: 24),
Text( Text(
l10n.upgradePrompt, state.message,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: theme.textTheme.titleMedium,
color: Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
if (state.showUpgrade) ...[
const SizedBox(height: 8),
Text(
l10n.upgradePrompt,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.onSurface.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info_outline_rounded,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.dishRecognitionHint,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: _minimize,
child: Text(l10n.minimize),
),
),
], ],
], );
), },
); ),
}, ),
);
}
}
// Pulsing animated icon shown while dish recognition is in progress.
class _PulsingRecognitionIcon extends StatefulWidget {
const _PulsingRecognitionIcon();
@override
State<_PulsingRecognitionIcon> createState() =>
_PulsingRecognitionIconState();
}
class _PulsingRecognitionIconState extends State<_PulsingRecognitionIcon>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _ringScale;
late final Animation<double> _ringOpacity;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
)..repeat(reverse: true);
_ringScale = Tween<double>(begin: 0.88, end: 1.12).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
_ringOpacity = Tween<double>(begin: 0.12, end: 0.45).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return SizedBox(
width: 100,
height: 100,
child: AnimatedBuilder(
animation: _controller,
builder: (_, __) => Stack(
alignment: Alignment.center,
children: [
Transform.scale(
scale: _ringScale.value,
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primary
.withValues(alpha: _ringOpacity.value),
),
),
),
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primaryContainer,
),
child: Icon(
Icons.restaurant_outlined,
size: 30,
color: colorScheme.onPrimaryContainer,
),
),
],
),
),
); );
} }
} }

View File

@@ -231,5 +231,7 @@
} }
}, },
"recognitionReplaceProduct": "استبدال المنتج", "recognitionReplaceProduct": "استبدال المنتج",
"scanJobCloseHint": "يمكنك إغلاق التطبيق — سيظهر هذا المسح في عمليات المسح الأخيرة في شاشة المنتجات" "scanJobCloseHint": "يمكنك إغلاق التطبيق — سيظهر هذا المسح في عمليات المسح الأخيرة في شاشة المنتجات",
"minimize": "تصغير",
"dishRecognitionHint": "يمكنك التصغير — ستظهر النتيجة في الشاشة الرئيسية عند الانتهاء"
} }

View File

@@ -231,5 +231,7 @@
} }
}, },
"recognitionReplaceProduct": "Produkt ersetzen", "recognitionReplaceProduct": "Produkt ersetzen",
"scanJobCloseHint": "Du kannst die App schließen — dieser Scan erscheint in Letzte Scans auf dem Produktbildschirm" "scanJobCloseHint": "Du kannst die App schließen — dieser Scan erscheint in Letzte Scans auf dem Produktbildschirm",
"minimize": "Minimieren",
"dishRecognitionHint": "Du kannst minimieren — das Ergebnis erscheint auf dem Startbildschirm, wenn fertig"
} }

View File

@@ -231,5 +231,7 @@
} }
}, },
"recognitionReplaceProduct": "Replace product", "recognitionReplaceProduct": "Replace product",
"scanJobCloseHint": "You can close the app — this scan will appear in Recent Scans on the Products screen" "scanJobCloseHint": "You can close the app — this scan will appear in Recent Scans on the Products screen",
"minimize": "Minimize",
"dishRecognitionHint": "You can minimize — the result will appear on the Home screen when done"
} }

View File

@@ -231,5 +231,7 @@
} }
}, },
"recognitionReplaceProduct": "Reemplazar producto", "recognitionReplaceProduct": "Reemplazar producto",
"scanJobCloseHint": "Puedes cerrar la app — este escaneo aparecerá en Escaneos recientes en la pantalla de Productos" "scanJobCloseHint": "Puedes cerrar la app — este escaneo aparecerá en Escaneos recientes en la pantalla de Productos",
"minimize": "Minimizar",
"dishRecognitionHint": "Puedes minimizar — el resultado aparecerá en la pantalla principal cuando esté listo"
} }

View File

@@ -231,5 +231,7 @@
} }
}, },
"recognitionReplaceProduct": "Remplacer le produit", "recognitionReplaceProduct": "Remplacer le produit",
"scanJobCloseHint": "Vous pouvez fermer l'app — ce scan apparaîtra dans Scans récents sur l'écran Produits" "scanJobCloseHint": "Vous pouvez fermer l'app — ce scan apparaîtra dans Scans récents sur l'écran Produits",
"minimize": "Réduire",
"dishRecognitionHint": "Vous pouvez réduire — le résultat apparaîtra sur l'écran d'accueil quand c'est terminé"
} }

View File

@@ -231,5 +231,7 @@
} }
}, },
"recognitionReplaceProduct": "उत्पाद बदलें", "recognitionReplaceProduct": "उत्पाद बदलें",
"scanJobCloseHint": "आप ऐप बंद कर सकते हैं — यह स्कैन उत्पाद स्क्रीन पर हाल के स्कैन में दिखाई देगा" "scanJobCloseHint": "आप ऐप बंद कर सकते हैं — यह स्कैन उत्पाद स्क्रीन पर हाल के स्कैन में दिखाई देगा",
"minimize": "छोटा करें",
"dishRecognitionHint": "आप छोटा कर सकते हैं — पूर्ण होने पर परिणाम होम स्क्रीन पर दिखेगा"
} }

View File

@@ -231,5 +231,7 @@
} }
}, },
"recognitionReplaceProduct": "Sostituisci prodotto", "recognitionReplaceProduct": "Sostituisci prodotto",
"scanJobCloseHint": "Puoi chiudere l'app — questa scansione apparirà in Scansioni recenti nella schermata Prodotti" "scanJobCloseHint": "Puoi chiudere l'app — questa scansione apparirà in Scansioni recenti nella schermata Prodotti",
"minimize": "Riduci a icona",
"dishRecognitionHint": "Puoi ridurre a icona — il risultato apparirà nella schermata principale al termine"
} }

View File

@@ -231,5 +231,7 @@
} }
}, },
"recognitionReplaceProduct": "商品を置き換える", "recognitionReplaceProduct": "商品を置き換える",
"scanJobCloseHint": "アプリを閉じても大丈夫です — このスキャンは製品画面の最近のスキャンに表示されます" "scanJobCloseHint": "アプリを閉じても大丈夫です — このスキャンは製品画面の最近のスキャンに表示されます",
"minimize": "最小化",
"dishRecognitionHint": "最小化できます — 完了したら結果がホーム画面に表示されます"
} }

View File

@@ -231,5 +231,7 @@
} }
}, },
"recognitionReplaceProduct": "제품 교체", "recognitionReplaceProduct": "제품 교체",
"scanJobCloseHint": "앱을 닫아도 됩니다 — 이 스캔은 제품 화면의 최근 스캔에 표시됩니다" "scanJobCloseHint": "앱을 닫아도 됩니다 — 이 스캔은 제품 화면의 최근 스캔에 표시됩니다",
"minimize": "최소화",
"dishRecognitionHint": "최소화할 수 있습니다 — 완료되면 홈 화면에 결과가 표시됩니다"
} }

View File

@@ -1215,6 +1215,18 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'You can close the app — this scan will appear in Recent Scans on the Products screen'** /// **'You can close the app — this scan will appear in Recent Scans on the Products screen'**
String get scanJobCloseHint; String get scanJobCloseHint;
/// No description provided for @minimize.
///
/// In en, this message translates to:
/// **'Minimize'**
String get minimize;
/// No description provided for @dishRecognitionHint.
///
/// In en, this message translates to:
/// **'You can minimize — the result will appear on the Home screen when done'**
String get dishRecognitionHint;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View File

@@ -575,4 +575,11 @@ class AppLocalizationsAr extends AppLocalizations {
@override @override
String get scanJobCloseHint => String get scanJobCloseHint =>
'يمكنك إغلاق التطبيق — سيظهر هذا المسح في عمليات المسح الأخيرة في شاشة المنتجات'; 'يمكنك إغلاق التطبيق — سيظهر هذا المسح في عمليات المسح الأخيرة في شاشة المنتجات';
@override
String get minimize => 'تصغير';
@override
String get dishRecognitionHint =>
'يمكنك التصغير — ستظهر النتيجة في الشاشة الرئيسية عند الانتهاء';
} }

View File

@@ -579,4 +579,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get scanJobCloseHint => String get scanJobCloseHint =>
'Du kannst die App schließen — dieser Scan erscheint in Letzte Scans auf dem Produktbildschirm'; 'Du kannst die App schließen — dieser Scan erscheint in Letzte Scans auf dem Produktbildschirm';
@override
String get minimize => 'Minimieren';
@override
String get dishRecognitionHint =>
'Du kannst minimieren — das Ergebnis erscheint auf dem Startbildschirm, wenn fertig';
} }

View File

@@ -577,4 +577,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get scanJobCloseHint => String get scanJobCloseHint =>
'You can close the app — this scan will appear in Recent Scans on the Products screen'; 'You can close the app — this scan will appear in Recent Scans on the Products screen';
@override
String get minimize => 'Minimize';
@override
String get dishRecognitionHint =>
'You can minimize — the result will appear on the Home screen when done';
} }

View File

@@ -580,4 +580,11 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get scanJobCloseHint => String get scanJobCloseHint =>
'Puedes cerrar la app — este escaneo aparecerá en Escaneos recientes en la pantalla de Productos'; 'Puedes cerrar la app — este escaneo aparecerá en Escaneos recientes en la pantalla de Productos';
@override
String get minimize => 'Minimizar';
@override
String get dishRecognitionHint =>
'Puedes minimizar — el resultado aparecerá en la pantalla principal cuando esté listo';
} }

View File

@@ -580,4 +580,11 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get scanJobCloseHint => String get scanJobCloseHint =>
'Vous pouvez fermer l\'app — ce scan apparaîtra dans Scans récents sur l\'écran Produits'; 'Vous pouvez fermer l\'app — ce scan apparaîtra dans Scans récents sur l\'écran Produits';
@override
String get minimize => 'Réduire';
@override
String get dishRecognitionHint =>
'Vous pouvez réduire — le résultat apparaîtra sur l\'écran d\'accueil quand c\'est terminé';
} }

View File

@@ -578,4 +578,11 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get scanJobCloseHint => String get scanJobCloseHint =>
'आप ऐप बंद कर सकते हैं — यह स्कैन उत्पाद स्क्रीन पर हाल के स्कैन में दिखाई देगा'; 'आप ऐप बंद कर सकते हैं — यह स्कैन उत्पाद स्क्रीन पर हाल के स्कैन में दिखाई देगा';
@override
String get minimize => 'छोटा करें';
@override
String get dishRecognitionHint =>
'आप छोटा कर सकते हैं — पूर्ण होने पर परिणाम होम स्क्रीन पर दिखेगा';
} }

View File

@@ -580,4 +580,11 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get scanJobCloseHint => String get scanJobCloseHint =>
'Puoi chiudere l\'app — questa scansione apparirà in Scansioni recenti nella schermata Prodotti'; 'Puoi chiudere l\'app — questa scansione apparirà in Scansioni recenti nella schermata Prodotti';
@override
String get minimize => 'Riduci a icona';
@override
String get dishRecognitionHint =>
'Puoi ridurre a icona — il risultato apparirà nella schermata principale al termine';
} }

View File

@@ -571,4 +571,10 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get scanJobCloseHint => 'アプリを閉じても大丈夫です — このスキャンは製品画面の最近のスキャンに表示されます'; String get scanJobCloseHint => 'アプリを閉じても大丈夫です — このスキャンは製品画面の最近のスキャンに表示されます';
@override
String get minimize => '最小化';
@override
String get dishRecognitionHint => '最小化できます — 完了したら結果がホーム画面に表示されます';
} }

View File

@@ -571,4 +571,10 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get scanJobCloseHint => '앱을 닫아도 됩니다 — 이 스캔은 제품 화면의 최근 스캔에 표시됩니다'; String get scanJobCloseHint => '앱을 닫아도 됩니다 — 이 스캔은 제품 화면의 최근 스캔에 표시됩니다';
@override
String get minimize => '최소화';
@override
String get dishRecognitionHint => '최소화할 수 있습니다 — 완료되면 홈 화면에 결과가 표시됩니다';
} }

View File

@@ -579,4 +579,11 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get scanJobCloseHint => String get scanJobCloseHint =>
'Você pode fechar o app — este scan aparecerá em Scans recentes na tela de Produtos'; 'Você pode fechar o app — este scan aparecerá em Scans recentes na tela de Produtos';
@override
String get minimize => 'Minimizar';
@override
String get dishRecognitionHint =>
'Você pode minimizar — o resultado aparecerá na tela inicial quando concluído';
} }

View File

@@ -578,4 +578,11 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get scanJobCloseHint => String get scanJobCloseHint =>
'Можно закрыть приложение — скан появится в последних сканированиях на экране продуктов'; 'Можно закрыть приложение — скан появится в последних сканированиях на экране продуктов';
@override
String get minimize => 'Свернуть';
@override
String get dishRecognitionHint =>
'Можно свернуть — результат появится на главном экране по завершении';
} }

View File

@@ -570,4 +570,10 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get scanJobCloseHint => '您可以关闭应用 — 此扫描将显示在产品页面的最近扫描中'; String get scanJobCloseHint => '您可以关闭应用 — 此扫描将显示在产品页面的最近扫描中';
@override
String get minimize => '最小化';
@override
String get dishRecognitionHint => '可以最小化 — 完成后结果将显示在主屏幕上';
} }

View File

@@ -231,5 +231,7 @@
} }
}, },
"recognitionReplaceProduct": "Substituir produto", "recognitionReplaceProduct": "Substituir produto",
"scanJobCloseHint": "Você pode fechar o app — este scan aparecerá em Scans recentes na tela de Produtos" "scanJobCloseHint": "Você pode fechar o app — este scan aparecerá em Scans recentes na tela de Produtos",
"minimize": "Minimizar",
"dishRecognitionHint": "Você pode minimizar — o resultado aparecerá na tela inicial quando concluído"
} }

View File

@@ -231,5 +231,7 @@
} }
}, },
"recognitionReplaceProduct": "Заменить продукт", "recognitionReplaceProduct": "Заменить продукт",
"scanJobCloseHint": "Можно закрыть приложение — скан появится в последних сканированиях на экране продуктов" "scanJobCloseHint": "Можно закрыть приложение — скан появится в последних сканированиях на экране продуктов",
"minimize": "Свернуть",
"dishRecognitionHint": "Можно свернуть — результат появится на главном экране по завершении"
} }

View File

@@ -231,5 +231,7 @@
} }
}, },
"recognitionReplaceProduct": "替换产品", "recognitionReplaceProduct": "替换产品",
"scanJobCloseHint": "您可以关闭应用 — 此扫描将显示在产品页面的最近扫描中" "scanJobCloseHint": "您可以关闭应用 — 此扫描将显示在产品页面的最近扫描中",
"minimize": "最小化",
"dishRecognitionHint": "可以最小化 — 完成后结果将显示在主屏幕上"
} }