feat(flyer-import): add detailed product signals and display names
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 5m12s
Test Suite / flutter-quality (push) Failing after 2m8s

- Added `signals` and `displayNameDetailed` fields to FlyerItem model in Prisma schema
- Introduced `FlyerImportSignals` type with origin countries, labels, quality flags, variant, and packaging
- Added `displayNameDetailed` field to FlyerImportItem DTO and Flutter model
- Implemented utility functions for signal extraction and display name building
- Updated flyer import service to persist and return signals/category data
- Enhanced Flutter UI to display detailed product information including badges for signals
- Added new test coverage for signals persistence and display name generation
- Added new import-common module for shared import utilities
- Created database migration for new fields
- Added Kilo plan for feature development
This commit is contained in:
Nils-Johan Gynther
2026-05-24 19:32:13 +02:00
parent d9f992ca9a
commit b04d157915
16 changed files with 1124 additions and 107 deletions
@@ -1,15 +1,60 @@
import 'flyer_reason_descriptor.dart';
class FlyerImportSignals {
final List<String> originCountries;
final List<String> labels;
final List<String> qualityFlags;
final String? variant;
final String? packaging;
const FlyerImportSignals({
this.originCountries = const [],
this.labels = const [],
this.qualityFlags = const [],
this.variant,
this.packaging,
});
factory FlyerImportSignals.fromJson(Map<String, dynamic> json) {
return FlyerImportSignals(
originCountries:
(json['originCountries'] as List?)?.map((e) => e.toString()).toList() ?? const [],
labels: (json['labels'] as List?)?.map((e) => e.toString()).toList() ?? const [],
qualityFlags:
(json['qualityFlags'] as List?)?.map((e) => e.toString()).toList() ?? const [],
variant: json['variant'] as String?,
packaging: json['packaging'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'originCountries': originCountries,
'labels': labels,
'qualityFlags': qualityFlags,
'variant': variant,
'packaging': packaging,
};
}
}
class FlyerImportItem {
final int? flyerItemId;
final String rawName;
final String normalizedName;
final String rawName;
final String? displayNameDetailed;
final String normalizedName;
final String? brand;
final String? category;
final int? categoryId;
final double? price;
final String? priceUnit;
final String? offerText;
final bool isOffer;
final double? price;
final String? priceUnit;
final String? weight;
final String? bundleWeight;
final bool isBundle;
final List<String> bundleItems;
final FlyerImportSignals? signals;
final String? offerText;
final bool isOffer;
final String? offerLimitText;
final double? comparisonPrice;
final String? comparisonUnit;
@@ -24,14 +69,21 @@ class FlyerImportItem {
final List<FlyerReasonDescriptor> matchReasonsDetailed;
FlyerImportItem({
required this.flyerItemId,
required this.rawName,
required this.normalizedName,
required this.flyerItemId,
required this.rawName,
this.displayNameDetailed,
required this.normalizedName,
this.brand,
this.category,
this.categoryId,
this.price,
this.priceUnit,
this.offerText,
this.price,
this.priceUnit,
this.weight,
this.bundleWeight,
this.isBundle = false,
this.bundleItems = const [],
this.signals,
this.offerText,
this.isOffer = false,
this.offerLimitText,
this.comparisonPrice,
@@ -49,14 +101,23 @@ class FlyerImportItem {
factory FlyerImportItem.fromJson(Map<String, dynamic> json) {
return FlyerImportItem(
flyerItemId: (json['flyerItemId'] as num?)?.toInt(),
rawName: json['rawName'] as String? ?? '',
normalizedName: json['normalizedName'] as String? ?? '',
flyerItemId: (json['flyerItemId'] as num?)?.toInt(),
rawName: json['rawName'] as String? ?? '',
displayNameDetailed: json['displayNameDetailed'] as String?,
normalizedName: json['normalizedName'] as String? ?? '',
brand: json['brand'] as String?,
category: json['category'] as String?,
categoryId: (json['categoryId'] as num?)?.toInt(),
price: (json['price'] as num?)?.toDouble(),
priceUnit: json['priceUnit'] as String?,
offerText: json['offerText'] as String?,
price: (json['price'] as num?)?.toDouble(),
priceUnit: json['priceUnit'] as String?,
weight: json['weight'] as String?,
bundleWeight: json['bundleWeight'] as String?,
isBundle: json['isBundle'] == true,
bundleItems: (json['bundleItems'] as List?)?.map((e) => e.toString()).toList() ?? const [],
signals: json['signals'] is Map
? FlyerImportSignals.fromJson(Map<String, dynamic>.from(json['signals'] as Map))
: null,
offerText: json['offerText'] as String?,
isOffer: json['isOffer'] == true,
offerLimitText: json['offerLimitText'] as String?,
comparisonPrice: (json['comparisonPrice'] as num?)?.toDouble(),
@@ -87,11 +148,18 @@ class FlyerImportItem {
return {
'flyerItemId': flyerItemId,
'rawName': rawName,
'displayNameDetailed': displayNameDetailed,
'normalizedName': normalizedName,
'brand': brand,
'category': category,
'categoryId': categoryId,
'price': price,
'priceUnit': priceUnit,
'weight': weight,
'bundleWeight': bundleWeight,
'isBundle': isBundle,
'bundleItems': bundleItems,
'signals': signals?.toJson(),
'offerText': offerText,
'isOffer': isOffer,
'offerLimitText': offerLimitText,
@@ -119,11 +187,18 @@ class FlyerImportItem {
return FlyerImportItem(
flyerItemId: flyerItemId,
rawName: rawName ?? this.rawName,
displayNameDetailed: displayNameDetailed,
normalizedName: normalizedName,
brand: brand,
category: category ?? this.category,
categoryId: categoryId ?? this.categoryId,
price: price,
priceUnit: priceUnit,
weight: weight,
bundleWeight: bundleWeight,
isBundle: isBundle,
bundleItems: bundleItems,
signals: signals,
offerText: offerText,
isOffer: isOffer,
offerLimitText: offerLimitText,
@@ -501,6 +501,40 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
);
}
Widget _buildMetadataBadges(FlyerImportItem item, ThemeData theme) {
final values = <String>[
...(item.signals?.originCountries ?? const <String>[]),
...(item.signals?.labels ?? const <String>[]),
];
if (values.isEmpty) return const SizedBox.shrink();
return Wrap(
spacing: 6,
runSpacing: 6,
children: values
.map(
(value) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.25),
),
),
child: Text(
value,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
)
.toList(),
);
}
Future<void> _copyText(String value, String label) async {
await Clipboard.setData(ClipboardData(text: value));
if (!mounted) return;
@@ -744,7 +778,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
},
title: Row(
children: [
Expanded(child: Text(item.rawName)),
Expanded(child: Text(item.displayNameDetailed ?? item.rawName)),
IconButton(
tooltip: 'Redigera',
visualDensity: VisualDensity.compact,
@@ -765,6 +799,10 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (priceText.isNotEmpty) Text('Pris: $priceText'),
if (item.isBundle && item.bundleItems.isNotEmpty)
Text('Paketinnehåll: ${item.bundleItems.join(' + ')}'),
if (item.brand != null && item.brand!.trim().isNotEmpty)
Text('Varumärke: ${item.brand}'),
if ((item.category ?? '').trim().isNotEmpty)
Text('Kategori: ${item.category}'),
if (comparisonText.isNotEmpty)
@@ -778,6 +816,11 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
),
),
if (sanitizedOfferText.isNotEmpty) Text(sanitizedOfferText),
if ((item.signals?.originCountries.isNotEmpty ?? false) ||
(item.signals?.labels.isNotEmpty ?? false)) ...[
const SizedBox(height: 6),
_buildMetadataBadges(item, theme),
],
if (item.matchedProductName != null)
Text('Match: ${item.matchedProductName}'),
if (detailedReasons.isNotEmpty) ...[
@@ -0,0 +1,29 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:recipe_flutter/features/import/domain/flyer_import_item.dart';
void main() {
test('parses signals and detailed bundle name', () {
final item = FlyerImportItem.fromJson({
'flyerItemId': 7,
'rawName': 'Kaptenens Favoriter',
'displayNameDetailed':
'Kaptenens Favoriter (Chumlax 3x100g + Alaska pollock 3x100g)',
'normalizedName': 'kaptenens favoriter',
'isBundle': true,
'bundleItems': ['Chumlax 3x100g', 'Alaska pollock 3x100g'],
'signals': {
'originCountries': ['Sverige'],
'labels': ['Ekologisk'],
'qualityFlags': ['eco'],
'variant': null,
'packaging': 'multipack',
},
});
expect(item.isBundle, isTrue);
expect(item.displayNameDetailed, contains('Chumlax 3x100g'));
expect(item.bundleItems, hasLength(2));
expect(item.signals?.originCountries, ['Sverige']);
expect(item.toJson()['signals'], isA<Map<String, dynamic>>());
});
}