Zum Inhalt

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

  • ArticleDocument Model (lib/models/articles/article_document.dart)
  • ArticleDocumentCategory Enum mit 10 Kategorien
  • ✅ Unterstützung für Mehrsprachigkeit (LanguageEnum)
  • ✅ Länder-spezifische Freigaben (CountryEnum)
  • isPublic Flag 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

  • ArticleDocumentFirebaseService mit CRUD-Operationen
  • ✅ Real-time Stream für Live-Updates
  • ✅ Batch-Operationen für Sortierung
  • ✅ Filter für öffentliche Dokumente

4. State Management

  • ArticleDocumentBloc mit BLoC Pattern
  • ✅ Events: Load, Create, Update, Delete, Reorder
  • ✅ Automatische Synchronisation mit Firestore
  • ✅ Fehlerbehandlung

5. UI Components

  • ArticleDocumentsSection Widget
  • ✅ 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:

dependencies:
  file_picker: ^latest  # Für Datei-Upload
  # ... existing packages

🌐 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:

flutter gen-l10n

🔐 Security & Permissions

Firestore Rules

  • Lesen: Alle authentifizierten User
  • Erstellen: Admin ODER canCreateArticles Permission
  • Bearbeiten: Admin ODER canEditArticles Permission
  • Löschen: Admin ODER canDeleteArticles Permission

Storage Rules

  • Lesen: Authentifiziert
  • Schreiben: Authentifiziert + Max 10 MB

🚀 Deployment

1. Rules deployen

firebase deploy --only firestore:rules,storage:rules

2. Firestore Index (falls nötig)

Wenn die Firebase Console einen Index-Fehler anzeigt:

firebase firestore:indexes

3. App neu builden

flutter clean
flutter pub get
flutter run

📱 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 LoadArticleDocuments Event 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

flutter pub add flutter_pdfview dio path_provider share_plus permission_handler open_file

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.xml anpassen
  • ✅ iOS: Info.plist anpassen

4. Integration im Artikel-Detail

  • ✅ Tab "Dokumente" hinzufügen
  • ArticleDocumentsList Widget 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: true Dokumente
  • ✅ Nur isActive: true Dokumente
  • ✅ 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

  1. Prüfe isActive: true Flag
  2. Dokumente müssen aktiv sein (nicht archiviert)
  3. Bei Versionierung: Nur die neueste Version ist aktiv

  4. Prüfe Gültigkeitsdatum

  5. validFrom: Dokument noch nicht gültig?
  6. validUntil: Dokument bereits abgelaufen?
  7. Aktuelles Datum muss zwischen validFrom und validUntil liegen

  8. Prüfe Länder-Filter

  9. Wenn countryIds leer: Dokument für alle sichtbar ✅
  10. Wenn countryIds gesetzt: User-Land muss in Liste sein

  11. Firestore Query prüfen (via Firebase Console)

    articles/{articleId}/articleDocuments
    WHERE isPublic == true
    AND isActive == true
    

  12. Debug in Mobile App

    final docs = await ArticleDocumentFirebaseService()
        .getPublicArticleDocuments(articleId);
    print('Gefundene Dokumente: ${docs.length}');
    docs.forEach((doc) {
      print('- ${doc.displayName}: '
            'isPublic=${doc.isPublic}, '
            'isActive=${doc.isActive}, '
            'valid=${doc.isCurrentlyValid}');
    });
    


📞 Zusammenfassung

Was wurde dokumentiert:

  1. ✅ Vollständige Service-Layer für Mobile
  2. ✅ UI Components (Liste, Card, PDF-Viewer)
  3. ✅ Download & Caching Service
  4. ✅ Permissions Setup (Android/iOS)
  5. ✅ Integration im Artikel-Detail
  6. ✅ Best Practices & Troubleshooting

Nächste Schritte für Implementierung:

  1. Dependencies installieren
  2. Service-Dateien erstellen
  3. UI-Widgets erstellen
  4. Permissions konfigurieren
  5. 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

  1. Gehe zu Firebase Console
  2. Wähle dein Projekt
  3. Navigation: Firestore Database

2. Artikel-ID finden

Collection: articles
Suche nach: 
  - Feld "number" = "000000001"
  - ODER Feld "name" = "OTTOSEAL S100"

Gefundene Artikel-ID notieren! (z.B. abc123xyz)

3. Dokumente überprüfen

Collection: articles/{articleId}/articleDocuments

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:

{
  "countryIds": ["germany", "austria"]   Nur für DE/AT sichtbar
}

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

Collection: articles/{articleId}/articleDocuments
Filter:
  - isPublic == true
  - isActive == true

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:

  1. Navigiere zu: articles/{articleId}/articleDocuments/{documentId}
  2. Klicke auf "Edit document" (Stift-Icon)
  3. Setze:
    isPublic: true
    isActive: true
    
  4. Entferne (falls vorhanden):
    validFrom: [löschen]
    validUntil: [löschen]
    
  5. Speichern

⚠️ Achtung: Änderungen in Firebase Console werden NICHT im ERP angezeigt!
Besser: Im ERP ändern!


📱 Test in Mobile App

Nach Änderungen:

  1. Mobile App neu starten (Pull-to-Refresh reicht oft nicht)
  2. Artikel aufrufen
  3. Tab "Dokumente" öffnen

Sollte jetzt sichtbar sein!


🆘 Wenn nichts funktioniert

Debug-Log aktivieren

  1. ERP öffnen
  2. Firebase Storage Rules prüfen:

    match /article_documents/{articleId}/{documentId} {
      allow read: if request.auth != null;  ← Muss vorhanden sein!
    }
    

  3. Firestore Rules prüfen:

    match /articles/{articleId}/articleDocuments/{documentId} {
      allow read: if isAuthenticated();  ← Muss vorhanden sein!
    }
    

  4. Mobile App User authentifiziert?

  5. User eingeloggt?
  6. 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 isPublic sichtbar)
  • [ ] Firebase Console: Screenshot der articleDocuments Collection
  • [ ] 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

dependencies:
  cached_network_image: ^3.3.0

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:

if (hasBanner && imageIndex == 0) {
  // Banner-Overlay 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:

  1. ✅ Artikel ohne Bilder (Fallback-Icon anzeigen)
  2. ✅ Artikel mit 1 Bild (kein Page-Indicator)
  3. ✅ Artikel mit mehreren Bildern (Swipe-Funktion)
  4. ✅ Variante ohne Bilder (Fallback auf Artikelbilder)
  5. ✅ Variante mit eigenen Bildern (Varianten-Galerie anzeigen)
  6. ✅ Banner aktiv (Banner auf erstem Bild)
  7. ✅ Banner abgelaufen (kein Banner anzeigen)
  8. ✅ Langsame Netzwerkverbindung (Loading-Spinner)
  9. ✅ Offline-Modus (gecachte Bilder anzeigen)
  10. ✅ 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


✅ 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

// Beispiel: €99.99 - €149.99
final priceRange = variantService.getPriceRange(article);

Bei einer Variante: Einzelpreis anzeigen

// Beispiel: €99.99
final price = variantService.formatPrice(variant.price);

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:

  1. ✅ Artikel ohne Varianten (Fallback-Verhalten)
  2. ✅ Artikel mit 1 Variante (keine Auswahl nötig)
  3. ✅ Artikel mit mehreren Varianten (Auswahl-UI anzeigen)
  4. ✅ Variante ohne Bilder (Artikelbilder als Fallback)
  5. ✅ Variante mit eigenen Bildern (Varianten-Galerie)
  6. ✅ Nicht verfügbare Variante (ausgegraut, nicht bestellbar)
  7. showInShop: false (Variante nicht anzeigen)
  8. ✅ Preisspanne (günstigste bis teuerste Variante)
  9. ✅ Variantenwechsel (Bilder & Preis aktualisieren)
  10. ✅ 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


✅ Checkliste für Integration

  • [ ] ArticleVariant Model verstanden
  • [ ] ArticleVariantMobileService implementiert
  • [ ] UI-Komponenten erstellt (VariantSelector, etc.)
  • [ ] Filterung nach isAvailable && showInShop implementiert
  • [ ] 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?

  1. Bild dekodieren: Originales Bild wird geladen
  2. Transparenz-Prüfung: System erkennt automatisch transparente Pixel
  3. Größenanpassung: Wenn nötig, wird das Bild proportional skaliert
  4. Format-Wahl: PNG für Transparenz, JPEG für opake Bilder
  5. Komprimierung: Bild wird optimal komprimiert
  6. 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):

static const int jpegQuality = 95;
static const int pngLevel = 3;

Mehr Kompression (kleinere Dateien, niedrigere Qualität):

static const int jpegQuality = 75;
static const int pngLevel = 9;

🚀 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:

❌ Error compressing image: ...

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

dependencies:
  image: ^4.3.0  # Für Bildverarbeitung

🔄 Migration für existierende Bilder

Existierende Bilder werden nicht automatisch neu komprimiert. Wenn du alte Bilder komprimieren möchtest:

  1. Export alte Bilder aus Firebase Storage
  2. 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:

  1. Hochladen eines großen Fotos (5 MB+)
  2. Wird automatisch komprimiert und verkleinert

  3. Hochladen eines transparenten Logos

  4. Wird als PNG gespeichert, Transparenz bleibt erhalten

  5. Hochladen eines kleinen Bildes (< 1920x1920)

  6. Wird nur komprimiert, nicht skaliert

  7. Mehrere Bilder nacheinander hochladen

  8. 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

  1. Verwende hochauflösende Originale: Die Komprimierung produziert bessere Ergebnisse mit hochauflösenden Quellbildern
  2. PNG für Logos: Wenn möglich, lade Logos im PNG-Format hoch
  3. JPEG für Fotos: Normale Fotos können direkt als JPEG hochgeladen werden
  4. 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:

  1. ArticleCategoryImportFile.xlsx - Artikelkategorien
  2. CustomerCategoryImportFile.xlsx - Kundenkategorien
  3. ArticleImportFile.xlsx - Artikel (referenziert #1)
  4. 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:

Getränke | de:Getränke;en:Beverages;fr:Boissons;it:Bevande;es:Bebidas


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:

Premium | de:Premium Kunden;en:Premium Customers;fr:... | 4294956800


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

  1. Download der Template-Dateien über die Import-Einstellungen
  2. Bearbeitung in Excel - Templates enthalten bereits 3 Beispieldatensätze
  3. Eigene Daten hinzufügen - Beispiele als Vorlage verwenden
  4. Import in korrekter Reihenfolge:
  5. Zuerst: Artikelkategorien importieren
  6. Dann: Kundenkategorien importieren
  7. Danach: Artikel importieren (können nun auf Kategorien referenzieren)
  8. Zuletzt: Kunden importieren (können nun auf Kundenkategorien referenzieren)

Wichtige Hinweise

Sprach-Format ✅

  • Konsistent: Alle Kategorien verwenden de:Text;en:Text Format
  • 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/aktiv
  • 0 = false/nein/inaktiv

Zahlen-Format

  • Dezimalzahlen: 24.99 oder 24,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 counters Collection
  • 🔧 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:

Pattern: ^KUN-\d{6}$
→ Extrahiertes Prefix: "KUN-"

Beispiel Artikelnummer:

Pattern: ^ART-\d{5}$
→ Extrahiertes Prefix: "ART-"

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:

  1. Öffnen Sie Settings → Allgemeine Einstellungen
  2. Aktivieren Sie die Kundennummer Validierung
  3. Pattern z.B.: ^KUN-\d{6}$ (für Format KUN-000001)
  4. Beschreibung: "Format KUN-000001"
  5. Aktivieren Sie die Artikelnummer Validierung
  6. Pattern z.B.: ^ART-\d{5}$ (für Format ART-00001)
  7. Beschreibung: "Format ART-00001"
  8. 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:

cd functions/dev-scripts
node initialize_number_counters.js

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:

// Beispiele:
"KUN-00123"  123
"CUST-456"   456
"12345"      12345
"A-B-789"    789

Algorithmus: 1. Entferne alle nicht-numerischen Zeichen 2. Parse als Integer

Formatierung

// Mit Prefix und Padding:
formatNumber(124, 5)  "00124"
prefix + formatNumber  "KUN-00124"

📊 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:

node functions/dev-scripts/initialize_number_counters.js

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

  1. Validierungsregel erstellen (Settings → Allgemeine Einstellungen):

    Pattern: ^ORD-\d{6}$
    Beschreibung: Format ORD-000001
    

  2. 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');
    }
    

  3. Editor-Integration:

    @override
    void initState() {
      if (widget.order == null) {
        _loadNextOrderNumber();
      }
      super.initState();
    }
    
    // Nach dem Speichern:
    _numberSuggestionService.updateOrderCounter(_orderNumberController.text);
    

🎯 Best Practices

  1. Immer Migration Script ausführen nach dem ersten Deployment
  2. Counter manuell prüfen in Firebase Console unter counters Collection
  3. Logs überwachen für Counter-Updates in Firebase Functions
  4. Security Rules anpassen um Counter zu schützen
  5. Backup erstellen vor größeren Änderungen

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 Artikel
  • hasMore: Gibt an, ob weitere Artikel verfügbar sind
  • lastDocument: 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 Artikel
  • hasMore: Weitere Artikel verfügbar
  • lastDocument: Cursor für nächste Seite
  • searchText: Aktueller Suchtext
  • categoryIds: Gefilterte Kategorien
  • isLoadingMore: 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 Suchtext
  • categoryIds: Optionale Kategorie-Filter
  • isLoadMore: 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 searchKeywords Feld
  • 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 searchKeywords Feld 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 EsInfiniteListView statt EsListView
  • 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 LoadArticlesPaged aus (statt SearchArticles)
  • Kategorie-Selektor löst LoadArticlesPaged aus
  • 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:

Collection: articles
Fields to index:
  - searchKeywords (Array)
  - number (Ascending)

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 + SearchArticlesArticlesLoaded - Neuer Ansatz: LoadArticlesPagedArticlesPagedLoaded

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

  1. Initial Load: Prüfen, ob 50 Artikel geladen werden
  2. Infinite Scroll: Nach unten scrollen → weitere 50 Artikel laden
  3. Suche: Suchtext eingeben → gefilterte Ergebnisse laden
  4. Kategorien: Kategorien auswählen → gefilterte Ergebnisse laden
  5. Suche + Kategorien: Kombination testen
  6. Load More: Prüfen, ob hasMore korrekt ist
  7. Artikel-Editor: Öffnen und Speichern funktioniert
  8. Order-Editor: Artikel-Suche funktioniert
  9. Customer Articles: Zuweisung/Suche funktioniert

Migration-Checkliste

  • [x] EsInfiniteListView Widget erstellt
  • [x] PagedArticlesResult Klasse erstellt
  • [x] ArticlesPagedLoaded State erstellt
  • [x] LoadArticlesPaged Event erstellt
  • [x] getArticlesPaged() Service-Methode erstellt
  • [x] _onLoadArticlesPaged() Bloc-Handler erstellt
  • [x] articles_page.dart angepasst
  • [x] articles_management_page.dart angepasst
  • [x] customer_assigned_articles_body_widget.dart angepasst
  • [x] order_editor_page.dart angepasst
  • [ ] Firestore-Index erstellen
  • [ ] searchKeywords Feld bei existierenden Artikeln nachträglich befüllen
  • [ ] Testing durchführen

Nächste Schritte

  1. Firestore-Index erstellen (siehe oben)
  2. Migration-Script für searchKeywords:
    // Alle Artikel laden und searchKeywords hinzufügen
    final articles = await articleFirebaseService.getArticles();
    for (final article in articles) {
      final keywords = ArticleFirebaseService.generateSearchKeywords(
        article.name,
        article.number,
      );
      // Update article mit searchKeywords Feld
    }
    
  3. Testing auf Entwicklungsumgebung
  4. 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 validFrom und validUntil im 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 validFrom werden 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 validUntil werden 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 isActive Status 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:

firebase functions:shell
> manualDocumentValidityCheck()

📖 Verwendung

Dokument mit Gültigkeitszeitraum erstellen:

  1. Artikel bearbeiten → Dokument hochladen
  2. Gültigkeitszeitraum festlegen:
  3. Gültig ab: Dokument wird an diesem Datum automatisch aktiviert
  4. Gültig bis: Dokument wird an diesem Datum automatisch deaktiviert
  5. 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:

  • FunctionsscheduledDocumentValidityCheck → 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

  1. Zeitzone: Function läuft in Europe/Berlin Timezone
  2. Verzögerung: Änderungen erfolgen beim nächsten Scheduled Run (max. 24h Verzögerung)
  3. Manuelle Übersteuerung: isActive kann jederzeit manuell gesetzt werden, wird aber beim nächsten Run wieder automatisch angepasst
  4. Genehmigungspflicht: Dokumente mit requiresApproval=true werden nur aktiviert, wenn isApproved=true

🎯 Best Practices

  1. Planung: Setze validFrom für zukünftige Dokumente, um automatische Aktivierung zu nutzen
  2. Überlappung vermeiden: Bei allowMultipleActive=false keine überlappenden Gültigkeitszeiträume verwenden
  3. Puffer einplanen: Setze validFrom mit Puffer (z.B. 1 Tag früher), um sicherzustellen, dass das neue Dokument rechtzeitig aktiv ist
  4. 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 Validierung
  • description: Beschreibung für Benutzer
  • isActive: Flag zur Aktivierung/Deaktivierung
  • validate(value): Methode zur Validierung eines Wertes

general_settings.dart

  • GeneralSettings: Zentrale Einstellung für Validierungsregeln
  • customerNumberValidationRule: Regel für Kundennummern
  • articleNumberValidationRule: Regel für Artikelnummern
  • customValidationRules: Liste für zusätzliche benutzerdefinierte Regeln
  • validateField(fieldType, value): Validiert einen Wert basierend auf Feldtyp

2. UI Component (lib/pages/widgets/controls/)

es_text_field.dart (erweitert)

  • EsTextFieldType erweitert um custom Typ
  • Neue Properties:
  • customValidationPattern: Regex-Pattern für benutzerdefinierte Validierung
  • customValidationErrorMessage: 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 generalSettings Property
  • 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"

Pattern: ^[A-Z]{3}-\d{6}$
Beschreibung: Format "KUN-123456" (3 Buchstaben, Bindestrich, 6 Ziffern)

Beispiel 2: Artikelnummer Format "AB12345"

Pattern: ^[A-Z]{2}\d{5}$
Beschreibung: Format "AB12345" (2 Buchstaben, 5 Ziffern)

Beispiel 3: Alphanumerisch 3-8 Zeichen

Pattern: ^[A-Z0-9]{3,8}$
Beschreibung: Alphanumerisch, 3-8 Zeichen

Datenfluss

  1. Konfiguration:
  2. Admin öffnet "Allgemeine Einstellungen"
  3. Definiert Validierungsmuster (Regex)
  4. Testet das Muster (Live-Test mit Beispielwert)
  5. Speichert die Einstellungen

  6. Speicherung:

  7. SystemSettingsBloc speichert in Firestore
  8. Regel wird in systemSettings/general_settings gespeichert

  9. Verwendung:

  10. Beim Anlegen/Ändern von Kunden/Artikeln wird EsTextField verwendet
  11. EsTextField erhält das Pattern vom GeneralSettings via Dependency Injection
  12. 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

  1. Multi-Language Support: Validierungsfehlermeldungen in verschiedenen Sprachen
  2. Custom Rules für weitere Felder: Erweiterung von ValidationRuleType
  3. Rule Templates: Vordefinierte Muster für häufige Formate
  4. Validierungshistorie: Logging von Validierungsfehlern
  5. 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

  1. Beschreibungen verwenden: Geben Sie aussagekräftige Beschreibungen für Validierungsmuster an
  2. Aktivierung nutzen: Deaktivieren Sie Validierungen, wenn Sie nicht mehr benötigt werden
  3. Server-Side Validierung: Implementieren Sie zusätzliche Validierung auf dem Server
  4. Tests schreiben: Testen Sie Ihre Validierungsmuster gründlich
  5. 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.dart
  • ValidationRuleType Enum
  • ValidationRule Klasse mit Validierungsmethoden

  • lib/models/system_settings/general_settings.dart

  • GeneralSettings Klasse 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 Dokumentation
  • VALIDATION_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 EsTextFieldType um custom erweitert
  • Properties für customValidationPattern und customValidationErrorMessage hinzugefü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 SystemSettingsData um generalSettings Property erweitert
  • SystemSettingsLoaded State aktualisiert

  • lib/blocs/system_settings_bloc/system_settings_bloc.dart

  • Field _generalSettings hinzugefü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:

  1. Öffnet Einstellungen → "Allgemeine Einstellungen"
  2. Aktiviert "Kundennummer Validierung"
  3. Gibt Pattern ein: ^[A-Z]{3}-\d{6}$
  4. Gibt Beschreibung ein: "Format KUN-123456"
  5. Testet mit Beispielwert "KUN-123456" → ✓ Gültig
  6. 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

  1. Generisch: Beliebige Regex-Patterns möglich
  2. Zentral verwaltbar: Alle Validierungsregeln an einem Ort
  3. Echtzeit-Feedback: Live-Validierung beim Eingeben
  4. Erweiterbar: Neue Feldtypen leicht hinzufügbar
  5. Aktivierbar/Deaktivierbar: Regeln können ohne Neudefinition aktiviert/deaktiviert werden
  6. 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)

  1. Integration in Customer Editor:
  2. Siehe VALIDATION_INTEGRATION_GUIDE.md
  3. Validierungsregel beim Kundennummern-Input anwenden

  4. Integration in Article Editor:

  5. Validierungsregel beim Artikelnummern-Input anwenden

  6. Server-Side Validierung:

  7. Cloud Functions zur zusätzlichen Validierung auf dem Server

  8. Validierungshistorie:

  9. Logging von Validierungsfehlern für Audit-Trail

  10. Multi-Language Support:

  11. 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