diff --git a/lib/models/post_item.dart b/lib/models/post_item.dart index 97c165c..64c8c73 100644 --- a/lib/models/post_item.dart +++ b/lib/models/post_item.dart @@ -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 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 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 images = []; + if (map['imageUrls'] is List) { + images = List.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']*>.*?', dotAll: true), ''); + clean = clean.replaceAll(RegExp(r']*>.*?', 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 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, + ); + } } diff --git a/lib/screens/detail_screen.dart b/lib/screens/detail_screen.dart index 65da2b1..3b116db 100644 --- a/lib/screens/detail_screen.dart +++ b/lib/screens/detail_screen.dart @@ -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) ...[ diff --git a/lib/screens/pdf_viewer_screen.dart b/lib/screens/pdf_viewer_screen.dart new file mode 100644 index 0000000..0ad0c7d --- /dev/null +++ b/lib/screens/pdf_viewer_screen.dart @@ -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 _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'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/post_list_screen.dart b/lib/screens/post_list_screen.dart index e5a380d..184119b 100644 --- a/lib/screens/post_list_screen.dart +++ b/lib/screens/post_list_screen.dart @@ -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 { final FirestoreService _firestoreService = FirestoreService(); final StorageService _storageService = StorageService(); + final AussendungenService _aussendungenService = AussendungenService(); final Map _selectedItems = {}; String _searchQuery = ''; bool _isSearchActive = false; final TextEditingController _searchController = TextEditingController(); + List _aussendungen = []; + bool _isLoadingAussendungen = false; + String _newsFilter = 'all'; // 'all', 'aussendungen', 'news' + + @override + void initState() { + super.initState(); + if (widget.collectionName == 'News') { + _loadAussendungen(); + } + } + + Future _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 { ], ], ), - body: StreamBuilder>( - 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 { ), ); } + + 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>( + 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 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; + }, + ); + }, + ); + } } diff --git a/lib/services/aussendungen_service.dart b/lib/services/aussendungen_service.dart new file mode 100644 index 0000000..ec8e65c --- /dev/null +++ b/lib/services/aussendungen_service.dart @@ -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? _memoryCache; + + /// Returns cached posts immediately if available, followed by fresh network posts + Future> 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 []; + }); + 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> _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 items = []; + for (final item in decoded) { + if (item is Map) { + 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> _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 items = []; + for (final item in decoded) { + if (item is Map) { + 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 _saveToLocalCache(List jsonList) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_cacheKey, jsonEncode(jsonList)); + } catch (e) { + debugPrint('Error saving aussendungen to cache: '); + } + } +} diff --git a/pubspec.lock b/pubspec.lock index b8c71e0..821bb22 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -829,6 +829,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "17cd5e205ea615e2c6ea7a77323a11712dffa0720a8a90540db57a01347f9ad9" + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626" + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" + url: "https://pub.dev" + source: hosted + version: "2.3.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4" + url: "https://pub.dev" + source: hosted + version: "3.1.3" uuid: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index db92daa..9b3605d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,6 +54,7 @@ dependencies: provider: ^6.0.5 http: ^1.2.0 shared_preferences: ^2.2.2 + url_launcher: ^6.3.1 dev_dependencies: diff --git a/test/widget_test.dart b/test/widget_test.dart index f5c28cf..734bb41 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -41,5 +41,79 @@ void main() { expect(map['category'], 'Müll / Verunreinigung'); expect(map['status'], 'In Bearbeitung'); }); + + test('parses WordPress JSON accurately with HTML unescape and PDF extraction', () { + final wpJson = { + 'id': 659, + 'date': '2026-09-10T10:42:00', + 'title': {'rendered': 'Aussendung 17 – Konzept & Camping'}, + 'content': { + 'rendered': + '

Aussendung 17

' + }, + 'link': 'https://buergerforumoggau.at/2026/09/aussendung-17/', + }; + + final item = PostItem.fromWordPressJson(wpJson); + expect(item.id, 'wp_659'); + expect(item.title, 'Aussendung 17 – Konzept & Camping'); + expect(item.isAussendung, true); + expect(item.pdfUrl, + 'https://buergerforumoggau.at/wp-content/uploads/2026/09/20260917_Aussendung_17_-_Camping_Final.pdf'); + expect(item.webUrl, 'https://buergerforumoggau.at/2026/09/aussendung-17/'); + expect(item.category, 'Aussendung'); + expect(item.createdDate, DateTime.parse('2026-09-10T10:42:00')); + }); + + test('serializes to and from local JSON cache correctly', () { + final item = PostItem( + id: 'wp_999', + title: 'Aussendung 99', + description: 'Test Aussendung', + createdDate: DateTime(2026, 9, 14, 12, 0), + pdfUrl: 'https://buergerforumoggau.at/test.pdf', + webUrl: 'https://buergerforumoggau.at/test/', + isAussendung: true, + ); + + final json = item.toJson(); + final restored = PostItem.fromJson(json); + + expect(restored.id, item.id); + expect(restored.title, item.title); + expect(restored.pdfUrl, item.pdfUrl); + expect(restored.webUrl, item.webUrl); + expect(restored.isAussendung, true); + expect(restored.createdDate, item.createdDate); + }); + + test('sorts mixed list of Firestore news and Aussendungen chronologically', () { + final oldNews = PostItem( + id: 'news_1', + title: 'Ältere News', + description: 'Text', + createdDate: DateTime(2026, 8, 1), + ); + final newAussendung = PostItem( + id: 'wp_659', + title: 'Aussendung 17', + description: 'Text', + createdDate: DateTime(2026, 9, 10), + isAussendung: true, + ); + final newestNews = PostItem( + id: 'news_2', + title: 'Neueste Nachricht', + description: 'Text', + createdDate: DateTime(2026, 9, 14), + ); + + final list = [oldNews, newAussendung, newestNews]; + list.sort((a, b) => b.createdDate.compareTo(a.createdDate)); + + expect(list[0].id, 'news_2'); + expect(list[1].id, 'wp_659'); + expect(list[2].id, 'news_1'); + }); }); }