feat: integrate WordPress PDF Aussendungen with in-app reader into News
This commit is contained in:
@@ -11,6 +11,9 @@ class PostItem {
|
||||
final String? street;
|
||||
final String? category;
|
||||
final String? status;
|
||||
final String? pdfUrl;
|
||||
final String? webUrl;
|
||||
final bool isAussendung;
|
||||
|
||||
PostItem({
|
||||
required this.id,
|
||||
@@ -23,6 +26,9 @@ class PostItem {
|
||||
this.street,
|
||||
this.category,
|
||||
this.status = 'Gemeldet',
|
||||
this.pdfUrl,
|
||||
this.webUrl,
|
||||
this.isAussendung = false,
|
||||
}) : imageUrls = imageUrls ?? (imageUrl.isNotEmpty ? [imageUrl] : []);
|
||||
|
||||
factory PostItem.fromFirestore(DocumentSnapshot doc) {
|
||||
@@ -54,6 +60,9 @@ class PostItem {
|
||||
street: data['street'] as String?,
|
||||
category: data['category'] as String?,
|
||||
status: data['status'] as String? ?? 'Gemeldet',
|
||||
pdfUrl: data['pdfUrl'] as String?,
|
||||
webUrl: data['webUrl'] as String?,
|
||||
isAussendung: data['isAussendung'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,6 +77,163 @@ class PostItem {
|
||||
if (street != null) 'street': street,
|
||||
if (category != null) 'category': category,
|
||||
if (status != null) 'status': status,
|
||||
if (pdfUrl != null) 'pdfUrl': pdfUrl,
|
||||
if (webUrl != null) 'webUrl': webUrl,
|
||||
if (isAussendung) 'isAussendung': isAussendung,
|
||||
};
|
||||
}
|
||||
|
||||
/// Convert to standard JSON map for local caching
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'imageUrl': imageUrl,
|
||||
'imageUrls': imageUrls,
|
||||
'createdDate': createdDate.toIso8601String(),
|
||||
'authorUid': authorUid,
|
||||
'street': street,
|
||||
'category': category,
|
||||
'status': status,
|
||||
'pdfUrl': pdfUrl,
|
||||
'webUrl': webUrl,
|
||||
'isAussendung': isAussendung,
|
||||
};
|
||||
}
|
||||
|
||||
/// Create from standard JSON map (local cache)
|
||||
factory PostItem.fromJson(Map<String, dynamic> map) {
|
||||
DateTime date;
|
||||
if (map['createdDate'] is String) {
|
||||
date = DateTime.tryParse(map['createdDate'] as String) ?? DateTime.now();
|
||||
} else {
|
||||
date = DateTime.now();
|
||||
}
|
||||
|
||||
final String legacyImage = map['imageUrl'] as String? ?? '';
|
||||
List<String> images = [];
|
||||
if (map['imageUrls'] is List) {
|
||||
images = List<String>.from(map['imageUrls'] as List);
|
||||
} else if (legacyImage.isNotEmpty) {
|
||||
images = [legacyImage];
|
||||
}
|
||||
|
||||
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 : ''),
|
||||
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',
|
||||
pdfUrl: map['pdfUrl'] as String?,
|
||||
webUrl: map['webUrl'] as String?,
|
||||
isAussendung: map['isAussendung'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Decode HTML entities commonly present in WordPress feeds
|
||||
static String unescapeHtml(String text) {
|
||||
var result = text
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('–', '–')
|
||||
.replaceAll('—', '—')
|
||||
.replaceAll('‘', '‘')
|
||||
.replaceAll('’', '’')
|
||||
.replaceAll('“', '“')
|
||||
.replaceAll('”', '”')
|
||||
.replaceAll('…', '…');
|
||||
|
||||
// Decode numerical entities &#DDD;
|
||||
result = result.replaceAllMapped(RegExp(r'&#(\d+);'), (match) {
|
||||
final code = int.tryParse(match.group(1)!);
|
||||
return code != null ? String.fromCharCode(code) : match.group(0)!;
|
||||
});
|
||||
|
||||
// Decode hex numerical entities &#xHH;
|
||||
result = result.replaceAllMapped(RegExp(r'&#x([0-9a-fA-F]+);'), (match) {
|
||||
final code = int.tryParse(match.group(1)!, radix: 16);
|
||||
return code != null ? String.fromCharCode(code) : match.group(0)!;
|
||||
});
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
/// Extracts the direct PDF URL from WordPress post HTML content
|
||||
static String? extractPdfUrl(String htmlContent) {
|
||||
final pdfRegExp = RegExp(
|
||||
r'https?://[^\s"<>]+?\.pdf',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final match = pdfRegExp.firstMatch(htmlContent);
|
||||
if (match != null) {
|
||||
return match.group(0);
|
||||
}
|
||||
// Fallback: check docx or doc files
|
||||
final docRegExp = RegExp(
|
||||
r'https?://[^\s"<>]+?\.(?:docx|doc)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final docMatch = docRegExp.firstMatch(htmlContent);
|
||||
return docMatch?.group(0);
|
||||
}
|
||||
|
||||
/// Strip HTML tags and clean whitespace for preview text
|
||||
static String extractCleanText(String htmlContent) {
|
||||
var clean = htmlContent.replaceAll(RegExp(r'<style[^>]*>.*?</style>', dotAll: true), '');
|
||||
clean = clean.replaceAll(RegExp(r'<script[^>]*>.*?</script>', dotAll: true), '');
|
||||
clean = clean.replaceAll(RegExp(r'<[^>]*>'), ' ');
|
||||
clean = unescapeHtml(clean);
|
||||
clean = clean.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
return clean;
|
||||
}
|
||||
|
||||
/// Parse a post from the WordPress REST API (/wp-json/wp/v2/posts)
|
||||
factory PostItem.fromWordPressJson(Map<String, dynamic> json) {
|
||||
final rawTitle = (json['title'] is Map)
|
||||
? (json['title']['rendered'] as String? ?? '')
|
||||
: (json['title'] as String? ?? '');
|
||||
final title = unescapeHtml(rawTitle);
|
||||
|
||||
final rawContent = (json['content'] is Map)
|
||||
? (json['content']['rendered'] as String? ?? '')
|
||||
: (json['content'] as String? ?? '');
|
||||
|
||||
final pdfUrl = extractPdfUrl(rawContent);
|
||||
final webUrl = json['link'] as String?;
|
||||
|
||||
DateTime date;
|
||||
if (json['date'] is String) {
|
||||
date = DateTime.tryParse(json['date'] as String) ?? DateTime.now();
|
||||
} else {
|
||||
date = DateTime.now();
|
||||
}
|
||||
|
||||
String desc = extractCleanText(rawContent);
|
||||
if (desc.isEmpty || desc == title) {
|
||||
desc = 'Offizielle Aussendung des Bürgerforums Oggau (PDF).';
|
||||
}
|
||||
|
||||
return PostItem(
|
||||
id: 'wp_${json['id']}',
|
||||
title: title.isNotEmpty ? title : 'Aussendung',
|
||||
description: desc,
|
||||
createdDate: date,
|
||||
category: 'Aussendung',
|
||||
status: 'Aussendung',
|
||||
pdfUrl: pdfUrl,
|
||||
webUrl: webUrl,
|
||||
isAussendung: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../models/post_item.dart';
|
||||
import '../services/pdf_service.dart';
|
||||
import 'pdf_viewer_screen.dart';
|
||||
|
||||
class DetailScreen extends StatelessWidget {
|
||||
final PostItem item;
|
||||
@@ -123,6 +124,41 @@ class DetailScreen extends StatelessWidget {
|
||||
item.description,
|
||||
style: const TextStyle(fontSize: 15, height: 1.5),
|
||||
),
|
||||
if (item.pdfUrl != null && item.pdfUrl!.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
color: Colors.red.shade50,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: Colors.red.shade200),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: const CircleAvatar(
|
||||
backgroundColor: Colors.red,
|
||||
child: Icon(Icons.picture_as_pdf, color: Colors.white),
|
||||
),
|
||||
title: const Text(
|
||||
'PDF-Aussendung ansehen',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle:
|
||||
const Text('Dokument jetzt in der App öffnen und lesen'),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (ctx) => PdfViewerScreen(
|
||||
title: item.title,
|
||||
pdfUrl: item.pdfUrl!,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
// Additional images gallery if there are multiple images
|
||||
if (item.imageUrls.length > 1) ...[
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:printing/printing.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class PdfViewerScreen extends StatelessWidget {
|
||||
final String title;
|
||||
final String pdfUrl;
|
||||
|
||||
const PdfViewerScreen({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.pdfUrl,
|
||||
});
|
||||
|
||||
Future<void> _openExternal() async {
|
||||
final uri = Uri.parse(pdfUrl);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final safeTitle = title.replaceAll(RegExp(r'[\\/:*?<>|]'), '_');
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(title, overflow: TextOverflow.ellipsis),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.open_in_browser),
|
||||
tooltip: 'Im Browser öffnen',
|
||||
onPressed: _openExternal,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: PdfPreview(
|
||||
build: (PdfPageFormat format) async {
|
||||
final response = await http
|
||||
.get(Uri.parse(pdfUrl))
|
||||
.timeout(const Duration(seconds: 15));
|
||||
if (response.statusCode == 200) {
|
||||
return response.bodyBytes;
|
||||
}
|
||||
throw Exception('Fehler beim Abrufen der Datei (${response.statusCode})');
|
||||
},
|
||||
canChangeOrientation: false,
|
||||
canChangePageFormat: false,
|
||||
canDebug: false,
|
||||
maxPageWidth: 700,
|
||||
pdfFileName: '$safeTitle.pdf',
|
||||
loadingWidget: const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Aussendung wird geladen...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
onError: (context, error) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Fehler beim Laden des Dokuments: $error',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _openExternal,
|
||||
icon: const Icon(Icons.open_in_browser),
|
||||
label: const Text('Direkt im Browser öffnen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+448
-228
@@ -2,7 +2,9 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../models/post_item.dart';
|
||||
import '../services/aussendungen_service.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../services/firestore_service.dart';
|
||||
import '../services/pdf_service.dart';
|
||||
@@ -11,6 +13,7 @@ import 'add_issue_screen.dart';
|
||||
import 'add_post_screen.dart';
|
||||
import 'auth_dialog.dart';
|
||||
import 'detail_screen.dart';
|
||||
import 'pdf_viewer_screen.dart';
|
||||
|
||||
class PostListScreen extends StatefulWidget {
|
||||
final String collectionName;
|
||||
@@ -29,12 +32,44 @@ class PostListScreen extends StatefulWidget {
|
||||
class _PostListScreenState extends State<PostListScreen> {
|
||||
final FirestoreService _firestoreService = FirestoreService();
|
||||
final StorageService _storageService = StorageService();
|
||||
final AussendungenService _aussendungenService = AussendungenService();
|
||||
|
||||
final Map<String, PostItem> _selectedItems = {};
|
||||
String _searchQuery = '';
|
||||
bool _isSearchActive = false;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
List<PostItem> _aussendungen = [];
|
||||
bool _isLoadingAussendungen = false;
|
||||
String _newsFilter = 'all'; // 'all', 'aussendungen', 'news'
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.collectionName == 'News') {
|
||||
_loadAussendungen();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadAussendungen({bool force = false}) async {
|
||||
if (widget.collectionName != 'News') return;
|
||||
if (mounted) setState(() => _isLoadingAussendungen = true);
|
||||
try {
|
||||
final items = await _aussendungenService.getAussendungen(forceRefresh: force);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_aussendungen = items;
|
||||
_isLoadingAussendungen = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error loading Aussendungen: $e');
|
||||
if (mounted) {
|
||||
setState(() => _isLoadingAussendungen = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
@@ -211,236 +246,23 @@ class _PostListScreenState extends State<PostListScreen> {
|
||||
],
|
||||
],
|
||||
),
|
||||
body: StreamBuilder<List<PostItem>>(
|
||||
stream: _firestoreService.getPostsStream(widget.collectionName),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Fehler beim Laden: ${snapshot.error}',
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final allItems = snapshot.data ?? [];
|
||||
final filteredItems = allItems.where((item) {
|
||||
if (_searchQuery.isEmpty) return true;
|
||||
final query = _searchQuery.toLowerCase();
|
||||
final matchesTitle = item.title.toLowerCase().contains(query);
|
||||
final matchesDesc = item.description.toLowerCase().contains(query);
|
||||
final matchesStreet =
|
||||
item.street?.toLowerCase().contains(query) ?? false;
|
||||
final matchesCategory =
|
||||
item.category?.toLowerCase().contains(query) ?? false;
|
||||
return matchesTitle || matchesDesc || matchesStreet || matchesCategory;
|
||||
}).toList();
|
||||
|
||||
if (filteredItems.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.inbox, size: 64, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_searchQuery.isNotEmpty
|
||||
? 'Keine Treffer für "$_searchQuery"'
|
||||
: 'Noch keine Einträge vorhanden.',
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 16),
|
||||
body: widget.collectionName == 'News'
|
||||
? Column(
|
||||
children: [
|
||||
_buildNewsFilterBar(),
|
||||
if (_isLoadingAussendungen && _aussendungen.isEmpty)
|
||||
const LinearProgressIndicator(minHeight: 2),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await _loadAussendungen(force: true);
|
||||
},
|
||||
child: _buildPostList(auth, currentUser),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: filteredItems.length,
|
||||
separatorBuilder: (ctx, idx) => const Divider(
|
||||
height: 1,
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
color: Colors.black12,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final item = filteredItems[index];
|
||||
final isSelected = _selectedItems.containsKey(item.id);
|
||||
final isAuthor =
|
||||
currentUser != null && item.authorUid == currentUser.uid;
|
||||
final canDelete = isAuthor || auth.isAdmin;
|
||||
final formattedDate =
|
||||
DateFormat('dd.MM.yyyy').format(item.createdDate);
|
||||
|
||||
Widget itemTile = ListTile(
|
||||
selected: isSelected,
|
||||
selectedTileColor: Colors.blue.shade50,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
onTap: () {
|
||||
if (_selectedItems.isNotEmpty) {
|
||||
_toggleSelection(item);
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (ctx) => DetailScreen(item: item),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPress: () => _toggleSelection(item),
|
||||
leading: item.imageUrl.isNotEmpty
|
||||
? Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.imageUrl,
|
||||
width: 70,
|
||||
height: 70,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (ctx, url) => Container(
|
||||
width: 70,
|
||||
height: 70,
|
||||
color: Colors.grey.shade200,
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
errorWidget: (ctx, url, err) => Container(
|
||||
width: 70,
|
||||
height: 70,
|
||||
color: Colors.grey.shade200,
|
||||
child: const Icon(Icons.image, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
const Positioned(
|
||||
top: 2,
|
||||
left: 2,
|
||||
child: CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundColor: Colors.blue,
|
||||
child: Icon(Icons.check, size: 16, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: isSelected
|
||||
? const CircleAvatar(
|
||||
backgroundColor: Colors.blue,
|
||||
child: Icon(Icons.check, color: Colors.white),
|
||||
)
|
||||
: null,
|
||||
title: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
formattedDate,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (item.street != null && item.street!.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on, size: 12, color: Colors.redAccent),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
item.street!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey.shade800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (canDelete) {
|
||||
return Dismissible(
|
||||
key: Key(item.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
color: Colors.red,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: const Icon(Icons.delete, color: Colors.white),
|
||||
),
|
||||
confirmDismiss: (direction) async {
|
||||
return await showDialog(
|
||||
context: context,
|
||||
builder: (dialogCtx) => AlertDialog(
|
||||
title: const Text('Eintrag löschen?'),
|
||||
content: const Text(
|
||||
'Möchten Sie diesen Eintrag wirklich unwiderruflich löschen?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogCtx, false),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red),
|
||||
onPressed: () => Navigator.pop(dialogCtx, true),
|
||||
child: const Text('Löschen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
onDismissed: (direction) => _deletePost(item),
|
||||
child: itemTile,
|
||||
);
|
||||
}
|
||||
|
||||
return itemTile;
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
: _buildPostList(auth, currentUser),
|
||||
floatingActionButton: (widget.collectionName == 'News' && !auth.isAdmin)
|
||||
? null
|
||||
: FloatingActionButton(
|
||||
@@ -452,4 +274,402 @@ class _PostListScreenState extends State<PostListScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNewsFilterBar() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: Colors.grey.shade50,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('Alle'),
|
||||
selected: _newsFilter == 'all',
|
||||
onSelected: (_) => setState(() => _newsFilter = 'all'),
|
||||
selectedColor: Colors.blue.shade100,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ChoiceChip(
|
||||
avatar: Icon(
|
||||
Icons.picture_as_pdf,
|
||||
size: 16,
|
||||
color: _newsFilter == 'aussendungen'
|
||||
? Colors.red.shade900
|
||||
: Colors.red.shade700,
|
||||
),
|
||||
label: Text('Aussendungen (${_aussendungen.length})'),
|
||||
selected: _newsFilter == 'aussendungen',
|
||||
onSelected: (_) => setState(() => _newsFilter = 'aussendungen'),
|
||||
selectedColor: Colors.red.shade100,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ChoiceChip(
|
||||
avatar: Icon(
|
||||
Icons.newspaper,
|
||||
size: 16,
|
||||
color: _newsFilter == 'news'
|
||||
? Colors.blue.shade900
|
||||
: Colors.blue.shade700,
|
||||
),
|
||||
label: const Text('Aktuelles'),
|
||||
selected: _newsFilter == 'news',
|
||||
onSelected: (_) => setState(() => _newsFilter = 'news'),
|
||||
selectedColor: Colors.blue.shade100,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPostList(AuthService auth, dynamic currentUser) {
|
||||
return StreamBuilder<List<PostItem>>(
|
||||
stream: _firestoreService.getPostsStream(widget.collectionName),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Fehler beim Laden: ${snapshot.error}',
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshot.connectionState == ConnectionState.waiting &&
|
||||
widget.collectionName != 'News') {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final firestoreItems = snapshot.data ?? [];
|
||||
List<PostItem> allItems;
|
||||
|
||||
if (widget.collectionName == 'News') {
|
||||
allItems = [...firestoreItems, ..._aussendungen];
|
||||
allItems.sort((a, b) => b.createdDate.compareTo(a.createdDate));
|
||||
|
||||
if (_newsFilter == 'aussendungen') {
|
||||
allItems = allItems.where((item) => item.isAussendung).toList();
|
||||
} else if (_newsFilter == 'news') {
|
||||
allItems = allItems.where((item) => !item.isAussendung).toList();
|
||||
}
|
||||
} else {
|
||||
allItems = firestoreItems;
|
||||
}
|
||||
|
||||
final filteredItems = allItems.where((item) {
|
||||
if (_searchQuery.isEmpty) return true;
|
||||
final query = _searchQuery.toLowerCase();
|
||||
final matchesTitle = item.title.toLowerCase().contains(query);
|
||||
final matchesDesc = item.description.toLowerCase().contains(query);
|
||||
final matchesStreet =
|
||||
item.street?.toLowerCase().contains(query) ?? false;
|
||||
final matchesCategory =
|
||||
item.category?.toLowerCase().contains(query) ?? false;
|
||||
return matchesTitle || matchesDesc || matchesStreet || matchesCategory;
|
||||
}).toList();
|
||||
|
||||
if (filteredItems.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.inbox, size: 64, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_searchQuery.isNotEmpty
|
||||
? 'Keine Treffer für "$_searchQuery"'
|
||||
: 'Noch keine Einträge vorhanden.',
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: filteredItems.length,
|
||||
separatorBuilder: (ctx, idx) => const Divider(
|
||||
height: 1,
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
color: Colors.black12,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final item = filteredItems[index];
|
||||
final isSelected = _selectedItems.containsKey(item.id);
|
||||
final isAuthor =
|
||||
currentUser != null && item.authorUid == currentUser.uid;
|
||||
final canDelete = !item.isAussendung && (isAuthor || auth.isAdmin);
|
||||
final formattedDate =
|
||||
DateFormat('dd.MM.yyyy').format(item.createdDate);
|
||||
|
||||
Widget? leadingWidget;
|
||||
if (item.imageUrl.isNotEmpty) {
|
||||
leadingWidget = Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.imageUrl,
|
||||
width: 70,
|
||||
height: 70,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (ctx, url) => Container(
|
||||
width: 70,
|
||||
height: 70,
|
||||
color: Colors.grey.shade200,
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
errorWidget: (ctx, url, err) => Container(
|
||||
width: 70,
|
||||
height: 70,
|
||||
color: Colors.grey.shade200,
|
||||
child: const Icon(Icons.image, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
const Positioned(
|
||||
top: 2,
|
||||
left: 2,
|
||||
child: CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundColor: Colors.blue,
|
||||
child: Icon(Icons.check, size: 16, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (item.isAussendung) {
|
||||
leadingWidget = Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 58,
|
||||
height: 58,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.red.shade200),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.picture_as_pdf,
|
||||
color: Colors.red.shade700, size: 26),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'PDF',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red.shade800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
const Positioned(
|
||||
top: 2,
|
||||
left: 2,
|
||||
child: CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundColor: Colors.blue,
|
||||
child: Icon(Icons.check, size: 16, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (isSelected) {
|
||||
leadingWidget = const CircleAvatar(
|
||||
backgroundColor: Colors.blue,
|
||||
child: Icon(Icons.check, color: Colors.white),
|
||||
);
|
||||
}
|
||||
|
||||
Widget itemTile = ListTile(
|
||||
selected: isSelected,
|
||||
selectedTileColor: Colors.blue.shade50,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
onTap: () async {
|
||||
if (_selectedItems.isNotEmpty) {
|
||||
_toggleSelection(item);
|
||||
} else if (item.isAussendung) {
|
||||
if (item.pdfUrl != null && item.pdfUrl!.isNotEmpty) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (ctx) => PdfViewerScreen(
|
||||
title: item.title,
|
||||
pdfUrl: item.pdfUrl!,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (item.webUrl != null && item.webUrl!.isNotEmpty) {
|
||||
final uri = Uri.parse(item.webUrl!);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri,
|
||||
mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (ctx) => DetailScreen(item: item),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPress: () => _toggleSelection(item),
|
||||
leading: leadingWidget,
|
||||
trailing: item.isAussendung
|
||||
? Icon(Icons.arrow_forward_ios,
|
||||
size: 14, color: Colors.grey.shade400)
|
||||
: null,
|
||||
title: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
formattedDate,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (item.street != null && item.street!.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on,
|
||||
size: 12, color: Colors.redAccent),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
item.street!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
if (item.isAussendung)
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade100,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'Aussendung (PDF)',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red.shade900,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.description,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Text(
|
||||
item.description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey.shade800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (canDelete) {
|
||||
return Dismissible(
|
||||
key: Key(item.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
color: Colors.red,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: const Icon(Icons.delete, color: Colors.white),
|
||||
),
|
||||
confirmDismiss: (direction) async {
|
||||
return await showDialog(
|
||||
context: context,
|
||||
builder: (dialogCtx) => AlertDialog(
|
||||
title: const Text('Eintrag löschen?'),
|
||||
content: const Text(
|
||||
'Möchten Sie diesen Eintrag wirklich unwiderruflich löschen?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogCtx, false),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red),
|
||||
onPressed: () => Navigator.pop(dialogCtx, true),
|
||||
child: const Text('Löschen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
onDismissed: (direction) => _deletePost(item),
|
||||
child: itemTile,
|
||||
);
|
||||
}
|
||||
|
||||
return itemTile;
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/post_item.dart';
|
||||
|
||||
class AussendungenService {
|
||||
static const String _cacheKey = 'cached_aussendungen_v1';
|
||||
static const String _apiUrl =
|
||||
'https://buergerforumoggau.at/wp-json/wp/v2/posts?categories=9&per_page=50';
|
||||
|
||||
static List<PostItem>? _memoryCache;
|
||||
|
||||
/// Returns cached posts immediately if available, followed by fresh network posts
|
||||
Future<List<PostItem>> getAussendungen({bool forceRefresh = false}) async {
|
||||
if (!forceRefresh && _memoryCache != null && _memoryCache!.isNotEmpty) {
|
||||
return _memoryCache!;
|
||||
}
|
||||
|
||||
// Try loading from local SharedPreferences cache first
|
||||
if (!forceRefresh) {
|
||||
final cached = await _loadFromLocalCache();
|
||||
if (cached.isNotEmpty) {
|
||||
_memoryCache = cached;
|
||||
// Trigger background refresh silently
|
||||
_fetchFromNetworkAndCache().catchError((e) {
|
||||
debugPrint('Silent background refresh failed: ');
|
||||
return <PostItem>[];
|
||||
});
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise fetch fresh from network
|
||||
try {
|
||||
final fresh = await _fetchFromNetworkAndCache();
|
||||
if (fresh.isNotEmpty) {
|
||||
_memoryCache = fresh;
|
||||
return fresh;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Aussendungen network fetch error: ');
|
||||
final fallback = await _loadFromLocalCache();
|
||||
if (fallback.isNotEmpty) {
|
||||
_memoryCache = fallback;
|
||||
return fallback;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
|
||||
return _memoryCache ?? [];
|
||||
}
|
||||
|
||||
/// Network request to WordPress REST API
|
||||
Future<List<PostItem>> _fetchFromNetworkAndCache() async {
|
||||
final response = await http
|
||||
.get(Uri.parse(_apiUrl))
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final dynamic decoded = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
if (decoded is List) {
|
||||
final List<PostItem> items = [];
|
||||
for (final item in decoded) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
try {
|
||||
final post = PostItem.fromWordPressJson(item);
|
||||
items.add(post);
|
||||
} catch (e) {
|
||||
debugPrint('Error parsing WP post item: ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _saveToLocalCache(decoded);
|
||||
_memoryCache = items;
|
||||
return items;
|
||||
}
|
||||
} else {
|
||||
throw Exception('Server returned ');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Load from SharedPreferences
|
||||
Future<List<PostItem>> _loadFromLocalCache() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final String? jsonString = prefs.getString(_cacheKey);
|
||||
if (jsonString != null && jsonString.isNotEmpty) {
|
||||
final dynamic decoded = jsonDecode(jsonString);
|
||||
if (decoded is List) {
|
||||
final List<PostItem> items = [];
|
||||
for (final item in decoded) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
try {
|
||||
items.add(PostItem.fromWordPressJson(item));
|
||||
} catch (e) {
|
||||
debugPrint('Error parsing cached WP item: ');
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error loading cached aussendungen: ');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Save to SharedPreferences
|
||||
Future<void> _saveToLocalCache(List<dynamic> jsonList) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_cacheKey, jsonEncode(jsonList));
|
||||
} catch (e) {
|
||||
debugPrint('Error saving aussendungen to cache: ');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user