eigentliche Handwerksapp
This commit is contained in:
49
firebase_options.dart
Normal file
49
firebase_options.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
// Ersetze diese Datei durch die Ausgabe von:
|
||||
// dart pub global activate flutterfire_cli
|
||||
// flutterfire configure
|
||||
// Bis dahin: Platzhalter-Werte (Auth schlägt ohne echtes Firebase-Projekt fehl).
|
||||
|
||||
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||
import 'package:flutter/foundation.dart'
|
||||
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||
|
||||
class DefaultFirebaseOptions {
|
||||
static FirebaseOptions get currentPlatform {
|
||||
if (kIsWeb) {
|
||||
throw UnsupportedError(
|
||||
'Screen 1: Bitte zuerst iOS- oder Android-Gerät/Simulator nutzen '
|
||||
'(oder Web in Firebase anlegen + flutterfire configure).',
|
||||
);
|
||||
}
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
return android;
|
||||
case TargetPlatform.iOS:
|
||||
return ios;
|
||||
case TargetPlatform.macOS:
|
||||
// Entwicklung: gleiche Firebase-App wie iOS (flutterfire configure mit macOS ergänzen).
|
||||
return ios;
|
||||
default:
|
||||
throw UnsupportedError(
|
||||
'Nur iOS und Android sind für Screen 1 vorgesehen.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static const FirebaseOptions android = FirebaseOptions(
|
||||
apiKey: 'REPLACE_ME',
|
||||
appId: '1:000000000000:android:0000000000000000000000',
|
||||
messagingSenderId: '000000000000',
|
||||
projectId: 'handwerksapp-placeholder',
|
||||
storageBucket: 'handwerksapp-placeholder.appspot.com',
|
||||
);
|
||||
|
||||
static const FirebaseOptions ios = FirebaseOptions(
|
||||
apiKey: 'REPLACE_ME',
|
||||
appId: '1:000000000000:ios:0000000000000000000000',
|
||||
messagingSenderId: '000000000000',
|
||||
projectId: 'handwerksapp-placeholder',
|
||||
storageBucket: 'handwerksapp-placeholder.appspot.com',
|
||||
iosBundleId: 'com.example.handwerksapp',
|
||||
);
|
||||
}
|
||||
57
main.dart
Normal file
57
main.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'firebase_options.dart';
|
||||
import 'screens/auth/auth_screen.dart';
|
||||
import 'screens/firebase_setup_required_screen.dart';
|
||||
import 'screens/home/auftraege_home_screen.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
try {
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
} catch (e, st) {
|
||||
debugPrint('Firebase init failed: $e\n$st');
|
||||
runApp(FirebaseSetupRequiredScreen(error: e));
|
||||
return;
|
||||
}
|
||||
runApp(const HandwerksApp());
|
||||
}
|
||||
|
||||
class HandwerksApp extends StatelessWidget {
|
||||
const HandwerksApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Handwerksapp',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light(),
|
||||
home: const _AuthGate(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AuthGate extends StatelessWidget {
|
||||
const _AuthGate();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<User?>(
|
||||
stream: FirebaseAuth.instance.authStateChanges(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (snapshot.hasData) {
|
||||
return const AuftraegeHomeScreen();
|
||||
}
|
||||
return const AuthScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
64
models/auftrag.dart
Normal file
64
models/auftrag.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
/// Ein Datensatz = eine Rechnung / dokumentierter Auftrag (Fotos, Unterschrift, PDF).
|
||||
class Auftrag {
|
||||
Auftrag({
|
||||
required this.id,
|
||||
required this.titel,
|
||||
required this.beschreibung,
|
||||
required this.kundenName,
|
||||
required this.kundenEmail,
|
||||
required this.rechnungsnummer,
|
||||
required this.betragText,
|
||||
required this.fotoUrls,
|
||||
required this.unterschriftUrl,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String titel;
|
||||
final String beschreibung;
|
||||
final String kundenName;
|
||||
final String kundenEmail;
|
||||
final String rechnungsnummer;
|
||||
final String betragText;
|
||||
final List<String> fotoUrls;
|
||||
final String? unterschriftUrl;
|
||||
final DateTime? createdAt;
|
||||
|
||||
bool get hasUnterschrift =>
|
||||
unterschriftUrl != null && unterschriftUrl!.isNotEmpty;
|
||||
|
||||
factory Auftrag.fromDoc(DocumentSnapshot<Map<String, dynamic>> doc) {
|
||||
final d = doc.data() ?? {};
|
||||
final fotos = d['fotoUrls'];
|
||||
return Auftrag(
|
||||
id: doc.id,
|
||||
titel: d['titel'] as String? ?? '',
|
||||
beschreibung: d['beschreibung'] as String? ?? '',
|
||||
kundenName: d['kundenName'] as String? ?? '',
|
||||
kundenEmail: d['kundenEmail'] as String? ?? '',
|
||||
rechnungsnummer: d['rechnungsnummer'] as String? ?? '',
|
||||
betragText: d['betragText'] as String? ?? '',
|
||||
fotoUrls: fotos is List
|
||||
? fotos.map((e) => e.toString()).toList()
|
||||
: const <String>[],
|
||||
unterschriftUrl: d['unterschriftUrl'] as String?,
|
||||
createdAt: (d['createdAt'] as Timestamp?)?.toDate(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'titel': titel,
|
||||
'beschreibung': beschreibung,
|
||||
'kundenName': kundenName,
|
||||
'kundenEmail': kundenEmail,
|
||||
'rechnungsnummer': rechnungsnummer,
|
||||
'betragText': betragText,
|
||||
'fotoUrls': fotoUrls,
|
||||
'unterschriftUrl': unterschriftUrl,
|
||||
'updatedAt': FieldValue.serverTimestamp(),
|
||||
};
|
||||
}
|
||||
}
|
||||
BIN
screens/.DS_Store
vendored
Normal file
BIN
screens/.DS_Store
vendored
Normal file
Binary file not shown.
463
screens/auftrag/auftrag_bearbeiten_screen.dart
Normal file
463
screens/auftrag/auftrag_bearbeiten_screen.dart
Normal file
@@ -0,0 +1,463 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_email_sender/flutter_email_sender.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:signature/signature.dart';
|
||||
|
||||
import '../../models/auftrag.dart';
|
||||
import '../../services/auftrag_repository.dart';
|
||||
import '../../services/auftrag_storage_service.dart';
|
||||
import '../../services/pdf_export_service.dart';
|
||||
|
||||
class AuftragBearbeitenScreen extends StatefulWidget {
|
||||
const AuftragBearbeitenScreen({super.key, this.auftragId});
|
||||
|
||||
final String? auftragId;
|
||||
|
||||
@override
|
||||
State<AuftragBearbeitenScreen> createState() =>
|
||||
_AuftragBearbeitenScreenState();
|
||||
}
|
||||
|
||||
class _AuftragBearbeitenScreenState extends State<AuftragBearbeitenScreen> {
|
||||
final _repo = AuftragRepository();
|
||||
final _storage = AuftragStorageService();
|
||||
final _rechnungsnummer = TextEditingController();
|
||||
final _titel = TextEditingController();
|
||||
final _beschreibung = TextEditingController();
|
||||
final _kunde = TextEditingController();
|
||||
final _kundenEmail = TextEditingController();
|
||||
final _betrag = TextEditingController();
|
||||
|
||||
late final String _docId;
|
||||
late final SignatureController _signatur;
|
||||
final _picker = ImagePicker();
|
||||
|
||||
bool _isNeu = true;
|
||||
bool _loading = true;
|
||||
bool _saving = false;
|
||||
List<String> _fotoUrls = [];
|
||||
String? _unterschriftUrl;
|
||||
bool _signaturVorhanden = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_signatur = SignatureController(
|
||||
penStrokeWidth: 2,
|
||||
penColor: Colors.black,
|
||||
exportBackgroundColor: Colors.white,
|
||||
);
|
||||
_docId = widget.auftragId ?? _repo.neueId();
|
||||
if (widget.auftragId != null) {
|
||||
_laden();
|
||||
} else {
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _laden() async {
|
||||
final a = await _repo.get(widget.auftragId!);
|
||||
if (!mounted) return;
|
||||
if (a == null) {
|
||||
setState(() => _loading = false);
|
||||
_snack('Rechnung nicht gefunden.');
|
||||
return;
|
||||
}
|
||||
_isNeu = false;
|
||||
_rechnungsnummer.text = a.rechnungsnummer;
|
||||
_titel.text = a.titel;
|
||||
_beschreibung.text = a.beschreibung;
|
||||
_kunde.text = a.kundenName;
|
||||
_kundenEmail.text = a.kundenEmail;
|
||||
_betrag.text = a.betragText;
|
||||
_fotoUrls = List.from(a.fotoUrls);
|
||||
_unterschriftUrl = a.unterschriftUrl;
|
||||
_signaturVorhanden = a.hasUnterschrift;
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_rechnungsnummer.dispose();
|
||||
_titel.dispose();
|
||||
_beschreibung.dispose();
|
||||
_kunde.dispose();
|
||||
_kundenEmail.dispose();
|
||||
_betrag.dispose();
|
||||
_signatur.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _snack(String t) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t)));
|
||||
}
|
||||
|
||||
Auftrag _aktuellerAuftrag(String? sigUrl) {
|
||||
var rn = _rechnungsnummer.text.trim();
|
||||
if (rn.isEmpty) {
|
||||
final compact = _docId.replaceAll('-', '');
|
||||
final suffix = compact.length >= 6
|
||||
? compact.substring(0, 6)
|
||||
: compact.padRight(6, '0');
|
||||
rn = 'RE-${DateTime.now().year}-$suffix';
|
||||
}
|
||||
return Auftrag(
|
||||
id: _docId,
|
||||
titel: _titel.text.trim(),
|
||||
beschreibung: _beschreibung.text.trim(),
|
||||
kundenName: _kunde.text.trim(),
|
||||
kundenEmail: _kundenEmail.text.trim(),
|
||||
rechnungsnummer: rn,
|
||||
betragText: _betrag.text.trim(),
|
||||
fotoUrls: _fotoUrls,
|
||||
unterschriftUrl: sigUrl,
|
||||
createdAt: null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _fotosHinzufuegen() async {
|
||||
final files = await _picker.pickMultiImage(imageQuality: 85);
|
||||
if (files.isEmpty) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
var i = _fotoUrls.length;
|
||||
for (final x in files) {
|
||||
final bytes = await x.readAsBytes();
|
||||
final name = '${DateTime.now().millisecondsSinceEpoch}_$i.jpg';
|
||||
final url = await _storage.hochladenFoto(_docId, bytes, name);
|
||||
_fotoUrls.add(url);
|
||||
i++;
|
||||
}
|
||||
setState(() {});
|
||||
} catch (e) {
|
||||
_snack('Foto-Upload: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _fotoEntfernen(int index) {
|
||||
setState(() {
|
||||
_fotoUrls.removeAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _speichern() async {
|
||||
final titel = _titel.text.trim();
|
||||
if (titel.isEmpty) {
|
||||
_snack('Bitte einen Titel / Leistung eintragen.');
|
||||
return;
|
||||
}
|
||||
if (_rechnungsnummer.text.trim().isEmpty) {
|
||||
final compact = _docId.replaceAll('-', '');
|
||||
final suffix = compact.length >= 6
|
||||
? compact.substring(0, 6)
|
||||
: compact.padRight(6, '0');
|
||||
_rechnungsnummer.text = 'RE-${DateTime.now().year}-$suffix';
|
||||
}
|
||||
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
String? sigUrl = _unterschriftUrl;
|
||||
if (_signatur.isNotEmpty) {
|
||||
final png = await _signatur.toPngBytes();
|
||||
if (png != null && png.isNotEmpty) {
|
||||
sigUrl = await _storage.hochladenUnterschrift(_docId, png);
|
||||
_signatur.clear();
|
||||
_signaturVorhanden = true;
|
||||
_unterschriftUrl = sigUrl;
|
||||
}
|
||||
}
|
||||
|
||||
final auftrag = _aktuellerAuftrag(sigUrl);
|
||||
await _repo.speichern(id: _docId, daten: auftrag, isNeu: _isNeu);
|
||||
_isNeu = false;
|
||||
if (mounted) setState(() {});
|
||||
_snack('Rechnung gespeichert.');
|
||||
} catch (e) {
|
||||
_snack('Speichern fehlgeschlagen: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> _pdfDateiErzeugen() async {
|
||||
final titel = _titel.text.trim();
|
||||
if (titel.isEmpty) {
|
||||
_snack('Titel eintragen (für die Rechnung).');
|
||||
return null;
|
||||
}
|
||||
|
||||
String? sigUrl = _unterschriftUrl;
|
||||
Uint8List? sigBytes;
|
||||
if (_signatur.isNotEmpty) {
|
||||
sigBytes = await _signatur.toPngBytes();
|
||||
}
|
||||
if ((sigBytes == null || sigBytes.isEmpty) &&
|
||||
sigUrl != null &&
|
||||
sigUrl.isNotEmpty) {
|
||||
sigBytes = await PdfExportService.ladeUrl(sigUrl);
|
||||
}
|
||||
|
||||
final fotoBytes = <Uint8List?>[];
|
||||
for (final url in _fotoUrls) {
|
||||
fotoBytes.add(await PdfExportService.ladeUrl(url));
|
||||
}
|
||||
|
||||
final auftrag = _aktuellerAuftrag(sigUrl);
|
||||
return PdfExportService.buildPdf(
|
||||
auftrag: auftrag,
|
||||
fotoBytes: fotoBytes,
|
||||
unterschriftBytes: sigBytes,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pdfTeilen() async {
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final file = await _pdfDateiErzeugen();
|
||||
if (file == null) return;
|
||||
final rn = _rechnungsnummer.text.trim();
|
||||
await PdfExportService.teilen(file, rechnungsnummer: rn);
|
||||
} catch (e) {
|
||||
_snack('PDF / Teilen: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _emailMitPdf() async {
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final file = await _pdfDateiErzeugen();
|
||||
if (file == null) return;
|
||||
|
||||
final empf = _kundenEmail.text.trim();
|
||||
final rn = _rechnungsnummer.text.trim().isEmpty
|
||||
? 'Rechnung'
|
||||
: _rechnungsnummer.text.trim();
|
||||
|
||||
await FlutterEmailSender.send(
|
||||
Email(
|
||||
subject: 'Rechnung $rn',
|
||||
body:
|
||||
'Guten Tag,\n\nanbei die Rechnung als PDF.\n\nMit freundlichen Grüßen',
|
||||
recipients: empf.contains('@') ? [empf] : [],
|
||||
attachmentPaths: [file.path],
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
_snack('E-Mail: $e (Mail-App / Konto prüfen)');
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_loading) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_isNeu ? 'Neue Rechnung' : 'Rechnung bearbeiten'),
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _rechnungsnummer,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Rechnungsnummer',
|
||||
hintText: 'Wird beim Speichern automatisch gesetzt, wenn leer',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _titel,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Leistung / Titel',
|
||||
hintText: 'z. B. Elektroinstallation Küche',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _kunde,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Kunde (Name)',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _kundenEmail,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autocorrect: false,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Kunden-E-Mail',
|
||||
hintText: 'Für „Per E-Mail senden“',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _betrag,
|
||||
keyboardType: TextInputType.text,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Betrag brutto (€)',
|
||||
hintText: 'z. B. 1.234,56',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _beschreibung,
|
||||
minLines: 3,
|
||||
maxLines: 8,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Beschreibung / Positionen',
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Fotos',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _saving ? null : _fotosHinzufuegen,
|
||||
icon: const Icon(Icons.add_photo_alternate_outlined),
|
||||
label: const Text('Hinzufügen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_fotoUrls.isEmpty)
|
||||
Text(
|
||||
'Noch keine Fotos.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 100,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _fotoUrls.length,
|
||||
separatorBuilder: (context, i) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, i) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
_fotoUrls[i],
|
||||
width: 100,
|
||||
height: 100,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stack) => Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
color: Colors.grey.shade300,
|
||||
child: const Icon(Icons.broken_image),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: -4,
|
||||
right: -4,
|
||||
child: IconButton.filled(
|
||||
style: IconButton.styleFrom(
|
||||
padding: const EdgeInsets.all(4),
|
||||
minimumSize: const Size(28, 28),
|
||||
),
|
||||
onPressed:
|
||||
_saving ? null : () => _fotoEntfernen(i),
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Unterschrift Kunde',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (_signaturVorhanden && _signatur.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'Es liegt bereits eine Unterschrift. Neu zeichnen und '
|
||||
'speichern ersetzt sie.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
height: 180,
|
||||
child: Signature(
|
||||
key: const Key('signatur'),
|
||||
controller: _signatur,
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _saving ? null : () => _signatur.clear(),
|
||||
icon: const Icon(Icons.clear),
|
||||
label: const Text('Unterschrift leeren'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: _saving ? null : _speichern,
|
||||
icon: const Icon(Icons.save_outlined),
|
||||
label: const Text('Rechnung speichern'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _saving ? null : _pdfTeilen,
|
||||
icon: const Icon(Icons.share_outlined),
|
||||
label: const Text('PDF erzeugen & teilen'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _saving ? null : _emailMitPdf,
|
||||
icon: const Icon(Icons.email_outlined),
|
||||
label: const Text('PDF per E-Mail senden'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'E-Mail: Öffnet die Mail-App mit Anhang. Auf dem Simulator '
|
||||
'oft nicht verfügbar – echtes Gerät nutzen. '
|
||||
'Kunden-E-Mail oben ausfüllen oder Empfänger in der Mail-App wählen.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
if (_saving)
|
||||
const ModalBarrier(
|
||||
dismissible: false,
|
||||
color: Color(0x33000000),
|
||||
),
|
||||
if (_saving)
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
288
screens/auth/auth_screen.dart
Normal file
288
screens/auth/auth_screen.dart
Normal file
@@ -0,0 +1,288 @@
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Screen 1: Login & Registrierung (E-Mail / Passwort) über Firebase Auth.
|
||||
class AuthScreen extends StatefulWidget {
|
||||
const AuthScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AuthScreen> createState() => _AuthScreenState();
|
||||
}
|
||||
|
||||
class _AuthScreenState extends State<AuthScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController;
|
||||
|
||||
final _loginEmail = TextEditingController();
|
||||
final _loginPassword = TextEditingController();
|
||||
|
||||
final _regName = TextEditingController();
|
||||
final _regEmail = TextEditingController();
|
||||
final _regPassword = TextEditingController();
|
||||
|
||||
bool _loading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_loginEmail.dispose();
|
||||
_loginPassword.dispose();
|
||||
_regName.dispose();
|
||||
_regEmail.dispose();
|
||||
_regPassword.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _snack(String text) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
|
||||
}
|
||||
|
||||
String _authMessage(FirebaseAuthException e) {
|
||||
switch (e.code) {
|
||||
case 'user-not-found':
|
||||
case 'wrong-password':
|
||||
case 'invalid-credential':
|
||||
return 'E-Mail oder Passwort ist falsch.';
|
||||
case 'email-already-in-use':
|
||||
return 'Diese E-Mail ist bereits registriert.';
|
||||
case 'weak-password':
|
||||
return 'Passwort ist zu schwach (mindestens 6 Zeichen).';
|
||||
case 'invalid-email':
|
||||
return 'Ungültige E-Mail-Adresse.';
|
||||
case 'network-request-failed':
|
||||
return 'Netzwerkfehler. Internet prüfen.';
|
||||
default:
|
||||
return e.message ?? 'Fehler: ${e.code}';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _login() async {
|
||||
final email = _loginEmail.text.trim();
|
||||
final password = _loginPassword.text;
|
||||
if (email.isEmpty || password.isEmpty) {
|
||||
_snack('E-Mail und Passwort eingeben.');
|
||||
return;
|
||||
}
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
await FirebaseAuth.instance.signInWithEmailAndPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
} on FirebaseAuthException catch (e) {
|
||||
_snack(_authMessage(e));
|
||||
} catch (e) {
|
||||
_snack('Unerwarteter Fehler: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _register() async {
|
||||
final email = _regEmail.text.trim();
|
||||
final password = _regPassword.text;
|
||||
final name = _regName.text.trim();
|
||||
if (email.isEmpty || password.isEmpty) {
|
||||
_snack('E-Mail und Passwort eingeben.');
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
_snack('Passwort mindestens 6 Zeichen (Firebase).');
|
||||
return;
|
||||
}
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final cred = await FirebaseAuth.instance.createUserWithEmailAndPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
if (name.isNotEmpty && cred.user != null) {
|
||||
await cred.user!.updateDisplayName(name);
|
||||
}
|
||||
} on FirebaseAuthException catch (e) {
|
||||
_snack(_authMessage(e));
|
||||
} catch (e) {
|
||||
_snack('Unerwarteter Fehler: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
Icon(Icons.construction, size: 48, color: scheme.primary),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Handwerksapp',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Nach dem Login: Aufträge anlegen, Fotos, Kunden-Unterschrift, PDF teilen.\n'
|
||||
'Zuerst: Konto anlegen oder anmelden.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(text: 'Anmelden'),
|
||||
Tab(text: 'Registrieren'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 320,
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_LoginForm(
|
||||
email: _loginEmail,
|
||||
password: _loginPassword,
|
||||
loading: _loading,
|
||||
onSubmit: _login,
|
||||
),
|
||||
_RegisterForm(
|
||||
name: _regName,
|
||||
email: _regEmail,
|
||||
password: _regPassword,
|
||||
loading: _loading,
|
||||
onSubmit: _register,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginForm extends StatelessWidget {
|
||||
const _LoginForm({
|
||||
required this.email,
|
||||
required this.password,
|
||||
required this.loading,
|
||||
required this.onSubmit,
|
||||
});
|
||||
|
||||
final TextEditingController email;
|
||||
final TextEditingController password;
|
||||
final bool loading;
|
||||
final VoidCallback onSubmit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: email,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autocorrect: false,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
decoration: const InputDecoration(labelText: 'E-Mail'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: password,
|
||||
obscureText: true,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
decoration: const InputDecoration(labelText: 'Passwort'),
|
||||
onSubmitted: (_) => onSubmit(),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: loading ? null : onSubmit,
|
||||
child: loading
|
||||
? const SizedBox(
|
||||
height: 22,
|
||||
width: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Anmelden'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RegisterForm extends StatelessWidget {
|
||||
const _RegisterForm({
|
||||
required this.name,
|
||||
required this.email,
|
||||
required this.password,
|
||||
required this.loading,
|
||||
required this.onSubmit,
|
||||
});
|
||||
|
||||
final TextEditingController name;
|
||||
final TextEditingController email;
|
||||
final TextEditingController password;
|
||||
final bool loading;
|
||||
final VoidCallback onSubmit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: name,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Firmen- oder Anzeigename',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: email,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autocorrect: false,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
decoration: const InputDecoration(labelText: 'E-Mail'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: password,
|
||||
obscureText: true,
|
||||
autofillHints: const [AutofillHints.newPassword],
|
||||
decoration: const InputDecoration(labelText: 'Passwort (min. 6 Zeichen)'),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: loading ? null : onSubmit,
|
||||
child: loading
|
||||
? const SizedBox(
|
||||
height: 22,
|
||||
width: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Konto erstellen'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
47
screens/firebase_setup_required_screen.dart
Normal file
47
screens/firebase_setup_required_screen.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Wird angezeigt, wenn Firebase.initializeApp fehlschlägt (z. B. Platzhalter-Keys).
|
||||
class FirebaseSetupRequiredScreen extends StatelessWidget {
|
||||
const FirebaseSetupRequiredScreen({super.key, required this.error});
|
||||
|
||||
final Object error;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Firebase einrichten',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'1. In der Firebase Console ein Projekt anlegen.\n'
|
||||
'2. Android-App (Package com.example.handwerksapp) und iOS-App '
|
||||
'(Bundle com.example.handwerksapp) registrieren.\n'
|
||||
'3. Authentication → E-Mail/Passwort aktivieren.\n'
|
||||
'4. Im Projektordner im Terminal:\n\n'
|
||||
' dart pub global activate flutterfire_cli\n'
|
||||
' flutterfire configure\n\n'
|
||||
' Das überschreibt lib/firebase_options.dart und die '
|
||||
'google-services / GoogleService-Info Dateien.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Technische Meldung:\n$error',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
161
screens/home/auftraege_home_screen.dart
Normal file
161
screens/home/auftraege_home_screen.dart
Normal file
@@ -0,0 +1,161 @@
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../models/auftrag.dart';
|
||||
import '../../services/auftrag_repository.dart';
|
||||
import '../auftrag/auftrag_bearbeiten_screen.dart';
|
||||
|
||||
class AuftraegeHomeScreen extends StatelessWidget {
|
||||
const AuftraegeHomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final repo = AuftragRepository();
|
||||
final user = FirebaseAuth.instance.currentUser;
|
||||
final name = user?.displayName?.trim();
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Meine Rechnungen'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
tooltip: 'Abmelden',
|
||||
onPressed: () => FirebaseAuth.instance.signOut(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Card(
|
||||
color: scheme.primaryContainer.withValues(alpha: 0.35),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: scheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Nach dem Login bist du hier. Tippe unten auf '
|
||||
'„Neue Rechnung“: Daten eintragen, Fotos & Unterschrift, '
|
||||
'dann PDF erzeugen oder per E-Mail senden.\n\n'
|
||||
'Wichtig: Nach großen Code-Änderungen App neu starten '
|
||||
'(Stop ▶), kein reines Hot Reload.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||
child: Text(
|
||||
name != null && name.isNotEmpty ? 'Hallo, $name' : 'Übersicht',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<Auftrag>>(
|
||||
stream: repo.watchAuftraege(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'Rechnungen konnten nicht geladen werden.\n'
|
||||
'Firestore aktiviert und Sicherheitsregeln gesetzt?\n\n'
|
||||
'${snapshot.error}',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final list = snapshot.data!;
|
||||
if (list.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(
|
||||
'Noch keine Rechnungen.\n'
|
||||
'Unten auf „Neue Rechnung“ tippen.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 88),
|
||||
itemCount: list.length,
|
||||
separatorBuilder: (context, i) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, i) {
|
||||
final a = list[i];
|
||||
final datum = a.createdAt != null
|
||||
? DateFormat('dd.MM.yyyy').format(a.createdAt!)
|
||||
: '';
|
||||
final nr = a.rechnungsnummer.isNotEmpty
|
||||
? a.rechnungsnummer
|
||||
: 'ohne Nr.';
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
a.titel.isEmpty ? '(Ohne Titel)' : a.titel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
'$nr · ${a.kundenName.isEmpty ? "Kunde —" : a.kundenName}'
|
||||
'${datum.isNotEmpty ? " · $datum" : ""}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) =>
|
||||
AuftragBearbeitenScreen(auftragId: a.id),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const AuftragBearbeitenScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Neue Rechnung'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
50
services/auftrag_repository.dart
Normal file
50
services/auftrag_repository.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
|
||||
import '../models/auftrag.dart';
|
||||
|
||||
class AuftragRepository {
|
||||
AuftragRepository({FirebaseFirestore? firestore, FirebaseAuth? auth})
|
||||
: _db = firestore ?? FirebaseFirestore.instance,
|
||||
_auth = auth ?? FirebaseAuth.instance;
|
||||
|
||||
final FirebaseFirestore _db;
|
||||
final FirebaseAuth _auth;
|
||||
|
||||
CollectionReference<Map<String, dynamic>> get _col {
|
||||
final uid = _auth.currentUser?.uid;
|
||||
if (uid == null) {
|
||||
throw StateError('Nicht angemeldet');
|
||||
}
|
||||
return _db.collection('users').doc(uid).collection('auftraege');
|
||||
}
|
||||
|
||||
Stream<List<Auftrag>> watchAuftraege() {
|
||||
return _col.orderBy('createdAt', descending: true).snapshots().map(
|
||||
(snap) => snap.docs.map(Auftrag.fromDoc).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Auftrag?> get(String id) async {
|
||||
final doc = await _col.doc(id).get();
|
||||
if (!doc.exists) return null;
|
||||
return Auftrag.fromDoc(doc);
|
||||
}
|
||||
|
||||
/// Neue Dokument-ID ohne Schreibzugriff (für Storage-Pfade vor erstem Speichern).
|
||||
String neueId() => _col.doc().id;
|
||||
|
||||
Future<void> speichern({
|
||||
required String id,
|
||||
required Auftrag daten,
|
||||
bool isNeu = false,
|
||||
}) async {
|
||||
final payload = daten.toMap();
|
||||
if (isNeu) {
|
||||
payload['createdAt'] = FieldValue.serverTimestamp();
|
||||
}
|
||||
await _col.doc(id).set(payload, SetOptions(merge: true));
|
||||
}
|
||||
|
||||
Future<void> loeschen(String id) => _col.doc(id).delete();
|
||||
}
|
||||
38
services/auftrag_storage_service.dart
Normal file
38
services/auftrag_storage_service.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
|
||||
class AuftragStorageService {
|
||||
AuftragStorageService({FirebaseStorage? storage, FirebaseAuth? auth})
|
||||
: _storage = storage ?? FirebaseStorage.instance,
|
||||
_auth = auth ?? FirebaseAuth.instance;
|
||||
|
||||
final FirebaseStorage _storage;
|
||||
final FirebaseAuth _auth;
|
||||
|
||||
Reference _basis(String auftragId) {
|
||||
final uid = _auth.currentUser?.uid;
|
||||
if (uid == null) throw StateError('Nicht angemeldet');
|
||||
return _storage.ref('users/$uid/auftraege/$auftragId');
|
||||
}
|
||||
|
||||
Future<String> hochladenFoto(String auftragId, Uint8List bytes, String dateiname) async {
|
||||
final ref = _basis(auftragId).child('fotos/$dateiname');
|
||||
await ref.putData(
|
||||
bytes,
|
||||
SettableMetadata(contentType: 'image/jpeg'),
|
||||
);
|
||||
return ref.getDownloadURL();
|
||||
}
|
||||
|
||||
Future<String> hochladenUnterschrift(String auftragId, Uint8List pngBytes) async {
|
||||
final ref = _basis(auftragId).child('unterschrift.png');
|
||||
await ref.putData(
|
||||
pngBytes,
|
||||
SettableMetadata(contentType: 'image/png'),
|
||||
);
|
||||
return ref.getDownloadURL();
|
||||
}
|
||||
|
||||
}
|
||||
138
services/pdf_export_service.dart
Normal file
138
services/pdf_export_service.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus, XFile;
|
||||
|
||||
import '../models/auftrag.dart';
|
||||
|
||||
class PdfExportService {
|
||||
static Future<File> buildPdf({
|
||||
required Auftrag auftrag,
|
||||
required List<Uint8List?> fotoBytes,
|
||||
Uint8List? unterschriftBytes,
|
||||
}) async {
|
||||
final doc = pw.Document();
|
||||
final nr = auftrag.rechnungsnummer.isEmpty
|
||||
? 'Entwurf'
|
||||
: auftrag.rechnungsnummer;
|
||||
|
||||
doc.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(40),
|
||||
build: (ctx) => [
|
||||
pw.Header(
|
||||
level: 0,
|
||||
child: pw.Text(
|
||||
'Rechnung',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Text('Rechnungs-Nr.: $nr',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
)),
|
||||
pw.SizedBox(height: 16),
|
||||
pw.Text('Leistung / Titel: ${auftrag.titel}',
|
||||
style: const pw.TextStyle(fontSize: 14)),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Text('Kunde: ${auftrag.kundenName}',
|
||||
style: const pw.TextStyle(fontSize: 14)),
|
||||
if (auftrag.kundenEmail.isNotEmpty) ...[
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text('E-Mail: ${auftrag.kundenEmail}',
|
||||
style: const pw.TextStyle(fontSize: 11)),
|
||||
],
|
||||
if (auftrag.betragText.isNotEmpty) ...[
|
||||
pw.SizedBox(height: 12),
|
||||
pw.Text('Betrag (Brutto): ${auftrag.betragText} €',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
)),
|
||||
],
|
||||
pw.SizedBox(height: 16),
|
||||
pw.Text('Beschreibung / Positionen:',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
)),
|
||||
pw.SizedBox(height: 6),
|
||||
pw.Text(
|
||||
auftrag.beschreibung.isEmpty ? '—' : auftrag.beschreibung,
|
||||
style: const pw.TextStyle(fontSize: 11),
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
if (fotoBytes.any((b) => b != null && b.isNotEmpty)) ...[
|
||||
pw.Text('Fotos',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
)),
|
||||
pw.SizedBox(height: 8),
|
||||
for (final b in fotoBytes)
|
||||
if (b != null && b.isNotEmpty)
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.only(bottom: 12),
|
||||
child: pw.Image(
|
||||
pw.MemoryImage(b),
|
||||
fit: pw.BoxFit.contain,
|
||||
height: 200,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (unterschriftBytes != null && unterschriftBytes.isNotEmpty) ...[
|
||||
pw.SizedBox(height: 16),
|
||||
pw.Text('Unterschrift Kunde',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
)),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Image(
|
||||
pw.MemoryImage(unterschriftBytes),
|
||||
height: 100,
|
||||
fit: pw.BoxFit.contain,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final bytes = await doc.save();
|
||||
final dir = await getTemporaryDirectory();
|
||||
final safeNr = nr.replaceAll(RegExp(r'[^\w\-]'), '_');
|
||||
final name = 'rechnung_${safeNr.isEmpty ? auftrag.id.substring(0, 8) : safeNr}.pdf';
|
||||
final file = File('${dir.path}/$name');
|
||||
await file.writeAsBytes(bytes);
|
||||
return file;
|
||||
}
|
||||
|
||||
static Future<Uint8List?> ladeUrl(String url) async {
|
||||
try {
|
||||
final r = await http.get(Uri.parse(url));
|
||||
if (r.statusCode == 200) return r.bodyBytes;
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<void> teilen(File file, {String? rechnungsnummer}) async {
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
files: [XFile(file.path)],
|
||||
subject: rechnungsnummer != null && rechnungsnummer.isNotEmpty
|
||||
? 'Rechnung $rechnungsnummer'
|
||||
: 'Rechnung PDF',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
37
theme/app_theme.dart
Normal file
37
theme/app_theme.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Ruhiges, seriöses Farbschema für Handwerks-Betriebe (Elektrik, Maler, SHK).
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
static const Color _seed = Color(0xFF0D47A1);
|
||||
|
||||
static ThemeData light() {
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: _seed,
|
||||
brightness: Brightness.light,
|
||||
surface: const Color(0xFFF5F7FA),
|
||||
);
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
appBarTheme: AppBarTheme(
|
||||
centerTitle: true,
|
||||
backgroundColor: scheme.surface,
|
||||
foregroundColor: scheme.onSurface,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 1,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user