Article Documents Feature - Implementation Guide¶
📋 Übersicht¶
Dieses Feature ermöglicht das Hochladen, Verwalten und Kategorisieren von Dokumenten für Artikel (z.B. Datenblätter, Handbücher, Zertifikate).
✅ Was wurde implementiert (Phase 1 - MVP)¶
1. Model & Datenstruktur¶
- ✅
ArticleDocumentModel (lib/models/articles/article_document.dart) - ✅
ArticleDocumentCategoryEnum mit 10 Kategorien - ✅ Unterstützung für Mehrsprachigkeit (
LanguageEnum) - ✅ Länder-spezifische Freigaben (
CountryEnum) - ✅
isPublicFlag für Mobile App Sichtbarkeit
2. Firebase Backend¶
- ✅ Firestore Sub-Collection:
articles/{articleId}/articleDocuments/{documentId} - ✅ Firebase Storage Pfad:
article_documents/{articleId}/{documentId}.{ext} - ✅ Firestore Rules erweitert (permissions-basiert)
- ✅ Storage Rules erweitert (Max 10 MB pro Datei)
3. Service Layer¶
- ✅
ArticleDocumentFirebaseServicemit CRUD-Operationen - ✅ Real-time Stream für Live-Updates
- ✅ Batch-Operationen für Sortierung
- ✅ Filter für öffentliche Dokumente
4. State Management¶
- ✅
ArticleDocumentBlocmit BLoC Pattern - ✅ Events: Load, Create, Update, Delete, Reorder
- ✅ Automatische Synchronisation mit Firestore
- ✅ Fehlerbehandlung
5. UI Components¶
- ✅
ArticleDocumentsSectionWidget - ✅ Upload-Dialog mit Konfiguration
- ✅ Dokumenten-Liste mit Vorschau
- ✅ Kategorie/Sprache/Länder Auswahl
- ✅ Download & Löschen Funktionen
🗂️ Dateistruktur¶
lib/
├── models/articles/
│ └── article_document.dart # Model & Category Enum
├── services/firebase_services/
│ └── article_document_firebase_service.dart
├── blocs/article_document_bloc/
│ ├── article_document_bloc.dart
│ ├── article_document_events.dart
│ └── article_document_states.dart
├── pages/articles/widgets/
│ └── article_documents_section.dart # UI Widget
├── l10n/
│ └── article_document_l10n_keys.dart # Lokalisierungskeys
└── constants/
└── firebase_collection_names.dart # Collection Name
firestore.rules # Firestore Security Rules
storage.rules # Storage Security Rules
🔧 Integration in Article Editor¶
Option 1: Als Tab (empfohlen)¶
// In article_editor_page.dart
TabBar(
tabs: [
Tab(text: 'Allgemein'),
Tab(text: 'Bilder'),
Tab(text: 'Dokumente'), // NEU
],
)
TabBarView(
children: [
// ... existing tabs
ArticleDocumentsSection(articleId: widget.article!.id),
],
)
Option 2: Als eigene Section¶
// Nach den Bildern einfügen
if (widget.article != null)
ArticleDocumentsSection(articleId: widget.article!.id),
📦 Dependencies¶
Diese Packages müssen in pubspec.yaml vorhanden sein:
🌐 Lokalisierung¶
Die Lokalisierungskeys müssen in die .arb Dateien eingefügt werden:
- Siehe: lib/l10n/article_document_l10n_keys.dart
- DE: lib/l10n/app_de.arb
- EN: lib/l10n/app_en.arb
Nach dem Hinzufügen:
🔐 Security & Permissions¶
Firestore Rules¶
- Lesen: Alle authentifizierten User
- Erstellen: Admin ODER
canCreateArticlesPermission - Bearbeiten: Admin ODER
canEditArticlesPermission - Löschen: Admin ODER
canDeleteArticlesPermission
Storage Rules¶
- Lesen: Authentifiziert
- Schreiben: Authentifiziert + Max 10 MB
🚀 Deployment¶
1. Rules deployen¶
2. Firestore Index (falls nötig)¶
Wenn die Firebase Console einen Index-Fehler anzeigt:
3. App neu builden¶
📱 Mobile App Integration (Future)¶
Für die Mobile App können nur öffentliche Dokumente geladen werden:
final publicDocs = await ArticleDocumentFirebaseService()
.getPublicArticleDocuments(articleId);
// Optional: Nach Land filtern
final visibleDocs = publicDocs.where((doc) =>
doc.isVisibleForCountry(userCountry)
).toList();
🎯 Unterstützte Dateiformate¶
- PDF: ✅
.pdf - Word: ✅
.doc,.docx - Excel: ✅
.xls,.xlsx - Text: ✅
.txt - Archive: ✅
.zip
📊 Datenbank-Schema¶
Firestore Document¶
{
"fileName": "Datenblatt_EN.pdf",
"displayName": "Product Datasheet",
"fileType": "pdf",
"fileSize": 2048000,
"storagePath": "article_documents/abc123/doc456.pdf",
"downloadUrl": "https://...",
"category": "datasheet",
"index": 0,
"language": "en",
"countryIds": ["germany", "austria", "switzerland"],
"isPublic": true,
"createdAt": "2026-01-06T10:00:00Z",
"modifiedAt": "2026-01-06T10:00:00Z",
"createdBy": "user123",
"modifiedBy": "user123"
}
🐛 Troubleshooting¶
Problem: "Missing permissions"¶
- Prüfe ob User die richtige Permission hat (
canCreateArticles, etc.) - Prüfe ob Firestore Rules deployed wurden
Problem: "Storage upload failed"¶
- Prüfe Storage Rules (Max 10 MB)
- Prüfe Dateityp (nur erlaubte Extensions)
- Prüfe Authentifizierung
Problem: "Documents not loading"¶
- Prüfe ob
LoadArticleDocumentsEvent gefeuert wird - Prüfe Firestore Rules
- Prüfe Console Logs für Fehler
🔮 Future Enhancements (Phase 2+)¶
- [ ] Versionierung von Dokumenten
- [ ] Gültigkeitsdaten (validFrom/validUntil)
- [ ] PDF Vorschau im Browser
- [ ] Drag & Drop Upload
- [ ] Bulk Upload
- [ ] Pflichtdokumente (isMandatory)
- [ ] Approval Workflow
- [ ] Audit Trail
- [ ] ERP Import Integration
📞 Support¶
Bei Fragen oder Problemen: 1. Prüfe diese README 2. Prüfe Console Logs 3. Prüfe Firebase Console (Firestore & Storage)
📱 Article Documents - Mobile App Integration Guide¶
📋 Übersicht¶
Diese Dokumentation beschreibt die vollständige Integration der Artikel-Dokumente-Funktionalität in die Mobile App. Kunden können öffentliche Dokumente (Datenblätter, Handbücher, etc.) zu Artikeln ansehen, herunterladen und offline verfügbar machen.
🎯 Anforderungen & Scope¶
Was Mobile App User können sollen:¶
- ✅ Liste aller öffentlichen Dokumente eines Artikels sehen
- ✅ Dokumente nach Typ/Kategorie filtern
- ✅ Dokumente nach Sprache filtern
- ✅ Dokumente nach Länder-Freigabe filtern
- ✅ PDF-Dokumente direkt in der App anzeigen
- ✅ Dokumente herunterladen (für Offline-Nutzung)
- ✅ Dokumente teilen (Share-Funktion)
- ✅ Download-Status & -Fortschritt anzeigen
Was Mobile App User NICHT können:¶
- ❌ Dokumente hochladen/erstellen
- ❌ Dokumente bearbeiten/löschen
- ❌ Private Dokumente sehen (nur
isPublic: true) - ❌ Dokumente versionieren
🏗️ Architektur¶
1. Model (bereits vorhanden)¶
Das ArticleDocument Model kann 1:1 wiederverwendet werden:
- lib/models/articles/article_document.dart
- lib/models/articles/article_document_type.dart
2. Service Layer¶
ArticleDocumentService (Mobile-spezifisch)¶
// lib/services/mobile/article_document_mobile_service.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../models/articles/article_document.dart';
import '../../models/articles/article_document_type.dart';
import '../../models/all_enums/country_enum.dart';
import '../../models/all_enums/language_enum.dart';
class ArticleDocumentMobileService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
/// Lädt nur öffentliche Dokumente eines Artikels
/// WICHTIG: Diese Methode existiert bereits in ArticleDocumentFirebaseService
/// als streamPublicArticleDocuments() - verwende diese!
Stream<List<ArticleDocument>> streamPublicDocuments(String articleId) {
// Option 1: Verwende die existierende Methode (EMPFOHLEN)
final firebaseService = ArticleDocumentFirebaseService();
return firebaseService.streamPublicArticleDocuments(articleId);
// Option 2: Eigene Implementierung (falls angepasste Logik benötigt)
/*
return _firestore
.collection('articles')
.doc(articleId)
.collection('articleDocuments')
.where('isPublic', isEqualTo: true)
.where('isActive', isEqualTo: true) // Nur aktive Dokumente
.orderBy('index')
.snapshots()
.map((snapshot) {
return snapshot.docs
.map((doc) => ArticleDocument.fromMap(doc.id, doc.data()))
.where((doc) => doc.isCurrentlyValid) // Nur gültige Dokumente
.toList();
});
*/
}
/// Prüft ob Dokument aktuell gültig ist (validFrom/validUntil)
bool _isCurrentlyValid(ArticleDocument document) {
final now = DateTime.now();
if (document.validFrom != null && now.isBefore(document.validFrom!)) {
return false; // Noch nicht gültig
}
if (document.validUntil != null && now.isAfter(document.validUntil!)) {
return false; // Abgelaufen
}
return true;
}
/// Filtert Dokumente nach Land
List<ArticleDocument> filterByCountry(
List<ArticleDocument> documents,
CountryEnum userCountry,
) {
return documents.where((doc) {
// Wenn keine Länder definiert sind, ist es für alle sichtbar
if (doc.countryIds.isEmpty) return true;
return doc.countryIds.contains(userCountry.key);
}).toList();
}
/// Filtert Dokumente nach Sprache
List<ArticleDocument> filterByLanguage(
List<ArticleDocument> documents,
LanguageEnum? language,
) {
if (language == null) return documents;
return documents.where((doc) {
// Dokumente ohne Sprache sind für alle sichtbar
if (doc.language == null) return true;
return doc.language == language;
}).toList();
}
/// Gruppiert Dokumente nach Typ
Map<String, List<ArticleDocument>> groupByDocumentType(
List<ArticleDocument> documents,
) {
final Map<String, List<ArticleDocument>> grouped = {};
for (final doc in documents) {
final typeId = doc.documentTypeId;
if (!grouped.containsKey(typeId)) {
grouped[typeId] = [];
}
grouped[typeId]!.add(doc);
}
return grouped;
}
/// Lädt Dokumenttypen für einen Kunden (für Anzeige/Gruppierung)
Stream<List<ArticleDocumentType>> streamDocumentTypes(String customerId) {
return _firestore
.collection('customers')
.doc(customerId)
.collection('articleDocumentTypes')
.where('deletedAt', isNull: true)
.orderBy('sortOrder')
.snapshots()
.map((snapshot) {
return snapshot.docs
.map((doc) => ArticleDocumentType.fromFirestore(doc))
.toList();
});
}
}
📦 Dependencies¶
Füge in pubspec.yaml hinzu:
dependencies:
# Für PDF-Anzeige
flutter_pdfview: ^1.3.2
# Alternativ (neuere Version):
# pdfx: ^2.6.0
# Für Datei-Downloads
dio: ^5.4.0
path_provider: ^2.1.1
# Für Permissions
permission_handler: ^11.1.0
# Für Sharing
share_plus: ^7.2.1
# Optional: Für Download-Fortschritt UI
percent_indicator: ^4.2.3
🎨 UI Components¶
1. DocumentListWidget¶
Widget zur Anzeige aller Dokumente eines Artikels:
// lib/widgets/mobile/article_documents_list.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../models/articles/article_document.dart';
import '../../models/articles/article_document_type.dart';
import '../../services/mobile/article_document_mobile_service.dart';
import '../../blocs/user_bloc/user_bloc.dart';
import 'document_card.dart';
class ArticleDocumentsList extends StatefulWidget {
final String articleId;
final String customerId;
const ArticleDocumentsList({
Key? key,
required this.articleId,
required this.customerId,
}) : super(key: key);
@override
State<ArticleDocumentsList> createState() => _ArticleDocumentsListState();
}
class _ArticleDocumentsListState extends State<ArticleDocumentsList> {
final _service = ArticleDocumentMobileService();
String? _selectedTypeId; // Filter nach Typ
LanguageEnum? _selectedLanguage; // Filter nach Sprache
@override
Widget build(BuildContext context) {
final userBloc = context.read<UserBloc>();
final userCountry = userBloc.currentUser?.country; // User-Land
return Column(
children: [
// Filter-Leiste
_buildFilterBar(),
// Dokumente-Liste
Expanded(
child: StreamBuilder<List<ArticleDocument>>(
stream: _service.streamPublicDocuments(widget.articleId),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text('Fehler: ${snapshot.error}'),
);
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
var documents = snapshot.data!;
// Filter nach Land
if (userCountry != null) {
documents = _service.filterByCountry(documents, userCountry);
}
// Filter nach Sprache
if (_selectedLanguage != null) {
documents = _service.filterByLanguage(
documents,
_selectedLanguage,
);
}
// Filter nach Typ
if (_selectedTypeId != null) {
documents = documents
.where((doc) => doc.documentTypeId == _selectedTypeId)
.toList();
}
if (documents.isEmpty) {
return _buildEmptyState();
}
return _buildDocumentsList(documents);
},
),
),
],
);
}
Widget _buildFilterBar() {
return Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Typ-Filter Dropdown
Expanded(
child: StreamBuilder<List<ArticleDocumentType>>(
stream: _service.streamDocumentTypes(widget.customerId),
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox.shrink();
final types = snapshot.data!;
return DropdownButton<String?>(
value: _selectedTypeId,
hint: const Text('Alle Typen'),
isExpanded: true,
items: [
const DropdownMenuItem(
value: null,
child: Text('Alle Typen'),
),
...types.map((type) {
return DropdownMenuItem(
value: type.id,
child: Row(
children: [
if (type.icon != null)
Icon(
IconData(
type.icon!,
fontFamily: 'MaterialIcons',
),
size: 20,
),
const SizedBox(width: 8),
Text(type.name),
],
),
);
}),
],
onChanged: (value) {
setState(() {
_selectedTypeId = value;
});
},
);
},
),
),
const SizedBox(width: 16),
// Sprach-Filter Dropdown
Expanded(
child: DropdownButton<LanguageEnum?>(
value: _selectedLanguage,
hint: const Text('Alle Sprachen'),
isExpanded: true,
items: [
const DropdownMenuItem(
value: null,
child: Text('Alle Sprachen'),
),
...LanguageEnum.values.map((lang) {
return DropdownMenuItem(
value: lang,
child: Text(lang.displayName),
);
}),
],
onChanged: (value) {
setState(() {
_selectedLanguage = value;
});
},
),
),
],
),
);
}
Widget _buildDocumentsList(List<ArticleDocument> documents) {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: documents.length,
itemBuilder: (context, index) {
final document = documents[index];
return DocumentCard(
document: document,
customerId: widget.customerId,
);
},
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.insert_drive_file_outlined,
size: 64,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
'Keine Dokumente verfügbar',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
),
),
],
),
);
}
}
2. DocumentCard¶
Card-Widget für ein einzelnes Dokument:
// lib/widgets/mobile/document_card.dart
import 'package:flutter/material.dart';
import '../../models/articles/article_document.dart';
import '../../models/articles/article_document_type.dart';
import '../../services/mobile/article_document_mobile_service.dart';
import '../../services/mobile/document_download_service.dart';
import 'pdf_viewer_page.dart';
class DocumentCard extends StatefulWidget {
final ArticleDocument document;
final String customerId;
const DocumentCard({
Key? key,
required this.document,
required this.customerId,
}) : super(key: key);
@override
State<DocumentCard> createState() => _DocumentCardState();
}
class _DocumentCardState extends State<DocumentCard> {
final _downloadService = DocumentDownloadService();
final _documentService = ArticleDocumentMobileService();
bool _isDownloading = false;
double _downloadProgress = 0.0;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: _openDocument,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// Icon basierend auf Dateityp
_buildFileIcon(),
const SizedBox(width: 12),
// Titel & Details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.document.displayName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
_buildMetadata(),
],
),
),
// Action Buttons
_buildActionButtons(),
],
),
// Download Progress (wenn aktiv)
if (_isDownloading)
Padding(
padding: const EdgeInsets.only(top: 12),
child: LinearProgressIndicator(value: _downloadProgress),
),
// Version & Gültigkeit
if (widget.document.version != null ||
widget.document.validUntil != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: _buildAdditionalInfo(),
),
],
),
),
),
);
}
Widget _buildFileIcon() {
IconData iconData;
Color iconColor;
switch (widget.document.fileType.toLowerCase()) {
case 'pdf':
iconData = Icons.picture_as_pdf;
iconColor = Colors.red;
break;
case 'doc':
case 'docx':
iconData = Icons.description;
iconColor = Colors.blue;
break;
case 'xls':
case 'xlsx':
iconData = Icons.table_chart;
iconColor = Colors.green;
break;
default:
iconData = Icons.insert_drive_file;
iconColor = Colors.grey;
}
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: iconColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(iconData, color: iconColor, size: 32),
);
}
Widget _buildMetadata() {
final parts = <String>[];
// Dateityp
parts.add(widget.document.fileType.toUpperCase());
// Dateigröße
parts.add(_formatFileSize(widget.document.fileSize));
// Sprache
if (widget.document.language != null) {
parts.add(widget.document.language!.displayName);
}
return Text(
parts.join(' • '),
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
);
}
Widget _buildActionButtons() {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// Download Button
IconButton(
icon: const Icon(Icons.download),
onPressed: _isDownloading ? null : _downloadDocument,
tooltip: 'Herunterladen',
),
// Share Button
IconButton(
icon: const Icon(Icons.share),
onPressed: _shareDocument,
tooltip: 'Teilen',
),
],
);
}
Widget _buildAdditionalInfo() {
return Row(
children: [
if (widget.document.version != null) ...[
Chip(
label: Text('v${widget.document.version}'),
backgroundColor: Colors.blue.shade50,
labelStyle: TextStyle(
fontSize: 11,
color: Colors.blue.shade700,
),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
const SizedBox(width: 8),
],
if (widget.document.validUntil != null) ...[
Chip(
label: Text(
'Gültig bis ${_formatDate(widget.document.validUntil!)}',
),
backgroundColor: Colors.orange.shade50,
labelStyle: TextStyle(
fontSize: 11,
color: Colors.orange.shade700,
),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
],
],
);
}
void _openDocument() async {
if (widget.document.fileType.toLowerCase() == 'pdf') {
// PDF direkt in der App öffnen
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PdfViewerPage(
document: widget.document,
),
),
);
} else {
// Andere Dateitypen: Download starten
await _downloadDocument();
}
}
Future<void> _downloadDocument() async {
setState(() {
_isDownloading = true;
_downloadProgress = 0.0;
});
try {
await _downloadService.downloadDocument(
widget.document,
onProgress: (progress) {
setState(() {
_downloadProgress = progress;
});
},
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${widget.document.displayName} heruntergeladen'),
action: SnackBarAction(
label: 'Öffnen',
onPressed: () {
_downloadService.openDownloadedFile(widget.document);
},
),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Download fehlgeschlagen: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isDownloading = false;
});
}
}
}
Future<void> _shareDocument() async {
await _downloadService.shareDocument(widget.document);
}
String _formatFileSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
String _formatDate(DateTime date) {
return '${date.day}.${date.month}.${date.year}';
}
}
3. PDF Viewer Page¶
Seite zur Anzeige von PDF-Dokumenten:
// lib/widgets/mobile/pdf_viewer_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_pdfview/flutter_pdfview.dart';
import 'dart:io';
import '../../models/articles/article_document.dart';
import '../../services/mobile/document_download_service.dart';
class PdfViewerPage extends StatefulWidget {
final ArticleDocument document;
const PdfViewerPage({
Key? key,
required this.document,
}) : super(key: key);
@override
State<PdfViewerPage> createState() => _PdfViewerPageState();
}
class _PdfViewerPageState extends State<PdfViewerPage> {
final _downloadService = DocumentDownloadService();
String? _localPath;
bool _isLoading = true;
String? _error;
int _currentPage = 0;
int _totalPages = 0;
@override
void initState() {
super.initState();
_loadPdf();
}
Future<void> _loadPdf() async {
try {
setState(() {
_isLoading = true;
_error = null;
});
// Download PDF temporär (oder aus Cache laden)
final path = await _downloadService.getCachedOrDownload(widget.document);
setState(() {
_localPath = path;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = 'PDF konnte nicht geladen werden: $e';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.document.displayName),
actions: [
// Download Button
IconButton(
icon: const Icon(Icons.download),
onPressed: () async {
await _downloadService.downloadDocument(widget.document);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('PDF heruntergeladen')),
);
}
},
),
// Share Button
IconButton(
icon: const Icon(Icons.share),
onPressed: () {
_downloadService.shareDocument(widget.document);
},
),
],
),
body: _buildBody(),
bottomNavigationBar: _totalPages > 0
? _buildPageIndicator()
: null,
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (_error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(_error!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadPdf,
child: const Text('Erneut versuchen'),
),
],
),
);
}
if (_localPath == null) {
return const Center(
child: Text('Kein PDF verfügbar'),
);
}
return PDFView(
filePath: _localPath!,
enableSwipe: true,
swipeHorizontal: false,
autoSpacing: true,
pageFling: true,
pageSnap: true,
onRender: (pages) {
setState(() {
_totalPages = pages ?? 0;
});
},
onPageChanged: (page, total) {
setState(() {
_currentPage = page ?? 0;
});
},
onError: (error) {
setState(() {
_error = error.toString();
});
},
);
}
Widget _buildPageIndicator() {
return Container(
padding: const EdgeInsets.all(16),
color: Colors.black87,
child: Text(
'Seite ${_currentPage + 1} von $_totalPages',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
);
}
}
📥 Download Service¶
Service für Downloads und Caching:
// lib/services/mobile/document_download_service.dart
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:open_file/open_file.dart';
import '../../models/articles/article_document.dart';
class DocumentDownloadService {
final Dio _dio = Dio();
/// Download-Verzeichnis abrufen
Future<Directory> getDownloadDirectory() async {
if (Platform.isAndroid) {
// Auf Android: Downloads-Ordner
return Directory('/storage/emulated/0/Download');
} else if (Platform.isIOS) {
// Auf iOS: Application Documents Directory
return await getApplicationDocumentsDirectory();
}
throw UnsupportedError('Plattform nicht unterstützt');
}
/// Cache-Verzeichnis für temporäre PDFs
Future<Directory> getCacheDirectory() async {
return await getTemporaryDirectory();
}
/// Dokument herunterladen
Future<String> downloadDocument(
ArticleDocument document, {
Function(double)? onProgress,
}) async {
final downloadDir = await getDownloadDirectory();
final filePath = '${downloadDir.path}/${document.fileName}';
await _dio.download(
document.downloadUrl,
filePath,
onReceiveProgress: (received, total) {
if (total != -1 && onProgress != null) {
onProgress(received / total);
}
},
);
return filePath;
}
/// Dokument aus Cache laden oder herunterladen
Future<String> getCachedOrDownload(ArticleDocument document) async {
final cacheDir = await getCacheDirectory();
final cachedPath = '${cacheDir.path}/${document.fileName}';
final cachedFile = File(cachedPath);
// Wenn bereits im Cache, verwende es
if (await cachedFile.exists()) {
return cachedPath;
}
// Sonst: Download in Cache
await _dio.download(document.downloadUrl, cachedPath);
return cachedPath;
}
/// Dokument teilen
Future<void> shareDocument(ArticleDocument document) async {
try {
// Erst herunterladen/cachen
final path = await getCachedOrDownload(document);
// Dann teilen
await Share.shareXFiles(
[XFile(path)],
text: document.displayName,
);
} catch (e) {
throw Exception('Dokument konnte nicht geteilt werden: $e');
}
}
/// Heruntergeladenes Dokument öffnen
Future<void> openDownloadedFile(ArticleDocument document) async {
final downloadDir = await getDownloadDirectory();
final filePath = '${downloadDir.path}/${document.fileName}';
final result = await OpenFile.open(filePath);
if (result.type != ResultType.done) {
throw Exception('Datei konnte nicht geöffnet werden: ${result.message}');
}
}
/// Cache leeren
Future<void> clearCache() async {
final cacheDir = await getCacheDirectory();
final files = cacheDir.listSync();
for (final file in files) {
if (file is File) {
await file.delete();
}
}
}
/// Größe des Caches berechnen
Future<int> getCacheSize() async {
final cacheDir = await getCacheDirectory();
final files = cacheDir.listSync();
int totalSize = 0;
for (final file in files) {
if (file is File) {
totalSize += await file.length();
}
}
return totalSize;
}
}
🔒 Permissions (Android/iOS)¶
Android (android/app/src/main/AndroidManifest.xml)¶
<manifest>
<!-- Internet Permission (bereits vorhanden) -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Storage Permissions für Downloads -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- Für Android 13+ -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
</manifest>
iOS (ios/Runner/Info.plist)¶
<key>NSPhotoLibraryUsageDescription</key>
<string>Wir benötigen Zugriff zum Speichern von Dokumenten</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Wir möchten Dokumente in Ihrer Galerie speichern</string>
🧪 Beispiel: Integration im Artikel-Detail¶
// lib/pages/mobile/article_detail_page.dart
import 'package:flutter/material.dart';
import '../../models/articles/article.dart';
import '../../widgets/mobile/article_documents_list.dart';
class ArticleDetailPage extends StatelessWidget {
final Article article;
const ArticleDetailPage({
Key? key,
required this.article,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: Text(article.name),
bottom: const TabBar(
tabs: [
Tab(text: 'Details', icon: Icon(Icons.info_outline)),
Tab(text: 'Bilder', icon: Icon(Icons.photo_library)),
Tab(text: 'Dokumente', icon: Icon(Icons.description)),
],
),
),
body: TabBarView(
children: [
// Tab 1: Details
_buildDetailsTab(),
// Tab 2: Bilder
_buildImagesTab(),
// Tab 3: Dokumente
ArticleDocumentsList(
articleId: article.id,
customerId: article.customerId,
),
],
),
),
);
}
Widget _buildDetailsTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Artikelnummer: ${article.articleNumber}'),
const SizedBox(height: 8),
Text('Beschreibung: ${article.description ?? "-"}'),
// ... weitere Details
],
),
);
}
Widget _buildImagesTab() {
// Implementierung für Bilder-Tab
return const Center(child: Text('Bilder'));
}
}
📊 Firestore Security Rules (Überprüfung)¶
Die bestehenden Rules erlauben bereits das Lesen:
match /articles/{articleId} {
match /articleDocuments/{documentId} {
// Lesen: Alle authentifizierten User ✅
allow read: if isAuthenticated();
}
}
Wichtig: Mobile App User dürfen nur LESEN, nicht schreiben!
🚀 Deployment Checkliste¶
1. Dependencies installieren¶
2. Dateien erstellen¶
- ✅
lib/services/mobile/article_document_mobile_service.dart - ✅
lib/services/mobile/document_download_service.dart - ✅
lib/widgets/mobile/article_documents_list.dart - ✅
lib/widgets/mobile/document_card.dart - ✅
lib/widgets/mobile/pdf_viewer_page.dart
3. Permissions konfigurieren¶
- ✅ Android:
AndroidManifest.xmlanpassen - ✅ iOS:
Info.plistanpassen
4. Integration im Artikel-Detail¶
- ✅ Tab "Dokumente" hinzufügen
- ✅
ArticleDocumentsListWidget einbinden
5. Testen¶
- ✅ Dokumente-Liste lädt
- ✅ Filter (Typ, Sprache) funktionieren
- ✅ PDF-Viewer öffnet
- ✅ Download funktioniert
- ✅ Share-Funktion funktioniert
- ✅ Permissions werden korrekt angefragt
🎯 Best Practices¶
Performance¶
- ✅ Verwende Streams für Real-time Updates
- ✅ Implementiere Caching für PDFs
- ✅ Lade nur öffentliche Dokumente
- ✅ Filtern nach Land bereits auf Client-Seite
UX¶
- ✅ Zeige Download-Fortschritt
- ✅ Offline-Verfügbarkeit durch Cache
- ✅ Klare Fehler-Meldungen
- ✅ Intuitive Filter-Optionen
Security¶
- ✅ Nur
isPublic: trueDokumente - ✅ Nur
isActive: trueDokumente - ✅ Validitätsdatum prüfen
- ✅ Länder-Filter anwenden
🐛 Troubleshooting¶
Problem: "PDF lädt nicht"¶
Lösung:
- Prüfe Internet-Verbindung
- Prüfe downloadUrl in Firestore
- Prüfe Storage-Permissions
Problem: "Download schlägt fehl"¶
Lösung: - Prüfe Storage-Permissions (Android/iOS) - Prüfe Speicherplatz auf Gerät - Prüfe Dateigröße (< 10 MB?)
Problem: "Keine Dokumente sichtbar"¶
Lösung:
1. Prüfe isPublic: true Flag
- Im ERP: Artikel-Detail → Dokumente → Dokument bearbeiten
- "Öffentlich (Mobile App)" Schalter muss aktiviert sein
- Prüfe
isActive: trueFlag - Dokumente müssen aktiv sein (nicht archiviert)
-
Bei Versionierung: Nur die neueste Version ist aktiv
-
Prüfe Gültigkeitsdatum
validFrom: Dokument noch nicht gültig?validUntil: Dokument bereits abgelaufen?-
Aktuelles Datum muss zwischen validFrom und validUntil liegen
-
Prüfe Länder-Filter
- Wenn
countryIdsleer: Dokument für alle sichtbar ✅ -
Wenn
countryIdsgesetzt: User-Land muss in Liste sein -
Firestore Query prüfen (via Firebase Console)
-
Debug in Mobile App
📞 Zusammenfassung¶
Was wurde dokumentiert:¶
- ✅ Vollständige Service-Layer für Mobile
- ✅ UI Components (Liste, Card, PDF-Viewer)
- ✅ Download & Caching Service
- ✅ Permissions Setup (Android/iOS)
- ✅ Integration im Artikel-Detail
- ✅ Best Practices & Troubleshooting
Nächste Schritte für Implementierung:¶
- Dependencies installieren
- Service-Dateien erstellen
- UI-Widgets erstellen
- Permissions konfigurieren
- Integration testen
Diese Dokumentation kann direkt als Prompt für die Mobile App Entwicklung verwendet werden! 🚀
🔍 Diagnose-Anleitung: Artikel-Dokumente in Mobile App nicht sichtbar¶
Problem¶
Artikel 000000001 / OTTOSEAL S100 zeigt in der Mobile App keine Dokumente an, obwohl im ERP Dokumente vorhanden sind.
✅ Checkliste zur Diagnose¶
1. Firebase Console öffnen¶
- Gehe zu Firebase Console
- Wähle dein Projekt
- Navigation: Firestore Database
2. Artikel-ID finden¶
Gefundene Artikel-ID notieren! (z.B. abc123xyz)
3. Dokumente überprüfen¶
Navigiere zu: articles/{articleId}/articleDocuments
Für JEDES Dokument prüfen:¶
| Feld | Soll-Wert | Problem wenn |
|---|---|---|
isPublic |
true |
false = Dokument ist privat → IN ERP AUF JA SETZEN |
isActive |
true |
false = Dokument archiviert → Historie prüfen |
validFrom |
null oder Datum in Vergangenheit |
Datum in Zukunft = noch nicht gültig |
validUntil |
null oder Datum in Zukunft |
Datum in Vergangenheit = abgelaufen |
countryIds |
[] (leer = alle Länder) |
Wenn gesetzt: User-Land muss in Liste sein |
🛠️ Typische Probleme & Lösungen¶
❌ Problem 1: isPublic = false¶
Symptom: Dokument im ERP sichtbar, aber NICHT in Mobile App
Lösung im ERP: 1. Artikel-Detail öffnen 2. Tab "Dokumente" 3. Dokument bearbeiten (✏️) 4. "Öffentlich (Mobile App)" aktivieren ✅ 5. Speichern
Firestore-Wert: isPublic: true
❌ Problem 2: isActive = false¶
Symptom: Dokument wurde archiviert (z.B. durch Versionierung)
Lösung im ERP: 1. Artikel-Detail → Dokumente 2. "Historie"-Button klicken 3. Altes Dokument prüfen 4. Entweder: - Neues Dokument hochladen (→ wird automatisch aktiv) - Altes Dokument reaktivieren (falls Option vorhanden)
Firestore-Wert: isActive: true
❌ Problem 3: Gültigkeitsdatum falsch¶
Symptom: Dokument noch nicht gültig oder bereits abgelaufen
Beispiel Fehler:
{
"validFrom": "2026-03-01T00:00:00Z", ← Dokument erst ab 1. März 2026 gültig!
"validUntil": "2026-01-31T23:59:59Z" ← Dokument war nur bis 31. Januar gültig!
}
Lösung im ERP: 1. Dokument bearbeiten 2. Gültigkeitsdaten prüfen/anpassen: - "Gültig ab": Leer lassen ODER Datum in Vergangenheit - "Gültig bis": Leer lassen ODER Datum in Zukunft 3. Speichern
Aktuelles Datum: 4. Februar 2026
❌ Problem 4: Länder-Filter blockiert¶
Symptom: Dokument nur für bestimmte Länder sichtbar
Beispiel:
Wenn User aus der Schweiz ist → Dokument NICHT sichtbar!
Lösung im ERP: 1. Dokument bearbeiten 2. "Länder" prüfen: - Leer = für alle Länder sichtbar ✅ - Oder User-Land hinzufügen 3. Speichern
📊 Quick-Check via Firebase Console¶
Test-Query in Firestore¶
Erwartetes Ergebnis: Mindestens 1 Dokument
Wenn 0 Dokumente:
1. Filter entfernen
2. ALLE Dokumente anschauen
3. isPublic und isActive Werte prüfen
🔧 Sofort-Fix (Manuell in Firebase Console)¶
Wenn du direkten Zugriff auf Firestore hast:
- Navigiere zu:
articles/{articleId}/articleDocuments/{documentId} - Klicke auf "Edit document" (Stift-Icon)
- Setze:
- Entferne (falls vorhanden):
- Speichern
⚠️ Achtung: Änderungen in Firebase Console werden NICHT im ERP angezeigt!
Besser: Im ERP ändern!
📱 Test in Mobile App¶
Nach Änderungen:
- Mobile App neu starten (Pull-to-Refresh reicht oft nicht)
- Artikel aufrufen
- Tab "Dokumente" öffnen
Sollte jetzt sichtbar sein! ✅
🆘 Wenn nichts funktioniert¶
Debug-Log aktivieren¶
- ERP öffnen
-
Firebase Storage Rules prüfen:
-
Firestore Rules prüfen:
-
Mobile App User authentifiziert?
- User eingeloggt?
- Token gültig?
📋 Checkliste für Support-Ticket¶
Wenn Problem weiterhin besteht, folgende Infos sammeln:
- [ ] Artikel-ID:
_____________ - [ ] Artikel-Nummer:
000000001 - [ ] Artikel-Name:
OTTOSEAL S100 - [ ] Anzahl Dokumente im ERP:
_____ - [ ] Screenshot: Dokument-Details im ERP (mit
isPublicsichtbar) - [ ] Firebase Console: Screenshot der
articleDocumentsCollection - [ ] Mobile App: Screenshot (keine Dokumente sichtbar)
- [ ] User-Land in Mobile App:
_____________
✅ Erfolgs-Kriterien¶
Dokument ist in Mobile App sichtbar wenn ALLE erfüllt sind:
✅ isPublic = true
✅ isActive = true
✅ validFrom = null ODER Datum in Vergangenheit
✅ validUntil = null ODER Datum in Zukunft
✅ countryIds = [] ODER User-Land enthalten
✅ User in Mobile App authentifiziert
🔗 Weiterführende Dokumentation¶
Zuletzt aktualisiert: 4. Februar 2026
📱 Article Images & Variant Images - Mobile App Integration Guide¶
📋 Übersicht¶
Diese Dokumentation beschreibt die vollständige Integration der Artikelbilder- und Variantenbilder-Funktionalität in die Mobile App. Die Implementierung basiert auf der bestehenden Web/Desktop-Lösung und ermöglicht Kunden die Anzeige von Artikelbildern sowie Bildern für spezifische Artikelvarianten.
🎯 Anforderungen & Scope¶
Was Mobile App User können sollen:¶
- ✅ Artikelbilder eines Produkts anzeigen (Galerie-Ansicht)
- ✅ Variantenbilder für spezifische Artikelvarianten anzeigen
- ✅ Bilder in sortierter Reihenfolge anzeigen (nach Index)
- ✅ Bilder in Vollbild-Ansicht betrachten (Zoom, Swipe)
- ✅ Zwischen mehreren Bildern wischen/swipen
- ✅ Erstes Bild als Thumbnail in Produktlisten verwenden
- ✅ Banner-Informationen auf Artikelbildern anzeigen (falls aktiv)
- ✅ Bilder offline cachen für bessere Performance
- ✅ Ladezustände während Bilddownload anzeigen
- ✅ Fallback-Icon anzeigen, wenn keine Bilder vorhanden
Was Mobile App User NICHT können:¶
- ❌ Bilder hochladen/erstellen
- ❌ Bilder bearbeiten/löschen
- ❌ Bilder neu sortieren (Drag & Drop)
- ❌ Banner erstellen/bearbeiten
🏗️ Architektur & Datenmodell¶
1. Datenmodell (bereits vorhanden)¶
BaseImage¶
// lib/models/all_base/base_image.dart
class BaseImage {
final String id;
final int index; // Sortier-Reihenfolge (0, 1, 2, ...)
final String downloadLink; // Firebase Storage Download-URL
final String storageFilePath; // Pfad in Firebase Storage
}
ArticleImage (extends BaseImage)¶
// lib/models/articles/article_image.dart
class ArticleImage extends BaseImage {
ArticleImage({
super.id = "",
required super.index,
required super.downloadLink,
required super.storageFilePath,
});
factory ArticleImage.fromMap(Map<String, dynamic> json) {
return ArticleImage(
id: json['id'] ?? '',
index: json['index'] ?? 0,
downloadLink: json['downloadLink'] ?? '',
storageFilePath: json['storageFilePath'] ?? '',
);
}
}
Article Model¶
// lib/models/articles/article.dart
class Article extends EntityBase {
final String id;
final String number;
final String name;
final List<ArticleImage> articleImages; // ← Artikelbilder
final List<ArticleVariant> articleVariants; // ← Varianten mit Bildern
// Banner-Feature (optional auf Bildern angezeigt)
final Map<LanguageEnum, String> bannerTitleLanguages;
final Color? bannerBackgroundColor;
final Color? bannerFontColor;
final DateTime? bannerFrom;
final DateTime? bannerTo;
// ... weitere Felder
}
ArticleVariant Model¶
// lib/models/articles/article_variant.dart
class ArticleVariant {
final String id;
final String name;
final String number;
final double price;
final List<ArticleImage> variantImages; // ← Variantenbilder
factory ArticleVariant.fromMap(String id, Map<String, dynamic> json) {
final List<ArticleImage> images = [];
if (json['variantImages'] != null) {
final imagesData = json['variantImages'] as List;
for (var imageData in imagesData) {
images.add(ArticleImage.fromMap(imageData));
}
// Wichtig: Sortiere Bilder nach Index!
images.sort((a, b) => a.index.compareTo(b.index));
}
return ArticleVariant(
id: id,
variantImages: images,
// ... weitere Felder
);
}
}
2. Datenstruktur in Firestore¶
Artikelbilder (Subcollection)¶
/articles/{articleId}/articleImages/{imageId}
└─ id: string
└─ index: number (0, 1, 2, ...)
└─ downloadLink: string (Firebase Storage URL)
└─ storageFilePath: string (Pfad für Löschung)
Variantenbilder (im Article Document)¶
/articles/{articleId}
└─ articleVariants: map
└─ {variantId}:
└─ name: string
└─ number: string
└─ price: number
└─ variantImages: array [
{
id: string
index: number
downloadLink: string
storageFilePath: string
},
...
]
Wichtiger Unterschied:
- Artikelbilder: Separate Subcollection (articleImages/)
- Variantenbilder: Direkt im Varianten-Objekt als Array gespeichert
3. Firebase Storage Struktur¶
/articleImages/
└─ {articleId}/
├─ {uuid}.jpg ← Artikelbilder
├─ {uuid}.png
└─ variants/
└─ {variantId}/
├─ {uuid}.jpg ← Variantenbilder
├─ {uuid}.png
└─ ...
Naming Convention:
- Artikelbilder: articleImages/{articleId}/{uuid}.{extension}
- Variantenbilder: articleImages/{articleId}/variants/{variantId}/{uuid}.{extension}
📡 Service Layer¶
ArticleImageMobileService¶
// lib/services/mobile/article_image_mobile_service.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_storage/firebase_storage.dart';
import '../../models/articles/article.dart';
import '../../models/articles/article_image.dart';
import '../../models/articles/article_variant.dart';
class ArticleImageMobileService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
final FirebaseStorage _storage = FirebaseStorage.instance;
/// Lädt alle Artikelbilder (sortiert nach Index)
Future<List<ArticleImage>> loadArticleImages(String articleId) async {
try {
final snapshot = await _firestore
.collection('articles')
.doc(articleId)
.collection('articleImages')
.orderBy('index') // ← Wichtig für korrekte Reihenfolge
.get();
return snapshot.docs
.map((doc) => ArticleImage.fromMap(doc.data()))
.toList();
} catch (e) {
debugPrint('Error loading article images: $e');
return [];
}
}
/// Stream für Artikelbilder (für Echtzeit-Updates)
Stream<List<ArticleImage>> streamArticleImages(String articleId) {
return _firestore
.collection('articles')
.doc(articleId)
.collection('articleImages')
.orderBy('index')
.snapshots()
.map((snapshot) {
return snapshot.docs
.map((doc) => ArticleImage.fromMap(doc.data()))
.toList();
});
}
/// Holt das erste Bild eines Artikels (für Thumbnails in Listen)
Future<ArticleImage?> getFirstArticleImage(String articleId) async {
try {
final snapshot = await _firestore
.collection('articles')
.doc(articleId)
.collection('articleImages')
.orderBy('index')
.limit(1)
.get();
if (snapshot.docs.isEmpty) return null;
return ArticleImage.fromMap(snapshot.docs.first.data());
} catch (e) {
debugPrint('Error loading first article image: $e');
return null;
}
}
/// Batch-Laden von ersten Bildern für mehrere Artikel
/// (Performance-Optimierung für Produktlisten)
Future<Map<String, ArticleImage>> getFirstImagesForArticles(
List<String> articleIds,
) async {
final Map<String, ArticleImage> result = {};
try {
// Firestore erlaubt max. 10 parallele Anfragen
final batches = <List<String>>[];
for (var i = 0; i < articleIds.length; i += 10) {
final end = (i + 10 < articleIds.length) ? i + 10 : articleIds.length;
batches.add(articleIds.sublist(i, end));
}
for (final batch in batches) {
final futures = batch.map((articleId) async {
final image = await getFirstArticleImage(articleId);
if (image != null) {
result[articleId] = image;
}
});
await Future.wait(futures);
}
} catch (e) {
debugPrint('Error batch loading article images: $e');
}
return result;
}
/// Holt Variantenbilder aus einem Artikel
List<ArticleImage> getVariantImages(
Article article,
String variantId,
) {
try {
final variant = article.articleVariants.firstWhere(
(v) => v.id == variantId,
);
// Bilder sind bereits nach Index sortiert (siehe ArticleVariant.fromMap)
return variant.variantImages;
} catch (e) {
debugPrint('Variant not found: $variantId');
return [];
}
}
/// Holt das erste Variantenbild (für Thumbnails)
ArticleImage? getFirstVariantImage(
Article article,
String variantId,
) {
final images = getVariantImages(article, variantId);
return images.isNotEmpty ? images.first : null;
}
/// Prüft ob ein Artikel aktive Banner-Informationen hat
bool hasActiveBanner(Article article) {
final now = DateTime.now();
// Prüfe ob Banner-Daten vorhanden sind
if (article.bannerTitleLanguages.isEmpty) return false;
// Prüfe Gültigkeitszeitraum
if (article.bannerFrom != null && now.isBefore(article.bannerFrom!)) {
return false; // Banner noch nicht aktiv
}
if (article.bannerTo != null && now.isAfter(article.bannerTo!)) {
return false; // Banner abgelaufen
}
return true;
}
/// Holt Banner-Text für die aktuelle Sprache
String getBannerText(
Article article,
LanguageEnum userLanguage,
) {
return article.bannerTitleLanguages[userLanguage] ??
article.bannerTitleLanguages.values.firstOrNull ??
'';
}
}
🎨 UI-Komponenten für Mobile App¶
1. ArticleImageGallery - Hauptkomponente¶
// lib/pages/mobile/widgets/article_image_gallery.dart
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../../models/articles/article.dart';
import '../../../models/articles/article_image.dart';
import '../../../services/mobile/article_image_mobile_service.dart';
class ArticleImageGallery extends StatefulWidget {
final Article article;
final ArticleVariant? selectedVariant; // null = Artikelbilder anzeigen
const ArticleImageGallery({
super.key,
required this.article,
this.selectedVariant,
});
@override
State<ArticleImageGallery> createState() => _ArticleImageGalleryState();
}
class _ArticleImageGalleryState extends State<ArticleImageGallery> {
final _imageService = ArticleImageMobileService();
final PageController _pageController = PageController();
int _currentPage = 0;
List<ArticleImage> _images = [];
@override
void initState() {
super.initState();
_loadImages();
}
Future<void> _loadImages() async {
List<ArticleImage> images;
if (widget.selectedVariant != null) {
// Variantenbilder
images = _imageService.getVariantImages(
widget.article,
widget.selectedVariant!.id,
);
} else {
// Artikelbilder
images = await _imageService.loadArticleImages(widget.article.id);
}
setState(() {
_images = images;
});
}
@override
Widget build(BuildContext context) {
if (_images.isEmpty) {
return _buildEmptyState();
}
return Column(
children: [
// Bild-Carousel
SizedBox(
height: 300,
child: PageView.builder(
controller: _pageController,
onPageChanged: (index) => setState(() => _currentPage = index),
itemCount: _images.length,
itemBuilder: (context, index) {
return _buildImageCard(_images[index], index);
},
),
),
// Seiten-Indikator
if (_images.length > 1)
Padding(
padding: const EdgeInsets.only(top: 16),
child: _buildPageIndicator(),
),
],
);
}
Widget _buildImageCard(ArticleImage image, int index) {
final hasBanner = widget.selectedVariant == null &&
_imageService.hasActiveBanner(widget.article);
return GestureDetector(
onTap: () => _openFullscreen(index),
child: Stack(
children: [
// Hauptbild
CachedNetworkImage(
imageUrl: image.downloadLink,
fit: BoxFit.contain,
placeholder: (context, url) => const Center(
child: CircularProgressIndicator(),
),
errorWidget: (context, url, error) => _buildErrorState(),
),
// Banner-Overlay (nur bei Artikelbildern)
if (hasBanner && index == 0) // Banner nur auf erstem Bild
Positioned(
top: 16,
right: 16,
child: _buildBanner(),
),
],
),
);
}
Widget _buildBanner() {
final userLanguage = LanguageEnum.german; // TODO: Von User-Einstellungen holen
final bannerText = _imageService.getBannerText(
widget.article,
userLanguage,
);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: widget.article.bannerBackgroundColor ?? Colors.red,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.flag,
size: 16,
color: widget.article.bannerFontColor ?? Colors.white,
),
const SizedBox(width: 4),
Text(
bannerText,
style: TextStyle(
color: widget.article.bannerFontColor ?? Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
Widget _buildPageIndicator() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_images.length, (index) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
width: _currentPage == index ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: _currentPage == index
? Theme.of(context).primaryColor
: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
);
}),
);
}
Widget _buildEmptyState() {
return Container(
height: 300,
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.image_not_supported,
size: 80,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
'Keine Bilder vorhanden',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
),
),
],
),
);
}
Widget _buildErrorState() {
return Container(
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.broken_image,
size: 60,
color: Colors.grey.shade400,
),
const SizedBox(height: 8),
Text(
'Bild konnte nicht geladen werden',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
],
),
);
}
void _openFullscreen(int initialIndex) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ArticleImageFullscreen(
images: _images,
initialIndex: initialIndex,
),
),
);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
}
2. ArticleImageFullscreen - Vollbild-Ansicht¶
// lib/pages/mobile/widgets/article_image_fullscreen.dart
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
import '../../../models/articles/article_image.dart';
class ArticleImageFullscreen extends StatefulWidget {
final List<ArticleImage> images;
final int initialIndex;
const ArticleImageFullscreen({
super.key,
required this.images,
this.initialIndex = 0,
});
@override
State<ArticleImageFullscreen> createState() =>
_ArticleImageFullscreenState();
}
class _ArticleImageFullscreenState extends State<ArticleImageFullscreen> {
late PageController _pageController;
late int _currentIndex;
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex;
_pageController = PageController(initialPage: widget.initialIndex);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: Text(
'Bild ${_currentIndex + 1} von ${widget.images.length}',
style: const TextStyle(color: Colors.white),
),
),
body: PhotoViewGallery.builder(
pageController: _pageController,
itemCount: widget.images.length,
onPageChanged: (index) => setState(() => _currentIndex = index),
builder: (context, index) {
return PhotoViewGalleryPageOptions(
imageProvider: CachedNetworkImageProvider(
widget.images[index].downloadLink,
),
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 2,
heroAttributes: PhotoViewHeroAttributes(
tag: widget.images[index].id,
),
);
},
scrollPhysics: const BouncingScrollPhysics(),
backgroundDecoration: const BoxDecoration(color: Colors.black),
),
);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
}
3. ArticleThumbnailImage - Kompakte Liste¶
// lib/pages/mobile/widgets/article_thumbnail_image.dart
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../../models/articles/article.dart';
import '../../../models/articles/article_image.dart';
class ArticleThumbnailImage extends StatelessWidget {
final Article article;
final ArticleVariant? variant;
final double size;
final BorderRadius? borderRadius;
const ArticleThumbnailImage({
super.key,
required this.article,
this.variant,
this.size = 80,
this.borderRadius,
});
@override
Widget build(BuildContext context) {
ArticleImage? thumbnailImage;
// Ermittle erstes Bild
if (variant != null && variant!.variantImages.isNotEmpty) {
thumbnailImage = variant!.variantImages.first;
} else if (article.articleImages.isNotEmpty) {
thumbnailImage = article.articleImages.first;
}
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: borderRadius ?? BorderRadius.circular(8),
),
child: thumbnailImage != null
? ClipRRect(
borderRadius: borderRadius ?? BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: thumbnailImage.downloadLink,
fit: BoxFit.cover,
placeholder: (context, url) => const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
errorWidget: (context, url, error) => _buildPlaceholder(),
),
)
: _buildPlaceholder(),
);
}
Widget _buildPlaceholder() {
return Center(
child: Icon(
Icons.image,
size: size * 0.4,
color: Colors.grey.shade400,
),
);
}
}
4. VariantSelector mit Thumbnails¶
// lib/pages/mobile/widgets/variant_selector_with_images.dart
import 'package:flutter/material.dart';
import '../../../models/articles/article.dart';
import '../../../models/articles/article_variant.dart';
import 'article_thumbnail_image.dart';
class VariantSelectorWithImages extends StatelessWidget {
final Article article;
final ArticleVariant? selectedVariant;
final Function(ArticleVariant) onVariantSelected;
const VariantSelectorWithImages({
super.key,
required this.article,
this.selectedVariant,
required this.onVariantSelected,
});
@override
Widget build(BuildContext context) {
if (article.articleVariants.isEmpty) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Varianten',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: article.articleVariants.length,
itemBuilder: (context, index) {
final variant = article.articleVariants[index];
final isSelected = selectedVariant?.id == variant.id;
return Padding(
padding: const EdgeInsets.only(right: 12),
child: GestureDetector(
onTap: () => onVariantSelected(variant),
child: Container(
width: 100,
decoration: BoxDecoration(
border: Border.all(
color: isSelected
? Theme.of(context).primaryColor
: Colors.grey.shade300,
width: isSelected ? 3 : 1,
),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
// Varianten-Thumbnail
ArticleThumbnailImage(
article: article,
variant: variant,
size: 80,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
// Varianten-Name
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.all(4),
child: Text(
variant.name,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
color: isSelected
? Theme.of(context).primaryColor
: Colors.black87,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
),
),
),
],
),
),
),
);
},
),
),
],
);
}
}
📱 Verwendungsbeispiel: Produktdetailseite¶
// lib/pages/mobile/article_detail_page_mobile.dart
import 'package:flutter/material.dart';
import '../../models/articles/article.dart';
import '../../models/articles/article_variant.dart';
import 'widgets/article_image_gallery.dart';
import 'widgets/variant_selector_with_images.dart';
class ArticleDetailPageMobile extends StatefulWidget {
final Article article;
const ArticleDetailPageMobile({
super.key,
required this.article,
});
@override
State<ArticleDetailPageMobile> createState() =>
_ArticleDetailPageMobileState();
}
class _ArticleDetailPageMobileState extends State<ArticleDetailPageMobile> {
ArticleVariant? _selectedVariant;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.article.name),
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Bild-Galerie (passt sich automatisch an Variante an)
ArticleImageGallery(
article: widget.article,
selectedVariant: _selectedVariant,
),
const SizedBox(height: 16),
// Varianten-Auswahl (falls vorhanden)
if (widget.article.articleVariants.isNotEmpty)
VariantSelectorWithImages(
article: widget.article,
selectedVariant: _selectedVariant,
onVariantSelected: (variant) {
setState(() {
_selectedVariant = variant;
});
},
),
const SizedBox(height: 16),
// Produkt-Informationen
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.article.name,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Art.-Nr.: ${widget.article.number}',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
// ... weitere Artikel-Details
],
),
),
],
),
),
);
}
}
🔧 Wichtige Implementierungshinweise¶
1. Bildkompression (bereits implementiert im Backend)¶
Im Web/Desktop erfolgt die Bildkompression vor dem Upload:
// lib/services/image_compression_service.dart
class ImageCompressionService {
static const int maxWidth = 1920;
static const int maxHeight = 1920;
static const int jpegQuality = 85;
Future<ImageCompressionResult> compressImage(Uint8List imageBytes) async {
// Komprimierung mit Transparenz-Support (PNG/JPEG)
// ...
}
}
Für Mobile: Bereits komprimierte Bilder werden geliefert - keine zusätzliche Kompression nötig!
2. Caching-Strategie¶
Empfohlen: cached_network_image Package
Vorteile: - ✅ Automatisches Caching - ✅ Placeholder während Laden - ✅ Error-Handling - ✅ Memory-Management
3. Performance-Optimierungen¶
Listen mit vielen Artikeln:¶
// Batch-Laden von Thumbnails
final thumbnails = await _imageService.getFirstImagesForArticles(
articleIds, // Max. 100 Artikel pro Request
);
Lazy Loading:¶
// Nur erste Bilder in Liste laden, vollständige Galerie erst in Detail-Ansicht
ListView.builder(
itemBuilder: (context, index) {
final article = articles[index];
return ListTile(
leading: ArticleThumbnailImage(article: article, size: 50),
title: Text(article.name),
);
},
);
4. Sortierung beachten!¶
Wichtig: Bilder IMMER nach index sortieren:
// ✅ Richtig
images.sort((a, b) => a.index.compareTo(b.index));
// ❌ Falsch
images // Unsortiert aus Firestore
5. Banner-Feature¶
Prüfung der Gültigkeit:
bool hasActiveBanner(Article article) {
final now = DateTime.now();
// Banner-Daten vorhanden?
if (article.bannerTitleLanguages.isEmpty) return false;
// Im Gültigkeitszeitraum?
if (article.bannerFrom != null && now.isBefore(article.bannerFrom!)) {
return false;
}
if (article.bannerTo != null && now.isAfter(article.bannerTo!)) {
return false;
}
return true;
}
Banner nur auf erstem Bild anzeigen:
📦 Benötigte Dependencies¶
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
# Firebase (bereits vorhanden)
cloud_firestore: ^4.13.0
firebase_storage: ^11.5.0
# Bild-Caching
cached_network_image: ^3.3.0
# Vollbild-Ansicht mit Zoom
photo_view: ^0.14.0
# Optional: Offline-Support
hive: ^2.2.3
hive_flutter: ^1.1.0
🧪 Testing-Szenarien¶
Test-Fälle:¶
- ✅ Artikel ohne Bilder (Fallback-Icon anzeigen)
- ✅ Artikel mit 1 Bild (kein Page-Indicator)
- ✅ Artikel mit mehreren Bildern (Swipe-Funktion)
- ✅ Variante ohne Bilder (Fallback auf Artikelbilder)
- ✅ Variante mit eigenen Bildern (Varianten-Galerie anzeigen)
- ✅ Banner aktiv (Banner auf erstem Bild)
- ✅ Banner abgelaufen (kein Banner anzeigen)
- ✅ Langsame Netzwerkverbindung (Loading-Spinner)
- ✅ Offline-Modus (gecachte Bilder anzeigen)
- ✅ Fehlerhafte Bild-URL (Error-State anzeigen)
🔐 Sicherheit & Zugriffskontrolle¶
Firebase Storage Rules¶
// storage.rules
service firebase.storage {
match /b/{bucket}/o {
match /articleImages/{articleId}/{allPaths=**} {
// Lesen: Für authentifizierte Nutzer erlaubt
allow read: if request.auth != null;
// Schreiben: Nur für privilegierte User (Web/Desktop)
allow write: if request.auth != null
&& request.auth.token.role in ['admin', 'manager'];
}
}
}
Firestore Security Rules¶
// firestore.rules
service cloud.firestore {
match /databases/{database}/documents {
match /articles/{articleId}/articleImages/{imageId} {
// Lesen: Für authentifizierte Nutzer erlaubt
allow read: if request.auth != null;
// Schreiben: Nur für privilegierte User
allow write: if request.auth != null
&& request.auth.token.role in ['admin', 'manager'];
}
}
}
❓ FAQ¶
F: Warum separate Subcollection für Artikelbilder, aber Array für Variantenbilder?¶
A: Performance-Optimierung: - Artikelbilder: Können viele sein (5-20+) → Subcollection ermöglicht Lazy Loading - Variantenbilder: Meist wenige (1-5) → Array ist effizienter und wird mit Variante geladen
F: Wie werden gelöschte Bilder behandelt?¶
A: Im Backend (Web/Desktop) werden sowohl Firestore-Einträge als auch Storage-Files gelöscht:
// 1. Firestore-Dokument löschen
await firestore.collection('articles/$articleId/articleImages').doc(imageId).delete();
// 2. Storage-File löschen
await storage.ref().child(storageFilePath).delete();
F: Was passiert bei Netzwerkfehlern?¶
A: cached_network_image liefert:
1. Gecachtes Bild (falls vorhanden)
2. Error-Widget (falls nicht gecacht)
3. Retry-Mechanismus automatisch
F: Wie groß sind die komprimierten Bilder?¶
A: Nach Kompression: - Max. Auflösung: 1920x1920 Pixel - JPEG-Qualität: 85% - Durchschnittliche Größe: 200-500 KB pro Bild
📚 Weiterführende Dokumentation¶
- IMAGE_COMPRESSION_README.md - Details zur Bildkompression
- ARTICLE_DOCUMENTS_MOBILE_APP.md - Ähnliche Integration für Dokumente
- Firebase Storage Best Practices
- cached_network_image Documentation
✅ Checkliste für Integration¶
- [ ] Dependencies installiert (
cached_network_image,photo_view) - [ ] Service Layer implementiert (
ArticleImageMobileService) - [ ] UI-Komponenten erstellt (
ArticleImageGallery, etc.) - [ ] Caching konfiguriert
- [ ] Banner-Logik integriert
- [ ] Error-Handling implementiert
- [ ] Testing durchgeführt
- [ ] Performance optimiert (Lazy Loading, Batch Loading)
- [ ] Offline-Support getestet
- [ ] Security Rules aktualisiert
Letzte Aktualisierung: 5. Februar 2026
Version: 1.0.0
📱 Article Variants - Mobile App Integration Guide¶
📋 Übersicht¶
Diese Dokumentation beschreibt die vollständige Integration der Artikelvarianten-Funktionalität in die Mobile App. Artikelvarianten ermöglichen es, verschiedene Ausführungen eines Produkts (z.B. unterschiedliche Größen, Farben, Konfigurationen) mit individuellen Preisen, Mengen und Bildern zu verwalten. Kunden können in der Mobile App zwischen Varianten wählen und diese bestellen.
🎯 Anforderungen & Scope¶
Was Mobile App User können sollen:¶
- ✅ Alle Varianten eines Artikels anzeigen
- ✅ Variantendetails sehen (Name, Nummer, Preis, Menge)
- ✅ Verfügbarkeitsstatus pro Variante prüfen
- ✅ Variantenbilder anzeigen (falls vorhanden)
- ✅ Zwischen Varianten wechseln und auswählen
- ✅ Variante in den Warenkorb legen
- ✅ Preisunterschiede zwischen Varianten erkennen
- ✅ "Im Shop anzeigen" Status beachten (nur sichtbare Varianten anzeigen)
- ✅ In Listen/Übersichten Varianten-Thumbnails anzeigen
Was Mobile App User NICHT können:¶
- ❌ Varianten erstellen/hinzufügen
- ❌ Varianten bearbeiten
- ❌ Varianten löschen
- ❌ Preise ändern
- ❌ Verfügbarkeit verwalten
- ❌ Variantenbilder hochladen
🏗️ Architektur & Datenmodell¶
1. Datenmodell¶
ArticleVariant Model¶
// lib/models/articles/article_variant.dart
class ArticleVariant {
final String id; // Eindeutige ID (UUID)
final String name; // Variantenname (z.B. "Groß", "Rot")
final String number; // Artikelnummer der Variante
final double price; // Preis der Variante
final double quantity; // Menge/Inhalt (z.B. 1.0, 2.5)
final bool isAvailable; // Verfügbar/Nicht verfügbar
final bool showInShop; // Im Shop anzeigen
final List<ArticleImage> variantImages; // Eigene Bilder für Variante
ArticleVariant({
required this.id,
required this.name,
required this.number,
required this.price,
this.quantity = 1.0,
this.isAvailable = true,
this.showInShop = true,
this.variantImages = const [],
});
// Deserialisierung aus Firestore
factory ArticleVariant.fromMap(String id, Map<String, dynamic> json) {
// Parse variant images if they exist
final List<ArticleImage> images = [];
if (json['variantImages'] != null) {
final imagesData = json['variantImages'] as List;
for (var imageData in imagesData) {
images.add(ArticleImage.fromMap(imageData));
}
// WICHTIG: Sortiere Bilder nach Index!
images.sort((a, b) => a.index.compareTo(b.index));
}
return ArticleVariant(
id: id,
name: json['name'] ?? '',
number: json['number'] ?? '',
price: (json['price'] ?? 0).toDouble(),
quantity: (json['quantity'] ?? 1.0).toDouble(),
isAvailable: json['isAvailable'] ?? true,
showInShop: json['showInShop'] ?? true,
variantImages: images,
);
}
// Serialisierung für Firestore
Map<String, dynamic> toMap() {
return {
'name': name,
'number': number,
'price': price,
'quantity': quantity,
'isAvailable': isAvailable,
'showInShop': showInShop,
'variantImages': variantImages.map((img) => img.toMap()).toList(),
};
}
// copyWith für Immutability
ArticleVariant copyWith({
String? id,
String? name,
String? number,
double? price,
double? quantity,
bool? isAvailable,
bool? showInShop,
List<ArticleImage>? variantImages,
}) {
return ArticleVariant(
id: id ?? this.id,
name: name ?? this.name,
number: number ?? this.number,
price: price ?? this.price,
quantity: quantity ?? this.quantity,
isAvailable: isAvailable ?? this.isAvailable,
showInShop: showInShop ?? this.showInShop,
variantImages: variantImages ?? this.variantImages,
);
}
}
Article Model (Auszug)¶
// lib/models/articles/article.dart
class Article extends EntityBase {
final String id;
final String number; // Haupt-Artikelnummer
final String name; // Haupt-Artikelname
final List<ArticleImage> articleImages; // Haupt-Artikelbilder
final List<ArticleVariant> articleVariants; // ← Liste aller Varianten
// ... weitere Felder
}
2. Datenstruktur in Firestore¶
Speicherung im Article Document¶
/articles/{articleId}
├─ id: string
├─ number: string
├─ name: string
├─ isAvailable: boolean
├─ articleVariants: map {
│ {variantId_1}: {
│ name: "Standard"
│ number: "ART-12345-01"
│ price: 99.99
│ quantity: 1.0
│ isAvailable: true
│ showInShop: true
│ variantImages: [
│ {
│ id: "img-uuid"
│ index: 0
│ downloadLink: "https://..."
│ storageFilePath: "articleImages/..."
│ }
│ ]
│ }
│ {variantId_2}: {
│ name: "Groß"
│ number: "ART-12345-02"
│ price: 129.99
│ quantity: 2.0
│ isAvailable: true
│ showInShop: true
│ variantImages: [...]
│ }
└─ }
Wichtige Eigenschaften: - ✅ Varianten sind direkt im Article-Dokument als Map gespeichert - ✅ Jede Variante hat eine eindeutige UUID als Key - ✅ Variantenbilder sind als Array in der Variante enthalten - ✅ Keine separate Subcollection (anders als bei Artikelbildern)
3. Beziehung: Artikel ↔ Varianten¶
┌─────────────────────────────────────────────────┐
│ Article (Hauptartikel) │
├─────────────────────────────────────────────────┤
│ • Nummer: "ART-12345" │
│ • Name: "Premium Widget" │
│ • Bilder: [img1.jpg, img2.jpg] │
│ • isAvailable: true (Haupt-Status) │
└──────────────────┬──────────────────────────────┘
│
┌──────────┴──────────┐
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Variante 1 │ │ Variante 2 │
├───────────────┤ ├───────────────┤
│ • Name: "S" │ │ • Name: "L" │
│ • Nr: "-01" │ │ • Nr: "-02" │
│ • Preis: €99 │ │ • Preis: €129 │
│ • Menge: 1.0 │ │ • Menge: 2.0 │
│ • Bilder: [1] │ │ • Bilder: [2] │
│ • verfügbar │ │ • verfügbar │
│ • im Shop ✓ │ │ • im Shop ✓ │
└───────────────┘ └───────────────┘
📡 Service Layer für Mobile App¶
ArticleVariantMobileService¶
// lib/services/mobile/article_variant_mobile_service.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../models/articles/article.dart';
import '../../models/articles/article_variant.dart';
import '../../models/articles/article_image.dart';
class ArticleVariantMobileService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
/// Lädt alle Varianten eines Artikels (bereits im Article enthalten)
List<ArticleVariant> getVariants(Article article) {
return article.articleVariants;
}
/// Filtert nur verfügbare Varianten
List<ArticleVariant> getAvailableVariants(Article article) {
return article.articleVariants
.where((variant) => variant.isAvailable)
.toList();
}
/// Filtert nur Varianten, die im Shop angezeigt werden sollen
List<ArticleVariant> getShopVisibleVariants(Article article) {
return article.articleVariants
.where((variant) => variant.showInShop)
.toList();
}
/// Filtert Varianten die sowohl verfügbar als auch im Shop sichtbar sind
/// Dies ist die empfohlene Methode für die Mobile App
List<ArticleVariant> getShoppableVariants(Article article) {
return article.articleVariants
.where((variant) => variant.isAvailable && variant.showInShop)
.toList();
}
/// Holt eine spezifische Variante nach ID
ArticleVariant? getVariantById(Article article, String variantId) {
try {
return article.articleVariants.firstWhere(
(variant) => variant.id == variantId,
);
} catch (e) {
debugPrint('Variant not found: $variantId');
return null;
}
}
/// Findet Variante nach Nummer (falls bekannt)
ArticleVariant? getVariantByNumber(Article article, String number) {
try {
return article.articleVariants.firstWhere(
(variant) => variant.number == number,
);
} catch (e) {
debugPrint('Variant not found by number: $number');
return null;
}
}
/// Holt die günstigste Variante
ArticleVariant? getCheapestVariant(Article article) {
final shoppableVariants = getShoppableVariants(article);
if (shoppableVariants.isEmpty) return null;
return shoppableVariants.reduce((current, next) {
return current.price < next.price ? current : next;
});
}
/// Holt die teuerste Variante
ArticleVariant? getMostExpensiveVariant(Article article) {
final shoppableVariants = getShoppableVariants(article);
if (shoppableVariants.isEmpty) return null;
return shoppableVariants.reduce((current, next) {
return current.price > next.price ? current : next;
});
}
/// Berechnet die Preisspanne (min - max)
String getPriceRange(Article article) {
final shoppableVariants = getShoppableVariants(article);
if (shoppableVariants.isEmpty) return '€0.00';
if (shoppableVariants.length == 1) {
return '€${shoppableVariants.first.price.toStringAsFixed(2)}';
}
final cheapest = getCheapestVariant(article)!;
final mostExpensive = getMostExpensiveVariant(article)!;
if (cheapest.price == mostExpensive.price) {
return '€${cheapest.price.toStringAsFixed(2)}';
}
return '€${cheapest.price.toStringAsFixed(2)} - €${mostExpensive.price.toStringAsFixed(2)}';
}
/// Holt die erste verfügbare Variante (als Standard-Auswahl)
ArticleVariant? getDefaultVariant(Article article) {
final shoppableVariants = getShoppableVariants(article);
return shoppableVariants.isNotEmpty ? shoppableVariants.first : null;
}
/// Holt das erste Bild einer Variante
ArticleImage? getVariantThumbnail(ArticleVariant variant) {
if (variant.variantImages.isEmpty) return null;
// Bilder sind bereits nach Index sortiert (siehe ArticleVariant.fromMap)
return variant.variantImages.first;
}
/// Prüft ob Artikel Varianten hat
bool hasVariants(Article article) {
return article.articleVariants.isNotEmpty;
}
/// Prüft ob Artikel mehrere Varianten hat
bool hasMultipleVariants(Article article) {
return article.articleVariants.length > 1;
}
/// Gruppiert Varianten nach Verfügbarkeit
Map<String, List<ArticleVariant>> groupByAvailability(Article article) {
final Map<String, List<ArticleVariant>> grouped = {
'available': [],
'unavailable': [],
};
for (final variant in article.articleVariants) {
if (variant.isAvailable && variant.showInShop) {
grouped['available']!.add(variant);
} else {
grouped['unavailable']!.add(variant);
}
}
return grouped;
}
/// Sortiert Varianten nach Preis (aufsteigend)
List<ArticleVariant> sortByPriceAsc(List<ArticleVariant> variants) {
final sorted = List<ArticleVariant>.from(variants);
sorted.sort((a, b) => a.price.compareTo(b.price));
return sorted;
}
/// Sortiert Varianten nach Preis (absteigend)
List<ArticleVariant> sortByPriceDesc(List<ArticleVariant> variants) {
final sorted = List<ArticleVariant>.from(variants);
sorted.sort((a, b) => b.price.compareTo(a.price));
return sorted;
}
/// Sortiert Varianten alphabetisch nach Name
List<ArticleVariant> sortByName(List<ArticleVariant> variants) {
final sorted = List<ArticleVariant>.from(variants);
sorted.sort((a, b) => a.name.compareTo(b.name));
return sorted;
}
/// Formatiert Preis für Anzeige
String formatPrice(double price, {String currency = '€'}) {
return '$currency${price.toStringAsFixed(2)}';
}
/// Formatiert Menge mit Einheit
String formatQuantity(
ArticleVariant variant,
ArticleQuantityType quantityType,
) {
return '${variant.quantity.toStringAsFixed(2)} ${quantityType.shortName}';
}
}
🎨 UI-Komponenten für Mobile App¶
1. VariantSelector - Kompakte Variantenauswahl¶
// lib/pages/mobile/widgets/variant_selector.dart
import 'package:flutter/material.dart';
import '../../../models/articles/article.dart';
import '../../../models/articles/article_variant.dart';
import '../../../services/mobile/article_variant_mobile_service.dart';
class VariantSelector extends StatelessWidget {
final Article article;
final ArticleVariant? selectedVariant;
final Function(ArticleVariant) onVariantSelected;
final bool showImages;
final bool compactMode;
const VariantSelector({
super.key,
required this.article,
this.selectedVariant,
required this.onVariantSelected,
this.showImages = true,
this.compactMode = false,
});
@override
Widget build(BuildContext context) {
final variantService = ArticleVariantMobileService();
final variants = variantService.getShoppableVariants(article);
if (variants.isEmpty) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Variante wählen',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
if (variants.length > 1)
Text(
'${variants.length} Optionen',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
],
),
),
if (compactMode)
_buildCompactList(context, variants, variantService)
else
_buildExpandedList(context, variants, variantService),
],
);
}
Widget _buildCompactList(
BuildContext context,
List<ArticleVariant> variants,
ArticleVariantMobileService variantService,
) {
return SizedBox(
height: 60,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: variants.length,
itemBuilder: (context, index) {
final variant = variants[index];
final isSelected = selectedVariant?.id == variant.id;
return Padding(
padding: const EdgeInsets.only(right: 12),
child: _buildVariantChip(
context,
variant,
isSelected,
variantService,
),
);
},
),
);
}
Widget _buildExpandedList(
BuildContext context,
List<ArticleVariant> variants,
ArticleVariantMobileService variantService,
) {
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: variants.length,
itemBuilder: (context, index) {
final variant = variants[index];
final isSelected = selectedVariant?.id == variant.id;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildVariantCard(
context,
variant,
isSelected,
variantService,
),
);
},
);
}
Widget _buildVariantChip(
BuildContext context,
ArticleVariant variant,
bool isSelected,
ArticleVariantMobileService variantService,
) {
return GestureDetector(
onTap: () => onVariantSelected(variant),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).primaryColor
: Colors.grey.shade200,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected
? Theme.of(context).primaryColor
: Colors.grey.shade300,
width: 2,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
variant.name,
style: TextStyle(
fontSize: 14,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? Colors.white : Colors.black87,
),
),
const SizedBox(width: 8),
Text(
variantService.formatPrice(variant.price),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: isSelected ? Colors.white : Colors.black87,
),
),
],
),
),
);
}
Widget _buildVariantCard(
BuildContext context,
ArticleVariant variant,
bool isSelected,
ArticleVariantMobileService variantService,
) {
final thumbnail = variantService.getVariantThumbnail(variant);
return GestureDetector(
onTap: () => onVariantSelected(variant),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? Theme.of(context).primaryColor
: Colors.grey.shade300,
width: isSelected ? 3 : 1,
),
boxShadow: [
if (isSelected)
BoxShadow(
color: Theme.of(context).primaryColor.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
// Thumbnail
if (showImages && thumbnail != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
thumbnail.downloadLink,
width: 60,
height: 60,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return _buildPlaceholderIcon();
},
),
)
else if (showImages)
_buildPlaceholderIcon(),
if (showImages) const SizedBox(width: 12),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
variant.name,
style: TextStyle(
fontSize: 16,
fontWeight:
isSelected ? FontWeight.bold : FontWeight.w600,
color: isSelected
? Theme.of(context).primaryColor
: Colors.black87,
),
),
const SizedBox(height: 4),
Text(
'Art.-Nr.: ${variant.number}',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 4),
Text(
variantService.formatQuantity(
variant,
article.articleQuantityType,
),
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
),
// Preis & Status
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
variantService.formatPrice(variant.price),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: isSelected
? Theme.of(context).primaryColor
: Colors.black87,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: variant.isAvailable
? Colors.green.withOpacity(0.1)
: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
variant.isAvailable ? 'Verfügbar' : 'Nicht verfügbar',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: variant.isAvailable ? Colors.green : Colors.red,
),
),
),
],
),
// Checkmark
if (isSelected) ...[
const SizedBox(width: 8),
Icon(
Icons.check_circle,
color: Theme.of(context).primaryColor,
size: 28,
),
],
],
),
),
);
}
Widget _buildPlaceholderIcon() {
return Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.inventory_2,
size: 30,
color: Colors.grey.shade400,
),
);
}
}
2. VariantPriceDisplay - Preisanzeige in Listen¶
// lib/pages/mobile/widgets/variant_price_display.dart
import 'package:flutter/material.dart';
import '../../../models/articles/article.dart';
import '../../../services/mobile/article_variant_mobile_service.dart';
class VariantPriceDisplay extends StatelessWidget {
final Article article;
final TextStyle? style;
final bool showRange;
const VariantPriceDisplay({
super.key,
required this.article,
this.style,
this.showRange = true,
});
@override
Widget build(BuildContext context) {
final variantService = ArticleVariantMobileService();
if (!variantService.hasVariants(article)) {
return Text(
'€0.00',
style: style ?? const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
);
}
if (!showRange || !variantService.hasMultipleVariants(article)) {
final firstVariant = variantService.getDefaultVariant(article);
if (firstVariant == null) {
return Text(
'Nicht verfügbar',
style: style?.copyWith(color: Colors.grey) ?? TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.grey.shade600,
),
);
}
return Text(
variantService.formatPrice(firstVariant.price),
style: style ?? const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
);
}
// Zeige Preisspanne
final priceRange = variantService.getPriceRange(article);
return Text(
priceRange,
style: style ?? const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
);
}
}
3. VariantAvailabilityBadge - Status-Badge¶
// lib/pages/mobile/widgets/variant_availability_badge.dart
import 'package:flutter/material.dart';
import '../../../models/articles/article_variant.dart';
class VariantAvailabilityBadge extends StatelessWidget {
final ArticleVariant variant;
final bool compact;
const VariantAvailabilityBadge({
super.key,
required this.variant,
this.compact = false,
});
@override
Widget build(BuildContext context) {
if (!variant.showInShop) {
return _buildBadge(
'Nicht im Shop',
Colors.grey,
Icons.visibility_off,
);
}
if (!variant.isAvailable) {
return _buildBadge(
'Nicht verfügbar',
Colors.red,
Icons.cancel,
);
}
return _buildBadge(
'Verfügbar',
Colors.green,
Icons.check_circle,
);
}
Widget _buildBadge(String label, Color color, IconData icon) {
return Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 6 : 10,
vertical: compact ? 3 : 6,
),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(compact ? 8 : 12),
border: Border.all(
color: color.withOpacity(0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: compact ? 12 : 14,
color: color,
),
SizedBox(width: compact ? 4 : 6),
Text(
label,
style: TextStyle(
fontSize: compact ? 10 : 12,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}
}
4. VariantComparisonSheet - Bottom Sheet Vergleich¶
// lib/pages/mobile/widgets/variant_comparison_sheet.dart
import 'package:flutter/material.dart';
import '../../../models/articles/article.dart';
import '../../../models/articles/article_variant.dart';
import '../../../services/mobile/article_variant_mobile_service.dart';
import 'variant_availability_badge.dart';
class VariantComparisonSheet extends StatelessWidget {
final Article article;
final Function(ArticleVariant) onVariantSelected;
const VariantComparisonSheet({
super.key,
required this.article,
required this.onVariantSelected,
});
static Future<void> show(
BuildContext context, {
required Article article,
required Function(ArticleVariant) onVariantSelected,
}) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => VariantComparisonSheet(
article: article,
onVariantSelected: onVariantSelected,
),
);
}
@override
Widget build(BuildContext context) {
final variantService = ArticleVariantMobileService();
final variants = variantService.getShoppableVariants(article);
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.8,
),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(
top: Radius.circular(20),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle
Container(
margin: const EdgeInsets.only(top: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
// Header
Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Icon(
Icons.compare_arrows,
color: Theme.of(context).primaryColor,
size: 28,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Varianten vergleichen',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${variants.length} Optionen verfügbar',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
),
Divider(height: 1, color: Colors.grey.shade300),
// Variants List
Flexible(
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: variants.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final variant = variants[index];
return _buildComparisonCard(
context,
variant,
variantService,
);
},
),
),
],
),
);
}
Widget _buildComparisonCard(
BuildContext context,
ArticleVariant variant,
ArticleVariantMobileService variantService,
) {
return GestureDetector(
onTap: () {
onVariantSelected(variant);
Navigator.pop(context);
},
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.shade300,
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
variant.name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
Text(
variantService.formatPrice(variant.price),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
],
),
const SizedBox(height: 8),
_buildInfoRow(
Icons.tag,
'Artikelnummer',
variant.number,
),
const SizedBox(height: 6),
_buildInfoRow(
Icons.inventory,
'Menge',
variantService.formatQuantity(
variant,
article.articleQuantityType,
),
),
const SizedBox(height: 12),
VariantAvailabilityBadge(variant: variant),
],
),
),
);
}
Widget _buildInfoRow(IconData icon, String label, String value) {
return Row(
children: [
Icon(
icon,
size: 16,
color: Colors.grey.shade600,
),
const SizedBox(width: 8),
Text(
'$label: ',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
);
}
}
📱 Verwendungsbeispiele¶
Beispiel 1: Produktdetailseite mit Variantenauswahl¶
// lib/pages/mobile/article_detail_page_mobile.dart
import 'package:flutter/material.dart';
import '../../models/articles/article.dart';
import '../../models/articles/article_variant.dart';
import '../../services/mobile/article_variant_mobile_service.dart';
import 'widgets/variant_selector.dart';
import 'widgets/variant_price_display.dart';
import 'widgets/article_image_gallery.dart';
class ArticleDetailPageMobile extends StatefulWidget {
final Article article;
const ArticleDetailPageMobile({
super.key,
required this.article,
});
@override
State<ArticleDetailPageMobile> createState() =>
_ArticleDetailPageMobileState();
}
class _ArticleDetailPageMobileState extends State<ArticleDetailPageMobile> {
final _variantService = ArticleVariantMobileService();
ArticleVariant? _selectedVariant;
@override
void initState() {
super.initState();
// Wähle die erste verfügbare Variante als Standard
_selectedVariant = _variantService.getDefaultVariant(widget.article);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.article.name),
actions: [
IconButton(
icon: const Icon(Icons.compare_arrows),
onPressed: _showVariantComparison,
),
],
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Artikelbilder (oder Variantenbilder falls Variante ausgewählt)
ArticleImageGallery(
article: widget.article,
selectedVariant: _selectedVariant,
),
const SizedBox(height: 16),
// Produktname & Preis
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.article.name,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
if (_selectedVariant != null)
Text(
'Variante: ${_selectedVariant!.name}',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade700,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
VariantPriceDisplay(
article: widget.article,
showRange: false,
),
],
),
),
const SizedBox(height: 24),
// Variantenauswahl
if (_variantService.hasVariants(widget.article))
VariantSelector(
article: widget.article,
selectedVariant: _selectedVariant,
onVariantSelected: (variant) {
setState(() {
_selectedVariant = variant;
});
},
showImages: true,
compactMode: false,
),
const SizedBox(height: 24),
// Beschreibung
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Beschreibung',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
widget.article.descriptionLanguages[LanguageEnum.german] ??
'Keine Beschreibung verfügbar',
style: const TextStyle(
fontSize: 14,
height: 1.5,
),
),
],
),
),
const SizedBox(height: 24),
],
),
),
bottomNavigationBar: _buildBottomBar(),
);
}
Widget _buildBottomBar() {
if (_selectedVariant == null) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: const Center(
child: Text(
'Keine Variante verfügbar',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
),
);
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: Row(
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_variantService.formatPrice(_selectedVariant!.price),
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
Text(
_selectedVariant!.name,
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: _selectedVariant!.isAvailable
? () => _addToCart(_selectedVariant!)
: null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'In den Warenkorb',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
);
}
void _showVariantComparison() {
VariantComparisonSheet.show(
context,
article: widget.article,
onVariantSelected: (variant) {
setState(() {
_selectedVariant = variant;
});
},
);
}
void _addToCart(ArticleVariant variant) {
// TODO: Implementiere Warenkorb-Logik
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${variant.name} wurde in den Warenkorb gelegt'),
backgroundColor: Colors.green,
),
);
}
}
Beispiel 2: Produktliste mit Preisspanne¶
// lib/pages/mobile/article_list_tile_mobile.dart
import 'package:flutter/material.dart';
import '../../models/articles/article.dart';
import '../../services/mobile/article_variant_mobile_service.dart';
import 'widgets/variant_price_display.dart';
import 'widgets/article_thumbnail_image.dart';
class ArticleListTileMobile extends StatelessWidget {
final Article article;
final VoidCallback onTap;
const ArticleListTileMobile({
super.key,
required this.article,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final variantService = ArticleVariantMobileService();
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
// Thumbnail
ArticleThumbnailImage(
article: article,
size: 80,
),
const SizedBox(width: 12),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
article.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'Art.-Nr.: ${article.number}',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 8),
// Preis oder Preisspanne
VariantPriceDisplay(
article: article,
showRange: true,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
// Varianten-Info
if (variantService.hasMultipleVariants(article))
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'${article.articleVariants.length} Varianten',
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
),
),
),
],
),
),
// Arrow
Icon(
Icons.chevron_right,
color: Colors.grey.shade400,
),
],
),
),
);
}
}
🔧 Wichtige Implementierungshinweise¶
1. Varianten-Filterung¶
Wichtig: In der Mobile App sollten nur kaufbare Varianten angezeigt werden:
// ✅ Richtig: Nur verfügbare & sichtbare Varianten
final variants = variantService.getShoppableVariants(article);
// ❌ Falsch: Alle Varianten (inkl. deaktivierte)
final variants = article.articleVariants;
2. Standard-Variante¶
Bei Artikeln mit Varianten sollte immer eine Variante vorausgewählt sein:
@override
void initState() {
super.initState();
// Erste verfügbare Variante als Standard
_selectedVariant = _variantService.getDefaultVariant(widget.article);
}
3. Preisanzeige in Listen¶
Bei mehreren Varianten: Preisspanne anzeigen
Bei einer Variante: Einzelpreis anzeigen
4. Bilder-Logik¶
Priorität für Bildanzeige: 1. Variantenbilder (falls Variante ausgewählt und Bilder vorhanden) 2. Artikelbilder (als Fallback) 3. Placeholder-Icon (wenn keine Bilder)
ArticleImage? getThumbnail(Article article, ArticleVariant? selectedVariant) {
// 1. Variantenbilder prüfen
if (selectedVariant != null && selectedVariant.variantImages.isNotEmpty) {
return selectedVariant.variantImages.first;
}
// 2. Artikelbilder als Fallback
if (article.articleImages.isNotEmpty) {
return article.articleImages.first;
}
// 3. Kein Bild vorhanden
return null;
}
5. Warenkorb-Integration¶
Wichtig: Im Warenkorb muss die Varianten-ID gespeichert werden, nicht die Artikel-ID!
// ✅ Richtig
CartItem(
articleId: article.id,
variantId: selectedVariant.id, // ← Varianten-ID speichern!
quantity: 1,
price: selectedVariant.price,
);
// ❌ Falsch - Variante fehlt
CartItem(
articleId: article.id,
// Ohne variantId kann nicht zugeordnet werden!
quantity: 1,
);
6. Verfügbarkeitsprüfung¶
bool canOrder(Article article, ArticleVariant variant) {
// Artikel muss verfügbar sein
if (!article.isAvailable) return false;
// Variante muss verfügbar sein
if (!variant.isAvailable) return false;
// Variante muss im Shop angezeigt werden
if (!variant.showInShop) return false;
return true;
}
7. Performance-Optimierung¶
Batch-Laden von Artikeln mit Varianten:
// Firestore lädt Varianten automatisch mit dem Article-Dokument
// Kein extra Query nötig - Varianten sind bereits enthalten!
final article = await firestore.collection('articles').doc(articleId).get();
final variants = article.data()!['articleVariants']; // ← Bereits geladen
🧪 Testing-Szenarien¶
Test-Fälle für Varianten:¶
- ✅ Artikel ohne Varianten (Fallback-Verhalten)
- ✅ Artikel mit 1 Variante (keine Auswahl nötig)
- ✅ Artikel mit mehreren Varianten (Auswahl-UI anzeigen)
- ✅ Variante ohne Bilder (Artikelbilder als Fallback)
- ✅ Variante mit eigenen Bildern (Varianten-Galerie)
- ✅ Nicht verfügbare Variante (ausgegraut, nicht bestellbar)
- ✅
showInShop: false(Variante nicht anzeigen) - ✅ Preisspanne (günstigste bis teuerste Variante)
- ✅ Variantenwechsel (Bilder & Preis aktualisieren)
- ✅ Warenkorb mit Varianten (richtige Zuordnung)
📊 Datenbeispiel¶
Vollständiges Beispiel eines Artikels mit Varianten¶
{
"id": "article-123",
"number": "ART-12345",
"name": "Premium Widget",
"isAvailable": true,
"showInShop": true,
"articleImages": [
{
"id": "img-1",
"index": 0,
"downloadLink": "https://storage.../main1.jpg",
"storageFilePath": "articleImages/article-123/img1.jpg"
}
],
"articleVariants": {
"variant-uuid-1": {
"name": "Klein",
"number": "ART-12345-S",
"price": 99.99,
"quantity": 1.0,
"isAvailable": true,
"showInShop": true,
"variantImages": [
{
"id": "vimg-1",
"index": 0,
"downloadLink": "https://storage.../klein.jpg",
"storageFilePath": "articleImages/article-123/variants/variant-uuid-1/img1.jpg"
}
]
},
"variant-uuid-2": {
"name": "Mittel",
"number": "ART-12345-M",
"price": 119.99,
"quantity": 1.5,
"isAvailable": true,
"showInShop": true,
"variantImages": []
},
"variant-uuid-3": {
"name": "Groß",
"number": "ART-12345-L",
"price": 149.99,
"quantity": 2.0,
"isAvailable": false,
"showInShop": false,
"variantImages": []
}
}
}
In diesem Beispiel: - 3 Varianten vorhanden - Variante "Klein" hat eigene Bilder - Variante "Groß" ist nicht verfügbar und nicht im Shop → wird in Mobile App nicht angezeigt - Preisspanne: €99.99 - €119.99 (ohne "Groß")
🔐 Sicherheit & Validation¶
Client-seitige Validierung¶
class VariantValidator {
/// Prüft ob Variante bestellbar ist
static bool isOrderable(Article article, ArticleVariant variant) {
// Artikel-Level Check
if (!article.isAvailable || !article.showInShop) {
return false;
}
// Varianten-Level Check
if (!variant.isAvailable || !variant.showInShop) {
return false;
}
// Preis-Check
if (variant.price <= 0) {
return false;
}
return true;
}
/// Prüft ob Variante existiert
static bool variantExists(Article article, String variantId) {
return article.articleVariants.any((v) => v.id == variantId);
}
/// Validiert Menge
static bool isValidQuantity(double quantity, ArticleVariant variant) {
return quantity > 0 && quantity <= variant.quantity * 1000; // Max-Limit
}
}
Firestore Security Rules¶
// firestore.rules
service cloud.firestore {
match /databases/{database}/documents {
match /articles/{articleId} {
// Lesen: Für authentifizierte Nutzer erlaubt
allow read: if request.auth != null;
// Schreiben: Nur für privilegierte User
allow write: if request.auth != null
&& request.auth.token.role in ['admin', 'manager'];
// Validierung: Varianten müssen valide sein
allow update: if validateArticleVariants(request.resource.data);
}
}
}
function validateArticleVariants(data) {
return data.articleVariants is map
&& data.articleVariants.size() <= 50; // Max 50 Varianten
}
❓ FAQ¶
F: Warum sind Varianten als Map gespeichert, nicht als Array?¶
A: Performance und Eindeutigkeit: - Map: Direkter Zugriff via ID, keine Duplikate möglich - Array: Müsste durchsucht werden, Duplikate möglich - Update-Logik ist mit Map einfacher (keine Array-Indizes)
F: Was passiert, wenn ein Artikel keine Varianten hat?¶
A: articleVariants ist ein leeres Array []. Die App sollte einen Fallback haben:
if (!variantService.hasVariants(article)) {
// Zeige Artikel ohne Variantenauswahl
return ArticleWithoutVariants(article: article);
}
F: Können Varianten unterschiedliche Bilder haben?¶
A: Ja! Jede Variante hat ihr eigenes variantImages Array. Falls leer, zeige Artikelbilder als Fallback.
F: Wie wird der Preis in einer Bestellung gespeichert?¶
A: Zum Zeitpunkt der Bestellung wird der Preis der Variante in die Bestellung übernommen (Snapshot). Spätere Preisänderungen beeinflussen alte Bestellungen nicht.
F: Was ist mit Varianten-Kombinationen (z.B. Farbe + Größe)?¶
A: Aktuell: Jede Kombination ist eine eigene Variante: - "Rot - Klein" → Variante 1 - "Rot - Groß" → Variante 2 - "Blau - Klein" → Variante 3 - etc.
Zukünftig könnte ein Varianten-Attribut-System implementiert werden.
📚 Weiterführende Dokumentation¶
- ARTICLE_IMAGES_MOBILE_APP.md - Integration der Artikelbilder
- ARTICLE_DOCUMENTS_MOBILE_APP.md - Integration der Artikeldokumente
- Firestore Data Modeling Best Practices
✅ Checkliste für Integration¶
- [ ]
ArticleVariantModel verstanden - [ ]
ArticleVariantMobileServiceimplementiert - [ ] UI-Komponenten erstellt (
VariantSelector, etc.) - [ ] Filterung nach
isAvailable&&showInShopimplementiert - [ ] Standard-Variante wird vorausgewählt
- [ ] Preisanzeige/-spanne funktioniert
- [ ] Bilder-Fallback (Variante → Artikel → Placeholder)
- [ ] Warenkorb speichert Varianten-ID
- [ ] Validation implementiert
- [ ] Testing durchgeführt
- [ ] Offline-Support getestet (Varianten gecacht)
Letzte Aktualisierung: 5. Februar 2026
Version: 1.0.0
Bildkomprimierung und -optimierung¶
📋 Übersicht¶
Das System komprimiert und optimiert automatisch alle hochgeladenen Bilder, um Speicherplatz zu sparen und Ladezeiten zu verbessern, ohne die Bildqualität wesentlich zu beeinträchtigen.
✅ Features¶
1. Keine Verzerrung¶
- Das Seitenverhältnis bleibt immer erhalten
- Bilder werden proportional skaliert, nie gestreckt oder gequetscht
2. Transparenz-Unterstützung¶
- Bilder mit transparentem Hintergrund werden als PNG gespeichert
- Transparenz bleibt vollständig erhalten
- Keine weißen Hintergründe bei transparenten Bildern
3. Intelligente Format-Wahl¶
- PNG: Für Bilder mit Transparenz (z.B. Logos, Icons)
- JPEG: Für opake Bilder (bessere Kompression, kleinere Dateigröße)
4. Automatische Größenanpassung¶
- Maximale Bildgröße: 1920x1920 Pixel
- Kleinere Bilder bleiben unverändert in ihrer Größe
- Große Bilder werden proportional verkleinert
🎯 Wo wird komprimiert?¶
Artikel-Bilder¶
- Alle Bilder, die zu Artikeln hochgeladen werden
- Bildgalerien in der Artikelverwaltung
System-Bilder¶
- Login-Header-Logo
- Mobile App Hintergrundbilder
- Theme-Bilder
🔧 Technische Details¶
Komprimierungs-Einstellungen¶
// Standard-Einstellungen (in ImageCompressionService)
maxWidth: 1920 Pixel
maxHeight: 1920 Pixel
jpegQuality: 85 (von 100)
pngLevel: 6 (von 9)
Wie funktioniert es?¶
- Bild dekodieren: Originales Bild wird geladen
- Transparenz-Prüfung: System erkennt automatisch transparente Pixel
- Größenanpassung: Wenn nötig, wird das Bild proportional skaliert
- Format-Wahl: PNG für Transparenz, JPEG für opake Bilder
- Komprimierung: Bild wird optimal komprimiert
- Upload: Komprimiertes Bild wird zu Firebase Storage hochgeladen
📊 Beispiel-Ergebnisse¶
Typische Kompressionsraten:¶
| Bildtyp | Original | Komprimiert | Ersparnis |
|---|---|---|---|
| Foto (opak) | 5 MB | 800 KB | ~84% |
| Logo (transparent) | 2 MB | 400 KB | ~80% |
| Screenshot | 3 MB | 600 KB | ~80% |
| Icon (transparent) | 500 KB | 150 KB | ~70% |
🛠️ Verwendung im Code¶
Artikel-Bilder hochladen¶
// In article_editor_page.dart
onAddImage: (Uint8List fileBytes) {
// Bild wird automatisch komprimiert im ArticlesBloc
articleBloc.add(AddArticleImage(articleId, fileBytes));
}
Logo/Hintergrund hochladen¶
// In SystemSettingsBloc
context.read<SystemSettingsBloc>().add(
AddLoginHeaderLogoImage(fileBytes, isDraft: true)
);
Manuell komprimieren (falls nötig)¶
final compressionService = ImageCompressionService();
final result = await compressionService.compressImage(
imageBytes,
customMaxWidth: 1000, // Optional: eigene Größe
customJpegQuality: 90, // Optional: höhere Qualität
);
print('Original: ${result.originalSize} bytes');
print('Komprimiert: ${result.compressedSize} bytes');
print('Format: ${result.extension}');
print('Hat Transparenz: ${result.hasTransparency}');
Mehrere Bilder parallel komprimieren¶
final results = await compressionService.compressMultipleImages([
image1Bytes,
image2Bytes,
image3Bytes,
]);
📝 Logging¶
Das System gibt detaillierte Debug-Informationen aus:
🖼️ Starting image compression...
📦 Original size: 5242880 bytes
📐 Original dimensions: 4000x3000
🎨 Has transparency: false
🔄 Resizing image (maintaining aspect ratio)...
✅ Resized to: 1920x1440
💾 Encoding as JPEG...
✅ Compressed size: 819200 bytes
📊 Compression ratio: 15.6%
💾 Saved: 4320.0 KB
🎨 Transparenz-Beispiele¶
Wird als PNG gespeichert ✅¶
- Logos mit transparentem Hintergrund
- Icons mit Transparenz
- Bilder mit semi-transparenten Bereichen
Wird als JPEG gespeichert ✅¶
- Produktfotos
- Landschaftsbilder
- Screenshots ohne Transparenz
- Normale Fotos
⚙️ Konfiguration anpassen¶
Wenn du die Standard-Einstellungen ändern möchtest:
In image_compression_service.dart:¶
class ImageCompressionService {
// Passe diese Werte an:
static const int maxWidth = 1920; // Maximale Breite
static const int maxHeight = 1920; // Maximale Höhe
static const int jpegQuality = 85; // JPEG-Qualität (0-100)
static const int pngLevel = 6; // PNG-Kompression (0-9)
}
Höhere Qualität (größere Dateien):¶
Mehr Kompression (kleinere Dateien, niedrigere Qualität):¶
🚀 Performance¶
- Schnell: Komprimierung erfolgt asynchron, UI bleibt responsive
- Effizient: Batch-Verarbeitung für mehrere Bilder möglich
- Optimiert: Keine unnötigen Kopien oder Speicher-Allokationen
🔍 Troubleshooting¶
Problem: Bild wird nicht hochgeladen¶
Lösung: Prüfe die Debug-Logs auf Fehlermeldungen:
Problem: Transparenz geht verloren¶
Prüfe:
- Original-Bild hat tatsächlich Transparenz?
- Wird das Bild als PNG gespeichert? (Sieh im Log nach: 💾 Encoding as PNG)
Problem: Bilder sind zu groß/klein¶
Lösung: Passe maxWidth und maxHeight in image_compression_service.dart an
📦 Dependencies¶
🔄 Migration für existierende Bilder¶
Existierende Bilder werden nicht automatisch neu komprimiert. Wenn du alte Bilder komprimieren möchtest:
- Export alte Bilder aus Firebase Storage
- Re-upload über die App (werden dann automatisch komprimiert)
Oder erstelle ein Script:
// Beispiel für Batch-Komprimierung
final storage = FirebaseStorage.instance;
final articles = await getArticles();
for (var article in articles) {
for (var image in article.articleImages) {
// Download original
final ref = storage.refFromURL(image.downloadLink);
final bytes = await ref.getData();
// Compress
final result = await compressionService.compressImage(bytes);
// Re-upload
await ref.putData(result.bytes);
}
}
✅ Testing¶
Test-Szenarien:¶
- ✅ Hochladen eines großen Fotos (5 MB+)
-
Wird automatisch komprimiert und verkleinert
-
✅ Hochladen eines transparenten Logos
-
Wird als PNG gespeichert, Transparenz bleibt erhalten
-
✅ Hochladen eines kleinen Bildes (< 1920x1920)
-
Wird nur komprimiert, nicht skaliert
-
✅ Mehrere Bilder nacheinander hochladen
- Jedes Bild wird einzeln komprimiert
📚 Weitere Informationen¶
- Service:
lib/services/image_compression_service.dart - Artikel-Bilder:
lib/blocs/article_bloc/articles_bloc.dart - System-Bilder:
lib/blocs/system_settings_bloc/system_settings_bloc.dart
🎯 Best Practices¶
- Verwende hochauflösende Originale: Die Komprimierung produziert bessere Ergebnisse mit hochauflösenden Quellbildern
- PNG für Logos: Wenn möglich, lade Logos im PNG-Format hoch
- JPEG für Fotos: Normale Fotos können direkt als JPEG hochgeladen werden
- Keine Vor-Komprimierung nötig: Lade die Original-Dateien hoch, das System kümmert sich um die Optimierung
Import Workflow - Excel Template Struktur¶
Übersicht¶
Die Excel-Import-Dateien wurden aktualisiert und enthalten jeweils 3 Beispieldatensätze mit korrekten Referenzen untereinander. Alle Dateien verwenden das einheitliche Format de:Text;en:Text für Mehrsprachigkeit.
Import-Reihenfolge ⚠️¶
Wichtig: Die Dateien müssen in dieser Reihenfolge importiert werden, da spätere Importe auf frühere referenzieren:
- ArticleCategoryImportFile.xlsx - Artikelkategorien
- CustomerCategoryImportFile.xlsx - Kundenkategorien
- ArticleImportFile.xlsx - Artikel (referenziert #1)
- CustomerImportFile.xlsx - Kunden (referenziert #2)
Datei-Strukturen¶
1. ArticleCategoryImportFile.xlsx¶
Sprachen (de:Text;en:Text)- Format:de:12er Karton;en:12-pack carton;fr:...
Beispiel:
Karton-12 | 12 | 2 | de:12er Karton;en:12-pack carton;fr:Carton de 12;it:Scatola da 12;es:Caja de 12
2. ArticleCategoryImportFile.xlsx¶
Spalten:
- Artikelkategoriename - Eindeutiger Identifier (z.B. "Getränke")
- Sprachen (de:Text;en:Text) - Format: de:Getränke;en:Beverages;fr:Boissons
Beispiel:
3. CustomerCategoryImportFile.xlsx¶
Spalten:
- Kundenkategoriename - Eindeutiger Identifier (z.B. "Premium")
- Sprachen (de:Text;en:Text) - Format: de:Premium Kunden;en:Premium Customers
- Farbe (ARGB) - Farbe als Integer (z.B. 4294956800 für Gold)
Farben-Beispiele:
- Gold: 4294956800 (0xFFFFD700)
- Blau: 4278190335 (0xFF0000FF)
- Rot: 4294901760 (0xFFFF0000)
- Weiß: 4294967295 (0xFFFFFFFF)
Beispiel:
4. ArticleImportFile.xlsx¶
Spalten:
- Artikelgruppen - (Legacy, nicht verwendet)
- Beschreibung - Deutsche Beschreibung
- Verfügbarkeit - 1 = verfügbar, 0 = nicht verfügbar
- Name - Artikelname
- Nummer - Artikelnummer (eindeutig)
- Preis - Preis (Dezimalzahl, z.B. 24.99)
- Kategorien - Referenzen zu Category Identifiern, getrennt mit ; (z.B. "Getränke;Süßwaren")
- Bilder - (Nicht verwendet, leer lassen)
- Banner Titel - Titel für Banner (optional)
- Banner Hintergrundfarbe - ARGB Integer (optional)
- Banner Schriftfarbe - ARGB Integer (optional)
- Banner Von - ISO8601 Datum (z.B. "2025-11-01T00:00:00.000")
- Banner Bis - ISO8601 Datum (z.B. "2025-12-31T23:59:59.999")
- Kundenkategorie IDs - Komma-getrennte Identifier (optional)
- Länder IDs - Komma-getrennte IDs (optional)
Beispiel:
| | Erfrischende Cola | 1 | Cola Classic | ART-001 | 24.99 | Getränke | | Neu im Sortiment | 4294901760 | 4294967295 | 2025-11-01T00:00:00.000 | 2025-12-31T23:59:59.999 | | |
5. CustomerImportFile.xlsx¶
Spalten:
- Kundenname - Firmenname
- Kundennummer - Eindeutige Kundennummer
- Kundenkategorie - Referenz zu CustomerCategory Identifier (z.B. "Premium")
- Sprache - Index der Sprache:
- 0 = german
- 1 = english
- 2 = french
- 3 = italian
- 4 = spanish
- Land - Index des Landes:
- 0 = germany
- 1 = austria
- 2 = switzerland
- 3 = france
- 4 = italy
- 5 = spain
- Stadt - Stadtname
- Straße - Straßenname und Hausnummer
- PLZ - Postleitzahl
- Telefon - Telefonnummer
- Mobile - Mobilnummer
- Homepage - Website URL
- Email - E-Mail-Adresse
- Mindestbestellmenge aktiv - 1 = aktiv, 0 = inaktiv
- Mindestbestellmenge - Betrag als Ganzzahl
- Auf Telefonliste - 1 = ja, 0 = nein
- Gesperrt - 1 = gesperrt, 0 = aktiv
- Preis in App anzeigen - 1 = ja, 0 = nein (optional)
- Kein Bedarf bis - ISO8601 Datum (optional)
- Benachrichtigungsgruppen IDs - Semikolon-getrennte IDs (optional)
Beispiel:
Restaurant Goldener Löwe | KD-001 | Premium | 0 | 0 | München | Maximilianstraße 1 | 80539 | +49 89 12345678 | +49 175 9876543 | www.goldener-loewe.de | info@goldener-loewe.de | 1 | 500 | 1 | 0 | 1 | | |
Referenzen zwischen Dateien¶
Artikel → Artikelkategorien¶
ArticleImportFile.xlsx Spalte [7] "Getränke;Süßwaren"
↓ referenziert
ArticleCategoryImportFile.xlsx Spalte [0] "Getränke", "Süßwaren"
Kunde → Kundenkategorie¶
CustomerImportFile.xlsx Spalte [2] "Premium"
↓ referenziert
CustomerCategoryImportFile.xlsx Spalte [0] "Premium"
Workflow für User¶
- Download der Template-Dateien über die Import-Einstellungen
- Bearbeitung in Excel - Templates enthalten bereits 3 Beispieldatensätze
- Eigene Daten hinzufügen - Beispiele als Vorlage verwenden
- Import in korrekter Reihenfolge:
- Zuerst: Artikelkategorien importieren
- Dann: Kundenkategorien importieren
- Danach: Artikel importieren (können nun auf Kategorien referenzieren)
- Zuletzt: Kunden importieren (können nun auf Kundenkategorien referenzieren)
Wichtige Hinweise¶
Sprach-Format ✅¶
- Konsistent: Alle Kategorien verwenden
de:Text;en:TextFormat - Kompatibel: Export und Import verwenden identisches Format
- Round-Trip: Exportierte Daten können direkt re-importiert werden
Referenzen ⚠️¶
- Identifiers sind case-sensitive
- Mehrere Referenzen mit
;trennen (z.B. "Getränke;Süßwaren") - Nicht existierende Referenzen werden ignoriert
Boolean-Werte¶
1= true/ja/aktiv0= false/nein/inaktiv
Zahlen-Format¶
- Dezimalzahlen:
24.99oder24,99(beide werden akzeptiert) - Integer:
500
Datum-Format¶
- ISO8601:
2025-11-01T00:00:00.000 - Optionale Felder können leer bleiben
Performance-Optimierung¶
Der Import-Code wurde optimiert: - Lookup-Maps für O(1) Zugriff statt O(n) Suche - Batch-Processing mit UI-Updates alle 10 Items - Keine künstlichen Delays mehr
Bei 2000 Datensätzen: ~5-10 Sekunden statt mehreren Minuten! Sample import files (CSV) for the app. Place these in Excel and save as .xlsx/.csv to import via the UI.
Files created: - import_article_categories.csv - Columns: identifier, languages (format: "de:Text;en:Text;...") - Example: getraenke,"de:Getränke;en:Beverages"
- import_articles.csv
-
Columns (by index expected in importer): 0 identifier (not used as id), 1 description (currently only German; format could be "de:Text;en:Text" if importer updated), 3 available (1/0), 4 name, 5 number, 6 price, 7 categories (semicolon separated identifiers), 8 imageUrls (semicolon separated), 9 bannerTitle (currently German), 10 bannerBackgroundARGB (int), 11 bannerFontARGB (int), 12 bannerFrom (ISO date), 13 bannerTo (ISO date), 14 customerCategoryIdentifiers (semicolon separated), 15 countryIdentifiers (semicolon separated)
-
import_customer_categories.csv
- Columns: identifier, translations (de;en;fr;it;es), colorARGB
-
Example: premium,"Premium;Premium;Premium;Premium;Premium",4294967295
-
import_customers.csv
- Columns mapping used by the importer: 0 companyName 1 customerNumber 2 customerCategoryIdentifier (identifier string) 3 languageIndex (index into LanguageEnum) 4 countryIndex (index into CountryEnum) 5 city 6 street 7 zipcode 8 phone 9 mobile 10 homepage 11 email 12 isMinimumOrderAmountActive (1/0) 13 minimumOrderAmount (int) 14 onTelephoneList (1/0) 15 isBlocked (1/0) 16 showPriceInMobileApp (1/0) 17 noDemandUntil (ISO date) 18 notificationGroupIds (semicolon separated)
- Note: importer currently sets orderMinimumQuantityMeasurementUnit to Euro by default and does NOT read a specific column for it. If you need to import that field, I'll update the importer to read an extra column.
Notes and recommendations: - The CSVs use semicolon ";" inside quoted fields to separate multi-values (categories, imageUrls, translations). - For IDs/references (article package sizes, categories) use the identifier strings as shown in the other CSVs (e.g. liter_1l, getraenke, premium). - If you want, I can convert these CSVs into real .xlsx files; right now they are CSV which Excel opens fine.
Automatische Nummernvorschläge für Kunden und Artikel¶
🎯 Übersicht¶
Dieses Feature schlägt automatisch die nächste verfügbare Nummer vor, wenn ein neuer Kunde oder Artikel erstellt wird. Die Lösung ist:
- ✅ Performant: Nur 2 Firestore-Reads pro Vorschlag (Settings + Counter)
- 💰 Kostengünstig: Nutzt die existierende
countersCollection - 🔧 Flexibel: Funktioniert mit beliebigen Formaten (KUN-00001, ART-12345, etc.)
- 🎯 Einfach: Keine Cloud Functions nötig - alles in Flutter!
- 📋 Dynamisch: Prefix wird aus den Validierungsregeln (Settings) gezogen
📐 Architektur¶
1. Counter Collection (Firestore)¶
Die existierende counters Collection wird um zwei neue Dokumente erweitert:
counters/
├── orderCounter (bereits vorhanden)
├── customerNumberCounter (NEU)
└── articleNumberCounter (NEU)
Struktur eines Counter-Dokuments:
{
"value": 123, // Höchste verwendete Nummer (nur numerischer Teil)
"prefix": "KUN-", // Prefix vor der Nummer (aus Validierungsregel)
"paddingLength": 5, // Anzahl der Stellen (00123)
"type": "customerNumber", // Art des Counters
"lastModified": <timestamp>
}
Wichtig: Das prefix wird immer aus den General Settings (Validierungsregel) gezogen, nicht aus den existierenden Daten!
2. Validierungsregeln (General Settings)¶
Das Prefix wird aus den Validierungsregeln extrahiert, die in den Allgemeinen Einstellungen definiert sind:
Settings → Allgemeine Einstellungen → Validierungsregeln
Beispiel Kundennummer:
Beispiel Artikelnummer:
Wenn Sie das Prefix ändern möchten:
1. Gehen Sie zu Settings → Allgemeine Einstellungen
2. Ändern Sie das Pattern der Validierungsregel (z.B. von ^KUN- zu ^KD-)
3. Das neue Prefix wird automatisch beim nächsten Speichern verwendet
4. Der value (Zähler) bleibt unverändert!
3. Flutter Service¶
Datei: lib/services/firebase_services/number_suggestion_service.dart
final service = NumberSuggestionService();
// Holt nächste Kundennummer (z.B. "KUN-00124")
// Prefix wird aus den Validierungsregeln geladen
final nextCustomerNumber = await service.getNextCustomerNumber();
// Holt nächste Artikelnummer (z.B. "ART-00456")
// Prefix wird aus den Validierungsregeln geladen
final nextArticleNumber = await service.getNextArticleNumber();
// Nach dem Speichern: Counter aktualisieren
// Prefix wird aus Settings aktualisiert, value bleibt erhalten
await service.updateCustomerCounter("KUN-00124");
await service.updateArticleCounter("ART-00456");
Performance: 2 Reads pro Aufruf (Settings + Counter) + 1 Write nach Speichern ✨
4. Counter-Update (direkt in Flutter)¶
3. Counter-Update (direkt in Flutter)¶
Die Counter werden direkt aus Flutter aktualisiert, wenn ein Kunde oder Artikel gespeichert wird:
Implementierung im Customer Editor:
// Nach dem Speichern
if (widget.customer != null) {
// Update bei Änderung
if (widget.customer!.customerNumber != _customerNumberController.text) {
_numberSuggestionService.updateCustomerCounter(_customerNumberController.text);
}
} else {
// Update bei Neuanlage
_numberSuggestionService.updateCustomerCounter(_customerNumberController.text);
}
Vorteile: - ✅ Keine Cloud Functions nötig - ✅ Einfacher zu debuggen - ✅ Keine zusätzlichen Kosten - ✅ Sofortiges Feedback
Logik:
- Counter value wird nur aktualisiert, wenn die neue Nummer höher ist
- Counter prefix wird immer aus den Settings aktualisiert (behält aber den value)
- Thread-safe durch Firestore Transactions
- Extrahiert automatisch die Nummer aus dem Format
🚀 Setup & Deployment¶
1. Validierungsregeln einrichten (WICHTIG!)¶
Bevor Sie die Counter initialisieren, stellen Sie sicher, dass die Validierungsregeln konfiguriert sind:
- Öffnen Sie Settings → Allgemeine Einstellungen
- Aktivieren Sie die Kundennummer Validierung
- Pattern z.B.:
^KUN-\d{6}$(für Format KUN-000001) - Beschreibung: "Format KUN-000001"
- Aktivieren Sie die Artikelnummer Validierung
- Pattern z.B.:
^ART-\d{5}$(für Format ART-00001) - Beschreibung: "Format ART-00001"
- Speichern Sie die Einstellungen
Das Prefix wird aus diesen Validierungsregeln extrahiert!
2. Migration Script ausführen¶
Initialisiert die Counter mit den aktuell höchsten Nummern:
Dies scannt alle existierenden Kunden und Artikel und erstellt die Counter-Dokumente mit dem Prefix aus den Validierungsregeln.
Beispiel-Output:
🚀 Starte Counter-Initialisierung...
👥 KUNDEN:
📊 Scanne Kunden...
✅ Kunden gescannt: 423
📈 Höchste Nummer: 423
📋 Prefix aus Validierungsregel: "KUN-"
✅ Counter 'customerNumberCounter' erstellt: KUN-423
📦 ARTIKEL:
📊 Scanne Artikel...
✅ Artikel gescannt: 1247
📈 Höchste Nummer: 1247
📋 Prefix aus Validierungsregel: "ART-"
✅ Counter 'articleNumberCounter' erstellt: ART-1247
✨ Counter-Initialisierung abgeschlossen!
📝 Hinweis: Das Prefix wird aus den Validierungsregeln (General Settings) übernommen.
Wenn Sie das Prefix ändern, wird es automatisch bei der nächsten Nummer aktualisiert.
Beispiel-Output:
- Kunden-Editor: lib/pages/customer/editor_page/customer_editor_page.dart
- Artikel-Editor: lib/pages/articles/article_editor_page.dart
Beim Öffnen des Dialogs für einen neuen Kunden/Artikel wird automatisch die nächste Nummer geladen und vorgeschlagen.
💡 Verwendung¶
Im Kunden-Editor¶
Wenn ein neuer Kunde erstellt wird: 1. Dialog öffnet sich 2. Service lädt automatisch die nächste Nummer aus den Settings (z.B. "KUN-00424") 3. Kundennummer-Feld wird automatisch befüllt 4. Benutzer kann die Nummer bei Bedarf anpassen 5. Beim Speichern wird der Counter automatisch aktualisiert
Im Artikel-Editor¶
Gleicher Ablauf wie beim Kunden-Editor: 1. Dialog öffnet sich 2. Nächste Artikelnummer wird aus den Settings geladen (z.B. "ART-01248") 3. Feld wird automatisch befüllt 4. Counter-Update erfolgt automatisch beim Speichern
🔧 Technische Details¶
Prefix aus Validierungsregel extrahieren¶
Das Prefix wird aus dem Regex-Pattern der Validierungsregel extrahiert:
// Beispiele:
"^KUN-\\d{6}$" → "KUN-"
"^ART-\\d{5}$" → "ART-"
"^CUST-\\d{4}$" → "CUST-"
"^[A-Z]{3}-\\d{5}$" → "" (kein festes Prefix erkennbar)
Algorithmus:
1. Entferne führendes ^ wenn vorhanden
2. Finde konstante Zeichen am Anfang (Buchstaben und -)
3. Stoppe bei erstem Regex-Zeichen (\, [, {, etc.)
Nummern-Extraktion¶
Die Lösung extrahiert intelligent Nummern aus verschiedenen Formaten:
Algorithmus: 1. Entferne alle nicht-numerischen Zeichen 2. Parse als Integer
Formatierung¶
📊 Performance-Vergleich¶
❌ Alte Methode (ohne Counter):¶
- Alle Kunden laden: 20.000 Reads
- Höchste Nummer finden: 20.000 Dokumente durchsuchen
- Kosten: ~ $0.12 pro Vorschlag 💸
✅ Neue Methode (mit Counter):¶
- Settings laden: 1 Read (für Prefix)
- Counter laden: 1 Read
- Nächste Nummer berechnen: clientseitig
- Counter aktualisieren: 1 Write (nach Speichern)
- Kosten: ~ $0.000024 pro Vorschlag 🎉
Einsparung: 99.98%!
🔐 Sicherheit¶
Firestore Security Rules¶
Die Counter sollten geschützt werden:
// firestore.rules
match /counters/{counterId} {
// Lesbar für authentifizierte Benutzer
allow read: if request.auth != null;
// Schreibbar für authentifizierte Benutzer (Counter-Update aus Flutter)
allow write: if request.auth != null;
}
Validierungsregeln ändern¶
Wenn Sie das Prefix ändern möchten:
1. Gehen Sie zu Settings → Allgemeine Einstellungen
2. Ändern Sie das Pattern (z.B. von ^KUN-\d{6}$ zu ^KD-\d{6}$)
3. Das neue Prefix wird automatisch beim nächsten Speichern verwendet
4. Der Counter-value bleibt unverändert (keine Zurücksetzung!)
Beispiel:
- Alter Counter: { value: 1234, prefix: "KUN-" }
- Pattern ändern: ^KD-\d{6}$
- Neuer Counter: { value: 1234, prefix: "KD-" } ← nur Prefix geändert!
- Nächste Nummer: KD-001235
Manuelle Änderungen¶
Falls ein Benutzer manuell eine höhere Nummer eingibt: - ✅ Counter wird beim Speichern automatisch aktualisiert - ✅ Zukünftige Vorschläge berücksichtigen die neue Nummer - ✅ Keine Duplikate möglich (Transaction-safe)
🐛 Troubleshooting¶
Problem: Vorschlag ist zu niedrig¶
Ursache: Counter wurde nicht korrekt initialisiert
Lösung: Migration Script erneut ausführen:
Problem: Counter wird nicht aktualisiert¶
Ursache: Counter-Update-Logik fehlt im Editor
Lösung: Prüfe, ob updateCustomerCounter() bzw. updateArticleCounter() nach dem Speichern aufgerufen wird
Problem: Falsches Prefix¶
Ursache: Validierungsregel fehlt oder ist falsch konfiguriert
Lösung:
1. Gehe zu Settings → Allgemeine Einstellungen
2. Aktiviere die Validierungsregel für Kunden/Artikel
3. Stelle sicher, dass das Pattern ein festes Prefix hat (z.B. ^KUN-\d{6}$)
4. Führe Migration Script erneut aus (optional)
Problem: Prefix ändert sich nicht nach Regeländerung¶
Ursache: Counter muss beim nächsten Speichern aktualisiert werden
Lösung: 1. Erstelle oder ändere einen Kunden/Artikel 2. Speichere ihn 3. Das Prefix wird automatisch aus den Settings aktualisiert 4. Ab jetzt werden alle neuen Nummern mit dem neuen Prefix vorgeschlagen
📝 Erweiterung¶
Neue Counter hinzufügen¶
-
Validierungsregel erstellen (Settings → Allgemeine Einstellungen):
-
Service erweitern (
number_suggestion_service.dart):Future<String> getNextOrderNumber() async { // Hole Settings für Prefix final settingsDoc = await _firestore .collection('systemSettings') .doc('general_settings') .get(); String prefix = 'ORD-'; // Fallback if (settingsDoc.exists) { final settings = GeneralSettings.fromJson(settingsDoc.data()!); // Hole Regel aus customValidationRules oder erstelle neue final rule = settings.customValidationRules .firstWhere((r) => r.id == 'orderNumber', orElse: () => null); if (rule != null && rule.isActive) { final extractedPrefix = extractPrefixFromPattern(rule.pattern); if (extractedPrefix.isNotEmpty) { prefix = extractedPrefix; } } } final docRef = _firestore.collection('counters').doc('orderNumberCounter'); // ... rest der Logik } Future<void> updateOrderCounter(String usedNumber) async { await _updateCounter('orderNumberCounter', usedNumber, 'orderNumber'); } -
Editor-Integration:
🎯 Best Practices¶
- Immer Migration Script ausführen nach dem ersten Deployment
- Counter manuell prüfen in Firebase Console unter
countersCollection - Logs überwachen für Counter-Updates in Firebase Functions
- Security Rules anpassen um Counter zu schützen
- Backup erstellen vor größeren Änderungen
📚 Weiterführende Links¶
Status: ✅ Produktionsbereit
Version: 1.0.0
Letzte Aktualisierung: Januar 2026
Artikel Paging Implementation¶
Übersicht¶
Die Artikel-Verwaltung wurde von einem vollständigen Laden aller Artikel auf ein Paging-System umgestellt, das nur 50 Artikel pro Seite lädt. Dies verbessert die Performance drastisch bei großen Datenmengen (20.000+ Artikel).
Änderungen¶
1. Neue Komponenten¶
EsInfiniteListView Widget¶
- Pfad:
lib/pages/widgets/controls/es_infinite_list_view.dart - Funktion: ListView/GridView mit Infinite Scrolling
- Features:
- Automatisches Laden weiterer Daten beim Scrollen
- Unterstützt 1-spaltig (ListView) und mehrspaltig (GridView)
- Loading-Indicator beim Nachladen
- Animations-Support
PagedArticlesResult Klasse¶
- Pfad:
lib/services/firebase_services/article_firebase_service.dart - Funktion: Result-Objekt für Paging-Anfragen
- Felder:
articles: Liste der geladenen ArtikelhasMore: Gibt an, ob weitere Artikel verfügbar sindlastDocument: Firestore-Dokument für Paging-Cursor
ArticlesPagedLoaded State¶
- Pfad:
lib/blocs/article_bloc/article_states.dart - Funktion: Bloc-State für Paging
- Felder:
articles: Geladene ArtikelhasMore: Weitere Artikel verfügbarlastDocument: Cursor für nächste SeitesearchText: Aktueller SuchtextcategoryIds: Gefilterte KategorienisLoadingMore: Loading-Status
LoadArticlesPaged Event¶
- Pfad:
lib/blocs/article_bloc/article_events.dart - Funktion: Event zum Laden von Artikeln mit Paging
- Parameter:
limit: Anzahl Artikel pro Seite (default: 50)searchText: Optionaler SuchtextcategoryIds: Optionale Kategorie-FilterisLoadMore: Flag für "Mehr laden" vs. "Neu laden"
2. Service-Methoden¶
getArticlesPaged()¶
- Pfad:
lib/services/firebase_services/article_firebase_service.dart - Funktion: Lädt Artikel seitenweise aus Firestore
- Features:
- Suchunterstützung via
searchKeywordsFeld - Paging mit
startAfterDocument - Sortierung nach Artikelnummer
- Limit pro Anfrage
generateSearchKeywords()¶
- Pfad:
lib/services/firebase_services/article_firebase_service.dart - Funktion: Generiert Suchschlüssel für Firestore
- Verwendung: Bei Artikel-Create/Update aufrufen, um
searchKeywordsFeld zu befüllen
3. Bloc-Handler¶
_onLoadArticlesPaged()¶
- Pfad:
lib/blocs/article_bloc/articles_bloc.dart - Funktion: Behandelt Paging-Events
- Features:
- Initial Load: Lädt erste Seite
- Load More: Hängt weitere Artikel an
- Kategorie-Filterung
- Suchtext-Filterung
4. UI-Anpassungen¶
articles_page.dart¶
- Verwendet nun
EsInfiniteListViewstattEsListView - Initial Load beim Start mit 50 Artikeln
- Infinite Scrolling für weitere Artikel
- Unterstützt beide State-Typen (Kompatibilität)
articles_management_page.dart¶
- Suchleiste löst
LoadArticlesPagedaus (stattSearchArticles) - Kategorie-Selektor löst
LoadArticlesPagedaus - Bei jeder Eingabe: neue Paging-Anfrage
customer_assigned_articles_body_widget.dart¶
- Unterstützt beide State-Typen
- Suchleiste löst Paging-Anfragen aus
- Zuweisung/Entfernung funktioniert mit Paging
order_editor_page.dart¶
- Artikel-Suche verwendet direkt
getArticlesPaged()(nicht Bloc-State) - Vermeidet Abhängigkeit von vollständig geladener Artikel-Liste
Wichtige Hinweise¶
Firestore-Index erforderlich¶
Für die Suchfunktion muss in Firestore ein Index auf searchKeywords erstellt werden:
searchKeywords Feld befüllen¶
Beim Erstellen/Aktualisieren von Artikeln muss das searchKeywords Feld befüllt werden:
final searchKeywords = ArticleFirebaseService.generateSearchKeywords(
article.name,
article.number,
);
// Beim Speichern im Article-Objekt hinzufügen
Kompatibilität¶
Der Code unterstützt beide Ansätze parallel:
- Alter Ansatz: LoadArticles + SearchArticles → ArticlesLoaded
- Neuer Ansatz: LoadArticlesPaged → ArticlesPagedLoaded
Dies ermöglicht eine schrittweise Migration und Rückwärtskompatibilität.
Performance-Verbesserungen¶
Vorher¶
- Alle 20.000 Artikel werden geladen
- Lange Ladezeiten beim Start
- Hoher Speicherverbrauch
- Firestore Read-Kosten: 20.000 Reads
Nachher¶
- Initial nur 50 Artikel
- Schneller Start (< 1 Sekunde)
- Geringer Speicherverbrauch
- Firestore Read-Kosten: 50 Reads initial, +50 pro Seite
- Einsparung: ~99% weniger Reads beim Start
Testing¶
Zu testen¶
- Initial Load: Prüfen, ob 50 Artikel geladen werden
- Infinite Scroll: Nach unten scrollen → weitere 50 Artikel laden
- Suche: Suchtext eingeben → gefilterte Ergebnisse laden
- Kategorien: Kategorien auswählen → gefilterte Ergebnisse laden
- Suche + Kategorien: Kombination testen
- Load More: Prüfen, ob
hasMorekorrekt ist - Artikel-Editor: Öffnen und Speichern funktioniert
- Order-Editor: Artikel-Suche funktioniert
- Customer Articles: Zuweisung/Suche funktioniert
Migration-Checkliste¶
- [x]
EsInfiniteListViewWidget erstellt - [x]
PagedArticlesResultKlasse erstellt - [x]
ArticlesPagedLoadedState erstellt - [x]
LoadArticlesPagedEvent erstellt - [x]
getArticlesPaged()Service-Methode erstellt - [x]
_onLoadArticlesPaged()Bloc-Handler erstellt - [x]
articles_page.dartangepasst - [x]
articles_management_page.dartangepasst - [x]
customer_assigned_articles_body_widget.dartangepasst - [x]
order_editor_page.dartangepasst - [ ] Firestore-Index erstellen
- [ ]
searchKeywordsFeld bei existierenden Artikeln nachträglich befüllen - [ ] Testing durchführen
Nächste Schritte¶
- Firestore-Index erstellen (siehe oben)
- Migration-Script für searchKeywords:
- Testing auf Entwicklungsumgebung
- Deployment nach erfolgreichem Testing
Document Validity & Lifecycle Management¶
📋 Overview¶
Das System verwaltet automatisch den Lebenszyklus von Artikeldokumenten basierend auf Gültigkeitszeiträumen (validFrom und validUntil).
✨ Features¶
1. Gültigkeitszeitraum-UI¶
- Date-Picker für
validFromundvalidUntilim Upload-Dialog - Visuelle Anzeige des Gültigkeitsstatus in Document Cards:
- 🔵 Blau: Noch nicht gültig (z.B. "Gültig in 5 Tagen")
- 🟢 Grün: Aktuell gültig
- 🟠 Orange: Läuft bald ab (≤30 Tage, z.B. "Läuft ab in 15 Tagen")
- 🔴 Rot: Abgelaufen (z.B. "Abgelaufen vor 3 Tagen")
2. Automatische Aktivierung¶
- Dokumente mit
validFromwerden automatisch aktiviert, wenn das Datum erreicht wird - Berücksichtigt
requiresApproval: Nur genehmigte Dokumente werden aktiviert - Berücksichtigt
allowMultipleActive: Deaktiviert ggf. andere aktive Dokumente desselben Typs
3. Automatische Deaktivierung¶
- Dokumente mit
validUntilwerden automatisch deaktiviert, wenn das Datum überschritten wird
4. Scheduled Cloud Function¶
- Läuft täglich um 00:30 Uhr (Europe/Berlin)
- Prüft alle Dokumente aller Artikel
- Aktualisiert den
isActiveStatus basierend auf Gültigkeitszeiträumen - Logs: Anzahl aktivierter/deaktivierter Dokumente
🚀 Deployment¶
Cloud Functions deployen:¶
cd functions
firebase deploy --only functions:scheduledDocumentValidityCheck
firebase deploy --only functions:manualDocumentValidityCheck
Manueller Test:¶
Firebase Console → Functions → manualDocumentValidityCheck aufrufen
Oder via Firebase CLI:
📖 Verwendung¶
Dokument mit Gültigkeitszeitraum erstellen:¶
- Artikel bearbeiten → Dokument hochladen
- Gültigkeitszeitraum festlegen:
- Gültig ab: Dokument wird an diesem Datum automatisch aktiviert
- Gültig bis: Dokument wird an diesem Datum automatisch deaktiviert
- Dokument hochladen
Beispiel-Szenarien:¶
Szenario 1: Neue Produktdatenblatt-Version vorbereiten¶
Aktuelles Datenblatt: v1 (aktiv)
Neues Datenblatt: v2
- validFrom: 01.02.2026
- allowMultipleActive: false
Ergebnis:
- Am 01.02.2026 um 00:30 Uhr:
→ v2 wird automatisch aktiviert
→ v1 wird automatisch deaktiviert
Szenario 2: Befristetes Sicherheitsdatenblatt¶
Sicherheitsdatenblatt:
- validFrom: 01.01.2026
- validUntil: 31.12.2026
Ergebnis:
- Am 01.01.2026: Automatisch aktiviert
- Am 31.12.2026: Automatisch deaktiviert
- UI zeigt ab 30 Tagen vor Ablauf: "Läuft ab in X Tagen" (orange)
Szenario 3: Gestaffelte Preisaktualisierung¶
Preisliste Q1: validFrom: 01.01.2026, validUntil: 31.03.2026
Preisliste Q2: validFrom: 01.04.2026, validUntil: 30.06.2026
Preisliste Q3: validFrom: 01.07.2026, validUntil: 30.09.2026
Preisliste Q4: validFrom: 01.10.2026, validUntil: 31.12.2026
Ergebnis:
- Automatischer Wechsel zwischen Preislisten ohne manuelle Eingriffe
- Immer nur die aktuelle Preisliste ist aktiv
🔧 Technische Details¶
Firestore-Felder:¶
ArticleDocument {
validFrom: Timestamp | null, // Gültig ab
validUntil: Timestamp | null, // Gültig bis
isActive: boolean, // Wird automatisch aktualisiert
modifiedAt: Timestamp, // Wird bei Auto-Update gesetzt
}
Cloud Function-Logik:¶
for each article:
for each document:
if (validFrom <= now && !isActive && isApproved):
// Aktivieren
if (!docType.allowMultipleActive):
// Andere aktive Docs desselben Typs deaktivieren
if (validUntil < now && isActive):
// Deaktivieren
Performance:¶
- Batch-Updates pro Artikel (max. 500 Operationen)
- Timeout: 540 Sekunden (9 Minuten)
- Memory: 512 MB
- Läuft nur bei tatsächlichen Änderungen
📊 Monitoring¶
Firebase Console:¶
- Functions →
scheduledDocumentValidityCheck→ Logs - Zeigt: Aktivierte/Deaktivierte Dokumente, Fehler
Log-Beispiel:¶
Starting document validity check at 2026-01-07T00:30:00.000Z
Activating document doc123 (validFrom reached)
Deactivating document doc456 (validUntil passed)
Updated 2 documents for article art789
Document validity check completed:
- Activated: 5 documents
- Deactivated: 3 documents
- Errors: 0
⚠️ Wichtige Hinweise¶
- Zeitzone: Function läuft in Europe/Berlin Timezone
- Verzögerung: Änderungen erfolgen beim nächsten Scheduled Run (max. 24h Verzögerung)
- Manuelle Übersteuerung:
isActivekann jederzeit manuell gesetzt werden, wird aber beim nächsten Run wieder automatisch angepasst - Genehmigungspflicht: Dokumente mit
requiresApproval=truewerden nur aktiviert, wennisApproved=true
🎯 Best Practices¶
- Planung: Setze
validFromfür zukünftige Dokumente, um automatische Aktivierung zu nutzen - Überlappung vermeiden: Bei
allowMultipleActive=falsekeine überlappenden Gültigkeitszeiträume verwenden - Puffer einplanen: Setze
validFrommit Puffer (z.B. 1 Tag früher), um sicherzustellen, dass das neue Dokument rechtzeitig aktiv ist - Monitoring: Überprüfe regelmäßig die Function-Logs auf Fehler
🔮 Zukünftige Erweiterungen¶
- [ ] E-Mail-Benachrichtigungen bei bevorstehenden Ablaufdaten
- [ ] Dashboard mit Übersicht aller ablaufenden Dokumente
- [ ] Automatische Reminder an Dokument-Owner
- [ ] Versionierungs-Historie mit Gültigkeitszeiträumen
- [ ] Rollback-Funktion für automatische Änderungen
Validierungsregeln für Kundennummern und Artikelnummern¶
Überblick¶
Das System erlaubt es Administratoren, generische Validierungsregeln für Kundennummern, Artikelnummern und benutzerdefinierte Felder zu definieren. Diese Regeln werden unter "Allgemeine Einstellungen" konfiguriert und beim Anlegen/Ändern von Kunden und Artikeln überprüft.
Architektur¶
1. Modelle (lib/models/system_settings/)¶
validation_rule.dart¶
- ValidationRuleType: Enum für vordefinierte Regel-Typen (customerNumber, articleNumber, custom)
- ValidationRule: Klasse für eine einzelne Validierungsregel
pattern: Regex-Pattern für Validierungdescription: Beschreibung für BenutzerisActive: Flag zur Aktivierung/Deaktivierungvalidate(value): Methode zur Validierung eines Wertes
general_settings.dart¶
- GeneralSettings: Zentrale Einstellung für Validierungsregeln
customerNumberValidationRule: Regel für KundennummernarticleNumberValidationRule: Regel für ArtikelnummerncustomValidationRules: Liste für zusätzliche benutzerdefinierte RegelnvalidateField(fieldType, value): Validiert einen Wert basierend auf Feldtyp
2. UI Component (lib/pages/widgets/controls/)¶
es_text_field.dart (erweitert)¶
- EsTextFieldType erweitert um
customTyp - Neue Properties:
customValidationPattern: Regex-Pattern für benutzerdefinierte ValidierungcustomValidationErrorMessage: Fehlertext bei Validierungsfehler- Validierungslogik unterstützt jetzt benutzerdefinierte Patterns
3. Settings UI (lib/pages/settings/general_settings/)¶
general_settings_page.dart¶
- Settings-Page für "Allgemeine Einstellungen"
- Ermöglicht Definition von Validierungsregeln für:
- Kundennummern (mit Pattern und optionaler Beschreibung)
- Artikelnummern (mit Pattern und optionaler Beschreibung)
- Features:
- Live-Validierungstester
- Regex-Muster Beispiele
- Toggle zur Aktivierung/Deaktivierung
- Speichern in Firestore
4. State Management (lib/blocs/system_settings_bloc/)¶
system_settings_bloc_events.dart¶
- UpdateGeneralSettings: Event zum Speichern von GeneralSettings
system_settings_bloc_states.dart¶
- SystemSettingsData Mixin erweitert um
generalSettingsProperty - SystemSettingsLoaded State enthält jetzt
generalSettings
system_settings_bloc.dart¶
- _generalSettings: Speichert die aktuellen Einstellungen
- _onUpdateGeneralSettings(): Handler für Speichern der Einstellungen
- _emitCurrentState(): Helper-Funktion zum Emittieren des aktuellen States
- _onLoadSystemSettings(): Lädt auch die generalSettings beim Laden
5. Firebase Service (lib/services/firebase_services/)¶
system_settings_firebase_service.dart (erweitert)¶
- getGeneralSettings(): Lädt die Einstellungen aus Firestore
- updateGeneralSettings(settings): Speichert die Einstellungen in Firestore
6. Navigation (lib/pages/settings/)¶
settings_page.dart (erweitert)¶
- Import der GeneralSettingsPage
- "Allgemeine Einstellungen" als erste Option im Settings-Menü hinzugefügt
Verwendungsbeispiele¶
Beispiel 1: Kundennummer Format "KUN-123456"¶
Beispiel 2: Artikelnummer Format "AB12345"¶
Beispiel 3: Alphanumerisch 3-8 Zeichen¶
Datenfluss¶
- Konfiguration:
- Admin öffnet "Allgemeine Einstellungen"
- Definiert Validierungsmuster (Regex)
- Testet das Muster (Live-Test mit Beispielwert)
-
Speichert die Einstellungen
-
Speicherung:
- SystemSettingsBloc speichert in Firestore
-
Regel wird in
systemSettings/general_settingsgespeichert -
Verwendung:
- Beim Anlegen/Ändern von Kunden/Artikeln wird EsTextField verwendet
- EsTextField erhält das Pattern vom GeneralSettings via Dependency Injection
- Validierung erfolgt in Echtzeit bei Eingabe
Generische Validierung¶
Das System ist vollständig generisch: - Beliebige Regex-Patterns möglich - Neue Feldtypen können einfach durch Erweiterung von ValidationRuleType hinzugefügt werden - Validierungsfehler sind anpassbar
Integration in Customer/Article Editor¶
// Beispiel: In Customer oder Article Editor Page
final generalSettings = systemSettingsBloc.state.generalSettings;
final customerNumberRule = generalSettings?.customerNumberValidationRule;
EsTextField(
label: 'Kundennummer',
controller: customerNumberController,
type: EsTextFieldType.custom,
customValidationPattern: customerNumberRule?.pattern,
customValidationErrorMessage: customerNumberRule?.description ?? 'Ungültiges Format',
)
Firestore Struktur¶
{
"systemSettings": {
"general_settings": {
"customerNumberValidationRule": {
"id": "customer_number",
"type": "customerNumber",
"pattern": "^[A-Z]{3}-\\d{6}$",
"description": "Format KUN-123456",
"isActive": true
},
"articleNumberValidationRule": {
"id": "article_number",
"type": "articleNumber",
"pattern": "^[A-Z]{2}\\d{5}$",
"description": "Format AB12345",
"isActive": true
},
"customValidationRules": []
}
}
}
Zukünftige Erweiterungen¶
- Multi-Language Support: Validierungsfehlermeldungen in verschiedenen Sprachen
- Custom Rules für weitere Felder: Erweiterung von ValidationRuleType
- Rule Templates: Vordefinierte Muster für häufige Formate
- Validierungshistorie: Logging von Validierungsfehlern
- Bulk-Validierung: Validierung bei Massenimport von Kunden/Artikeln
Integration der Validierungsregeln in Customer/Article Editor¶
Schritt 1: Validierungsregel vom SystemSettingsBloc abrufen¶
In der Customer Editor Page (lib/pages/customer/editor_page/customer_editor_page.dart):
import 'package:easy_sale_erp/blocs/system_settings_bloc/system_settings_bloc.dart';
class _CustomerEditorPageState extends State<CustomerEditorPage> {
// ... bestehender Code ...
@override
Widget build(BuildContext context) {
final systemSettingsBloc = context.read<SystemSettingsBloc>();
final state = systemSettingsBloc.state;
// Hole die Kundennummer-Validierungsregel
final customerNumberRule = state is SystemSettingsLoaded
? state.generalSettings?.customerNumberValidationRule
: null;
// ... Rest des Builds ...
}
}
Schritt 2: EsTextField mit Validierungsregel erweitern¶
Bei der Erstellung des EsTextFields für die Kundennummer:
EsTextField(
label: 'Kundennummer',
controller: _customerNumberController,
isMandatory: true,
type: customerNumberRule?.isActive == true
? EsTextFieldType.custom
: EsTextFieldType.text,
customValidationPattern: customerNumberRule?.pattern,
customValidationErrorMessage: customerNumberRule?.description
?? 'Ungültiges Format für Kundennummer',
infoText: customerNumberRule?.description
?? 'Bitte geben Sie die Kundennummer ein',
)
Schritt 3: Ähnlich für Artikelnummern¶
In der Article Editor Page:
import 'package:easy_sale_erp/blocs/system_settings_bloc/system_settings_bloc.dart';
// In der build() Methode:
final systemSettingsBloc = context.read<SystemSettingsBloc>();
final state = systemSettingsBloc.state;
final articleNumberRule = state is SystemSettingsLoaded
? state.generalSettings?.articleNumberValidationRule
: null;
// Bei EsTextField für Artikelnummer:
EsTextField(
label: 'Artikelnummer',
controller: articleNumberController,
isMandatory: true,
type: articleNumberRule?.isActive == true
? EsTextFieldType.custom
: EsTextFieldType.text,
customValidationPattern: articleNumberRule?.pattern,
customValidationErrorMessage: articleNumberRule?.description
?? 'Ungültiges Format für Artikelnummer',
infoText: articleNumberRule?.description
?? 'Bitte geben Sie die Artikelnummer ein',
)
Schritt 4: Helper-Funktion erstellen (Optional)¶
Sie können eine Helper-Funktion erstellen, um Code-Duplikation zu vermeiden:
// In einem Helper-File z.B. lib/helpers/validation_helper.dart
import 'package:easy_sale_erp/models/system_settings/general_settings.dart';
import 'package:easy_sale_erp/pages/widgets/controls/es_text_field.dart';
class ValidationHelper {
/// Bestimmt den EsTextFieldType basierend auf der Regel
static EsTextFieldType getFieldType(GeneralSettings? settings, String fieldType) {
final rule = settings?.getValidationRuleForField(fieldType);
return rule?.isActive == true ? EsTextFieldType.custom : EsTextFieldType.text;
}
/// Gibt die Validierungsbeschreibung zurück
static String? getValidationDescription(GeneralSettings? settings, String fieldType, String defaultText) {
final rule = settings?.getValidationRuleForField(fieldType);
return rule?.description ?? defaultText;
}
/// Gibt das Validierungsmuster zurück
static String? getValidationPattern(GeneralSettings? settings, String fieldType) {
final rule = settings?.getValidationRuleForField(fieldType);
return rule?.pattern;
}
}
Dann kann in den Editor Pages vereinfacht werden:
EsTextField(
label: 'Kundennummer',
controller: _customerNumberController,
isMandatory: true,
type: ValidationHelper.getFieldType(generalSettings, 'customerNumber'),
customValidationPattern: ValidationHelper.getValidationPattern(generalSettings, 'customerNumber'),
customValidationErrorMessage: ValidationHelper.getValidationDescription(
generalSettings,
'customerNumber',
'Ungültiges Format für Kundennummer'
),
infoText: ValidationHelper.getValidationDescription(
generalSettings,
'customerNumber',
'Bitte geben Sie die Kundennummer ein'
),
)
Schritt 5: Server-Side Validierung (Cloud Functions)¶
Für zusätzliche Sicherheit können Sie auch Validierung in den Firebase Cloud Functions implementieren:
// functions/index.js
const generalSettings = await admin.firestore()
.collection('systemSettings')
.doc('general_settings')
.get();
const rules = generalSettings.data();
// Validiere Kundennummer
const customerNumberPattern = rules?.customerNumberValidationRule?.pattern;
if (customerNumberPattern && rules?.customerNumberValidationRule?.isActive) {
const regex = new RegExp(customerNumberPattern);
if (!regex.test(newCustomerData.customerNumber)) {
throw new Error(`Ungültige Kundennummer: ${newCustomerData.customerNumber}`);
}
}
// Validiere Artikelnummer
const articleNumberPattern = rules?.articleNumberValidationRule?.pattern;
if (articleNumberPattern && rules?.articleNumberValidationRule?.isActive) {
const regex = new RegExp(articleNumberPattern);
if (!regex.test(newArticleData.number)) {
throw new Error(`Ungültige Artikelnummer: ${newArticleData.number}`);
}
}
Vollständiges Beispiel für Customer Editor¶
import 'package:easy_sale_erp/blocs/system_settings_bloc/system_settings_bloc.dart';
import 'package:easy_sale_erp/blocs/system_settings_bloc/system_settings_bloc_states.dart';
class _CustomerEditorPageState extends State<CustomerEditorPage> {
final TextEditingController _customerNumberController = TextEditingController();
@override
Widget build(BuildContext context) {
return BlocBuilder<SystemSettingsBloc, BaseBlocState>(
builder: (context, state) {
final generalSettings = state is SystemSettingsLoaded
? state.generalSettings
: null;
final customerNumberRule = generalSettings?.customerNumberValidationRule;
return Column(
children: [
EsTextField(
label: 'Kundennummer',
controller: _customerNumberController,
isMandatory: true,
type: customerNumberRule?.isActive == true
? EsTextFieldType.custom
: EsTextFieldType.text,
customValidationPattern: customerNumberRule?.pattern,
customValidationErrorMessage: customerNumberRule?.description
?? 'Ungültiges Format für Kundennummer',
infoText: customerNumberRule?.description
?? 'z.B. KUN-123456',
),
// ... weitere Fields ...
],
);
},
);
}
}
Testing¶
Sie können die Validierungsregeln wie folgt testen:
// Unit Test Beispiel
import 'package:easy_sale_erp/models/system_settings/validation_rule.dart';
void main() {
test('Validierungsregel für Kundennummer KUN-123456', () {
final rule = ValidationRule(
id: 'customer_number',
type: ValidationRuleType.customerNumber,
pattern: r'^[A-Z]{3}-\d{6}$',
isActive: true,
);
expect(rule.validate('KUN-123456'), true);
expect(rule.validate('KUN-12345'), false); // Zu wenig Ziffern
expect(rule.validate('kun-123456'), false); // Kleinbuchstaben
expect(rule.validate('ABC123456'), false); // Kein Bindestrich
});
}
Best Practices¶
- Beschreibungen verwenden: Geben Sie aussagekräftige Beschreibungen für Validierungsmuster an
- Aktivierung nutzen: Deaktivieren Sie Validierungen, wenn Sie nicht mehr benötigt werden
- Server-Side Validierung: Implementieren Sie zusätzliche Validierung auf dem Server
- Tests schreiben: Testen Sie Ihre Validierungsmuster gründlich
- Benutzerfeedback: Verwenden Sie aussagekräftige Fehlermeldungen
Implementierungs-Zusammenfassung: Validierungsregeln für Kundennummern und Artikelnummern¶
Was wurde implementiert?¶
Ein vollständiges, generisches System zur Definition und Validierung von Kundennummern und Artikelnummern über Regex-Patterns in den "Allgemeinen Einstellungen".
Neu erstellte Dateien¶
1. Models¶
lib/models/system_settings/validation_rule.dartValidationRuleTypeEnum-
ValidationRuleKlasse mit Validierungsmethoden -
lib/models/system_settings/general_settings.dart GeneralSettingsKlasse für zentrale Verwaltung aller Validierungsregeln- Methode zum Abrufen von Regeln für spezifische Feldtypen
2. UI Components¶
lib/pages/settings/general_settings/general_settings_page.dart- Vollständige Settings-Page für Validierungsregel-Definition
- Live-Validierungstester
- Beispiele für häufige Regex-Patterns
- Separate Sections für Kundennummern und Artikelnummern
- Toggle zur Aktivierung/Deaktivierung
3. Documentation¶
VALIDATION_RULES_README.md- Technische DokumentationVALIDATION_INTEGRATION_GUIDE.md- Integrations-Anleitung für Customer/Article Editor
Erweiterte/Modifizierte Dateien¶
1. UI Framework¶
lib/pages/widgets/controls/es_text_field.dart- Enum
EsTextFieldTypeumcustomerweitert - Properties für
customValidationPatternundcustomValidationErrorMessagehinzugefügt - Validierungslogik für benutzerdefinierte Regex-Patterns
2. Navigation & Settings¶
lib/pages/settings/settings_page.dart- Import der
GeneralSettingsPage - "Allgemeine Einstellungen" als erste Option im Settings-Menü hinzugefügt
3. State Management¶
lib/blocs/system_settings_bloc/system_settings_bloc_events.dart- Import von
GeneralSettings -
Neues Event:
UpdateGeneralSettings -
lib/blocs/system_settings_bloc/system_settings_bloc_states.dart - Mixin
SystemSettingsDataumgeneralSettingsProperty erweitert -
SystemSettingsLoadedState aktualisiert -
lib/blocs/system_settings_bloc/system_settings_bloc.dart - Field
_generalSettingshinzugefügt - Handler
_onUpdateGeneralSettings()implementiert - Helper-Funktion
_emitCurrentState()erstellt _onLoadSystemSettings()um Laden von GeneralSettings erweitert
4. Firebase Service¶
lib/services/firebase_services/system_settings_firebase_service.dart- Import von
GeneralSettings - Methode
getGeneralSettings()zur Abfrage aus Firestore - Methode
updateGeneralSettings()zum Speichern in Firestore
Verwendungsbeispiel¶
Admin konfiguriert Validierungsregeln:¶
- Öffnet Einstellungen → "Allgemeine Einstellungen"
- Aktiviert "Kundennummer Validierung"
- Gibt Pattern ein:
^[A-Z]{3}-\d{6}$ - Gibt Beschreibung ein: "Format KUN-123456"
- Testet mit Beispielwert "KUN-123456" → ✓ Gültig
- Speichert die Einstellung
Beim Anlegen eines Kunden:¶
- Das System lädt die Validierungsregel aus den Allgemeinen Einstellungen
- EsTextField validiert Eingabe gegen das Pattern
- Bei Eingabe von "KUN-123456" → ✓ Akzeptiert
- Bei Eingabe von "KUN-12345" → ✗ Fehler mit Beschreibung
- Speichern nur möglich, wenn Validierung erfolgreich ist
Architektur-Vorteile¶
- Generisch: Beliebige Regex-Patterns möglich
- Zentral verwaltbar: Alle Validierungsregeln an einem Ort
- Echtzeit-Feedback: Live-Validierung beim Eingeben
- Erweiterbar: Neue Feldtypen leicht hinzufügbar
- Aktivierbar/Deaktivierbar: Regeln können ohne Neudefinition aktiviert/deaktiviert werden
- Mit Beschreibungen: Benutzer sehen, welches Format erwartet wird
Regex-Beispiele (vorkonfiguriert in UI)¶
Kundennummer:
^[A-Z]{3}-\d{6}$ → KUN-123456
^KD-\d{6}$ → KD-123456
^[A-Z]{2}\d{4}$ → KD1234
Artikelnummer:
^[A-Z]{2}\d{5}$ → AB12345
^ART-\d{6}$ → ART-123456
^[A-Z0-9]{3,8}$ → Alphanumerisch 3-8 Zeichen
Firestore Speicherstruktur¶
systemSettings/
general_settings
{
"customerNumberValidationRule": {
"id": "customer_number",
"type": "customerNumber",
"pattern": "^[A-Z]{3}-\\d{6}$",
"description": "Format KUN-123456",
"isActive": true
},
"articleNumberValidationRule": { ... },
"customValidationRules": []
}
Nächste Schritte (Optional)¶
- Integration in Customer Editor:
- Siehe
VALIDATION_INTEGRATION_GUIDE.md -
Validierungsregel beim Kundennummern-Input anwenden
-
Integration in Article Editor:
-
Validierungsregel beim Artikelnummern-Input anwenden
-
Server-Side Validierung:
-
Cloud Functions zur zusätzlichen Validierung auf dem Server
-
Validierungshistorie:
-
Logging von Validierungsfehlern für Audit-Trail
-
Multi-Language Support:
- Validierungsfehlermeldungen in verschiedenen Sprachen
Testing¶
Die Validierungslogik kann einfach getestet werden:
final rule = ValidationRule(
id: 'customer_number',
type: ValidationRuleType.customerNumber,
pattern: r'^[A-Z]{3}-\d{6}$',
isActive: true,
);
print(rule.validate('KUN-123456')); // true
print(rule.validate('KUN-12345')); // false
Hinweise¶
- Das System ist vollständig typsicher (Dart)
- Validierung erfolgt clientseitig für schnelles Feedback
- Server-Side Validierung sollte zusätzlich implementiert werden
- Alle Regex-Patterns sind editierbar und können zur Laufzeit aktualisiert werden
- Die Settings können jederzeit ohne Neudeploy geändert werden