📱 Article Images & Variant Images - Mobile App Integration Guide¶
📋 Übersicht¶
Diese Dokumentation beschreibt die vollständige Integration der Artikelbilder- und Variantenbilder-Funktionalität in die Mobile App. Die Implementierung basiert auf der bestehenden Web/Desktop-Lösung und ermöglicht Kunden die Anzeige von Artikelbildern sowie Bildern für spezifische Artikelvarianten.
🎯 Anforderungen & Scope¶
Was Mobile App User können sollen:¶
- ✅ Artikelbilder eines Produkts anzeigen (Galerie-Ansicht)
- ✅ Variantenbilder für spezifische Artikelvarianten anzeigen
- ✅ Bilder in sortierter Reihenfolge anzeigen (nach Index)
- ✅ Bilder in Vollbild-Ansicht betrachten (Zoom, Swipe)
- ✅ Zwischen mehreren Bildern wischen/swipen
- ✅ Erstes Bild als Thumbnail in Produktlisten verwenden
- ✅ Banner-Informationen auf Artikelbildern anzeigen (falls aktiv)
- ✅ Bilder offline cachen für bessere Performance
- ✅ Ladezustände während Bilddownload anzeigen
- ✅ Fallback-Icon anzeigen, wenn keine Bilder vorhanden
Was Mobile App User NICHT können:¶
- ❌ Bilder hochladen/erstellen
- ❌ Bilder bearbeiten/löschen
- ❌ Bilder neu sortieren (Drag & Drop)
- ❌ Banner erstellen/bearbeiten
🏗️ Architektur & Datenmodell¶
1. Datenmodell (bereits vorhanden)¶
BaseImage¶
// lib/models/all_base/base_image.dart
class BaseImage {
final String id;
final int index; // Sortier-Reihenfolge (0, 1, 2, ...)
final String downloadLink; // Firebase Storage Download-URL
final String storageFilePath; // Pfad in Firebase Storage
}
ArticleImage (extends BaseImage)¶
// lib/models/articles/article_image.dart
class ArticleImage extends BaseImage {
ArticleImage({
super.id = "",
required super.index,
required super.downloadLink,
required super.storageFilePath,
});
factory ArticleImage.fromMap(Map<String, dynamic> json) {
return ArticleImage(
id: json['id'] ?? '',
index: json['index'] ?? 0,
downloadLink: json['downloadLink'] ?? '',
storageFilePath: json['storageFilePath'] ?? '',
);
}
}
Article Model¶
// lib/models/articles/article.dart
class Article extends EntityBase {
final String id;
final String number;
final String name;
final List<ArticleImage> articleImages; // ← Artikelbilder
final List<ArticleVariant> articleVariants; // ← Varianten mit Bildern
// Banner-Feature (optional auf Bildern angezeigt)
final Map<LanguageEnum, String> bannerTitleLanguages;
final Color? bannerBackgroundColor;
final Color? bannerFontColor;
final DateTime? bannerFrom;
final DateTime? bannerTo;
// ... weitere Felder
}
ArticleVariant Model¶
// lib/models/articles/article_variant.dart
class ArticleVariant {
final String id;
final String name;
final String number;
final double price;
final List<ArticleImage> variantImages; // ← Variantenbilder
factory ArticleVariant.fromMap(String id, Map<String, dynamic> json) {
final List<ArticleImage> images = [];
if (json['variantImages'] != null) {
final imagesData = json['variantImages'] as List;
for (var imageData in imagesData) {
images.add(ArticleImage.fromMap(imageData));
}
// Wichtig: Sortiere Bilder nach Index!
images.sort((a, b) => a.index.compareTo(b.index));
}
return ArticleVariant(
id: id,
variantImages: images,
// ... weitere Felder
);
}
}
2. Datenstruktur in Firestore¶
Artikelbilder (Subcollection)¶
/articles/{articleId}/articleImages/{imageId}
└─ id: string
└─ index: number (0, 1, 2, ...)
└─ downloadLink: string (Firebase Storage URL)
└─ storageFilePath: string (Pfad für Löschung)
Variantenbilder (im Article Document)¶
/articles/{articleId}
└─ articleVariants: map
└─ {variantId}:
└─ name: string
└─ number: string
└─ price: number
└─ variantImages: array [
{
id: string
index: number
downloadLink: string
storageFilePath: string
},
...
]
Wichtiger Unterschied:
- Artikelbilder: Separate Subcollection (articleImages/)
- Variantenbilder: Direkt im Varianten-Objekt als Array gespeichert
3. Firebase Storage Struktur¶
/articleImages/
└─ {articleId}/
├─ {uuid}.jpg ← Artikelbilder
├─ {uuid}.png
└─ variants/
└─ {variantId}/
├─ {uuid}.jpg ← Variantenbilder
├─ {uuid}.png
└─ ...
Naming Convention:
- Artikelbilder: articleImages/{articleId}/{uuid}.{extension}
- Variantenbilder: articleImages/{articleId}/variants/{variantId}/{uuid}.{extension}
📡 Service Layer¶
ArticleImageMobileService¶
// lib/services/mobile/article_image_mobile_service.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_storage/firebase_storage.dart';
import '../../models/articles/article.dart';
import '../../models/articles/article_image.dart';
import '../../models/articles/article_variant.dart';
class ArticleImageMobileService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
final FirebaseStorage _storage = FirebaseStorage.instance;
/// Lädt alle Artikelbilder (sortiert nach Index)
Future<List<ArticleImage>> loadArticleImages(String articleId) async {
try {
final snapshot = await _firestore
.collection('articles')
.doc(articleId)
.collection('articleImages')
.orderBy('index') // ← Wichtig für korrekte Reihenfolge
.get();
return snapshot.docs
.map((doc) => ArticleImage.fromMap(doc.data()))
.toList();
} catch (e) {
debugPrint('Error loading article images: $e');
return [];
}
}
/// Stream für Artikelbilder (für Echtzeit-Updates)
Stream<List<ArticleImage>> streamArticleImages(String articleId) {
return _firestore
.collection('articles')
.doc(articleId)
.collection('articleImages')
.orderBy('index')
.snapshots()
.map((snapshot) {
return snapshot.docs
.map((doc) => ArticleImage.fromMap(doc.data()))
.toList();
});
}
/// Holt das erste Bild eines Artikels (für Thumbnails in Listen)
Future<ArticleImage?> getFirstArticleImage(String articleId) async {
try {
final snapshot = await _firestore
.collection('articles')
.doc(articleId)
.collection('articleImages')
.orderBy('index')
.limit(1)
.get();
if (snapshot.docs.isEmpty) return null;
return ArticleImage.fromMap(snapshot.docs.first.data());
} catch (e) {
debugPrint('Error loading first article image: $e');
return null;
}
}
/// Batch-Laden von ersten Bildern für mehrere Artikel
/// (Performance-Optimierung für Produktlisten)
Future<Map<String, ArticleImage>> getFirstImagesForArticles(
List<String> articleIds,
) async {
final Map<String, ArticleImage> result = {};
try {
// Firestore erlaubt max. 10 parallele Anfragen
final batches = <List<String>>[];
for (var i = 0; i < articleIds.length; i += 10) {
final end = (i + 10 < articleIds.length) ? i + 10 : articleIds.length;
batches.add(articleIds.sublist(i, end));
}
for (final batch in batches) {
final futures = batch.map((articleId) async {
final image = await getFirstArticleImage(articleId);
if (image != null) {
result[articleId] = image;
}
});
await Future.wait(futures);
}
} catch (e) {
debugPrint('Error batch loading article images: $e');
}
return result;
}
/// Holt Variantenbilder aus einem Artikel
List<ArticleImage> getVariantImages(
Article article,
String variantId,
) {
try {
final variant = article.articleVariants.firstWhere(
(v) => v.id == variantId,
);
// Bilder sind bereits nach Index sortiert (siehe ArticleVariant.fromMap)
return variant.variantImages;
} catch (e) {
debugPrint('Variant not found: $variantId');
return [];
}
}
/// Holt das erste Variantenbild (für Thumbnails)
ArticleImage? getFirstVariantImage(
Article article,
String variantId,
) {
final images = getVariantImages(article, variantId);
return images.isNotEmpty ? images.first : null;
}
/// Prüft ob ein Artikel aktive Banner-Informationen hat
bool hasActiveBanner(Article article) {
final now = DateTime.now();
// Prüfe ob Banner-Daten vorhanden sind
if (article.bannerTitleLanguages.isEmpty) return false;
// Prüfe Gültigkeitszeitraum
if (article.bannerFrom != null && now.isBefore(article.bannerFrom!)) {
return false; // Banner noch nicht aktiv
}
if (article.bannerTo != null && now.isAfter(article.bannerTo!)) {
return false; // Banner abgelaufen
}
return true;
}
/// Holt Banner-Text für die aktuelle Sprache
String getBannerText(
Article article,
LanguageEnum userLanguage,
) {
return article.bannerTitleLanguages[userLanguage] ??
article.bannerTitleLanguages.values.firstOrNull ??
'';
}
}
🎨 UI-Komponenten für Mobile App¶
1. ArticleImageGallery - Hauptkomponente¶
// lib/pages/mobile/widgets/article_image_gallery.dart
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../../models/articles/article.dart';
import '../../../models/articles/article_image.dart';
import '../../../services/mobile/article_image_mobile_service.dart';
class ArticleImageGallery extends StatefulWidget {
final Article article;
final ArticleVariant? selectedVariant; // null = Artikelbilder anzeigen
const ArticleImageGallery({
super.key,
required this.article,
this.selectedVariant,
});
@override
State<ArticleImageGallery> createState() => _ArticleImageGalleryState();
}
class _ArticleImageGalleryState extends State<ArticleImageGallery> {
final _imageService = ArticleImageMobileService();
final PageController _pageController = PageController();
int _currentPage = 0;
List<ArticleImage> _images = [];
@override
void initState() {
super.initState();
_loadImages();
}
Future<void> _loadImages() async {
List<ArticleImage> images;
if (widget.selectedVariant != null) {
// Variantenbilder
images = _imageService.getVariantImages(
widget.article,
widget.selectedVariant!.id,
);
} else {
// Artikelbilder
images = await _imageService.loadArticleImages(widget.article.id);
}
setState(() {
_images = images;
});
}
@override
Widget build(BuildContext context) {
if (_images.isEmpty) {
return _buildEmptyState();
}
return Column(
children: [
// Bild-Carousel
SizedBox(
height: 300,
child: PageView.builder(
controller: _pageController,
onPageChanged: (index) => setState(() => _currentPage = index),
itemCount: _images.length,
itemBuilder: (context, index) {
return _buildImageCard(_images[index], index);
},
),
),
// Seiten-Indikator
if (_images.length > 1)
Padding(
padding: const EdgeInsets.only(top: 16),
child: _buildPageIndicator(),
),
],
);
}
Widget _buildImageCard(ArticleImage image, int index) {
final hasBanner = widget.selectedVariant == null &&
_imageService.hasActiveBanner(widget.article);
return GestureDetector(
onTap: () => _openFullscreen(index),
child: Stack(
children: [
// Hauptbild
CachedNetworkImage(
imageUrl: image.downloadLink,
fit: BoxFit.contain,
placeholder: (context, url) => const Center(
child: CircularProgressIndicator(),
),
errorWidget: (context, url, error) => _buildErrorState(),
),
// Banner-Overlay (nur bei Artikelbildern)
if (hasBanner && index == 0) // Banner nur auf erstem Bild
Positioned(
top: 16,
right: 16,
child: _buildBanner(),
),
],
),
);
}
Widget _buildBanner() {
final userLanguage = LanguageEnum.german; // TODO: Von User-Einstellungen holen
final bannerText = _imageService.getBannerText(
widget.article,
userLanguage,
);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: widget.article.bannerBackgroundColor ?? Colors.red,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.flag,
size: 16,
color: widget.article.bannerFontColor ?? Colors.white,
),
const SizedBox(width: 4),
Text(
bannerText,
style: TextStyle(
color: widget.article.bannerFontColor ?? Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
Widget _buildPageIndicator() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_images.length, (index) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
width: _currentPage == index ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: _currentPage == index
? Theme.of(context).primaryColor
: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
);
}),
);
}
Widget _buildEmptyState() {
return Container(
height: 300,
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.image_not_supported,
size: 80,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
'Keine Bilder vorhanden',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
),
),
],
),
);
}
Widget _buildErrorState() {
return Container(
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.broken_image,
size: 60,
color: Colors.grey.shade400,
),
const SizedBox(height: 8),
Text(
'Bild konnte nicht geladen werden',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
],
),
);
}
void _openFullscreen(int initialIndex) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ArticleImageFullscreen(
images: _images,
initialIndex: initialIndex,
),
),
);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
}
2. ArticleImageFullscreen - Vollbild-Ansicht¶
// lib/pages/mobile/widgets/article_image_fullscreen.dart
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
import '../../../models/articles/article_image.dart';
class ArticleImageFullscreen extends StatefulWidget {
final List<ArticleImage> images;
final int initialIndex;
const ArticleImageFullscreen({
super.key,
required this.images,
this.initialIndex = 0,
});
@override
State<ArticleImageFullscreen> createState() =>
_ArticleImageFullscreenState();
}
class _ArticleImageFullscreenState extends State<ArticleImageFullscreen> {
late PageController _pageController;
late int _currentIndex;
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex;
_pageController = PageController(initialPage: widget.initialIndex);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: Text(
'Bild ${_currentIndex + 1} von ${widget.images.length}',
style: const TextStyle(color: Colors.white),
),
),
body: PhotoViewGallery.builder(
pageController: _pageController,
itemCount: widget.images.length,
onPageChanged: (index) => setState(() => _currentIndex = index),
builder: (context, index) {
return PhotoViewGalleryPageOptions(
imageProvider: CachedNetworkImageProvider(
widget.images[index].downloadLink,
),
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 2,
heroAttributes: PhotoViewHeroAttributes(
tag: widget.images[index].id,
),
);
},
scrollPhysics: const BouncingScrollPhysics(),
backgroundDecoration: const BoxDecoration(color: Colors.black),
),
);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
}
3. ArticleThumbnailImage - Kompakte Liste¶
// lib/pages/mobile/widgets/article_thumbnail_image.dart
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../../models/articles/article.dart';
import '../../../models/articles/article_image.dart';
class ArticleThumbnailImage extends StatelessWidget {
final Article article;
final ArticleVariant? variant;
final double size;
final BorderRadius? borderRadius;
const ArticleThumbnailImage({
super.key,
required this.article,
this.variant,
this.size = 80,
this.borderRadius,
});
@override
Widget build(BuildContext context) {
ArticleImage? thumbnailImage;
// Ermittle erstes Bild
if (variant != null && variant!.variantImages.isNotEmpty) {
thumbnailImage = variant!.variantImages.first;
} else if (article.articleImages.isNotEmpty) {
thumbnailImage = article.articleImages.first;
}
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: borderRadius ?? BorderRadius.circular(8),
),
child: thumbnailImage != null
? ClipRRect(
borderRadius: borderRadius ?? BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: thumbnailImage.downloadLink,
fit: BoxFit.cover,
placeholder: (context, url) => const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
errorWidget: (context, url, error) => _buildPlaceholder(),
),
)
: _buildPlaceholder(),
);
}
Widget _buildPlaceholder() {
return Center(
child: Icon(
Icons.image,
size: size * 0.4,
color: Colors.grey.shade400,
),
);
}
}
4. VariantSelector mit Thumbnails¶
// lib/pages/mobile/widgets/variant_selector_with_images.dart
import 'package:flutter/material.dart';
import '../../../models/articles/article.dart';
import '../../../models/articles/article_variant.dart';
import 'article_thumbnail_image.dart';
class VariantSelectorWithImages extends StatelessWidget {
final Article article;
final ArticleVariant? selectedVariant;
final Function(ArticleVariant) onVariantSelected;
const VariantSelectorWithImages({
super.key,
required this.article,
this.selectedVariant,
required this.onVariantSelected,
});
@override
Widget build(BuildContext context) {
if (article.articleVariants.isEmpty) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Varianten',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: article.articleVariants.length,
itemBuilder: (context, index) {
final variant = article.articleVariants[index];
final isSelected = selectedVariant?.id == variant.id;
return Padding(
padding: const EdgeInsets.only(right: 12),
child: GestureDetector(
onTap: () => onVariantSelected(variant),
child: Container(
width: 100,
decoration: BoxDecoration(
border: Border.all(
color: isSelected
? Theme.of(context).primaryColor
: Colors.grey.shade300,
width: isSelected ? 3 : 1,
),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
// Varianten-Thumbnail
ArticleThumbnailImage(
article: article,
variant: variant,
size: 80,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
// Varianten-Name
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.all(4),
child: Text(
variant.name,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
color: isSelected
? Theme.of(context).primaryColor
: Colors.black87,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
),
),
),
],
),
),
),
);
},
),
),
],
);
}
}
📱 Verwendungsbeispiel: Produktdetailseite¶
// lib/pages/mobile/article_detail_page_mobile.dart
import 'package:flutter/material.dart';
import '../../models/articles/article.dart';
import '../../models/articles/article_variant.dart';
import 'widgets/article_image_gallery.dart';
import 'widgets/variant_selector_with_images.dart';
class ArticleDetailPageMobile extends StatefulWidget {
final Article article;
const ArticleDetailPageMobile({
super.key,
required this.article,
});
@override
State<ArticleDetailPageMobile> createState() =>
_ArticleDetailPageMobileState();
}
class _ArticleDetailPageMobileState extends State<ArticleDetailPageMobile> {
ArticleVariant? _selectedVariant;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.article.name),
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Bild-Galerie (passt sich automatisch an Variante an)
ArticleImageGallery(
article: widget.article,
selectedVariant: _selectedVariant,
),
const SizedBox(height: 16),
// Varianten-Auswahl (falls vorhanden)
if (widget.article.articleVariants.isNotEmpty)
VariantSelectorWithImages(
article: widget.article,
selectedVariant: _selectedVariant,
onVariantSelected: (variant) {
setState(() {
_selectedVariant = variant;
});
},
),
const SizedBox(height: 16),
// Produkt-Informationen
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.article.name,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Art.-Nr.: ${widget.article.number}',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
// ... weitere Artikel-Details
],
),
),
],
),
),
);
}
}
🔧 Wichtige Implementierungshinweise¶
1. Bildkompression (bereits implementiert im Backend)¶
Im Web/Desktop erfolgt die Bildkompression vor dem Upload:
// lib/services/image_compression_service.dart
class ImageCompressionService {
static const int maxWidth = 1920;
static const int maxHeight = 1920;
static const int jpegQuality = 85;
Future<ImageCompressionResult> compressImage(Uint8List imageBytes) async {
// Komprimierung mit Transparenz-Support (PNG/JPEG)
// ...
}
}
Für Mobile: Bereits komprimierte Bilder werden geliefert - keine zusätzliche Kompression nötig!
2. Caching-Strategie¶
Empfohlen: cached_network_image Package
Vorteile: - ✅ Automatisches Caching - ✅ Placeholder während Laden - ✅ Error-Handling - ✅ Memory-Management
3. Performance-Optimierungen¶
Listen mit vielen Artikeln:¶
// Batch-Laden von Thumbnails
final thumbnails = await _imageService.getFirstImagesForArticles(
articleIds, // Max. 100 Artikel pro Request
);
Lazy Loading:¶
// Nur erste Bilder in Liste laden, vollständige Galerie erst in Detail-Ansicht
ListView.builder(
itemBuilder: (context, index) {
final article = articles[index];
return ListTile(
leading: ArticleThumbnailImage(article: article, size: 50),
title: Text(article.name),
);
},
);
4. Sortierung beachten!¶
Wichtig: Bilder IMMER nach index sortieren:
// ✅ Richtig
images.sort((a, b) => a.index.compareTo(b.index));
// ❌ Falsch
images // Unsortiert aus Firestore
5. Banner-Feature¶
Prüfung der Gültigkeit:
bool hasActiveBanner(Article article) {
final now = DateTime.now();
// Banner-Daten vorhanden?
if (article.bannerTitleLanguages.isEmpty) return false;
// Im Gültigkeitszeitraum?
if (article.bannerFrom != null && now.isBefore(article.bannerFrom!)) {
return false;
}
if (article.bannerTo != null && now.isAfter(article.bannerTo!)) {
return false;
}
return true;
}
Banner nur auf erstem Bild anzeigen:
📦 Benötigte Dependencies¶
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
# Firebase (bereits vorhanden)
cloud_firestore: ^4.13.0
firebase_storage: ^11.5.0
# Bild-Caching
cached_network_image: ^3.3.0
# Vollbild-Ansicht mit Zoom
photo_view: ^0.14.0
# Optional: Offline-Support
hive: ^2.2.3
hive_flutter: ^1.1.0
🧪 Testing-Szenarien¶
Test-Fälle:¶
- ✅ Artikel ohne Bilder (Fallback-Icon anzeigen)
- ✅ Artikel mit 1 Bild (kein Page-Indicator)
- ✅ Artikel mit mehreren Bildern (Swipe-Funktion)
- ✅ Variante ohne Bilder (Fallback auf Artikelbilder)
- ✅ Variante mit eigenen Bildern (Varianten-Galerie anzeigen)
- ✅ Banner aktiv (Banner auf erstem Bild)
- ✅ Banner abgelaufen (kein Banner anzeigen)
- ✅ Langsame Netzwerkverbindung (Loading-Spinner)
- ✅ Offline-Modus (gecachte Bilder anzeigen)
- ✅ Fehlerhafte Bild-URL (Error-State anzeigen)
🔐 Sicherheit & Zugriffskontrolle¶
Firebase Storage Rules¶
// storage.rules
service firebase.storage {
match /b/{bucket}/o {
match /articleImages/{articleId}/{allPaths=**} {
// Lesen: Für authentifizierte Nutzer erlaubt
allow read: if request.auth != null;
// Schreiben: Nur für privilegierte User (Web/Desktop)
allow write: if request.auth != null
&& request.auth.token.role in ['admin', 'manager'];
}
}
}
Firestore Security Rules¶
// firestore.rules
service cloud.firestore {
match /databases/{database}/documents {
match /articles/{articleId}/articleImages/{imageId} {
// Lesen: Für authentifizierte Nutzer erlaubt
allow read: if request.auth != null;
// Schreiben: Nur für privilegierte User
allow write: if request.auth != null
&& request.auth.token.role in ['admin', 'manager'];
}
}
}
❓ FAQ¶
F: Warum separate Subcollection für Artikelbilder, aber Array für Variantenbilder?¶
A: Performance-Optimierung: - Artikelbilder: Können viele sein (5-20+) → Subcollection ermöglicht Lazy Loading - Variantenbilder: Meist wenige (1-5) → Array ist effizienter und wird mit Variante geladen
F: Wie werden gelöschte Bilder behandelt?¶
A: Im Backend (Web/Desktop) werden sowohl Firestore-Einträge als auch Storage-Files gelöscht:
// 1. Firestore-Dokument löschen
await firestore.collection('articles/$articleId/articleImages').doc(imageId).delete();
// 2. Storage-File löschen
await storage.ref().child(storageFilePath).delete();
F: Was passiert bei Netzwerkfehlern?¶
A: cached_network_image liefert:
1. Gecachtes Bild (falls vorhanden)
2. Error-Widget (falls nicht gecacht)
3. Retry-Mechanismus automatisch
F: Wie groß sind die komprimierten Bilder?¶
A: Nach Kompression: - Max. Auflösung: 1920x1920 Pixel - JPEG-Qualität: 85% - Durchschnittliche Größe: 200-500 KB pro Bild
📚 Weiterführende Dokumentation¶
- IMAGE_COMPRESSION_README.md - Details zur Bildkompression
- ARTICLE_DOCUMENTS_MOBILE_APP.md - Ähnliche Integration für Dokumente
- Firebase Storage Best Practices
- cached_network_image Documentation
✅ Checkliste für Integration¶
- [ ] Dependencies installiert (
cached_network_image,photo_view) - [ ] Service Layer implementiert (
ArticleImageMobileService) - [ ] UI-Komponenten erstellt (
ArticleImageGallery, etc.) - [ ] Caching konfiguriert
- [ ] Banner-Logik integriert
- [ ] Error-Handling implementiert
- [ ] Testing durchgeführt
- [ ] Performance optimiert (Lazy Loading, Batch Loading)
- [ ] Offline-Support getestet
- [ ] Security Rules aktualisiert
Letzte Aktualisierung: 5. Februar 2026
Version: 1.0.0
📱 Article Variants - Mobile App Integration Guide¶
📋 Übersicht¶
Diese Dokumentation beschreibt die vollständige Integration der Artikelvarianten-Funktionalität in die Mobile App. Artikelvarianten ermöglichen es, verschiedene Ausführungen eines Produkts (z.B. unterschiedliche Größen, Farben, Konfigurationen) mit individuellen Preisen, Mengen und Bildern zu verwalten. Kunden können in der Mobile App zwischen Varianten wählen und diese bestellen.
🎯 Anforderungen & Scope¶
Was Mobile App User können sollen:¶
- ✅ Alle Varianten eines Artikels anzeigen
- ✅ Variantendetails sehen (Name, Nummer, Preis, Menge)
- ✅ Verfügbarkeitsstatus pro Variante prüfen
- ✅ Variantenbilder anzeigen (falls vorhanden)
- ✅ Zwischen Varianten wechseln und auswählen
- ✅ Variante in den Warenkorb legen
- ✅ Preisunterschiede zwischen Varianten erkennen
- ✅ "Im Shop anzeigen" Status beachten (nur sichtbare Varianten anzeigen)
- ✅ In Listen/Übersichten Varianten-Thumbnails anzeigen
Was Mobile App User NICHT können:¶
- ❌ Varianten erstellen/hinzufügen
- ❌ Varianten bearbeiten
- ❌ Varianten löschen
- ❌ Preise ändern
- ❌ Verfügbarkeit verwalten
- ❌ Variantenbilder hochladen
🏗️ Architektur & Datenmodell¶
1. Datenmodell¶
ArticleVariant Model¶
// lib/models/articles/article_variant.dart
class ArticleVariant {
final String id; // Eindeutige ID (UUID)
final String name; // Variantenname (z.B. "Groß", "Rot")
final String number; // Artikelnummer der Variante
final double price; // Preis der Variante
final double quantity; // Menge/Inhalt (z.B. 1.0, 2.5)
final bool isAvailable; // Verfügbar/Nicht verfügbar
final bool showInShop; // Im Shop anzeigen
final List<ArticleImage> variantImages; // Eigene Bilder für Variante
ArticleVariant({
required this.id,
required this.name,
required this.number,
required this.price,
this.quantity = 1.0,
this.isAvailable = true,
this.showInShop = true,
this.variantImages = const [],
});
// Deserialisierung aus Firestore
factory ArticleVariant.fromMap(String id, Map<String, dynamic> json) {
// Parse variant images if they exist
final List<ArticleImage> images = [];
if (json['variantImages'] != null) {
final imagesData = json['variantImages'] as List;
for (var imageData in imagesData) {
images.add(ArticleImage.fromMap(imageData));
}
// WICHTIG: Sortiere Bilder nach Index!
images.sort((a, b) => a.index.compareTo(b.index));
}
return ArticleVariant(
id: id,
name: json['name'] ?? '',
number: json['number'] ?? '',
price: (json['price'] ?? 0).toDouble(),
quantity: (json['quantity'] ?? 1.0).toDouble(),
isAvailable: json['isAvailable'] ?? true,
showInShop: json['showInShop'] ?? true,
variantImages: images,
);
}
// Serialisierung für Firestore
Map<String, dynamic> toMap() {
return {
'name': name,
'number': number,
'price': price,
'quantity': quantity,
'isAvailable': isAvailable,
'showInShop': showInShop,
'variantImages': variantImages.map((img) => img.toMap()).toList(),
};
}
// copyWith für Immutability
ArticleVariant copyWith({
String? id,
String? name,
String? number,
double? price,
double? quantity,
bool? isAvailable,
bool? showInShop,
List<ArticleImage>? variantImages,
}) {
return ArticleVariant(
id: id ?? this.id,
name: name ?? this.name,
number: number ?? this.number,
price: price ?? this.price,
quantity: quantity ?? this.quantity,
isAvailable: isAvailable ?? this.isAvailable,
showInShop: showInShop ?? this.showInShop,
variantImages: variantImages ?? this.variantImages,
);
}
}
Article Model (Auszug)¶
// lib/models/articles/article.dart
class Article extends EntityBase {
final String id;
final String number; // Haupt-Artikelnummer
final String name; // Haupt-Artikelname
final List<ArticleImage> articleImages; // Haupt-Artikelbilder
final List<ArticleVariant> articleVariants; // ← Liste aller Varianten
// ... weitere Felder
}
2. Datenstruktur in Firestore¶
Speicherung im Article Document¶
/articles/{articleId}
├─ id: string
├─ number: string
├─ name: string
├─ isAvailable: boolean
├─ articleVariants: map {
│ {variantId_1}: {
│ name: "Standard"
│ number: "ART-12345-01"
│ price: 99.99
│ quantity: 1.0
│ isAvailable: true
│ showInShop: true
│ variantImages: [
│ {
│ id: "img-uuid"
│ index: 0
│ downloadLink: "https://..."
│ storageFilePath: "articleImages/..."
│ }
│ ]
│ }
│ {variantId_2}: {
│ name: "Groß"
│ number: "ART-12345-02"
│ price: 129.99
│ quantity: 2.0
│ isAvailable: true
│ showInShop: true
│ variantImages: [...]
│ }
└─ }
Wichtige Eigenschaften: - ✅ Varianten sind direkt im Article-Dokument als Map gespeichert - ✅ Jede Variante hat eine eindeutige UUID als Key - ✅ Variantenbilder sind als Array in der Variante enthalten - ✅ Keine separate Subcollection (anders als bei Artikelbildern)
3. Beziehung: Artikel ↔ Varianten¶
┌─────────────────────────────────────────────────┐
│ Article (Hauptartikel) │
├─────────────────────────────────────────────────┤
│ • Nummer: "ART-12345" │
│ • Name: "Premium Widget" │
│ • Bilder: [img1.jpg, img2.jpg] │
│ • isAvailable: true (Haupt-Status) │
└──────────────────┬──────────────────────────────┘
│
┌──────────┴──────────┐
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Variante 1 │ │ Variante 2 │
├───────────────┤ ├───────────────┤
│ • Name: "S" │ │ • Name: "L" │
│ • Nr: "-01" │ │ • Nr: "-02" │
│ • Preis: €99 │ │ • Preis: €129 │
│ • Menge: 1.0 │ │ • Menge: 2.0 │
│ • Bilder: [1] │ │ • Bilder: [2] │
│ • verfügbar │ │ • verfügbar │
│ • im Shop ✓ │ │ • im Shop ✓ │
└───────────────┘ └───────────────┘
📡 Service Layer für Mobile App¶
ArticleVariantMobileService¶
// lib/services/mobile/article_variant_mobile_service.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../models/articles/article.dart';
import '../../models/articles/article_variant.dart';
import '../../models/articles/article_image.dart';
class ArticleVariantMobileService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
/// Lädt alle Varianten eines Artikels (bereits im Article enthalten)
List<ArticleVariant> getVariants(Article article) {
return article.articleVariants;
}
/// Filtert nur verfügbare Varianten
List<ArticleVariant> getAvailableVariants(Article article) {
return article.articleVariants
.where((variant) => variant.isAvailable)
.toList();
}
/// Filtert nur Varianten, die im Shop angezeigt werden sollen
List<ArticleVariant> getShopVisibleVariants(Article article) {
return article.articleVariants
.where((variant) => variant.showInShop)
.toList();
}
/// Filtert Varianten die sowohl verfügbar als auch im Shop sichtbar sind
/// Dies ist die empfohlene Methode für die Mobile App
List<ArticleVariant> getShoppableVariants(Article article) {
return article.articleVariants
.where((variant) => variant.isAvailable && variant.showInShop)
.toList();
}
/// Holt eine spezifische Variante nach ID
ArticleVariant? getVariantById(Article article, String variantId) {
try {
return article.articleVariants.firstWhere(
(variant) => variant.id == variantId,
);
} catch (e) {
debugPrint('Variant not found: $variantId');
return null;
}
}
/// Findet Variante nach Nummer (falls bekannt)
ArticleVariant? getVariantByNumber(Article article, String number) {
try {
return article.articleVariants.firstWhere(
(variant) => variant.number == number,
);
} catch (e) {
debugPrint('Variant not found by number: $number');
return null;
}
}
/// Holt die günstigste Variante
ArticleVariant? getCheapestVariant(Article article) {
final shoppableVariants = getShoppableVariants(article);
if (shoppableVariants.isEmpty) return null;
return shoppableVariants.reduce((current, next) {
return current.price < next.price ? current : next;
});
}
/// Holt die teuerste Variante
ArticleVariant? getMostExpensiveVariant(Article article) {
final shoppableVariants = getShoppableVariants(article);
if (shoppableVariants.isEmpty) return null;
return shoppableVariants.reduce((current, next) {
return current.price > next.price ? current : next;
});
}
/// Berechnet die Preisspanne (min - max)
String getPriceRange(Article article) {
final shoppableVariants = getShoppableVariants(article);
if (shoppableVariants.isEmpty) return '€0.00';
if (shoppableVariants.length == 1) {
return '€${shoppableVariants.first.price.toStringAsFixed(2)}';
}
final cheapest = getCheapestVariant(article)!;
final mostExpensive = getMostExpensiveVariant(article)!;
if (cheapest.price == mostExpensive.price) {
return '€${cheapest.price.toStringAsFixed(2)}';
}
return '€${cheapest.price.toStringAsFixed(2)} - €${mostExpensive.price.toStringAsFixed(2)}';
}
/// Holt die erste verfügbare Variante (als Standard-Auswahl)
ArticleVariant? getDefaultVariant(Article article) {
final shoppableVariants = getShoppableVariants(article);
return shoppableVariants.isNotEmpty ? shoppableVariants.first : null;
}
/// Holt das erste Bild einer Variante
ArticleImage? getVariantThumbnail(ArticleVariant variant) {
if (variant.variantImages.isEmpty) return null;
// Bilder sind bereits nach Index sortiert (siehe ArticleVariant.fromMap)
return variant.variantImages.first;
}
/// Prüft ob Artikel Varianten hat
bool hasVariants(Article article) {
return article.articleVariants.isNotEmpty;
}
/// Prüft ob Artikel mehrere Varianten hat
bool hasMultipleVariants(Article article) {
return article.articleVariants.length > 1;
}
/// Gruppiert Varianten nach Verfügbarkeit
Map<String, List<ArticleVariant>> groupByAvailability(Article article) {
final Map<String, List<ArticleVariant>> grouped = {
'available': [],
'unavailable': [],
};
for (final variant in article.articleVariants) {
if (variant.isAvailable && variant.showInShop) {
grouped['available']!.add(variant);
} else {
grouped['unavailable']!.add(variant);
}
}
return grouped;
}
/// Sortiert Varianten nach Preis (aufsteigend)
List<ArticleVariant> sortByPriceAsc(List<ArticleVariant> variants) {
final sorted = List<ArticleVariant>.from(variants);
sorted.sort((a, b) => a.price.compareTo(b.price));
return sorted;
}
/// Sortiert Varianten nach Preis (absteigend)
List<ArticleVariant> sortByPriceDesc(List<ArticleVariant> variants) {
final sorted = List<ArticleVariant>.from(variants);
sorted.sort((a, b) => b.price.compareTo(a.price));
return sorted;
}
/// Sortiert Varianten alphabetisch nach Name
List<ArticleVariant> sortByName(List<ArticleVariant> variants) {
final sorted = List<ArticleVariant>.from(variants);
sorted.sort((a, b) => a.name.compareTo(b.name));
return sorted;
}
/// Formatiert Preis für Anzeige
String formatPrice(double price, {String currency = '€'}) {
return '$currency${price.toStringAsFixed(2)}';
}
/// Formatiert Menge mit Einheit
String formatQuantity(
ArticleVariant variant,
ArticleQuantityType quantityType,
) {
return '${variant.quantity.toStringAsFixed(2)} ${quantityType.shortName}';
}
}
🎨 UI-Komponenten für Mobile App¶
1. VariantSelector - Kompakte Variantenauswahl¶
// lib/pages/mobile/widgets/variant_selector.dart
import 'package:flutter/material.dart';
import '../../../models/articles/article.dart';
import '../../../models/articles/article_variant.dart';
import '../../../services/mobile/article_variant_mobile_service.dart';
class VariantSelector extends StatelessWidget {
final Article article;
final ArticleVariant? selectedVariant;
final Function(ArticleVariant) onVariantSelected;
final bool showImages;
final bool compactMode;
const VariantSelector({
super.key,
required this.article,
this.selectedVariant,
required this.onVariantSelected,
this.showImages = true,
this.compactMode = false,
});
@override
Widget build(BuildContext context) {
final variantService = ArticleVariantMobileService();
final variants = variantService.getShoppableVariants(article);
if (variants.isEmpty) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Variante wählen',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
if (variants.length > 1)
Text(
'${variants.length} Optionen',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
],
),
),
if (compactMode)
_buildCompactList(context, variants, variantService)
else
_buildExpandedList(context, variants, variantService),
],
);
}
Widget _buildCompactList(
BuildContext context,
List<ArticleVariant> variants,
ArticleVariantMobileService variantService,
) {
return SizedBox(
height: 60,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: variants.length,
itemBuilder: (context, index) {
final variant = variants[index];
final isSelected = selectedVariant?.id == variant.id;
return Padding(
padding: const EdgeInsets.only(right: 12),
child: _buildVariantChip(
context,
variant,
isSelected,
variantService,
),
);
},
),
);
}
Widget _buildExpandedList(
BuildContext context,
List<ArticleVariant> variants,
ArticleVariantMobileService variantService,
) {
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: variants.length,
itemBuilder: (context, index) {
final variant = variants[index];
final isSelected = selectedVariant?.id == variant.id;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildVariantCard(
context,
variant,
isSelected,
variantService,
),
);
},
);
}
Widget _buildVariantChip(
BuildContext context,
ArticleVariant variant,
bool isSelected,
ArticleVariantMobileService variantService,
) {
return GestureDetector(
onTap: () => onVariantSelected(variant),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).primaryColor
: Colors.grey.shade200,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected
? Theme.of(context).primaryColor
: Colors.grey.shade300,
width: 2,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
variant.name,
style: TextStyle(
fontSize: 14,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? Colors.white : Colors.black87,
),
),
const SizedBox(width: 8),
Text(
variantService.formatPrice(variant.price),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: isSelected ? Colors.white : Colors.black87,
),
),
],
),
),
);
}
Widget _buildVariantCard(
BuildContext context,
ArticleVariant variant,
bool isSelected,
ArticleVariantMobileService variantService,
) {
final thumbnail = variantService.getVariantThumbnail(variant);
return GestureDetector(
onTap: () => onVariantSelected(variant),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? Theme.of(context).primaryColor
: Colors.grey.shade300,
width: isSelected ? 3 : 1,
),
boxShadow: [
if (isSelected)
BoxShadow(
color: Theme.of(context).primaryColor.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
// Thumbnail
if (showImages && thumbnail != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
thumbnail.downloadLink,
width: 60,
height: 60,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return _buildPlaceholderIcon();
},
),
)
else if (showImages)
_buildPlaceholderIcon(),
if (showImages) const SizedBox(width: 12),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
variant.name,
style: TextStyle(
fontSize: 16,
fontWeight:
isSelected ? FontWeight.bold : FontWeight.w600,
color: isSelected
? Theme.of(context).primaryColor
: Colors.black87,
),
),
const SizedBox(height: 4),
Text(
'Art.-Nr.: ${variant.number}',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 4),
Text(
variantService.formatQuantity(
variant,
article.articleQuantityType,
),
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
),
// Preis & Status
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
variantService.formatPrice(variant.price),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: isSelected
? Theme.of(context).primaryColor
: Colors.black87,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: variant.isAvailable
? Colors.green.withOpacity(0.1)
: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
variant.isAvailable ? 'Verfügbar' : 'Nicht verfügbar',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: variant.isAvailable ? Colors.green : Colors.red,
),
),
),
],
),
// Checkmark
if (isSelected) ...[
const SizedBox(width: 8),
Icon(
Icons.check_circle,
color: Theme.of(context).primaryColor,
size: 28,
),
],
],
),
),
);
}
Widget _buildPlaceholderIcon() {
return Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.inventory_2,
size: 30,
color: Colors.grey.shade400,
),
);
}
}
2. VariantPriceDisplay - Preisanzeige in Listen¶
// lib/pages/mobile/widgets/variant_price_display.dart
import 'package:flutter/material.dart';
import '../../../models/articles/article.dart';
import '../../../services/mobile/article_variant_mobile_service.dart';
class VariantPriceDisplay extends StatelessWidget {
final Article article;
final TextStyle? style;
final bool showRange;
const VariantPriceDisplay({
super.key,
required this.article,
this.style,
this.showRange = true,
});
@override
Widget build(BuildContext context) {
final variantService = ArticleVariantMobileService();
if (!variantService.hasVariants(article)) {
return Text(
'€0.00',
style: style ?? const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
);
}
if (!showRange || !variantService.hasMultipleVariants(article)) {
final firstVariant = variantService.getDefaultVariant(article);
if (firstVariant == null) {
return Text(
'Nicht verfügbar',
style: style?.copyWith(color: Colors.grey) ?? TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.grey.shade600,
),
);
}
return Text(
variantService.formatPrice(firstVariant.price),
style: style ?? const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
);
}
// Zeige Preisspanne
final priceRange = variantService.getPriceRange(article);
return Text(
priceRange,
style: style ?? const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
);
}
}
3. VariantAvailabilityBadge - Status-Badge¶
// lib/pages/mobile/widgets/variant_availability_badge.dart
import 'package:flutter/material.dart';
import '../../../models/articles/article_variant.dart';
class VariantAvailabilityBadge extends StatelessWidget {
final ArticleVariant variant;
final bool compact;
const VariantAvailabilityBadge({
super.key,
required this.variant,
this.compact = false,
});
@override
Widget build(BuildContext context) {
if (!variant.showInShop) {
return _buildBadge(
'Nicht im Shop',
Colors.grey,
Icons.visibility_off,
);
}
if (!variant.isAvailable) {
return _buildBadge(
'Nicht verfügbar',
Colors.red,
Icons.cancel,
);
}
return _buildBadge(
'Verfügbar',
Colors.green,
Icons.check_circle,
);
}
Widget _buildBadge(String label, Color color, IconData icon) {
return Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 6 : 10,
vertical: compact ? 3 : 6,
),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(compact ? 8 : 12),
border: Border.all(
color: color.withOpacity(0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: compact ? 12 : 14,
color: color,
),
SizedBox(width: compact ? 4 : 6),
Text(
label,
style: TextStyle(
fontSize: compact ? 10 : 12,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}
}
4. VariantComparisonSheet - Bottom Sheet Vergleich¶
// lib/pages/mobile/widgets/variant_comparison_sheet.dart
import 'package:flutter/material.dart';
import '../../../models/articles/article.dart';
import '../../../models/articles/article_variant.dart';
import '../../../services/mobile/article_variant_mobile_service.dart';
import 'variant_availability_badge.dart';
class VariantComparisonSheet extends StatelessWidget {
final Article article;
final Function(ArticleVariant) onVariantSelected;
const VariantComparisonSheet({
super.key,
required this.article,
required this.onVariantSelected,
});
static Future<void> show(
BuildContext context, {
required Article article,
required Function(ArticleVariant) onVariantSelected,
}) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => VariantComparisonSheet(
article: article,
onVariantSelected: onVariantSelected,
),
);
}
@override
Widget build(BuildContext context) {
final variantService = ArticleVariantMobileService();
final variants = variantService.getShoppableVariants(article);
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.8,
),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(
top: Radius.circular(20),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle
Container(
margin: const EdgeInsets.only(top: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
// Header
Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Icon(
Icons.compare_arrows,
color: Theme.of(context).primaryColor,
size: 28,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Varianten vergleichen',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${variants.length} Optionen verfügbar',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
),
Divider(height: 1, color: Colors.grey.shade300),
// Variants List
Flexible(
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: variants.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final variant = variants[index];
return _buildComparisonCard(
context,
variant,
variantService,
);
},
),
),
],
),
);
}
Widget _buildComparisonCard(
BuildContext context,
ArticleVariant variant,
ArticleVariantMobileService variantService,
) {
return GestureDetector(
onTap: () {
onVariantSelected(variant);
Navigator.pop(context);
},
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.shade300,
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
variant.name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
Text(
variantService.formatPrice(variant.price),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
],
),
const SizedBox(height: 8),
_buildInfoRow(
Icons.tag,
'Artikelnummer',
variant.number,
),
const SizedBox(height: 6),
_buildInfoRow(
Icons.inventory,
'Menge',
variantService.formatQuantity(
variant,
article.articleQuantityType,
),
),
const SizedBox(height: 12),
VariantAvailabilityBadge(variant: variant),
],
),
),
);
}
Widget _buildInfoRow(IconData icon, String label, String value) {
return Row(
children: [
Icon(
icon,
size: 16,
color: Colors.grey.shade600,
),
const SizedBox(width: 8),
Text(
'$label: ',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
);
}
}
📱 Verwendungsbeispiele¶
Beispiel 1: Produktdetailseite mit Variantenauswahl¶
// lib/pages/mobile/article_detail_page_mobile.dart
import 'package:flutter/material.dart';
import '../../models/articles/article.dart';
import '../../models/articles/article_variant.dart';
import '../../services/mobile/article_variant_mobile_service.dart';
import 'widgets/variant_selector.dart';
import 'widgets/variant_price_display.dart';
import 'widgets/article_image_gallery.dart';
class ArticleDetailPageMobile extends StatefulWidget {
final Article article;
const ArticleDetailPageMobile({
super.key,
required this.article,
});
@override
State<ArticleDetailPageMobile> createState() =>
_ArticleDetailPageMobileState();
}
class _ArticleDetailPageMobileState extends State<ArticleDetailPageMobile> {
final _variantService = ArticleVariantMobileService();
ArticleVariant? _selectedVariant;
@override
void initState() {
super.initState();
// Wähle die erste verfügbare Variante als Standard
_selectedVariant = _variantService.getDefaultVariant(widget.article);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.article.name),
actions: [
IconButton(
icon: const Icon(Icons.compare_arrows),
onPressed: _showVariantComparison,
),
],
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Artikelbilder (oder Variantenbilder falls Variante ausgewählt)
ArticleImageGallery(
article: widget.article,
selectedVariant: _selectedVariant,
),
const SizedBox(height: 16),
// Produktname & Preis
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.article.name,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
if (_selectedVariant != null)
Text(
'Variante: ${_selectedVariant!.name}',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade700,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
VariantPriceDisplay(
article: widget.article,
showRange: false,
),
],
),
),
const SizedBox(height: 24),
// Variantenauswahl
if (_variantService.hasVariants(widget.article))
VariantSelector(
article: widget.article,
selectedVariant: _selectedVariant,
onVariantSelected: (variant) {
setState(() {
_selectedVariant = variant;
});
},
showImages: true,
compactMode: false,
),
const SizedBox(height: 24),
// Beschreibung
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Beschreibung',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
widget.article.descriptionLanguages[LanguageEnum.german] ??
'Keine Beschreibung verfügbar',
style: const TextStyle(
fontSize: 14,
height: 1.5,
),
),
],
),
),
const SizedBox(height: 24),
],
),
),
bottomNavigationBar: _buildBottomBar(),
);
}
Widget _buildBottomBar() {
if (_selectedVariant == null) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: const Center(
child: Text(
'Keine Variante verfügbar',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
),
);
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: Row(
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_variantService.formatPrice(_selectedVariant!.price),
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
Text(
_selectedVariant!.name,
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: _selectedVariant!.isAvailable
? () => _addToCart(_selectedVariant!)
: null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'In den Warenkorb',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
);
}
void _showVariantComparison() {
VariantComparisonSheet.show(
context,
article: widget.article,
onVariantSelected: (variant) {
setState(() {
_selectedVariant = variant;
});
},
);
}
void _addToCart(ArticleVariant variant) {
// TODO: Implementiere Warenkorb-Logik
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${variant.name} wurde in den Warenkorb gelegt'),
backgroundColor: Colors.green,
),
);
}
}
Beispiel 2: Produktliste mit Preisspanne¶
// lib/pages/mobile/article_list_tile_mobile.dart
import 'package:flutter/material.dart';
import '../../models/articles/article.dart';
import '../../services/mobile/article_variant_mobile_service.dart';
import 'widgets/variant_price_display.dart';
import 'widgets/article_thumbnail_image.dart';
class ArticleListTileMobile extends StatelessWidget {
final Article article;
final VoidCallback onTap;
const ArticleListTileMobile({
super.key,
required this.article,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final variantService = ArticleVariantMobileService();
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
// Thumbnail
ArticleThumbnailImage(
article: article,
size: 80,
),
const SizedBox(width: 12),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
article.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'Art.-Nr.: ${article.number}',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 8),
// Preis oder Preisspanne
VariantPriceDisplay(
article: article,
showRange: true,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
// Varianten-Info
if (variantService.hasMultipleVariants(article))
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'${article.articleVariants.length} Varianten',
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
),
),
),
],
),
),
// Arrow
Icon(
Icons.chevron_right,
color: Colors.grey.shade400,
),
],
),
),
);
}
}
🔧 Wichtige Implementierungshinweise¶
1. Varianten-Filterung¶
Wichtig: In der Mobile App sollten nur kaufbare Varianten angezeigt werden:
// ✅ Richtig: Nur verfügbare & sichtbare Varianten
final variants = variantService.getShoppableVariants(article);
// ❌ Falsch: Alle Varianten (inkl. deaktivierte)
final variants = article.articleVariants;
2. Standard-Variante¶
Bei Artikeln mit Varianten sollte immer eine Variante vorausgewählt sein:
@override
void initState() {
super.initState();
// Erste verfügbare Variante als Standard
_selectedVariant = _variantService.getDefaultVariant(widget.article);
}
3. Preisanzeige in Listen¶
Bei mehreren Varianten: Preisspanne anzeigen
Bei einer Variante: Einzelpreis anzeigen
4. Bilder-Logik¶
Priorität für Bildanzeige: 1. Variantenbilder (falls Variante ausgewählt und Bilder vorhanden) 2. Artikelbilder (als Fallback) 3. Placeholder-Icon (wenn keine Bilder)
ArticleImage? getThumbnail(Article article, ArticleVariant? selectedVariant) {
// 1. Variantenbilder prüfen
if (selectedVariant != null && selectedVariant.variantImages.isNotEmpty) {
return selectedVariant.variantImages.first;
}
// 2. Artikelbilder als Fallback
if (article.articleImages.isNotEmpty) {
return article.articleImages.first;
}
// 3. Kein Bild vorhanden
return null;
}
5. Warenkorb-Integration¶
Wichtig: Im Warenkorb muss die Varianten-ID gespeichert werden, nicht die Artikel-ID!
// ✅ Richtig
CartItem(
articleId: article.id,
variantId: selectedVariant.id, // ← Varianten-ID speichern!
quantity: 1,
price: selectedVariant.price,
);
// ❌ Falsch - Variante fehlt
CartItem(
articleId: article.id,
// Ohne variantId kann nicht zugeordnet werden!
quantity: 1,
);
6. Verfügbarkeitsprüfung¶
bool canOrder(Article article, ArticleVariant variant) {
// Artikel muss verfügbar sein
if (!article.isAvailable) return false;
// Variante muss verfügbar sein
if (!variant.isAvailable) return false;
// Variante muss im Shop angezeigt werden
if (!variant.showInShop) return false;
return true;
}
7. Performance-Optimierung¶
Batch-Laden von Artikeln mit Varianten:
// Firestore lädt Varianten automatisch mit dem Article-Dokument
// Kein extra Query nötig - Varianten sind bereits enthalten!
final article = await firestore.collection('articles').doc(articleId).get();
final variants = article.data()!['articleVariants']; // ← Bereits geladen
🧪 Testing-Szenarien¶
Test-Fälle für Varianten:¶
- ✅ Artikel ohne Varianten (Fallback-Verhalten)
- ✅ Artikel mit 1 Variante (keine Auswahl nötig)
- ✅ Artikel mit mehreren Varianten (Auswahl-UI anzeigen)
- ✅ Variante ohne Bilder (Artikelbilder als Fallback)
- ✅ Variante mit eigenen Bildern (Varianten-Galerie)
- ✅ Nicht verfügbare Variante (ausgegraut, nicht bestellbar)
- ✅
showInShop: false(Variante nicht anzeigen) - ✅ Preisspanne (günstigste bis teuerste Variante)
- ✅ Variantenwechsel (Bilder & Preis aktualisieren)
- ✅ Warenkorb mit Varianten (richtige Zuordnung)
📊 Datenbeispiel¶
Vollständiges Beispiel eines Artikels mit Varianten¶
{
"id": "article-123",
"number": "ART-12345",
"name": "Premium Widget",
"isAvailable": true,
"showInShop": true,
"articleImages": [
{
"id": "img-1",
"index": 0,
"downloadLink": "https://storage.../main1.jpg",
"storageFilePath": "articleImages/article-123/img1.jpg"
}
],
"articleVariants": {
"variant-uuid-1": {
"name": "Klein",
"number": "ART-12345-S",
"price": 99.99,
"quantity": 1.0,
"isAvailable": true,
"showInShop": true,
"variantImages": [
{
"id": "vimg-1",
"index": 0,
"downloadLink": "https://storage.../klein.jpg",
"storageFilePath": "articleImages/article-123/variants/variant-uuid-1/img1.jpg"
}
]
},
"variant-uuid-2": {
"name": "Mittel",
"number": "ART-12345-M",
"price": 119.99,
"quantity": 1.5,
"isAvailable": true,
"showInShop": true,
"variantImages": []
},
"variant-uuid-3": {
"name": "Groß",
"number": "ART-12345-L",
"price": 149.99,
"quantity": 2.0,
"isAvailable": false,
"showInShop": false,
"variantImages": []
}
}
}
In diesem Beispiel: - 3 Varianten vorhanden - Variante "Klein" hat eigene Bilder - Variante "Groß" ist nicht verfügbar und nicht im Shop → wird in Mobile App nicht angezeigt - Preisspanne: €99.99 - €119.99 (ohne "Groß")
🔐 Sicherheit & Validation¶
Client-seitige Validierung¶
class VariantValidator {
/// Prüft ob Variante bestellbar ist
static bool isOrderable(Article article, ArticleVariant variant) {
// Artikel-Level Check
if (!article.isAvailable || !article.showInShop) {
return false;
}
// Varianten-Level Check
if (!variant.isAvailable || !variant.showInShop) {
return false;
}
// Preis-Check
if (variant.price <= 0) {
return false;
}
return true;
}
/// Prüft ob Variante existiert
static bool variantExists(Article article, String variantId) {
return article.articleVariants.any((v) => v.id == variantId);
}
/// Validiert Menge
static bool isValidQuantity(double quantity, ArticleVariant variant) {
return quantity > 0 && quantity <= variant.quantity * 1000; // Max-Limit
}
}
Firestore Security Rules¶
// firestore.rules
service cloud.firestore {
match /databases/{database}/documents {
match /articles/{articleId} {
// Lesen: Für authentifizierte Nutzer erlaubt
allow read: if request.auth != null;
// Schreiben: Nur für privilegierte User
allow write: if request.auth != null
&& request.auth.token.role in ['admin', 'manager'];
// Validierung: Varianten müssen valide sein
allow update: if validateArticleVariants(request.resource.data);
}
}
}
function validateArticleVariants(data) {
return data.articleVariants is map
&& data.articleVariants.size() <= 50; // Max 50 Varianten
}
❓ FAQ¶
F: Warum sind Varianten als Map gespeichert, nicht als Array?¶
A: Performance und Eindeutigkeit: - Map: Direkter Zugriff via ID, keine Duplikate möglich - Array: Müsste durchsucht werden, Duplikate möglich - Update-Logik ist mit Map einfacher (keine Array-Indizes)
F: Was passiert, wenn ein Artikel keine Varianten hat?¶
A: articleVariants ist ein leeres Array []. Die App sollte einen Fallback haben:
if (!variantService.hasVariants(article)) {
// Zeige Artikel ohne Variantenauswahl
return ArticleWithoutVariants(article: article);
}
F: Können Varianten unterschiedliche Bilder haben?¶
A: Ja! Jede Variante hat ihr eigenes variantImages Array. Falls leer, zeige Artikelbilder als Fallback.
F: Wie wird der Preis in einer Bestellung gespeichert?¶
A: Zum Zeitpunkt der Bestellung wird der Preis der Variante in die Bestellung übernommen (Snapshot). Spätere Preisänderungen beeinflussen alte Bestellungen nicht.
F: Was ist mit Varianten-Kombinationen (z.B. Farbe + Größe)?¶
A: Aktuell: Jede Kombination ist eine eigene Variante: - "Rot - Klein" → Variante 1 - "Rot - Groß" → Variante 2 - "Blau - Klein" → Variante 3 - etc.
Zukünftig könnte ein Varianten-Attribut-System implementiert werden.
📚 Weiterführende Dokumentation¶
- ARTICLE_IMAGES_MOBILE_APP.md - Integration der Artikelbilder
- ARTICLE_DOCUMENTS_MOBILE_APP.md - Integration der Artikeldokumente
- Firestore Data Modeling Best Practices
✅ Checkliste für Integration¶
- [ ]
ArticleVariantModel verstanden - [ ]
ArticleVariantMobileServiceimplementiert - [ ] UI-Komponenten erstellt (
VariantSelector, etc.) - [ ] Filterung nach
isAvailable&&showInShopimplementiert - [ ] Standard-Variante wird vorausgewählt
- [ ] Preisanzeige/-spanne funktioniert
- [ ] Bilder-Fallback (Variante → Artikel → Placeholder)
- [ ] Warenkorb speichert Varianten-ID
- [ ] Validation implementiert
- [ ] Testing durchgeführt
- [ ] Offline-Support getestet (Varianten gecacht)
Letzte Aktualisierung: 5. Februar 2026
Version: 1.0.0