feat(flyer-import): integrate AI-based flyer parsing with image support
- Add support for PNG, JPEG, and WebP image formats in flyer import - Replace external importer service with internal AI-based parsing pipeline - Add new services: TextExtractorService, AiFlyerParserService, FlyerNormalizerService - Integrate Mistral AI, pdf-parse, and tesseract.js dependencies - Add quality confidence indicators and warning panels in Flutter UI - Update package.json with new dependencies and transform ignore patterns - Add documentation for flyer importer system - Add Kilo AI planning file for Happy Island project BREAKING CHANGE: Flyer import now uses internal AI parsing instead of external importer service
This commit is contained in:
@@ -148,6 +148,87 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
);
|
||||
}
|
||||
|
||||
String _getQualityLevel(FlyerImportItem item) {
|
||||
final parseConf = item.parseConfidence ?? 0;
|
||||
final matchConf = item.matchConfidence ?? 0;
|
||||
final avgConf = (parseConf + matchConf) / 2;
|
||||
|
||||
if (avgConf >= 0.80) return 'Hög';
|
||||
if (avgConf >= 0.60) return 'Medel';
|
||||
return 'Låg';
|
||||
}
|
||||
|
||||
Color _getQualityColor(FlyerImportItem item) {
|
||||
final level = _getQualityLevel(item);
|
||||
if (level == 'Hög') return Colors.green.shade700;
|
||||
if (level == 'Medel') return Colors.orange.shade700;
|
||||
return Colors.red.shade700;
|
||||
}
|
||||
|
||||
Widget _buildQualityBadge(FlyerImportItem item, ThemeData theme) {
|
||||
final level = _getQualityLevel(item);
|
||||
final color = _getQualityColor(item);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: color.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Text(
|
||||
level,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWarningsPanel(ThemeData theme) {
|
||||
final warnings = _result?.warnings ?? const <String>[];
|
||||
if (warnings.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.shade50,
|
||||
border: Border.all(color: Colors.amber.shade300),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.amber.shade800, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Varningar (${warnings.length})',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: Colors.amber.shade900,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...warnings.map((warning) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
'• $warning',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.amber.shade900,
|
||||
),
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFlyerPreview(ThemeData theme) {
|
||||
final file = _pickedFile;
|
||||
final bytes = file?.bytes;
|
||||
@@ -177,9 +258,10 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
label: const Text('Visa flyer'),
|
||||
style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||
onPressed: () async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final opened = await openPdfBytes(bytes);
|
||||
if (!context.mounted || opened) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('PDF kan bara öppnas direkt i webbversionen just nu.'),
|
||||
),
|
||||
@@ -233,31 +315,33 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
label: const Text('Importera flyer'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildFlyerPreview(theme),
|
||||
if (_isLoading) ...[
|
||||
const SizedBox(height: 12),
|
||||
const LinearProgressIndicator(),
|
||||
],
|
||||
if (items.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('${items.length} rader hittades', style: theme.textTheme.titleSmall),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final target = selectedCount < items.length;
|
||||
setState(() {
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
_selected[i] = target;
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Text(selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildFlyerPreview(theme),
|
||||
if (_isLoading) ...[
|
||||
const SizedBox(height: 12),
|
||||
const LinearProgressIndicator(),
|
||||
],
|
||||
if (items.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
_buildWarningsPanel(theme),
|
||||
if ((_result?.warnings ?? const []).isNotEmpty) const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('${items.length} rader hittades', style: theme.textTheme.titleSmall),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final target = selectedCount < items.length;
|
||||
setState(() {
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
_selected[i] = target;
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Text(selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...items.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final item = entry.value;
|
||||
@@ -268,34 +352,37 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
? ''
|
||||
: _removeLimitTextFromOfferText(item.offerText!, limitText);
|
||||
|
||||
return CheckboxListTile(
|
||||
value: _selected[index] ?? false,
|
||||
onChanged: (value) => setState(() => _selected[index] = value ?? false),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(item.rawName)),
|
||||
_buildOfferBadge(item, theme),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (priceText.isNotEmpty) Text('Pris: $priceText'),
|
||||
if (comparisonText.isNotEmpty) Text('Jämförpris: $comparisonText'),
|
||||
if (limitText != null && limitText.isNotEmpty)
|
||||
Text(
|
||||
'Begränsning: $limitText',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.orange.shade900,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (sanitizedOfferText.isNotEmpty) Text(sanitizedOfferText),
|
||||
if (item.matchedProductName != null) Text('Match: ${item.matchedProductName}'),
|
||||
],
|
||||
),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
);
|
||||
return CheckboxListTile(
|
||||
value: _selected[index] ?? false,
|
||||
onChanged: (value) => setState(() => _selected[index] = value ?? false),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(item.rawName)),
|
||||
const SizedBox(width: 8),
|
||||
_buildQualityBadge(item, theme),
|
||||
const SizedBox(width: 8),
|
||||
_buildOfferBadge(item, theme),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (priceText.isNotEmpty) Text('Pris: $priceText'),
|
||||
if (comparisonText.isNotEmpty) Text('Jämförpris: $comparisonText'),
|
||||
if (limitText != null && limitText.isNotEmpty)
|
||||
Text(
|
||||
'Begränsning: $limitText',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.orange.shade900,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (sanitizedOfferText.isNotEmpty) Text(sanitizedOfferText),
|
||||
if (item.matchedProductName != null) Text('Match: ${item.matchedProductName}'),
|
||||
],
|
||||
),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
|
||||
Reference in New Issue
Block a user