📱 Article Documents - Mobile App Integration Guide¶
📋 Übersicht¶
Diese Dokumentation beschreibt die vollständige Integration der Artikel-Dokumente-Funktionalität in die Mobile App. Kunden können öffentliche Dokumente (Datenblätter, Handbücher, etc.) zu Artikeln ansehen, herunterladen und offline verfügbar machen.
🎯 Anforderungen & Scope¶
Was Mobile App User können sollen:¶
- ✅ Liste aller öffentlichen Dokumente eines Artikels sehen
- ✅ Dokumente nach Typ/Kategorie filtern
- ✅ Dokumente nach Sprache filtern
- ✅ Dokumente nach Länder-Freigabe filtern
- ✅ PDF-Dokumente direkt in der App anzeigen
- ✅ Dokumente herunterladen (für Offline-Nutzung)
- ✅ Dokumente teilen (Share-Funktion)
- ✅ Download-Status & -Fortschritt anzeigen
Was Mobile App User NICHT können:¶
- ❌ Dokumente hochladen/erstellen
- ❌ Dokumente bearbeiten/löschen
- ❌ Private Dokumente sehen (nur
isPublic: true) - ❌ Dokumente versionieren
🏗️ Architektur¶
1. Model (bereits vorhanden)¶
Das ArticleDocument Model kann 1:1 wiederverwendet werden:
- lib/models/articles/article_document.dart
- lib/models/articles/article_document_type.dart
2. Service Layer¶
ArticleDocumentService (Mobile-spezifisch)¶
// lib/services/mobile/article_document_mobile_service.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../models/articles/article_document.dart';
import '../../models/articles/article_document_type.dart';
import '../../models/all_enums/country_enum.dart';
import '../../models/all_enums/language_enum.dart';
class ArticleDocumentMobileService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
/// Lädt nur öffentliche Dokumente eines Artikels
Stream<List<ArticleDocument>> streamPublicDocuments(String articleId) {
return _firestore
.collection('articles')
.doc(articleId)
.collection('articleDocuments')
.where('isPublic', isEqualTo: true)
.where('isActive', isEqualTo: true) // Nur aktive Dokumente
.orderBy('index')
.snapshots()
.map((snapshot) {
return snapshot.docs
.map((doc) => ArticleDocument.fromMap(doc.id, doc.data()))
.where((doc) => _isCurrentlyValid(doc)) // Nur gültige Dokumente
.toList();
});
}
/// Prüft ob Dokument aktuell gültig ist (validFrom/validUntil)
bool _isCurrentlyValid(ArticleDocument document) {
final now = DateTime.now();
if (document.validFrom != null && now.isBefore(document.validFrom!)) {
return false; // Noch nicht gültig
}
if (document.validUntil != null && now.isAfter(document.validUntil!)) {
return false; // Abgelaufen
}
return true;
}
/// Filtert Dokumente nach Land
List<ArticleDocument> filterByCountry(
List<ArticleDocument> documents,
CountryEnum userCountry,
) {
return documents.where((doc) {
// Wenn keine Länder definiert sind, ist es für alle sichtbar
if (doc.countryIds.isEmpty) return true;
return doc.countryIds.contains(userCountry.key);
}).toList();
}
/// Filtert Dokumente nach Sprache
List<ArticleDocument> filterByLanguage(
List<ArticleDocument> documents,
LanguageEnum? language,
) {
if (language == null) return documents;
return documents.where((doc) {
// Dokumente ohne Sprache sind für alle sichtbar
if (doc.language == null) return true;
return doc.language == language;
}).toList();
}
/// Gruppiert Dokumente nach Typ
Map<String, List<ArticleDocument>> groupByDocumentType(
List<ArticleDocument> documents,
) {
final Map<String, List<ArticleDocument>> grouped = {};
for (final doc in documents) {
final typeId = doc.documentTypeId;
if (!grouped.containsKey(typeId)) {
grouped[typeId] = [];
}
grouped[typeId]!.add(doc);
}
return grouped;
}
/// Lädt Dokumenttypen für einen Kunden (für Anzeige/Gruppierung)
Stream<List<ArticleDocumentType>> streamDocumentTypes(String customerId) {
return _firestore
.collection('customers')
.doc(customerId)
.collection('articleDocumentTypes')
.where('deletedAt', isNull: true)
.orderBy('sortOrder')
.snapshots()
.map((snapshot) {
return snapshot.docs
.map((doc) => ArticleDocumentType.fromFirestore(doc))
.toList();
});
}
}
📦 Dependencies¶
Füge in pubspec.yaml hinzu:
dependencies:
# Für PDF-Anzeige
flutter_pdfview: ^1.3.2
# Alternativ (neuere Version):
# pdfx: ^2.6.0
# Für Datei-Downloads
dio: ^5.4.0
path_provider: ^2.1.1
# Für Permissions
permission_handler: ^11.1.0
# Für Sharing
share_plus: ^7.2.1
# Optional: Für Download-Fortschritt UI
percent_indicator: ^4.2.3
🎨 UI Components¶
1. DocumentListWidget¶
Widget zur Anzeige aller Dokumente eines Artikels:
// lib/widgets/mobile/article_documents_list.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../models/articles/article_document.dart';
import '../../models/articles/article_document_type.dart';
import '../../services/mobile/article_document_mobile_service.dart';
import '../../blocs/user_bloc/user_bloc.dart';
import 'document_card.dart';
class ArticleDocumentsList extends StatefulWidget {
final String articleId;
final String customerId;
const ArticleDocumentsList({
Key? key,
required this.articleId,
required this.customerId,
}) : super(key: key);
@override
State<ArticleDocumentsList> createState() => _ArticleDocumentsListState();
}
class _ArticleDocumentsListState extends State<ArticleDocumentsList> {
final _service = ArticleDocumentMobileService();
String? _selectedTypeId; // Filter nach Typ
LanguageEnum? _selectedLanguage; // Filter nach Sprache
@override
Widget build(BuildContext context) {
final userBloc = context.read<UserBloc>();
final userCountry = userBloc.currentUser?.country; // User-Land
return Column(
children: [
// Filter-Leiste
_buildFilterBar(),
// Dokumente-Liste
Expanded(
child: StreamBuilder<List<ArticleDocument>>(
stream: _service.streamPublicDocuments(widget.articleId),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text('Fehler: ${snapshot.error}'),
);
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
var documents = snapshot.data!;
// Filter nach Land
if (userCountry != null) {
documents = _service.filterByCountry(documents, userCountry);
}
// Filter nach Sprache
if (_selectedLanguage != null) {
documents = _service.filterByLanguage(
documents,
_selectedLanguage,
);
}
// Filter nach Typ
if (_selectedTypeId != null) {
documents = documents
.where((doc) => doc.documentTypeId == _selectedTypeId)
.toList();
}
if (documents.isEmpty) {
return _buildEmptyState();
}
return _buildDocumentsList(documents);
},
),
),
],
);
}
Widget _buildFilterBar() {
return Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Typ-Filter Dropdown
Expanded(
child: StreamBuilder<List<ArticleDocumentType>>(
stream: _service.streamDocumentTypes(widget.customerId),
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox.shrink();
final types = snapshot.data!;
return DropdownButton<String?>(
value: _selectedTypeId,
hint: const Text('Alle Typen'),
isExpanded: true,
items: [
const DropdownMenuItem(
value: null,
child: Text('Alle Typen'),
),
...types.map((type) {
return DropdownMenuItem(
value: type.id,
child: Row(
children: [
if (type.icon != null)
Icon(
IconData(
type.icon!,
fontFamily: 'MaterialIcons',
),
size: 20,
),
const SizedBox(width: 8),
Text(type.name),
],
),
);
}),
],
onChanged: (value) {
setState(() {
_selectedTypeId = value;
});
},
);
},
),
),
const SizedBox(width: 16),
// Sprach-Filter Dropdown
Expanded(
child: DropdownButton<LanguageEnum?>(
value: _selectedLanguage,
hint: const Text('Alle Sprachen'),
isExpanded: true,
items: [
const DropdownMenuItem(
value: null,
child: Text('Alle Sprachen'),
),
...LanguageEnum.values.map((lang) {
return DropdownMenuItem(
value: lang,
child: Text(lang.displayName),
);
}),
],
onChanged: (value) {
setState(() {
_selectedLanguage = value;
});
},
),
),
],
),
);
}
Widget _buildDocumentsList(List<ArticleDocument> documents) {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: documents.length,
itemBuilder: (context, index) {
final document = documents[index];
return DocumentCard(
document: document,
customerId: widget.customerId,
);
},
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.insert_drive_file_outlined,
size: 64,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
'Keine Dokumente verfügbar',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
),
),
],
),
);
}
}
2. DocumentCard¶
Card-Widget für ein einzelnes Dokument:
// lib/widgets/mobile/document_card.dart
import 'package:flutter/material.dart';
import '../../models/articles/article_document.dart';
import '../../models/articles/article_document_type.dart';
import '../../services/mobile/article_document_mobile_service.dart';
import '../../services/mobile/document_download_service.dart';
import 'pdf_viewer_page.dart';
class DocumentCard extends StatefulWidget {
final ArticleDocument document;
final String customerId;
const DocumentCard({
Key? key,
required this.document,
required this.customerId,
}) : super(key: key);
@override
State<DocumentCard> createState() => _DocumentCardState();
}
class _DocumentCardState extends State<DocumentCard> {
final _downloadService = DocumentDownloadService();
final _documentService = ArticleDocumentMobileService();
bool _isDownloading = false;
double _downloadProgress = 0.0;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: _openDocument,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// Icon basierend auf Dateityp
_buildFileIcon(),
const SizedBox(width: 12),
// Titel & Details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.document.displayName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
_buildMetadata(),
],
),
),
// Action Buttons
_buildActionButtons(),
],
),
// Download Progress (wenn aktiv)
if (_isDownloading)
Padding(
padding: const EdgeInsets.only(top: 12),
child: LinearProgressIndicator(value: _downloadProgress),
),
// Version & Gültigkeit
if (widget.document.version != null ||
widget.document.validUntil != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: _buildAdditionalInfo(),
),
],
),
),
),
);
}
Widget _buildFileIcon() {
IconData iconData;
Color iconColor;
switch (widget.document.fileType.toLowerCase()) {
case 'pdf':
iconData = Icons.picture_as_pdf;
iconColor = Colors.red;
break;
case 'doc':
case 'docx':
iconData = Icons.description;
iconColor = Colors.blue;
break;
case 'xls':
case 'xlsx':
iconData = Icons.table_chart;
iconColor = Colors.green;
break;
default:
iconData = Icons.insert_drive_file;
iconColor = Colors.grey;
}
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: iconColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(iconData, color: iconColor, size: 32),
);
}
Widget _buildMetadata() {
final parts = <String>[];
// Dateityp
parts.add(widget.document.fileType.toUpperCase());
// Dateigröße
parts.add(_formatFileSize(widget.document.fileSize));
// Sprache
if (widget.document.language != null) {
parts.add(widget.document.language!.displayName);
}
return Text(
parts.join(' • '),
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
);
}
Widget _buildActionButtons() {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// Download Button
IconButton(
icon: const Icon(Icons.download),
onPressed: _isDownloading ? null : _downloadDocument,
tooltip: 'Herunterladen',
),
// Share Button
IconButton(
icon: const Icon(Icons.share),
onPressed: _shareDocument,
tooltip: 'Teilen',
),
],
);
}
Widget _buildAdditionalInfo() {
return Row(
children: [
if (widget.document.version != null) ...[
Chip(
label: Text('v${widget.document.version}'),
backgroundColor: Colors.blue.shade50,
labelStyle: TextStyle(
fontSize: 11,
color: Colors.blue.shade700,
),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
const SizedBox(width: 8),
],
if (widget.document.validUntil != null) ...[
Chip(
label: Text(
'Gültig bis ${_formatDate(widget.document.validUntil!)}',
),
backgroundColor: Colors.orange.shade50,
labelStyle: TextStyle(
fontSize: 11,
color: Colors.orange.shade700,
),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
],
],
);
}
void _openDocument() async {
if (widget.document.fileType.toLowerCase() == 'pdf') {
// PDF direkt in der App öffnen
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PdfViewerPage(
document: widget.document,
),
),
);
} else {
// Andere Dateitypen: Download starten
await _downloadDocument();
}
}
Future<void> _downloadDocument() async {
setState(() {
_isDownloading = true;
_downloadProgress = 0.0;
});
try {
await _downloadService.downloadDocument(
widget.document,
onProgress: (progress) {
setState(() {
_downloadProgress = progress;
});
},
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${widget.document.displayName} heruntergeladen'),
action: SnackBarAction(
label: 'Öffnen',
onPressed: () {
_downloadService.openDownloadedFile(widget.document);
},
),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Download fehlgeschlagen: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isDownloading = false;
});
}
}
}
Future<void> _shareDocument() async {
await _downloadService.shareDocument(widget.document);
}
String _formatFileSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
String _formatDate(DateTime date) {
return '${date.day}.${date.month}.${date.year}';
}
}
3. PDF Viewer Page¶
Seite zur Anzeige von PDF-Dokumenten:
// lib/widgets/mobile/pdf_viewer_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_pdfview/flutter_pdfview.dart';
import 'dart:io';
import '../../models/articles/article_document.dart';
import '../../services/mobile/document_download_service.dart';
class PdfViewerPage extends StatefulWidget {
final ArticleDocument document;
const PdfViewerPage({
Key? key,
required this.document,
}) : super(key: key);
@override
State<PdfViewerPage> createState() => _PdfViewerPageState();
}
class _PdfViewerPageState extends State<PdfViewerPage> {
final _downloadService = DocumentDownloadService();
String? _localPath;
bool _isLoading = true;
String? _error;
int _currentPage = 0;
int _totalPages = 0;
@override
void initState() {
super.initState();
_loadPdf();
}
Future<void> _loadPdf() async {
try {
setState(() {
_isLoading = true;
_error = null;
});
// Download PDF temporär (oder aus Cache laden)
final path = await _downloadService.getCachedOrDownload(widget.document);
setState(() {
_localPath = path;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = 'PDF konnte nicht geladen werden: $e';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.document.displayName),
actions: [
// Download Button
IconButton(
icon: const Icon(Icons.download),
onPressed: () async {
await _downloadService.downloadDocument(widget.document);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('PDF heruntergeladen')),
);
}
},
),
// Share Button
IconButton(
icon: const Icon(Icons.share),
onPressed: () {
_downloadService.shareDocument(widget.document);
},
),
],
),
body: _buildBody(),
bottomNavigationBar: _totalPages > 0
? _buildPageIndicator()
: null,
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (_error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(_error!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadPdf,
child: const Text('Erneut versuchen'),
),
],
),
);
}
if (_localPath == null) {
return const Center(
child: Text('Kein PDF verfügbar'),
);
}
return PDFView(
filePath: _localPath!,
enableSwipe: true,
swipeHorizontal: false,
autoSpacing: true,
pageFling: true,
pageSnap: true,
onRender: (pages) {
setState(() {
_totalPages = pages ?? 0;
});
},
onPageChanged: (page, total) {
setState(() {
_currentPage = page ?? 0;
});
},
onError: (error) {
setState(() {
_error = error.toString();
});
},
);
}
Widget _buildPageIndicator() {
return Container(
padding: const EdgeInsets.all(16),
color: Colors.black87,
child: Text(
'Seite ${_currentPage + 1} von $_totalPages',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
);
}
}
📥 Download Service¶
Service für Downloads und Caching:
// lib/services/mobile/document_download_service.dart
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:open_file/open_file.dart';
import '../../models/articles/article_document.dart';
class DocumentDownloadService {
final Dio _dio = Dio();
/// Download-Verzeichnis abrufen
Future<Directory> getDownloadDirectory() async {
if (Platform.isAndroid) {
// Auf Android: Downloads-Ordner
return Directory('/storage/emulated/0/Download');
} else if (Platform.isIOS) {
// Auf iOS: Application Documents Directory
return await getApplicationDocumentsDirectory();
}
throw UnsupportedError('Plattform nicht unterstützt');
}
/// Cache-Verzeichnis für temporäre PDFs
Future<Directory> getCacheDirectory() async {
return await getTemporaryDirectory();
}
/// Dokument herunterladen
Future<String> downloadDocument(
ArticleDocument document, {
Function(double)? onProgress,
}) async {
final downloadDir = await getDownloadDirectory();
final filePath = '${downloadDir.path}/${document.fileName}';
await _dio.download(
document.downloadUrl,
filePath,
onReceiveProgress: (received, total) {
if (total != -1 && onProgress != null) {
onProgress(received / total);
}
},
);
return filePath;
}
/// Dokument aus Cache laden oder herunterladen
Future<String> getCachedOrDownload(ArticleDocument document) async {
final cacheDir = await getCacheDirectory();
final cachedPath = '${cacheDir.path}/${document.fileName}';
final cachedFile = File(cachedPath);
// Wenn bereits im Cache, verwende es
if (await cachedFile.exists()) {
return cachedPath;
}
// Sonst: Download in Cache
await _dio.download(document.downloadUrl, cachedPath);
return cachedPath;
}
/// Dokument teilen
Future<void> shareDocument(ArticleDocument document) async {
try {
// Erst herunterladen/cachen
final path = await getCachedOrDownload(document);
// Dann teilen
await Share.shareXFiles(
[XFile(path)],
text: document.displayName,
);
} catch (e) {
throw Exception('Dokument konnte nicht geteilt werden: $e');
}
}
/// Heruntergeladenes Dokument öffnen
Future<void> openDownloadedFile(ArticleDocument document) async {
final downloadDir = await getDownloadDirectory();
final filePath = '${downloadDir.path}/${document.fileName}';
final result = await OpenFile.open(filePath);
if (result.type != ResultType.done) {
throw Exception('Datei konnte nicht geöffnet werden: ${result.message}');
}
}
/// Cache leeren
Future<void> clearCache() async {
final cacheDir = await getCacheDirectory();
final files = cacheDir.listSync();
for (final file in files) {
if (file is File) {
await file.delete();
}
}
}
/// Größe des Caches berechnen
Future<int> getCacheSize() async {
final cacheDir = await getCacheDirectory();
final files = cacheDir.listSync();
int totalSize = 0;
for (final file in files) {
if (file is File) {
totalSize += await file.length();
}
}
return totalSize;
}
}
🔒 Permissions (Android/iOS)¶
Android (android/app/src/main/AndroidManifest.xml)¶
<manifest>
<!-- Internet Permission (bereits vorhanden) -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Storage Permissions für Downloads -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- Für Android 13+ -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
</manifest>
iOS (ios/Runner/Info.plist)¶
<key>NSPhotoLibraryUsageDescription</key>
<string>Wir benötigen Zugriff zum Speichern von Dokumenten</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Wir möchten Dokumente in Ihrer Galerie speichern</string>
🧪 Beispiel: Integration im Artikel-Detail¶
// lib/pages/mobile/article_detail_page.dart
import 'package:flutter/material.dart';
import '../../models/articles/article.dart';
import '../../widgets/mobile/article_documents_list.dart';
class ArticleDetailPage extends StatelessWidget {
final Article article;
const ArticleDetailPage({
Key? key,
required this.article,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: Text(article.name),
bottom: const TabBar(
tabs: [
Tab(text: 'Details', icon: Icon(Icons.info_outline)),
Tab(text: 'Bilder', icon: Icon(Icons.photo_library)),
Tab(text: 'Dokumente', icon: Icon(Icons.description)),
],
),
),
body: TabBarView(
children: [
// Tab 1: Details
_buildDetailsTab(),
// Tab 2: Bilder
_buildImagesTab(),
// Tab 3: Dokumente
ArticleDocumentsList(
articleId: article.id,
customerId: article.customerId,
),
],
),
),
);
}
Widget _buildDetailsTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Artikelnummer: ${article.articleNumber}'),
const SizedBox(height: 8),
Text('Beschreibung: ${article.description ?? "-"}'),
// ... weitere Details
],
),
);
}
Widget _buildImagesTab() {
// Implementierung für Bilder-Tab
return const Center(child: Text('Bilder'));
}
}
📊 Firestore Security Rules (Überprüfung)¶
Die bestehenden Rules erlauben bereits das Lesen:
match /articles/{articleId} {
match /articleDocuments/{documentId} {
// Lesen: Alle authentifizierten User ✅
allow read: if isAuthenticated();
}
}
Wichtig: Mobile App User dürfen nur LESEN, nicht schreiben!
🚀 Deployment Checkliste¶
1. Dependencies installieren¶
2. Dateien erstellen¶
- ✅
lib/services/mobile/article_document_mobile_service.dart - ✅
lib/services/mobile/document_download_service.dart - ✅
lib/widgets/mobile/article_documents_list.dart - ✅
lib/widgets/mobile/document_card.dart - ✅
lib/widgets/mobile/pdf_viewer_page.dart
3. Permissions konfigurieren¶
- ✅ Android:
AndroidManifest.xmlanpassen - ✅ iOS:
Info.plistanpassen
4. Integration im Artikel-Detail¶
- ✅ Tab "Dokumente" hinzufügen
- ✅
ArticleDocumentsListWidget einbinden
5. Testen¶
- ✅ Dokumente-Liste lädt
- ✅ Filter (Typ, Sprache) funktionieren
- ✅ PDF-Viewer öffnet
- ✅ Download funktioniert
- ✅ Share-Funktion funktioniert
- ✅ Permissions werden korrekt angefragt
🎯 Best Practices¶
Performance¶
- ✅ Verwende Streams für Real-time Updates
- ✅ Implementiere Caching für PDFs
- ✅ Lade nur öffentliche Dokumente
- ✅ Filtern nach Land bereits auf Client-Seite
UX¶
- ✅ Zeige Download-Fortschritt
- ✅ Offline-Verfügbarkeit durch Cache
- ✅ Klare Fehler-Meldungen
- ✅ Intuitive Filter-Optionen
Security¶
- ✅ Nur
isPublic: trueDokumente - ✅ Nur
isActive: trueDokumente - ✅ Validitätsdatum prüfen
- ✅ Länder-Filter anwenden
🐛 Troubleshooting¶
Problem: "PDF lädt nicht"¶
Lösung:
- Prüfe Internet-Verbindung
- Prüfe downloadUrl in Firestore
- Prüfe Storage-Permissions
Problem: "Download schlägt fehl"¶
Lösung: - Prüfe Storage-Permissions (Android/iOS) - Prüfe Speicherplatz auf Gerät - Prüfe Dateigröße (< 10 MB?)
Problem: "Keine Dokumente sichtbar"¶
Lösung:
- Prüfe isPublic: true Flag
- Prüfe isActive: true Flag
- Prüfe Länder-Filter
- Prüfe Gültigkeitsdatum
📞 Zusammenfassung¶
Was wurde dokumentiert:¶
- ✅ Vollständige Service-Layer für Mobile
- ✅ UI Components (Liste, Card, PDF-Viewer)
- ✅ Download & Caching Service
- ✅ Permissions Setup (Android/iOS)
- ✅ Integration im Artikel-Detail
- ✅ Best Practices & Troubleshooting
Nächste Schritte für Implementierung:¶
- Dependencies installieren
- Service-Dateien erstellen
- UI-Widgets erstellen
- Permissions konfigurieren
- Integration testen
Diese Dokumentation kann direkt als Prompt für die Mobile App Entwicklung verwendet werden! 🚀
🔍 Diagnose-Anleitung: Artikel-Dokumente in Mobile App nicht sichtbar¶
Problem¶
Artikel 000000001 / OTTOSEAL S100 zeigt in der Mobile App keine Dokumente an, obwohl im ERP Dokumente vorhanden sind.
✅ Checkliste zur Diagnose¶
1. Firebase Console öffnen¶
- Gehe zu Firebase Console
- Wähle dein Projekt
- Navigation: Firestore Database
2. Artikel-ID finden¶
Gefundene Artikel-ID notieren! (z.B. abc123xyz)
3. Dokumente überprüfen¶
Navigiere zu: articles/{articleId}/articleDocuments
Für JEDES Dokument prüfen:¶
| Feld | Soll-Wert | Problem wenn |
|---|---|---|
isPublic |
true |
false = Dokument ist privat → IN ERP AUF JA SETZEN |
isActive |
true |
false = Dokument archiviert → Historie prüfen |
validFrom |
null oder Datum in Vergangenheit |
Datum in Zukunft = noch nicht gültig |
validUntil |
null oder Datum in Zukunft |
Datum in Vergangenheit = abgelaufen |
countryIds |
[] (leer = alle Länder) |
Wenn gesetzt: User-Land muss in Liste sein |
🛠️ Typische Probleme & Lösungen¶
❌ Problem 1: isPublic = false¶
Symptom: Dokument im ERP sichtbar, aber NICHT in Mobile App
Lösung im ERP: 1. Artikel-Detail öffnen 2. Tab "Dokumente" 3. Dokument bearbeiten (✏️) 4. "Öffentlich (Mobile App)" aktivieren ✅ 5. Speichern
Firestore-Wert: isPublic: true
❌ Problem 2: isActive = false¶
Symptom: Dokument wurde archiviert (z.B. durch Versionierung)
Lösung im ERP: 1. Artikel-Detail → Dokumente 2. "Historie"-Button klicken 3. Altes Dokument prüfen 4. Entweder: - Neues Dokument hochladen (→ wird automatisch aktiv) - Altes Dokument reaktivieren (falls Option vorhanden)
Firestore-Wert: isActive: true
❌ Problem 3: Gültigkeitsdatum falsch¶
Symptom: Dokument noch nicht gültig oder bereits abgelaufen
Beispiel Fehler:
{
"validFrom": "2026-03-01T00:00:00Z", ← Dokument erst ab 1. März 2026 gültig!
"validUntil": "2026-01-31T23:59:59Z" ← Dokument war nur bis 31. Januar gültig!
}
Lösung im ERP: 1. Dokument bearbeiten 2. Gültigkeitsdaten prüfen/anpassen: - "Gültig ab": Leer lassen ODER Datum in Vergangenheit - "Gültig bis": Leer lassen ODER Datum in Zukunft 3. Speichern
Aktuelles Datum: 4. Februar 2026
❌ Problem 4: Länder-Filter blockiert¶
Symptom: Dokument nur für bestimmte Länder sichtbar
Beispiel:
Wenn User aus der Schweiz ist → Dokument NICHT sichtbar!
Lösung im ERP: 1. Dokument bearbeiten 2. "Länder" prüfen: - Leer = für alle Länder sichtbar ✅ - Oder User-Land hinzufügen 3. Speichern
📊 Quick-Check via Firebase Console¶
Test-Query in Firestore¶
Erwartetes Ergebnis: Mindestens 1 Dokument
Wenn 0 Dokumente:
1. Filter entfernen
2. ALLE Dokumente anschauen
3. isPublic und isActive Werte prüfen
🔧 Sofort-Fix (Manuell in Firebase Console)¶
Wenn du direkten Zugriff auf Firestore hast:
- Navigiere zu:
articles/{articleId}/articleDocuments/{documentId} - Klicke auf "Edit document" (Stift-Icon)
- Setze:
- Entferne (falls vorhanden):
- Speichern
⚠️ Achtung: Änderungen in Firebase Console werden NICHT im ERP angezeigt!
Besser: Im ERP ändern!
📱 Test in Mobile App¶
Nach Änderungen:
- Mobile App neu starten (Pull-to-Refresh reicht oft nicht)
- Artikel aufrufen
- Tab "Dokumente" öffnen
Sollte jetzt sichtbar sein! ✅
🆘 Wenn nichts funktioniert¶
Debug-Log aktivieren¶
- ERP öffnen
-
Firebase Storage Rules prüfen:
-
Firestore Rules prüfen:
-
Mobile App User authentifiziert?
- User eingeloggt?
- Token gültig?
📋 Checkliste für Support-Ticket¶
Wenn Problem weiterhin besteht, folgende Infos sammeln:
- [ ] Artikel-ID:
_____________ - [ ] Artikel-Nummer:
000000001 - [ ] Artikel-Name:
OTTOSEAL S100 - [ ] Anzahl Dokumente im ERP:
_____ - [ ] Screenshot: Dokument-Details im ERP (mit
isPublicsichtbar) - [ ] Firebase Console: Screenshot der
articleDocumentsCollection - [ ] Mobile App: Screenshot (keine Dokumente sichtbar)
- [ ] User-Land in Mobile App:
_____________
✅ Erfolgs-Kriterien¶
Dokument ist in Mobile App sichtbar wenn ALLE erfüllt sind:
✅ isPublic = true
✅ isActive = true
✅ validFrom = null ODER Datum in Vergangenheit
✅ validUntil = null ODER Datum in Zukunft
✅ countryIds = [] ODER User-Land enthalten
✅ User in Mobile App authentifiziert
🔗 Weiterführende Dokumentation¶
Zuletzt aktualisiert: 4. Februar 2026