Files
MABEA/flutter_app/lib/screens/mangel_melden_screen.dart
T
patrickandClaude Sonnet 5 c8d9480cf9
CI / backend-tests (push) Successful in 3m17s
CI / frontend-build (push) Successful in 22s
perf(mangel): Foto-Kompression vor Upload in beiden Clients ergänzt
PWA lud Mangel-Fotos bislang komplett unkomprimiert hoch (rohes File aus
capture-Input) - Canvas-basierte Verkleinerung/Neukodierung (max. 1600px,
JPEG q0.7) vor dem Enqueue ergänzt. Flutter-Client nutzte nur imageQuality
(reine JPEG-Kompression ohne Auflösungs-Downscale) - maxWidth/maxHeight
ergänzt. Beides zahlt sich bei schlechter Feld-Verbindung aus.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDMvneg6wKfXdXvTYNd3dN
2026-09-12 22:23:31 +02:00

182 lines
6.3 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../api/api_client.dart';
import '../offline/mangel_queue.dart';
/// MOBILE-004-Nachzug: Android-Pendant zu frontend/src/pages/MangelListePage.tsx
/// (Melde-Teil) - läuft wie die Kontroll-Erfassung immer über die lokale
/// Queue (MangelQueue), nie direkt blockierend gegen die API.
class MangelMeldenScreen extends StatefulWidget {
const MangelMeldenScreen({super.key, required this.apiClient, required this.objektId});
final ApiClient apiClient;
final int objektId;
@override
State<MangelMeldenScreen> createState() => _MangelMeldenScreenState();
}
class _MangelMeldenScreenState extends State<MangelMeldenScreen> {
final _beschreibungController = TextEditingController();
String _prioritaet = 'normal';
File? _foto;
bool _wirdGespeichert = false;
String? _fehler;
bool _online = true;
final _connectivity = Connectivity();
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
Timer? _periodischerSyncTimer;
@override
void initState() {
super.initState();
_connectivity.checkConnectivity().then((ergebnis) {
if (mounted) setState(() => _online = !ergebnis.contains(ConnectivityResult.none));
});
_connectivitySub = _connectivity.onConnectivityChanged.listen((ergebnis) {
final online = !ergebnis.contains(ConnectivityResult.none);
if (online && !_online) {
unawaited(MangelQueue.instance.synchronisiere(widget.apiClient));
}
if (mounted) setState(() => _online = online);
});
_periodischerSyncTimer = Timer.periodic(const Duration(seconds: 15), (_) {
unawaited(MangelQueue.instance.synchronisiere(widget.apiClient));
});
}
@override
void dispose() {
_connectivitySub?.cancel();
_periodischerSyncTimer?.cancel();
_beschreibungController.dispose();
super.dispose();
}
Future<void> _fotoAufnehmen() async {
// Fund (Foto-Kompression-Check): `imageQuality` allein komprimiert nur die
// JPEG-Qualität, verkleinert aber NICHT die Auflösung - ein 12MP-Kamerafoto
// bleibt trotzdem mehrere MB groß. `maxWidth`/`maxHeight` skaliert zusätzlich
// herunter, was bei schlechter Feld-Verbindung den eigentlichen Unterschied
// macht (1600px reicht für einen Mangel-Nachweis völlig aus).
final bild = await ImagePicker().pickImage(
source: ImageSource.camera,
imageQuality: 70,
maxWidth: 1600,
maxHeight: 1600,
);
if (bild == null || !mounted) return;
setState(() => _foto = File(bild.path));
}
Future<void> _melden() async {
final beschreibung = _beschreibungController.text.trim();
if (beschreibung.isEmpty) {
setState(() => _fehler = 'Beschreibung darf nicht leer sein.');
return;
}
setState(() {
_wirdGespeichert = true;
_fehler = null;
});
// Foto in App-eigenes Verzeichnis kopieren - die von image_picker gelieferte
// Datei liegt in einem temporären Cache, der vom System jederzeit geräumt
// werden kann, während der Mangel noch in der Queue auf Sync wartet.
String? fotoPfad;
final foto = _foto;
if (foto != null) {
final verzeichnis = await getApplicationDocumentsDirectory();
final ziel = p.join(
verzeichnis.path,
'mangel_foto_${DateTime.now().microsecondsSinceEpoch}${p.extension(foto.path)}',
);
await foto.copy(ziel);
fotoPfad = ziel;
}
await MangelQueue.instance.enqueue(
objektId: widget.objektId,
beschreibung: beschreibung,
prioritaet: _prioritaet,
fotoPfad: fotoPfad,
);
unawaited(MangelQueue.instance.synchronisiere(widget.apiClient));
if (mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Mangel melden')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!_online)
Container(
width: double.infinity,
color: Colors.amber.shade100,
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(bottom: 12),
child: const Text(
'Kein Netz – Meldung wird lokal gespeichert und automatisch übertragen, '
'sobald wieder Verbindung besteht.',
),
),
TextField(
controller: _beschreibungController,
decoration: const InputDecoration(labelText: 'Beschreibung'),
maxLines: 4,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _prioritaet,
decoration: const InputDecoration(labelText: 'Priorität'),
items: const [
DropdownMenuItem(value: 'niedrig', child: Text('Niedrig')),
DropdownMenuItem(value: 'normal', child: Text('Normal')),
DropdownMenuItem(value: 'hoch', child: Text('Hoch')),
],
onChanged: (wert) => setState(() => _prioritaet = wert ?? 'normal'),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _fotoAufnehmen,
icon: const Icon(Icons.camera_alt),
label: Text(_foto == null ? 'Foto aufnehmen' : 'Foto ersetzen'),
),
if (_foto != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Image.file(_foto!, height: 120),
),
if (_fehler != null)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(_fehler!, style: const TextStyle(color: Colors.red)),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _wirdGespeichert ? null : _melden,
child: const Text('Melden'),
),
),
],
),
),
);
}
}