fixes before build
This commit is contained in:
@@ -39,9 +39,7 @@ abstract final class AppTheme {
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
@@ -60,9 +58,7 @@ abstract final class AppTheme {
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.6),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||
@@ -88,9 +84,7 @@ abstract final class AppTheme {
|
||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
dividerTheme: DividerThemeData(
|
||||
color: colorScheme.outlineVariant,
|
||||
@@ -98,14 +92,10 @@ abstract final class AppTheme {
|
||||
),
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
listTileTheme: ListTileThemeData(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
iconColor: colorScheme.primary,
|
||||
),
|
||||
shadowColor: isDark ? Colors.black : const Color(0x1A0F172A),
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/// Case-insensitive product name matching and duplicate grouping.
|
||||
library;
|
||||
|
||||
import '../models/product.dart';
|
||||
|
||||
String normalizeProductName(String name) =>
|
||||
name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' ');
|
||||
|
||||
String normalizeSupermarket(String? supermarket) =>
|
||||
supermarket?.trim().toUpperCase() ?? '';
|
||||
|
||||
String productConflictKey({
|
||||
required String name,
|
||||
required String? supermarket,
|
||||
}) => '${normalizeSupermarket(supermarket)}|${normalizeProductName(name)}';
|
||||
|
||||
String mergeGroupKey(List<Product> products, String? supermarket) {
|
||||
final ids = products.map((product) => product.id).toList()..sort();
|
||||
return 'merge:${normalizeSupermarket(supermarket)}:'
|
||||
'${normalizeProductName(products.first.name)}:${ids.join(',')}';
|
||||
}
|
||||
|
||||
String spellingVariantKey(int productId, String? supermarket) =>
|
||||
'spelling:$productId:${normalizeSupermarket(supermarket)}';
|
||||
|
||||
List<List<Product>> nameConflictGroups(Iterable<Product> products) {
|
||||
final groups = <String, List<Product>>{};
|
||||
for (final product in products) {
|
||||
final key = productConflictKey(
|
||||
name: product.name,
|
||||
supermarket: product.supermarket,
|
||||
);
|
||||
if (normalizeProductName(product.name).isEmpty) continue;
|
||||
(groups[key] ??= []).add(product);
|
||||
}
|
||||
return [
|
||||
for (final group in groups.values)
|
||||
if (group.length > 1) group,
|
||||
];
|
||||
}
|
||||
|
||||
Set<int> nameConflictIds(Iterable<Product> products) {
|
||||
return {
|
||||
for (final group in nameConflictGroups(products))
|
||||
for (final product in group) product.id,
|
||||
};
|
||||
}
|
||||
|
||||
/// Separate catalog products with the same name at the same store.
|
||||
class ProductMergeGroup {
|
||||
const ProductMergeGroup({
|
||||
required this.products,
|
||||
required this.supermarket,
|
||||
required this.key,
|
||||
});
|
||||
|
||||
final List<Product> products;
|
||||
final String? supermarket;
|
||||
final String key;
|
||||
}
|
||||
|
||||
/// One catalog product bought under multiple spellings at one store.
|
||||
class ProductSpellingVariant {
|
||||
const ProductSpellingVariant({
|
||||
required this.product,
|
||||
required this.supermarket,
|
||||
required this.variants,
|
||||
required this.key,
|
||||
});
|
||||
|
||||
final Product product;
|
||||
final String? supermarket;
|
||||
final List<String> variants;
|
||||
final String key;
|
||||
}
|
||||
|
||||
/// Builds merge groups from catalog names and trip lines at the same store.
|
||||
List<ProductMergeGroup> buildNameConflictGroups({
|
||||
required Iterable<Product> products,
|
||||
required Iterable<({int productId, String name, String supermarket})>
|
||||
shopLines,
|
||||
}) {
|
||||
final productById = {for (final product in products) product.id: product};
|
||||
final idsByKey = <String, Set<int>>{};
|
||||
final supermarketByKey = <String, String>{};
|
||||
|
||||
void add(int productId, String name, String? supermarket) {
|
||||
final key = productConflictKey(name: name, supermarket: supermarket);
|
||||
if (normalizeProductName(name).isEmpty) return;
|
||||
final trimmed = supermarket?.trim();
|
||||
if (trimmed != null && trimmed.isNotEmpty) {
|
||||
supermarketByKey.putIfAbsent(key, () => trimmed);
|
||||
}
|
||||
(idsByKey[key] ??= {}).add(productId);
|
||||
}
|
||||
|
||||
for (final product in products) {
|
||||
add(product.id, product.name, product.supermarket);
|
||||
}
|
||||
for (final line in shopLines) {
|
||||
add(line.productId, line.name, line.supermarket);
|
||||
}
|
||||
|
||||
final groups = <ProductMergeGroup>[];
|
||||
for (final entry in idsByKey.entries) {
|
||||
if (entry.value.length < 2) continue;
|
||||
final productsInGroup = [
|
||||
for (final id in entry.value)
|
||||
if (productById.containsKey(id)) productById[id]!,
|
||||
];
|
||||
if (productsInGroup.length < 2) continue;
|
||||
|
||||
productsInGroup.sort(
|
||||
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
||||
);
|
||||
final supermarketLabel = supermarketByKey[entry.key];
|
||||
groups.add(
|
||||
ProductMergeGroup(
|
||||
products: productsInGroup,
|
||||
supermarket: supermarketLabel,
|
||||
key: mergeGroupKey(productsInGroup, supermarketLabel),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
groups.sort(
|
||||
(a, b) =>
|
||||
productConflictKey(
|
||||
name: a.products.first.name,
|
||||
supermarket: a.supermarket,
|
||||
).compareTo(
|
||||
productConflictKey(
|
||||
name: b.products.first.name,
|
||||
supermarket: b.supermarket,
|
||||
),
|
||||
),
|
||||
);
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// Finds receipt spellings that differ only by capitals at one store.
|
||||
List<ProductSpellingVariant> findSpellingVariants({
|
||||
required Iterable<Product> products,
|
||||
required Iterable<({int productId, String name, String supermarket})>
|
||||
shopLines,
|
||||
Iterable<int> mergeProductIds = const [],
|
||||
Iterable<String> mergeGroupKeys = const [],
|
||||
}) {
|
||||
final productById = {for (final product in products) product.id: product};
|
||||
final excludedProducts = mergeProductIds.toSet();
|
||||
final excludedGroups = mergeGroupKeys.toSet();
|
||||
final namesByProductStore = <String, Set<String>>{};
|
||||
final supermarketByKey = <String, String>{};
|
||||
|
||||
void addName(int productId, String name, String supermarket) {
|
||||
final storeKey = normalizeSupermarket(supermarket);
|
||||
final bucketKey = '$productId|$storeKey';
|
||||
supermarketByKey[bucketKey] = supermarket;
|
||||
(namesByProductStore[bucketKey] ??= {}).add(name);
|
||||
}
|
||||
|
||||
for (final line in shopLines) {
|
||||
addName(line.productId, line.name, line.supermarket);
|
||||
}
|
||||
for (final product in products) {
|
||||
final supermarket = product.supermarket;
|
||||
if (supermarket == null || supermarket.trim().isEmpty) continue;
|
||||
addName(product.id, product.name, supermarket);
|
||||
}
|
||||
|
||||
final issues = <ProductSpellingVariant>[];
|
||||
for (final entry in namesByProductStore.entries) {
|
||||
final parts = entry.key.split('|');
|
||||
final productId = int.parse(parts.first);
|
||||
if (excludedProducts.contains(productId)) continue;
|
||||
|
||||
final product = productById[productId];
|
||||
if (product == null) continue;
|
||||
|
||||
final supermarket = supermarketByKey[entry.key];
|
||||
final byNormalized = <String, Set<String>>{};
|
||||
for (final name in entry.value) {
|
||||
final key = normalizeProductName(name);
|
||||
if (key.isEmpty) continue;
|
||||
(byNormalized[key] ??= {}).add(name);
|
||||
}
|
||||
|
||||
for (final names in byNormalized.values) {
|
||||
if (names.length < 2) continue;
|
||||
final variants = names.toList()
|
||||
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
|
||||
final issue = ProductSpellingVariant(
|
||||
product: product,
|
||||
supermarket: supermarket,
|
||||
variants: variants,
|
||||
key: spellingVariantKey(productId, supermarket),
|
||||
);
|
||||
if (excludedGroups.contains(issue.key)) continue;
|
||||
issues.add(issue);
|
||||
}
|
||||
}
|
||||
|
||||
issues.sort(
|
||||
(a, b) =>
|
||||
productConflictKey(
|
||||
name: a.product.name,
|
||||
supermarket: a.supermarket,
|
||||
).compareTo(
|
||||
productConflictKey(name: b.product.name, supermarket: b.supermarket),
|
||||
),
|
||||
);
|
||||
return issues;
|
||||
}
|
||||
|
||||
/// Catalog merges and receipt spelling variants for the Products screen.
|
||||
class ProductNameIssues {
|
||||
const ProductNameIssues({
|
||||
required this.mergeGroups,
|
||||
required this.spellingVariants,
|
||||
});
|
||||
|
||||
final List<ProductMergeGroup> mergeGroups;
|
||||
final List<ProductSpellingVariant> spellingVariants;
|
||||
|
||||
bool get isEmpty => mergeGroups.isEmpty && spellingVariants.isEmpty;
|
||||
|
||||
bool get isNotEmpty => !isEmpty;
|
||||
|
||||
int get issueCount => mergeGroups.length + spellingVariants.length;
|
||||
|
||||
Set<int> get productIds => {
|
||||
for (final group in mergeGroups)
|
||||
for (final product in group.products) product.id,
|
||||
for (final variant in spellingVariants) variant.product.id,
|
||||
};
|
||||
}
|
||||
|
||||
ProductNameIssues buildProductNameIssues({
|
||||
required Iterable<Product> products,
|
||||
required Iterable<({int productId, String name, String supermarket})>
|
||||
shopLines,
|
||||
Set<String> dismissedKeys = const {},
|
||||
}) {
|
||||
final mergeGroups = [
|
||||
for (final group in buildNameConflictGroups(
|
||||
products: products,
|
||||
shopLines: shopLines,
|
||||
))
|
||||
if (!dismissedKeys.contains(group.key)) group,
|
||||
];
|
||||
final mergeIds = {
|
||||
for (final group in mergeGroups)
|
||||
for (final product in group.products) product.id,
|
||||
};
|
||||
final mergeKeys = {for (final group in mergeGroups) group.key};
|
||||
final spellingVariants = [
|
||||
for (final variant in findSpellingVariants(
|
||||
products: products,
|
||||
shopLines: shopLines,
|
||||
mergeProductIds: mergeIds,
|
||||
mergeGroupKeys: mergeKeys,
|
||||
))
|
||||
if (!dismissedKeys.contains(variant.key)) variant,
|
||||
];
|
||||
return ProductNameIssues(
|
||||
mergeGroups: mergeGroups,
|
||||
spellingVariants: spellingVariants,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user