Files
2026-05-05 14:15:28 +02:00

268 lines
8.9 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/api_providers.dart';
import '../../auth/data/auth_providers.dart';
class _AiSuggestion {
final String name;
final String description;
final List<String> mainIngredients;
final List<String> missingIngredients;
final String estimatedTime;
const _AiSuggestion({
required this.name,
required this.description,
required this.mainIngredients,
required this.missingIngredients,
required this.estimatedTime,
});
factory _AiSuggestion.fromJson(Map<String, dynamic> json) {
return _AiSuggestion(
name: json['name'] as String? ?? '',
description: json['description'] as String? ?? '',
mainIngredients: (json['mainIngredients'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ??
[],
missingIngredients: (json['missingIngredients'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ??
[],
estimatedTime: json['estimatedTime'] as String? ?? '',
);
}
}
class AiRecipeSuggestionsScreen extends ConsumerStatefulWidget {
const AiRecipeSuggestionsScreen({super.key});
@override
ConsumerState<AiRecipeSuggestionsScreen> createState() =>
_AiRecipeSuggestionsScreenState();
}
class _AiRecipeSuggestionsScreenState
extends ConsumerState<AiRecipeSuggestionsScreen> {
bool _isLoading = false;
String? _error;
List<_AiSuggestion> _suggestions = [];
bool _hasFetched = false;
Future<void> _generateSuggestions() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final token = await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider);
final data = await api.getJson(RecipeApiPaths.aiSuggestions, token: token);
if (!mounted) return;
final raw = data as Map<String, dynamic>;
final list = (raw['suggestions'] as List<dynamic>?) ?? [];
setState(() {
_suggestions =
list.map((e) => _AiSuggestion.fromJson(e as Map<String, dynamic>)).toList();
_hasFetched = true;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = 'Kunde inte hämta förslag. Försök igen.';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('AI-receptförslag'),
leading: BackButton(onPressed: () => context.pop()),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Vad kan jag laga?',
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Baserat på vad du har i ditt lager och skafferi föreslår AI vad du kan laga.',
style: theme.textTheme.bodyMedium
?.copyWith(color: cs.onSurfaceVariant),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _isLoading ? null : _generateSuggestions,
icon: _isLoading
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onPrimary,
),
)
: const Icon(Icons.auto_awesome),
label: Text(_isLoading ? 'Genererar förslag...' : 'Generera förslag'),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(
_error!,
style: theme.textTheme.bodySmall?.copyWith(color: cs.error),
),
],
const SizedBox(height: 16),
if (_hasFetched && _suggestions.isEmpty && !_isLoading)
Center(
child: Text(
'Inga förslag hittades. Lägg till fler varor i ditt lager.',
style: theme.textTheme.bodyMedium
?.copyWith(color: cs.onSurfaceVariant),
textAlign: TextAlign.center,
),
)
else
Expanded(
child: ListView.separated(
itemCount: _suggestions.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final s = _suggestions[index];
return _SuggestionCard(
suggestion: s,
onCreateRecipe: () => context.push(
'/recipes/create',
extra: {'markdown': '# ${s.name}\n\n${s.description}'},
),
);
},
),
),
],
),
),
);
}
}
class _SuggestionCard extends StatelessWidget {
final _AiSuggestion suggestion;
final VoidCallback onCreateRecipe;
const _SuggestionCard({
required this.suggestion,
required this.onCreateRecipe,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
suggestion.name,
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
),
if (suggestion.estimatedTime.isNotEmpty)
Chip(
label: Text(suggestion.estimatedTime),
avatar: const Icon(Icons.timer_outlined, size: 16),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
),
],
),
const SizedBox(height: 8),
Text(
suggestion.description,
style: theme.textTheme.bodyMedium,
),
if (suggestion.mainIngredients.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
'Ingredienser du har:',
style: theme.textTheme.labelMedium
?.copyWith(color: cs.primary),
),
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 4,
children: suggestion.mainIngredients
.map((ing) => Chip(
label: Text(ing),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
backgroundColor: cs.primaryContainer,
labelStyle: TextStyle(color: cs.onPrimaryContainer),
))
.toList(),
),
],
if (suggestion.missingIngredients.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Kan behövas:',
style: theme.textTheme.labelMedium
?.copyWith(color: cs.error),
),
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 4,
children: suggestion.missingIngredients
.map((ing) => Chip(
label: Text(ing),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
backgroundColor: cs.errorContainer,
labelStyle: TextStyle(color: cs.onErrorContainer),
))
.toList(),
),
],
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
onPressed: onCreateRecipe,
icon: const Icon(Icons.add, size: 18),
label: const Text('Skapa recept'),
),
),
],
),
),
);
}
}