Zum Inhalt

📱 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

flutter pub add flutter_pdfview dio path_provider share_plus permission_handler open_file

2. Dateien erstellen

  • lib/services/mobile/article_document_mobile_service.dart
  • lib/services/mobile/document_download_service.dart
  • lib/widgets/mobile/article_documents_list.dart
  • lib/widgets/mobile/document_card.dart
  • lib/widgets/mobile/pdf_viewer_page.dart

3. Permissions konfigurieren

  • ✅ Android: AndroidManifest.xml anpassen
  • ✅ iOS: Info.plist anpassen

4. Integration im Artikel-Detail

  • ✅ Tab "Dokumente" hinzufügen
  • ArticleDocumentsList Widget einbinden

5. Testen

  • ✅ Dokumente-Liste lädt
  • ✅ Filter (Typ, Sprache) funktionieren
  • ✅ PDF-Viewer öffnet
  • ✅ Download funktioniert
  • ✅ Share-Funktion funktioniert
  • ✅ Permissions werden korrekt angefragt

🎯 Best Practices

Performance

  • ✅ Verwende Streams für Real-time Updates
  • ✅ Implementiere Caching für PDFs
  • ✅ Lade nur öffentliche Dokumente
  • ✅ Filtern nach Land bereits auf Client-Seite

UX

  • ✅ Zeige Download-Fortschritt
  • ✅ Offline-Verfügbarkeit durch Cache
  • ✅ Klare Fehler-Meldungen
  • ✅ Intuitive Filter-Optionen

Security

  • ✅ Nur isPublic: true Dokumente
  • ✅ Nur isActive: true Dokumente
  • ✅ Validitätsdatum prüfen
  • ✅ Länder-Filter anwenden

🐛 Troubleshooting

Problem: "PDF lädt nicht"

Lösung: - Prüfe Internet-Verbindung - Prüfe downloadUrl in Firestore - Prüfe Storage-Permissions

Problem: "Download schlägt fehl"

Lösung: - Prüfe Storage-Permissions (Android/iOS) - Prüfe Speicherplatz auf Gerät - Prüfe Dateigröße (< 10 MB?)

Problem: "Keine Dokumente sichtbar"

Lösung: - Prüfe isPublic: true Flag - Prüfe isActive: true Flag - Prüfe Länder-Filter - Prüfe Gültigkeitsdatum


📞 Zusammenfassung

Was wurde dokumentiert:

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

Nächste Schritte für Implementierung:

  1. Dependencies installieren
  2. Service-Dateien erstellen
  3. UI-Widgets erstellen
  4. Permissions konfigurieren
  5. Integration testen

Diese Dokumentation kann direkt als Prompt für die Mobile App Entwicklung verwendet werden! 🚀

🔍 Diagnose-Anleitung: Artikel-Dokumente in Mobile App nicht sichtbar

Problem

Artikel 000000001 / OTTOSEAL S100 zeigt in der Mobile App keine Dokumente an, obwohl im ERP Dokumente vorhanden sind.

✅ Checkliste zur Diagnose

1. Firebase Console öffnen

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

2. Artikel-ID finden

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

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

3. Dokumente überprüfen

Collection: articles/{articleId}/articleDocuments

Navigiere zu: articles/{articleId}/articleDocuments

Für JEDES Dokument prüfen:

Feld Soll-Wert Problem wenn
isPublic true false = Dokument ist privat → IN ERP AUF JA SETZEN
isActive true false = Dokument archiviert → Historie prüfen
validFrom null oder Datum in Vergangenheit Datum in Zukunft = noch nicht gültig
validUntil null oder Datum in Zukunft Datum in Vergangenheit = abgelaufen
countryIds [] (leer = alle Länder) Wenn gesetzt: User-Land muss in Liste sein

🛠️ Typische Probleme & Lösungen

❌ Problem 1: isPublic = false

Symptom: Dokument im ERP sichtbar, aber NICHT in Mobile App

Lösung im ERP: 1. Artikel-Detail öffnen 2. Tab "Dokumente" 3. Dokument bearbeiten (✏️) 4. "Öffentlich (Mobile App)" aktivieren ✅ 5. Speichern

Firestore-Wert: isPublic: true


❌ Problem 2: isActive = false

Symptom: Dokument wurde archiviert (z.B. durch Versionierung)

Lösung im ERP: 1. Artikel-Detail → Dokumente 2. "Historie"-Button klicken 3. Altes Dokument prüfen 4. Entweder: - Neues Dokument hochladen (→ wird automatisch aktiv) - Altes Dokument reaktivieren (falls Option vorhanden)

Firestore-Wert: isActive: true


❌ Problem 3: Gültigkeitsdatum falsch

Symptom: Dokument noch nicht gültig oder bereits abgelaufen

Beispiel Fehler:

{
  "validFrom": "2026-03-01T00:00:00Z",   Dokument erst ab 1. März 2026 gültig!
  "validUntil": "2026-01-31T23:59:59Z"   Dokument war nur bis 31. Januar gültig!
}

Lösung im ERP: 1. Dokument bearbeiten 2. Gültigkeitsdaten prüfen/anpassen: - "Gültig ab": Leer lassen ODER Datum in Vergangenheit - "Gültig bis": Leer lassen ODER Datum in Zukunft 3. Speichern

Aktuelles Datum: 4. Februar 2026


❌ Problem 4: Länder-Filter blockiert

Symptom: Dokument nur für bestimmte Länder sichtbar

Beispiel:

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

Wenn User aus der Schweiz ist → Dokument NICHT sichtbar!

Lösung im ERP: 1. Dokument bearbeiten 2. "Länder" prüfen: - Leer = für alle Länder sichtbar ✅ - Oder User-Land hinzufügen 3. Speichern


📊 Quick-Check via Firebase Console

Test-Query in Firestore

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

Erwartetes Ergebnis: Mindestens 1 Dokument

Wenn 0 Dokumente: 1. Filter entfernen 2. ALLE Dokumente anschauen 3. isPublic und isActive Werte prüfen


🔧 Sofort-Fix (Manuell in Firebase Console)

Wenn du direkten Zugriff auf Firestore hast:

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

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


📱 Test in Mobile App

Nach Änderungen:

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

Sollte jetzt sichtbar sein!


🆘 Wenn nichts funktioniert

Debug-Log aktivieren

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

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

  3. Firestore Rules prüfen:

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

  4. Mobile App User authentifiziert?

  5. User eingeloggt?
  6. Token gültig?

📋 Checkliste für Support-Ticket

Wenn Problem weiterhin besteht, folgende Infos sammeln:

  • [ ] Artikel-ID: _____________
  • [ ] Artikel-Nummer: 000000001
  • [ ] Artikel-Name: OTTOSEAL S100
  • [ ] Anzahl Dokumente im ERP: _____
  • [ ] Screenshot: Dokument-Details im ERP (mit isPublic sichtbar)
  • [ ] Firebase Console: Screenshot der articleDocuments Collection
  • [ ] Mobile App: Screenshot (keine Dokumente sichtbar)
  • [ ] User-Land in Mobile App: _____________

✅ Erfolgs-Kriterien

Dokument ist in Mobile App sichtbar wenn ALLE erfüllt sind:

✅ isPublic = true
✅ isActive = true
✅ validFrom = null ODER Datum in Vergangenheit
✅ validUntil = null ODER Datum in Zukunft
✅ countryIds = [] ODER User-Land enthalten
✅ User in Mobile App authentifiziert

🔗 Weiterführende Dokumentation


Zuletzt aktualisiert: 4. Februar 2026