fix: PDF zoom boundaries, Mängel status display in list view, and swipeable image gallery with PDF export
Build Android APK / Build Release APKs (push) Successful in 14m49s

This commit is contained in:
2026-09-18 13:46:21 +02:00
parent f10d5ad5ed
commit 974223f7e7
6 changed files with 384 additions and 82 deletions
+18 -6
View File
@@ -19,17 +19,20 @@ class PostItem {
required this.id,
required this.title,
required this.description,
this.imageUrl = '',
String imageUrl = '',
List<String>? 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<String, dynamic>? ?? {};
@@ -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,
+154 -54
View File
@@ -27,6 +27,8 @@ class DetailScreen extends StatefulWidget {
class _DetailScreenState extends State<DetailScreen> {
late PostItem _item;
int _currentImageIndex = 0;
final PageController _pageController = PageController();
@override
void initState() {
@@ -34,6 +36,12 @@ class _DetailScreenState extends State<DetailScreen> {
_item = widget.item;
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
Future<void> _editItem() async {
final collection = widget.collectionName ??
(_item.street != null ? 'Issues' : 'CitizensForum');
@@ -118,6 +126,7 @@ class _DetailScreenState extends State<DetailScreen> {
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<DetailScreen> {
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<DetailScreen> {
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<DetailScreen> {
),
],
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,
),
);
},
),
),
],
],
),
),
+5 -3
View File
@@ -61,8 +61,9 @@ class _PdfViewerScreenState extends State<PdfViewerScreen>
} 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<PdfViewerScreen>
panEnabled: true,
scaleEnabled: true,
constrained: false,
alignment: Alignment.topCenter,
boundaryMargin: const EdgeInsets.symmetric(
horizontal: 48, vertical: 80),
child: SizedBox(
width: viewportWidth,
child: Padding(
+60 -1
View File
@@ -429,6 +429,8 @@ class _PostListScreenState extends State<PostListScreen> {
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<PostListScreen> {
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: [
+106 -18
View File
@@ -35,8 +35,15 @@ class PdfService {
/// Print single post item as PDF
static Future<void> 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<String> 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<pw.ImageProvider>().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<String, pw.ImageProvider?> 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: <String>['Datum', 'Örtlichkeit / Kat.', 'Titel & Mangel', 'Status', 'Foto'],
headers: <String>[
'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(),
),