feat(flyer-import): add detailed product signals and display names
- 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:
@@ -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>>());
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user