Zum Inhalt

📱 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