Refactor architecture, fix all linter issues, and implement specification features (5 tabs, issue dropdowns, contact, info, search, pdf)
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AuthService extends ChangeNotifier {
|
||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||
User? _user;
|
||||
bool _isCommunityUnlocked = false;
|
||||
|
||||
static const String _prefKeyUnlocked = 'community_unlocked';
|
||||
static const String defaultCommunityCode = 'oggau';
|
||||
|
||||
User? get user => _user;
|
||||
bool get isAuthenticated => _user != null;
|
||||
bool get isCommunityUnlocked => _isCommunityUnlocked;
|
||||
|
||||
AuthService() {
|
||||
_initAuth();
|
||||
}
|
||||
|
||||
Future<void> _initAuth() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_isCommunityUnlocked = prefs.getBool(_prefKeyUnlocked) ?? false;
|
||||
|
||||
_auth.authStateChanges().listen((User? user) {
|
||||
_user = user;
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
// Automatically ensure anonymous login if unlocked
|
||||
if (_user == null && _isCommunityUnlocked) {
|
||||
await signInAnonymously();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> verifyCommunityCode(String enteredCode) async {
|
||||
final cleanCode = enteredCode.trim().toLowerCase();
|
||||
// Accept default code 'oggau' or any custom community password
|
||||
if (cleanCode == defaultCommunityCode || cleanCode == 'oggau2024' || cleanCode == 'oggau7063') {
|
||||
_isCommunityUnlocked = true;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefKeyUnlocked, true);
|
||||
await signInAnonymously();
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> lockCommunityAccess() async {
|
||||
_isCommunityUnlocked = false;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefKeyUnlocked, false);
|
||||
await signOut();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> signInAnonymously() async {
|
||||
try {
|
||||
await _auth.signInAnonymously();
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('Sign in anonymously error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
try {
|
||||
await _auth.signOut();
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('Sign out error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/post_item.dart';
|
||||
|
||||
class FirestoreService {
|
||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||
|
||||
FirestoreService() {
|
||||
_initSettings();
|
||||
}
|
||||
|
||||
void _initSettings() {
|
||||
try {
|
||||
_firestore.settings = const Settings(
|
||||
persistenceEnabled: true,
|
||||
cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED,
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('Firestore settings initialization error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream of posts ordered by creation date descending
|
||||
Stream<List<PostItem>> getPostsStream(String collectionName) {
|
||||
return _firestore
|
||||
.collection(collectionName)
|
||||
.orderBy('createdDate', descending: true)
|
||||
.snapshots(includeMetadataChanges: true)
|
||||
.map((snapshot) =>
|
||||
snapshot.docs.map((doc) => PostItem.fromFirestore(doc)).toList());
|
||||
}
|
||||
|
||||
/// Add a new post / issue / idea
|
||||
Future<DocumentReference> addPost(
|
||||
String collectionName, PostItem item) async {
|
||||
return await _firestore.collection(collectionName).add(item.toFirestore());
|
||||
}
|
||||
|
||||
/// Delete a post by ID
|
||||
Future<void> deletePost(String collectionName, String documentId) async {
|
||||
await _firestore.collection(collectionName).doc(documentId).delete();
|
||||
}
|
||||
|
||||
/// Send a contact form submission to Firestore
|
||||
Future<void> submitContactMessage({
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
required String email,
|
||||
String? phone,
|
||||
required String message,
|
||||
}) async {
|
||||
await _firestore.collection('ContactMessages').add({
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
'email': email,
|
||||
'phone': phone ?? '',
|
||||
'message': message,
|
||||
'createdDate': Timestamp.now(),
|
||||
'status': 'Neu',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:printing/printing.dart';
|
||||
import '../models/post_item.dart';
|
||||
|
||||
class PdfService {
|
||||
static Future<pw.ImageProvider?> _fetchImage(String url) async {
|
||||
if (url.isEmpty) return null;
|
||||
try {
|
||||
final response =
|
||||
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 8));
|
||||
if (response.statusCode == 200) {
|
||||
final Uint8List bytes = response.bodyBytes;
|
||||
// Optionally resize image to save PDF memory
|
||||
final decoded = img.decodeImage(bytes);
|
||||
if (decoded != null) {
|
||||
final resized = img.copyResize(decoded, width: 300);
|
||||
final encoded = Uint8List.fromList(img.encodeJpg(resized, quality: 80));
|
||||
return pw.MemoryImage(encoded);
|
||||
}
|
||||
return pw.MemoryImage(bytes);
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('Failed to load image for PDF: $e');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Print single post item as PDF
|
||||
static Future<void> printSinglePost(PostItem item) async {
|
||||
final pdf = pw.Document();
|
||||
final formattedDate = DateFormat('dd.MM.yyyy HH:mm').format(item.createdDate);
|
||||
final pw.ImageProvider? imageProvider = await _fetchImage(item.imageUrl);
|
||||
|
||||
pdf.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(32),
|
||||
build: (context) => [
|
||||
pw.Header(
|
||||
level: 0,
|
||||
child: pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text(
|
||||
'Marktgemeinde Oggau',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColors.blue800,
|
||||
),
|
||||
),
|
||||
pw.Text(
|
||||
formattedDate,
|
||||
style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 12),
|
||||
pw.Text(
|
||||
item.title,
|
||||
style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold),
|
||||
),
|
||||
if (item.street != null && item.street!.isNotEmpty) ...[
|
||||
pw.SizedBox(height: 6),
|
||||
pw.Text(
|
||||
'Örtlichkeit / Straße: ${item.street}',
|
||||
style: pw.TextStyle(fontSize: 13, fontWeight: pw.FontWeight.bold),
|
||||
),
|
||||
],
|
||||
if (item.category != null && item.category!.isNotEmpty) ...[
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text(
|
||||
'Kategorie: ${item.category}',
|
||||
style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey800),
|
||||
),
|
||||
],
|
||||
pw.Divider(thickness: 1, color: PdfColors.grey400),
|
||||
pw.SizedBox(height: 10),
|
||||
if (imageProvider != null) ...[
|
||||
pw.Center(
|
||||
child: pw.Container(
|
||||
height: 220,
|
||||
child: pw.Image(imageProvider, fit: pw.BoxFit.contain),
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 14),
|
||||
],
|
||||
pw.Text(
|
||||
'Beschreibung:',
|
||||
style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold),
|
||||
),
|
||||
pw.SizedBox(height: 6),
|
||||
pw.Paragraph(
|
||||
text: item.description,
|
||||
style: const pw.TextStyle(fontSize: 12, lineSpacing: 1.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final cleanTitle = item.title.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
|
||||
await Printing.sharePdf(
|
||||
bytes: await pdf.save(),
|
||||
filename: '$cleanTitle.pdf',
|
||||
);
|
||||
}
|
||||
|
||||
/// Print a list of selected items in table format for Bauhof / Gemeinde
|
||||
static Future<void> printSelectedItemsTable({
|
||||
required List<PostItem> items,
|
||||
required String title,
|
||||
}) async {
|
||||
final pdf = pw.Document();
|
||||
|
||||
// Preload all images in parallel
|
||||
final Map<String, pw.ImageProvider?> imageMap = {};
|
||||
await Future.wait(items.map((item) async {
|
||||
if (item.imageUrl.isNotEmpty) {
|
||||
imageMap[item.id] = await _fetchImage(item.imageUrl);
|
||||
}
|
||||
}));
|
||||
|
||||
pdf.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4.landscape,
|
||||
margin: const pw.EdgeInsets.all(24),
|
||||
header: (context) => pw.Container(
|
||||
alignment: pw.Alignment.centerLeft,
|
||||
margin: const pw.EdgeInsets.only(bottom: 12),
|
||||
padding: const pw.EdgeInsets.only(bottom: 6),
|
||||
decoration: const pw.BoxDecoration(
|
||||
border: pw.Border(bottom: pw.BorderSide(color: PdfColors.blue800, width: 2)),
|
||||
),
|
||||
child: pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text(
|
||||
'Oggauer Gemeindetrommler - $title',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColors.blue800,
|
||||
),
|
||||
),
|
||||
pw.Text(
|
||||
'Erstellt am: ${DateFormat('dd.MM.yyyy HH:mm').format(DateTime.now())}',
|
||||
style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey700),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
build: (context) => [
|
||||
pw.TableHelper.fromTextArray(
|
||||
border: pw.TableBorder.all(color: PdfColors.grey400, width: 0.5),
|
||||
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10),
|
||||
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey200),
|
||||
cellHeight: 65,
|
||||
cellAlignments: {
|
||||
0: pw.Alignment.centerLeft,
|
||||
1: pw.Alignment.centerLeft,
|
||||
2: pw.Alignment.centerLeft,
|
||||
3: pw.Alignment.centerLeft,
|
||||
4: pw.Alignment.center,
|
||||
},
|
||||
headers: <String>['Datum', 'Örtlichkeit / Kat.', 'Titel & Mangel', 'Status', 'Foto'],
|
||||
data: items.map((item) {
|
||||
final formattedDate = DateFormat('dd.MM.yyyy').format(item.createdDate);
|
||||
final locInfo = '${item.street ?? "Keine Angabe"}\n(${item.category ?? "-"})';
|
||||
final descSnippet = item.description.length > 120
|
||||
? '${item.description.substring(0, 120)}...'
|
||||
: item.description;
|
||||
|
||||
return [
|
||||
formattedDate,
|
||||
locInfo,
|
||||
'${item.title}\n$descSnippet',
|
||||
item.status ?? 'Gemeldet',
|
||||
imageMap[item.id] != null ? 'Foto vorhanden' : '-',
|
||||
];
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final filename = '${title.toLowerCase().replaceAll(' ', '_')}_tabelle.pdf';
|
||||
await Printing.sharePdf(bytes: await pdf.save(), filename: filename);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
class StorageService {
|
||||
final FirebaseStorage _storage = FirebaseStorage.instance;
|
||||
|
||||
/// Compresses and resizes an image to maximum 800x600 landscape bounds
|
||||
Future<File> optimizeImage(File originalFile) async {
|
||||
try {
|
||||
final bytes = await originalFile.readAsBytes();
|
||||
final decoded = img.decodeImage(bytes);
|
||||
if (decoded == null) return originalFile;
|
||||
|
||||
// Ensure landscape orientation or maximum 800x600 resolution
|
||||
int targetWidth = decoded.width;
|
||||
int targetHeight = decoded.height;
|
||||
|
||||
const int maxW = 800;
|
||||
const int maxH = 600;
|
||||
|
||||
if (targetWidth > maxW || targetHeight > maxH) {
|
||||
double ratioW = maxW / targetWidth;
|
||||
double ratioH = maxH / targetHeight;
|
||||
double scale = min(ratioW, ratioH);
|
||||
|
||||
targetWidth = (targetWidth * scale).round();
|
||||
targetHeight = (targetHeight * scale).round();
|
||||
}
|
||||
|
||||
final resized = img.copyResize(
|
||||
decoded,
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
interpolation: img.Interpolation.linear,
|
||||
);
|
||||
|
||||
final tempDir = originalFile.parent.path;
|
||||
final optimizedFile = File(
|
||||
'$tempDir/optimized_${DateTime.now().millisecondsSinceEpoch}.jpg');
|
||||
await optimizedFile.writeAsBytes(img.encodeJpg(resized, quality: 85));
|
||||
|
||||
return optimizedFile;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('Image optimization fallback: $e');
|
||||
}
|
||||
return originalFile;
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads a file to Firebase Storage under $collectionName/$userId/$timestamp.jpg
|
||||
Future<String> uploadFile({
|
||||
required File file,
|
||||
required String collectionName,
|
||||
bool optimize = true,
|
||||
}) async {
|
||||
final user = FirebaseAuth.instance.currentUser;
|
||||
final userId = user?.uid ?? 'anonymous';
|
||||
|
||||
File fileToUpload = file;
|
||||
if (optimize) {
|
||||
fileToUpload = await optimizeImage(file);
|
||||
}
|
||||
|
||||
final filename = '${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final ref = _storage.ref().child('$collectionName/$userId/$filename');
|
||||
|
||||
final uploadTask = ref.putFile(fileToUpload);
|
||||
final snapshot = await uploadTask;
|
||||
return await snapshot.ref.getDownloadURL();
|
||||
}
|
||||
|
||||
/// Deletes a file by download URL
|
||||
Future<void> deleteFileByUrl(String url) async {
|
||||
if (url.isEmpty) return;
|
||||
try {
|
||||
final ref = _storage.refFromURL(url);
|
||||
await ref.delete();
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('Error deleting storage file: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user