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:
@@ -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> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user