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

@@ -120,6 +120,7 @@ class HomeScreen extends ConsumerWidget {
],
const SizedBox(height: 16),
_DailyMealsSection(
key: const ValueKey('daily_meals_section'),
mealTypeIds: userMealTypes,
entries: entries,
dateString: dateString,
@@ -777,6 +778,7 @@ class _DailyMealsSection extends ConsumerWidget {
final String dateString;
const _DailyMealsSection({
super.key,
required this.mealTypeIds,
required this.entries,
required this.dateString,
@@ -803,6 +805,7 @@ class _DailyMealsSection extends ConsumerWidget {
.where((slot) => slot.mealType == mealTypeId)
.toList();
return Padding(
key: ValueKey(mealTypeId),
padding: const EdgeInsets.only(bottom: 8),
child: _MealCard(
mealTypeOption: mealTypeOption,
@@ -857,13 +860,19 @@ Future<void> _pickAndShowDishResult(
if (image == null || !context.mounted) return;
// 3. Show progress dialog.
// Capture root navigator before await to avoid GoRouter inner-navigator issues.
final rootNavigator = Navigator.of(context, rootNavigator: true);
// Use a dismiss signal so the dialog can close itself from within its own
// context, avoiding GoRouter's inner-navigator pop() issues.
final progressNotifier = _DishProgressNotifier(initialMessage: l10n.analyzingPhoto);
final dismissSignal = ValueNotifier<bool>(false);
bool wasMinimizedByUser = false;
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => _DishProgressDialog(notifier: progressNotifier),
builder: (_) => _DishProgressDialog(
notifier: progressNotifier,
dismissSignal: dismissSignal,
onMinimize: () { wasMinimizedByUser = true; },
),
);
// 4. Determine target date and meal type for context.
@@ -882,7 +891,9 @@ Future<void> _pickAndShowDishResult(
targetDate: targetDate,
targetMealType: resolvedMealType,
);
// Refresh immediately so the new queued job appears in the home screen list.
if (!context.mounted) return;
ref.read(todayJobsProvider.notifier).refresh();
await for (final event in service.streamJobEvents(jobCreated.jobId)) {
if (!context.mounted) break;
@@ -896,23 +907,52 @@ Future<void> _pickAndShowDishResult(
case DishJobProcessing():
progressNotifier.update(message: l10n.processing);
case DishJobDone():
rootNavigator.pop(); // close dialog
dismissSignal.value = true;
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),
),
);
ref.read(todayJobsProvider.notifier).refresh();
if (wasMinimizedByUser) {
// Recognition finished in background — notify without opening the sheet.
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.dishRecognized),
action: SnackBarAction(
label: l10n.addToJournal,
onPressed: () {
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;
case DishJobFailed():
rootNavigator.pop(); // close dialog
dismissSignal.value = true;
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -929,11 +969,15 @@ Future<void> _pickAndShowDishResult(
} catch (recognitionError) {
debugPrint('Dish recognition error: $recognitionError');
if (context.mounted) {
rootNavigator.pop(); // close dialog
ScaffoldMessenger.of(context).showSnackBar(
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 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
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ListenableBuilder(
listenable: notifier,
builder: (context, _) {
final state = notifier.state;
return AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(state.message, textAlign: TextAlign.center),
if (state.showUpgrade) ...[
const SizedBox(height: 12),
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 24),
child: ListenableBuilder(
listenable: widget.notifier,
builder: (_, __) {
final state = widget.notifier.state;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const _PulsingRecognitionIcon(),
const SizedBox(height: 24),
Text(
l10n.upgradePrompt,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
state.message,
style: theme.textTheme.titleMedium,
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,
),
),
],
),
),
);
}
}