diff --git a/lib/models/post_item.dart b/lib/models/post_item.dart index 46b1b3f..fa11cfa 100644 --- a/lib/models/post_item.dart +++ b/lib/models/post_item.dart @@ -19,17 +19,20 @@ class PostItem { required this.id, required this.title, required this.description, - this.imageUrl = '', + String imageUrl = '', List? imageUrls, required this.createdDate, this.authorUid, this.street, this.category, - this.status = 'Gemeldet', + this.status, this.pdfUrl, this.webUrl, this.isAussendung = false, - }) : imageUrls = imageUrls ?? (imageUrl.isNotEmpty ? [imageUrl] : []); + }) : imageUrls = imageUrls ?? (imageUrl.isNotEmpty ? [imageUrl] : []), + imageUrl = imageUrl.isNotEmpty + ? imageUrl + : (imageUrls != null && imageUrls.isNotEmpty ? imageUrls.first : ''); factory PostItem.fromFirestore(DocumentSnapshot doc) { final data = doc.data() as Map? ?? {}; @@ -49,6 +52,9 @@ class PostItem { images = [legacyImage]; } + final hasIssueFields = data['street'] != null || data['category'] != null; + final status = data['status'] as String? ?? (hasIssueFields ? 'Gemeldet' : null); + return PostItem( id: doc.id, title: data['title'] as String? ?? '', @@ -59,7 +65,7 @@ class PostItem { authorUid: data['author_uid'] as String?, street: data['street'] as String?, category: data['category'] as String?, - status: data['status'] as String? ?? 'Gemeldet', + status: status, pdfUrl: data['pdfUrl'] as String?, webUrl: data['webUrl'] as String?, isAussendung: data['isAussendung'] as bool? ?? false, @@ -119,17 +125,23 @@ class PostItem { images = [legacyImage]; } + final hasIssueFields = map['street'] != null || map['category'] != null; + final status = + map['status'] as String? ?? (hasIssueFields ? 'Gemeldet' : null); + return PostItem( id: map['id'] as String? ?? '', title: map['title'] as String? ?? '', description: map['description'] as String? ?? '', - imageUrl: legacyImage.isNotEmpty ? legacyImage : (images.isNotEmpty ? images.first : ''), + imageUrl: legacyImage.isNotEmpty + ? legacyImage + : (images.isNotEmpty ? images.first : ''), imageUrls: images, createdDate: date, authorUid: map['authorUid'] as String?, street: map['street'] as String?, category: map['category'] as String?, - status: map['status'] as String? ?? 'Gemeldet', + status: status, pdfUrl: map['pdfUrl'] as String?, webUrl: map['webUrl'] as String?, isAussendung: map['isAussendung'] as bool? ?? false, diff --git a/lib/screens/detail_screen.dart b/lib/screens/detail_screen.dart index aef4498..ddb1b4c 100644 --- a/lib/screens/detail_screen.dart +++ b/lib/screens/detail_screen.dart @@ -27,6 +27,8 @@ class DetailScreen extends StatefulWidget { class _DetailScreenState extends State { late PostItem _item; + int _currentImageIndex = 0; + final PageController _pageController = PageController(); @override void initState() { @@ -34,6 +36,12 @@ class _DetailScreenState extends State { _item = widget.item; } + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + Future _editItem() async { final collection = widget.collectionName ?? (_item.street != null ? 'Issues' : 'CitizensForum'); @@ -118,6 +126,7 @@ class _DetailScreenState extends State { final isAuthor = currentUser != null && _item.authorUid == currentUser.uid; final canEdit = !_item.isAussendung && (isAuthor || auth.isAdmin); final canDelete = !_item.isAussendung && (isAuthor || auth.isAdmin); + final isIssue = widget.collectionName == 'Issues' || _item.street != null; final formattedDate = DateFormat('dd.MM.yyyy HH:mm').format(_item.createdDate); @@ -155,27 +164,131 @@ class _DetailScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (_item.imageUrls.isNotEmpty) - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: CachedNetworkImage( - imageUrl: _item.imageUrls.first, - width: double.infinity, - fit: BoxFit.cover, - placeholder: (context, url) => const Center( - child: Padding( - padding: EdgeInsets.all(32.0), - child: CircularProgressIndicator(), + if (_item.imageUrls.isNotEmpty) ...[ + if (_item.imageUrls.length == 1) + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: CachedNetworkImage( + imageUrl: _item.imageUrls.first, + width: double.infinity, + height: 240, + fit: BoxFit.cover, + placeholder: (context, url) => const Center( + child: Padding( + padding: EdgeInsets.all(32.0), + child: CircularProgressIndicator(), + ), + ), + errorWidget: (context, url, error) => Container( + height: 200, + color: Colors.grey.shade200, + child: const Icon(Icons.image_not_supported, size: 48), ), ), - errorWidget: (context, url, error) => Container( - height: 180, - color: Colors.grey.shade200, - child: const Icon(Icons.image_not_supported, size: 48), - ), + ) + else + Stack( + alignment: Alignment.bottomCenter, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + height: 260, + width: double.infinity, + child: PageView.builder( + controller: _pageController, + itemCount: _item.imageUrls.length, + onPageChanged: (index) { + setState(() { + _currentImageIndex = index; + }); + }, + itemBuilder: (context, index) { + return CachedNetworkImage( + imageUrl: _item.imageUrls[index], + width: double.infinity, + height: 260, + fit: BoxFit.cover, + placeholder: (context, url) => const Center( + child: Padding( + padding: EdgeInsets.all(32.0), + child: CircularProgressIndicator(), + ), + ), + errorWidget: (context, url, error) => Container( + height: 260, + color: Colors.grey.shade200, + child: const Icon(Icons.image_not_supported, + size: 48), + ), + ); + }, + ), + ), + ), + // Page indicator dots + Positioned( + bottom: 10, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: List.generate( + _item.imageUrls.length, + (i) => Container( + margin: + const EdgeInsets.symmetric(horizontal: 3), + width: _currentImageIndex == i ? 16 : 6, + height: 6, + decoration: BoxDecoration( + color: _currentImageIndex == i + ? Colors.white + : Colors.white54, + borderRadius: BorderRadius.circular(3), + ), + ), + ), + ), + ), + ), + // Photo counter badge (e.g. 1 / 2) + Positioned( + top: 10, + right: 10, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.photo_library, + color: Colors.white, size: 14), + const SizedBox(width: 4), + Text( + '${_currentImageIndex + 1} / ${_item.imageUrls.length}', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ], ), - ), - const SizedBox(height: 16), + const SizedBox(height: 16), + ], Text( _item.title, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold), @@ -189,22 +302,37 @@ class _DetailScreenState extends State { formattedDate, style: TextStyle(fontSize: 13, color: Colors.grey.shade700), ), - if (_item.status != null) ...[ + if (isIssue && _item.status != null) ...[ const Spacer(), Container( - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), decoration: BoxDecoration( - color: Colors.blue.shade50, + color: _item.status == 'Erledigt' + ? Colors.green.shade50 + : (_item.status == 'In Bearbeitung' + ? Colors.blue.shade50 + : Colors.orange.shade50), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.blue.shade200), + border: Border.all( + color: _item.status == 'Erledigt' + ? Colors.green.shade200 + : (_item.status == 'In Bearbeitung' + ? Colors.blue.shade200 + : Colors.orange.shade200), + ), ), child: Text( _item.status!, style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Colors.blue.shade800), + fontSize: 12, + fontWeight: FontWeight.bold, + color: _item.status == 'Erledigt' + ? Colors.green.shade800 + : (_item.status == 'In Bearbeitung' + ? Colors.blue.shade800 + : Colors.orange.shade900), + ), ), ), ], @@ -284,34 +412,6 @@ class _DetailScreenState extends State { ), ], const SizedBox(height: 24), - // Additional images gallery if there are multiple images - if (_item.imageUrls.length > 1) ...[ - const Text( - 'Weitere Fotos', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - SizedBox( - height: 120, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: _item.imageUrls.length - 1, - separatorBuilder: (ctx, i) => const SizedBox(width: 8), - itemBuilder: (ctx, index) { - final url = _item.imageUrls[index + 1]; - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - imageUrl: url, - width: 120, - height: 120, - fit: BoxFit.cover, - ), - ); - }, - ), - ), - ], ], ), ), diff --git a/lib/screens/pdf_viewer_screen.dart b/lib/screens/pdf_viewer_screen.dart index b3c8f15..0f87f35 100644 --- a/lib/screens/pdf_viewer_screen.dart +++ b/lib/screens/pdf_viewer_screen.dart @@ -61,8 +61,9 @@ class _PdfViewerScreenState extends State } else { // At standard size -> zoom in to tapped point (2.5x) final position = _doubleTapDetails?.localPosition ?? Offset.zero; - final x = -position.dx * (2.5 - 1.0); - final y = -position.dy * (2.5 - 1.0); + final scenePoint = _transformationController.toScene(position); + final x = position.dx - scenePoint.dx * 2.5; + final y = position.dy - scenePoint.dy * 2.5; targetMatrix = Matrix4.diagonal3Values(2.5, 2.5, 1.0) ..setTranslationRaw(x, y, 0.0); } @@ -205,7 +206,8 @@ class _PdfViewerScreenState extends State panEnabled: true, scaleEnabled: true, constrained: false, - alignment: Alignment.topCenter, + boundaryMargin: const EdgeInsets.symmetric( + horizontal: 48, vertical: 80), child: SizedBox( width: viewportWidth, child: Padding( diff --git a/lib/screens/post_list_screen.dart b/lib/screens/post_list_screen.dart index cd4144f..f7b2fee 100644 --- a/lib/screens/post_list_screen.dart +++ b/lib/screens/post_list_screen.dart @@ -429,6 +429,8 @@ class _PostListScreenState extends State { final canEdit = !item.isAussendung && (isAuthor || auth.isAdmin); final formattedDate = DateFormat('dd.MM.yyyy').format(item.createdDate); + final isIssue = + widget.collectionName == 'Issues' || item.street != null; Widget? leadingWidget; if (item.imageUrl.isNotEmpty) { @@ -657,7 +659,64 @@ class _PostListScreenState extends State { subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (item.street != null && item.street!.isNotEmpty) ...[ + if (isIssue && item.status != null && item.status!.isNotEmpty) ...[ + const SizedBox(height: 3), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + runSpacing: 4, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: item.status == 'Erledigt' + ? Colors.green.shade50 + : (item.status == 'In Bearbeitung' + ? Colors.blue.shade50 + : Colors.orange.shade50), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: item.status == 'Erledigt' + ? Colors.green.shade300 + : (item.status == 'In Bearbeitung' + ? Colors.blue.shade300 + : Colors.orange.shade300), + ), + ), + child: Text( + item.status!, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: item.status == 'Erledigt' + ? Colors.green.shade800 + : (item.status == 'In Bearbeitung' + ? Colors.blue.shade800 + : Colors.orange.shade900), + ), + ), + ), + if (item.street != null && item.street!.isNotEmpty) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.location_on, + size: 12, color: Colors.redAccent), + const SizedBox(width: 2), + Text( + item.street!, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.black87, + ), + ), + ], + ), + ], + ), + ] else if (item.street != null && item.street!.isNotEmpty) ...[ const SizedBox(height: 2), Row( children: [ diff --git a/lib/services/pdf_service.dart b/lib/services/pdf_service.dart index 876f7be..9d97e2a 100644 --- a/lib/services/pdf_service.dart +++ b/lib/services/pdf_service.dart @@ -35,8 +35,15 @@ class PdfService { /// Print single post item as PDF static Future printSinglePost(PostItem item) async { final pdf = pw.Document(); - final formattedDate = DateFormat('dd.MM.yyyy HH:mm').format(item.createdDate); - final pw.ImageProvider? imageProvider = await _fetchImage(item.imageUrl); + final formattedDate = + DateFormat('dd.MM.yyyy HH:mm').format(item.createdDate); + + final List urlsToFetch = item.imageUrls.isNotEmpty + ? item.imageUrls + : (item.imageUrl.isNotEmpty ? [item.imageUrl] : []); + final results = + await Future.wait(urlsToFetch.map((url) => _fetchImage(url))); + final imageProviders = results.whereType().toList(); pdf.addPage( pw.MultiPage( @@ -58,7 +65,8 @@ class PdfService { ), pw.Text( formattedDate, - style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700), + style: + const pw.TextStyle(fontSize: 12, color: PdfColors.grey700), ), ], ), @@ -68,6 +76,53 @@ class PdfService { item.title, style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold), ), + if (item.status != null && item.status!.isNotEmpty) ...[ + pw.SizedBox(height: 6), + pw.Row( + children: [ + pw.Text( + 'Status: ', + style: pw.TextStyle( + fontSize: 12, + fontWeight: pw.FontWeight.bold, + color: PdfColors.grey800, + ), + ), + pw.Container( + padding: + const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: pw.BoxDecoration( + color: item.status == 'Erledigt' + ? const PdfColor.fromInt(0xFFE8F5E9) + : (item.status == 'In Bearbeitung' + ? const PdfColor.fromInt(0xFFE3F2FD) + : const PdfColor.fromInt(0xFFFFF3E0)), + borderRadius: + const pw.BorderRadius.all(pw.Radius.circular(4)), + border: pw.Border.all( + color: item.status == 'Erledigt' + ? const PdfColor.fromInt(0xFF81C784) + : (item.status == 'In Bearbeitung' + ? const PdfColor.fromInt(0xFF64B5F6) + : const PdfColor.fromInt(0xFFFFB74D)), + ), + ), + child: pw.Text( + item.status!, + style: pw.TextStyle( + fontSize: 11, + fontWeight: pw.FontWeight.bold, + color: item.status == 'Erledigt' + ? const PdfColor.fromInt(0xFF2E7D32) + : (item.status == 'In Bearbeitung' + ? const PdfColor.fromInt(0xFF1565C0) + : const PdfColor.fromInt(0xFFE65100)), + ), + ), + ), + ], + ), + ], if (item.street != null && item.street!.isNotEmpty) ...[ pw.SizedBox(height: 6), pw.Text( @@ -84,13 +139,29 @@ class PdfService { ], pw.Divider(thickness: 1, color: PdfColors.grey400), pw.SizedBox(height: 10), - if (imageProvider != null) ...[ - pw.Center( - child: pw.Container( - height: 220, - child: pw.Image(imageProvider, fit: pw.BoxFit.contain), + if (imageProviders.isNotEmpty) ...[ + if (imageProviders.length == 1) + pw.Center( + child: pw.Container( + height: 220, + child: pw.Image(imageProviders.first, fit: pw.BoxFit.contain), + ), + ) + else + pw.Row( + children: [ + for (int i = 0; i < imageProviders.length; i++) ...[ + if (i > 0) pw.SizedBox(width: 12), + pw.Expanded( + child: pw.Container( + height: 180, + child: pw.Image(imageProviders[i], + fit: pw.BoxFit.contain), + ), + ), + ], + ], ), - ), pw.SizedBox(height: 14), ], pw.Text( @@ -123,8 +194,10 @@ class PdfService { // Preload all images in parallel final Map imageMap = {}; await Future.wait(items.map((item) async { - if (item.imageUrl.isNotEmpty) { - imageMap[item.id] = await _fetchImage(item.imageUrl); + final url = + item.imageUrls.isNotEmpty ? item.imageUrls.first : item.imageUrl; + if (url.isNotEmpty) { + imageMap[item.id] = await _fetchImage(url); } })); @@ -137,7 +210,8 @@ class PdfService { margin: const pw.EdgeInsets.only(bottom: 12), padding: const pw.EdgeInsets.only(bottom: 6), decoration: const pw.BoxDecoration( - border: pw.Border(bottom: pw.BorderSide(color: PdfColors.blue800, width: 2)), + border: pw.Border( + bottom: pw.BorderSide(color: PdfColors.blue800, width: 2)), ), child: pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, @@ -152,7 +226,8 @@ class PdfService { ), pw.Text( 'Erstellt am: ${DateFormat('dd.MM.yyyy HH:mm').format(DateTime.now())}', - style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey700), + style: + const pw.TextStyle(fontSize: 10, color: PdfColors.grey700), ), ], ), @@ -160,7 +235,8 @@ class PdfService { build: (context) => [ pw.TableHelper.fromTextArray( border: pw.TableBorder.all(color: PdfColors.grey400, width: 0.5), - headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10), + headerStyle: + pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10), headerDecoration: const pw.BoxDecoration(color: PdfColors.grey200), cellHeight: 65, cellAlignments: { @@ -170,20 +246,32 @@ class PdfService { 3: pw.Alignment.centerLeft, 4: pw.Alignment.center, }, - headers: ['Datum', 'Örtlichkeit / Kat.', 'Titel & Mangel', 'Status', 'Foto'], + headers: [ + 'Datum', + 'Örtlichkeit / Kat.', + 'Titel & Mangel', + 'Status', + 'Foto' + ], data: items.map((item) { - final formattedDate = DateFormat('dd.MM.yyyy').format(item.createdDate); - final locInfo = '${item.street ?? "Keine Angabe"}\n(${item.category ?? "-"})'; + final formattedDate = + DateFormat('dd.MM.yyyy').format(item.createdDate); + final locInfo = + '${item.street ?? "Keine Angabe"}\n(${item.category ?? "-"})'; final descSnippet = item.description.length > 120 ? '${item.description.substring(0, 120)}...' : item.description; + final imageCount = item.imageUrls.length; + final imageLabel = imageMap[item.id] != null + ? (imageCount > 1 ? '$imageCount Fotos' : 'Foto vorhanden') + : '-'; return [ formattedDate, locInfo, '${item.title}\n$descSnippet', item.status ?? 'Gemeldet', - imageMap[item.id] != null ? 'Foto vorhanden' : '-', + imageLabel, ]; }).toList(), ), diff --git a/test/widget_test.dart b/test/widget_test.dart index b01654c..d1f8a77 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -14,6 +14,7 @@ void main() { createdDate: now, street: 'Hauptstraße', category: 'Straßenbeleuchtung', + status: 'Gemeldet', ); expect(item.id, 'test-123'); @@ -24,6 +25,46 @@ void main() { expect(item.status, 'Gemeldet'); }); + test('defaults status to Gemeldet only for issues and null for news/ideas', () { + final issueJson = { + 'id': 'issue-1', + 'title': 'Schlagloch', + 'description': 'Tiefes Schlagloch', + 'createdDate': '2026-09-18T10:00:00.000', + 'street': 'Kirchengasse', + 'category': 'Straße', + }; + final issueItem = PostItem.fromJson(issueJson); + expect(issueItem.status, 'Gemeldet'); + + final newsJson = { + 'id': 'news-1', + 'title': 'Dorffest Ankündigung', + 'description': 'Am Samstag findet das Fest statt.', + 'createdDate': '2026-09-18T10:00:00.000', + }; + final newsItem = PostItem.fromJson(newsJson); + expect(newsItem.status, isNull); + }); + + test('handles multiple images in PostItem', () { + final item = PostItem( + id: 'multi-img-1', + title: 'Baustelle', + description: 'Zwei Fotos vorhanden', + createdDate: DateTime.now(), + imageUrls: [ + 'https://example.com/img1.jpg', + 'https://example.com/img2.jpg', + ], + ); + + expect(item.imageUrls.length, 2); + expect(item.imageUrls[0], 'https://example.com/img1.jpg'); + expect(item.imageUrls[1], 'https://example.com/img2.jpg'); + expect(item.imageUrl, 'https://example.com/img1.jpg'); + }); + test('serializes to Firestore map accurately', () { final now = DateTime.now(); final item = PostItem(