feat: integrate WordPress PDF Aussendungen with in-app reader into News

This commit is contained in:
2026-09-14 21:32:03 +02:00
parent e512c081e3
commit 9a823cbcdb
8 changed files with 1000 additions and 228 deletions
+166
View File
@@ -11,6 +11,9 @@ class PostItem {
final String? street; final String? street;
final String? category; final String? category;
final String? status; final String? status;
final String? pdfUrl;
final String? webUrl;
final bool isAussendung;
PostItem({ PostItem({
required this.id, required this.id,
@@ -23,6 +26,9 @@ class PostItem {
this.street, this.street,
this.category, this.category,
this.status = 'Gemeldet', this.status = 'Gemeldet',
this.pdfUrl,
this.webUrl,
this.isAussendung = false,
}) : imageUrls = imageUrls ?? (imageUrl.isNotEmpty ? [imageUrl] : []); }) : imageUrls = imageUrls ?? (imageUrl.isNotEmpty ? [imageUrl] : []);
factory PostItem.fromFirestore(DocumentSnapshot doc) { factory PostItem.fromFirestore(DocumentSnapshot doc) {
@@ -54,6 +60,9 @@ class PostItem {
street: data['street'] as String?, street: data['street'] as String?,
category: data['category'] as String?, category: data['category'] as String?,
status: data['status'] as String? ?? 'Gemeldet', 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 (street != null) 'street': street,
if (category != null) 'category': category, if (category != null) 'category': category,
if (status != null) 'status': status, 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('&amp;', '&')
.replaceAll('&quot;', '"')
.replaceAll('&apos;', "'")
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&nbsp;', ' ')
.replaceAll('&#8211;', '–')
.replaceAll('&#8212;', '—')
.replaceAll('&#8216;', '‘')
.replaceAll('&#8217;', '’')
.replaceAll('&#8220;', '“')
.replaceAll('&#8221;', '”')
.replaceAll('&#8230;', '…');
// 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,
);
}
} }
+36
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../models/post_item.dart'; import '../models/post_item.dart';
import '../services/pdf_service.dart'; import '../services/pdf_service.dart';
import 'pdf_viewer_screen.dart';
class DetailScreen extends StatelessWidget { class DetailScreen extends StatelessWidget {
final PostItem item; final PostItem item;
@@ -123,6 +124,41 @@ class DetailScreen extends StatelessWidget {
item.description, item.description,
style: const TextStyle(fontSize: 15, height: 1.5), 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), const SizedBox(height: 24),
// Additional images gallery if there are multiple images // Additional images gallery if there are multiple images
if (item.imageUrls.length > 1) ...[ if (item.imageUrls.length > 1) ...[
+90
View File
@@ -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'),
),
],
),
),
),
),
);
}
}
+260 -40
View File
@@ -2,7 +2,9 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/post_item.dart'; import '../models/post_item.dart';
import '../services/aussendungen_service.dart';
import '../services/auth_service.dart'; import '../services/auth_service.dart';
import '../services/firestore_service.dart'; import '../services/firestore_service.dart';
import '../services/pdf_service.dart'; import '../services/pdf_service.dart';
@@ -11,6 +13,7 @@ import 'add_issue_screen.dart';
import 'add_post_screen.dart'; import 'add_post_screen.dart';
import 'auth_dialog.dart'; import 'auth_dialog.dart';
import 'detail_screen.dart'; import 'detail_screen.dart';
import 'pdf_viewer_screen.dart';
class PostListScreen extends StatefulWidget { class PostListScreen extends StatefulWidget {
final String collectionName; final String collectionName;
@@ -29,12 +32,44 @@ class PostListScreen extends StatefulWidget {
class _PostListScreenState extends State<PostListScreen> { class _PostListScreenState extends State<PostListScreen> {
final FirestoreService _firestoreService = FirestoreService(); final FirestoreService _firestoreService = FirestoreService();
final StorageService _storageService = StorageService(); final StorageService _storageService = StorageService();
final AussendungenService _aussendungenService = AussendungenService();
final Map<String, PostItem> _selectedItems = {}; final Map<String, PostItem> _selectedItems = {};
String _searchQuery = ''; String _searchQuery = '';
bool _isSearchActive = false; bool _isSearchActive = false;
final TextEditingController _searchController = TextEditingController(); 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 @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
@@ -211,7 +246,86 @@ class _PostListScreenState extends State<PostListScreen> {
], ],
], ],
), ),
body: StreamBuilder<List<PostItem>>( 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),
),
),
],
)
: _buildPostList(auth, currentUser),
floatingActionButton: (widget.collectionName == 'News' && !auth.isAdmin)
? null
: FloatingActionButton(
onPressed: _onAddNewPressed,
tooltip: widget.collectionName == 'News'
? 'Nachricht verfassen'
: 'Neuen Eintrag erstellen',
child: const Icon(Icons.add),
),
);
}
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), stream: _firestoreService.getPostsStream(widget.collectionName),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) { if (snapshot.hasError) {
@@ -223,11 +337,27 @@ class _PostListScreenState extends State<PostListScreen> {
); );
} }
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting &&
widget.collectionName != 'News') {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
final allItems = snapshot.data ?? []; 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) { final filteredItems = allItems.where((item) {
if (_searchQuery.isEmpty) return true; if (_searchQuery.isEmpty) return true;
final query = _searchQuery.toLowerCase(); final query = _searchQuery.toLowerCase();
@@ -271,30 +401,13 @@ class _PostListScreenState extends State<PostListScreen> {
final isSelected = _selectedItems.containsKey(item.id); final isSelected = _selectedItems.containsKey(item.id);
final isAuthor = final isAuthor =
currentUser != null && item.authorUid == currentUser.uid; currentUser != null && item.authorUid == currentUser.uid;
final canDelete = isAuthor || auth.isAdmin; final canDelete = !item.isAussendung && (isAuthor || auth.isAdmin);
final formattedDate = final formattedDate =
DateFormat('dd.MM.yyyy').format(item.createdDate); DateFormat('dd.MM.yyyy').format(item.createdDate);
Widget itemTile = ListTile( Widget? leadingWidget;
selected: isSelected, if (item.imageUrl.isNotEmpty) {
selectedTileColor: Colors.blue.shade50, leadingWidget = Stack(
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: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -334,12 +447,94 @@ class _PostListScreenState extends State<PostListScreen> {
), ),
), ),
], ],
) );
: isSelected } else if (item.isAussendung) {
? const CircleAvatar( 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, backgroundColor: Colors.blue,
child: Icon(Icons.check, color: Colors.white), 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, : null,
title: Row( title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -351,7 +546,7 @@ class _PostListScreenState extends State<PostListScreen> {
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
maxLines: 1, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
@@ -372,7 +567,8 @@ class _PostListScreenState extends State<PostListScreen> {
const SizedBox(height: 2), const SizedBox(height: 2),
Row( Row(
children: [ children: [
const Icon(Icons.location_on, size: 12, color: Colors.redAccent), const Icon(Icons.location_on,
size: 12, color: Colors.redAccent),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
item.street!, item.street!,
@@ -386,6 +582,40 @@ class _PostListScreenState extends State<PostListScreen> {
), ),
], ],
const SizedBox(height: 4), 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( Text(
item.description, item.description,
maxLines: 2, maxLines: 2,
@@ -440,16 +670,6 @@ class _PostListScreenState extends State<PostListScreen> {
}, },
); );
}, },
),
floatingActionButton: (widget.collectionName == 'News' && !auth.isAdmin)
? null
: FloatingActionButton(
onPressed: _onAddNewPressed,
tooltip: widget.collectionName == 'News'
? 'Nachricht verfassen'
: 'Neuen Eintrag erstellen',
child: const Icon(Icons.add),
),
); );
} }
} }
+121
View File
@@ -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: ');
}
}
}
+64
View File
@@ -829,6 +829,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.2" 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: uuid:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -54,6 +54,7 @@ dependencies:
provider: ^6.0.5 provider: ^6.0.5
http: ^1.2.0 http: ^1.2.0
shared_preferences: ^2.2.2 shared_preferences: ^2.2.2
url_launcher: ^6.3.1
dev_dependencies: dev_dependencies:
+74
View File
@@ -41,5 +41,79 @@ void main() {
expect(map['category'], 'Müll / Verunreinigung'); expect(map['category'], 'Müll / Verunreinigung');
expect(map['status'], 'In Bearbeitung'); 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 &#8211; Konzept &amp; Camping'},
'content': {
'rendered':
'<h2 class="wp-block-heading">Aussendung 17</h2><div class="wp-block-file"><object data="https://buergerforumoggau.at/wp-content/uploads/2026/09/20260917_Aussendung_17_-_Camping_Final.pdf" type="application/pdf"></object><a href="https://buergerforumoggau.at/wp-content/uploads/2026/09/20260917_Aussendung_17_-_Camping_Final.pdf">Download</a></div>'
},
'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');
});
}); });
} }