chore(ci): update project documentation and flyer import features

Update project documentation with recent CI improvements and flyer import enhancements:

- Add ESLint configuration for backend and Dart lints for Flutter
- Document Prisma query logging via PRISMA_LOG_QUERIES environment variable
- Update NEXT_STEPS.md, README.md, and TEKNISK_BESKRIVNING.md with new features
- Add isOffer, offerLimitText, comparisonPrice, comparisonUnit, parseConfidence, and parseReasons fields to FlyerImportItem
- Update FlyerImportResponse type to include new fields
- Extend file picker to support image formats (png, jpg, jpeg, webp)
- Add offer badge display and price formatting in Flutter UI
- Implement PDF preview functionality for flyer import
This commit is contained in:
Nils-Johan Gynther
2026-05-18 23:27:20 +02:00
parent 3f242f9a6d
commit e658f2e6f1
7 changed files with 563 additions and 388 deletions
@@ -2,6 +2,7 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/utils/pdf_opener.dart';
import '../../auth/data/auth_providers.dart';
import '../data/import_providers.dart';
import '../domain/flyer_import_item.dart';
@@ -25,7 +26,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
Future<void> _pickFile() async {
final result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf', 'txt'],
allowedExtensions: ['pdf', 'txt', 'png', 'jpg', 'jpeg', 'webp'],
withData: true,
);
if (result == null || result.files.isEmpty) return;
@@ -108,6 +109,91 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
}
}
String _formatPrice(double? price, String? unit) {
if (price == null) return '';
final raw = price.toStringAsFixed(2).replaceAll('.', ',');
final unitPart = (unit != null && unit.trim().isNotEmpty) ? '/${unit.trim()}' : '';
return '$raw kr$unitPart';
}
Widget _buildOfferBadge(FlyerImportItem item, ThemeData theme) {
final hasOffer = item.isOffer || (item.offerText?.trim().isNotEmpty ?? false) || item.price != null;
if (!hasOffer) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: Colors.red.shade200),
),
child: Text(
'ERBJUDANDE',
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.red.shade900,
fontWeight: FontWeight.w700,
),
),
);
}
Widget _buildFlyerPreview(ThemeData theme) {
final file = _pickedFile;
final bytes = file?.bytes;
if (bytes == null) return const SizedBox.shrink();
final filename = file?.name ?? '';
final fallbackExt = filename.contains('.') ? filename.split('.').last : '';
final ext = (file?.extension ?? fallbackExt).toLowerCase();
final isImage = ['png', 'jpg', 'jpeg', 'webp', 'bmp'].contains(ext);
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
dense: true,
leading: Icon(
isImage ? Icons.image_outlined : Icons.picture_as_pdf_outlined,
color: theme.colorScheme.primary,
),
title: const Text('Flyerförhandsvisning'),
subtitle: Text(file?.name ?? ''),
trailing: isImage
? null
: OutlinedButton.icon(
icon: const Icon(Icons.open_in_new, size: 16),
label: const Text('Visa flyer'),
style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
onPressed: () async {
final opened = await openPdfBytes(bytes);
if (!context.mounted || opened) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('PDF kan bara öppnas direkt i webbversionen just nu.'),
),
);
},
),
),
if (isImage)
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 420),
child: Image.memory(bytes, fit: BoxFit.contain),
),
),
)
else
const SizedBox(height: 8),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -120,14 +206,14 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ladda upp flyer (PDF/txt), granska rader och planera inköp med ett klick.',
'Ladda upp flyer, granska erbjudanden och planera inköp med ett klick.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: _isLoading ? null : _pickFile,
icon: const Icon(Icons.attach_file),
label: Text(_pickedFile?.name ?? 'Välj flyerfil'),
label: Text(_pickedFile?.name ?? 'Välj flyerfil'),
),
const SizedBox(height: 12),
FilledButton.icon(
@@ -135,6 +221,8 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
icon: const Icon(Icons.auto_awesome),
label: const Text('Importera flyer'),
),
const SizedBox(height: 12),
_buildFlyerPreview(theme),
if (_isLoading) ...[
const SizedBox(height: 12),
const LinearProgressIndicator(),
@@ -154,7 +242,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
}
});
},
child: Text(selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'),
child: Text(selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'),
),
],
),
@@ -162,14 +250,29 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
...items.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
final priceText = _formatPrice(item.price, item.priceUnit);
final comparisonText = _formatPrice(item.comparisonPrice, item.comparisonUnit);
final limitText = item.offerLimitText?.trim();
return CheckboxListTile(
value: _selected[index] ?? false,
onChanged: (value) => setState(() => _selected[index] = value ?? false),
title: Text(item.rawName),
subtitle: Text([
if (item.offerText != null && item.offerText!.isNotEmpty) item.offerText!,
if (item.matchedProductName != null) 'Match: ${item.matchedProductName}',
].join(' · ')),
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'),
if ((item.offerText?.trim().isNotEmpty ?? false)) Text(item.offerText!.trim()),
if (item.matchedProductName != null) Text('Match: ${item.matchedProductName}'),
],
),
controlAffinity: ListTileControlAffinity.leading,
);
}),
@@ -193,4 +296,5 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
),
);
}
}
}