igel share

This commit is contained in:
2025-10-24 23:34:56 +02:00
parent 1cd4aebbeb
commit 74414e7728
9 changed files with 1493 additions and 520 deletions
+686 -230
View File
File diff suppressed because it is too large Load Diff
+21 -8
View File
@@ -5,8 +5,9 @@ import 'features/auth/presentation/login_screen.dart';
import 'features/auth/presentation/register_screen.dart';
import 'features/auth/application/splash_screen.dart';
import 'features/igel/presentation/igel_list_screen.dart';
import 'features/igel/presentation/igel_detail_screen.dart'; // <-- WICHTIG
import 'features/igel/presentation/igel_detail_screen.dart';
import 'features/igel/presentation/igel_gallery_screen.dart';
import 'features/igel/presentation/share_igel_page.dart';
GoRouter buildRouter() => GoRouter(
initialLocation: '/splash',
@@ -14,22 +15,33 @@ GoRouter buildRouter() => GoRouter(
GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()),
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
GoRoute(path: '/register', builder: (_, __) => const RegisterScreen()),
// Liste
GoRoute(path: '/igel', builder: (_, __) => const IgelListScreen()),
// Detail: /igel/:id (z. B. /igel/3)
// ⚠️ Wichtig: ZUERST die exakte Route, damit sie nicht von /igel/:id „geschluckt“ wird
GoRoute(
path: '/igel/shared', builder: (_, __) => const SharedIgelRoute()),
// Danach die parametrisierte Route
GoRoute(
path: '/igel/:id',
builder: (ctx, st) {
final idStr = st.pathParameters['id'] ?? '';
final id = int.tryParse(idStr);
final id = int.tryParse(st.pathParameters['id'] ?? '');
if (id == null) {
return const Scaffold(
body: Center(child: Text('Fehlerhafte ID')),
);
}
return IgelDetailScreen(igelId: id);
// NEU: Query-Parameter lesen
final role =
st.uri.queryParameters['role']; // "viewer" | "editor" | null
final from = st.uri.queryParameters['from']; // "shared" | null
return IgelDetailScreen(
igelId: id,
initialRole:
role, // <-- optionales neues Argument (siehe Schritt 3)
from: from, // <-- optionales neues Argument (siehe Schritt 3)
);
},
),
GoRoute(
@@ -40,7 +52,8 @@ GoRouter buildRouter() => GoRouter(
int.tryParse(st.uri.queryParameters['index'] ?? '0') ?? 0;
if (id == null) {
return const Scaffold(
body: Center(child: Text('Fehlerhafte ID')));
body: Center(child: Text('Fehlerhafte ID')),
);
}
return IgelGalleryScreen(igelId: id, initialIndex: initial);
},
+19
View File
@@ -0,0 +1,19 @@
import 'share.dart';
class AccessCache {
// Map<igelId, AccessRole>
final _map = <int, AccessRole>{};
void setRole(int igelId, AccessRole role) => _map[igelId] = role;
AccessRole getRole(int igelId) => _map[igelId] ?? AccessRole.none;
}
AccessRole roleFromString(String s) {
switch (s) {
case 'viewer':
return AccessRole.viewer;
case 'editor':
return AccessRole.editor;
default:
return AccessRole.none;
}
}
+66
View File
@@ -0,0 +1,66 @@
class Share {
final int id;
final int hedgehogId;
final int ownerUserId;
final int? targetUserId;
final String? invitedEmail;
final String? targetEmail; // bei accepted (vom Backend)
final String role; // 'viewer' | 'editor'
final String status; // 'pending' | 'accepted' | 'revoked'
final DateTime? createdAt;
final DateTime? updatedAt;
Share({
required this.id,
required this.hedgehogId,
required this.ownerUserId,
this.targetUserId,
this.invitedEmail,
this.targetEmail,
required this.role,
required this.status,
this.createdAt,
this.updatedAt,
});
factory Share.fromJson(Map<String, dynamic> j) => Share(
id: j['id'] as int,
hedgehogId: j['hedgehog_id'] as int,
ownerUserId: j['owner_user_id'] as int,
targetUserId: j['target_user_id'] as int?,
invitedEmail: j['invited_email'] as String?,
targetEmail: j['target_email'] as String?,
role: j['role'] as String,
status: j['status'] as String,
createdAt:
j['created_at'] != null ? DateTime.tryParse(j['created_at']) : null,
updatedAt:
j['updated_at'] != null ? DateTime.tryParse(j['updated_at']) : null,
);
}
class SharedIgelItem {
final int id; // igel.id
final String name;
final String role; // viewer|editor
final int ownerUserId;
final String? ownerEmail; // <— NEU
SharedIgelItem({
required this.id,
required this.name,
required this.role,
required this.ownerUserId,
this.ownerEmail, // <— NEU
});
factory SharedIgelItem.fromJson(Map<String, dynamic> j) => SharedIgelItem(
id: j['id'] as int,
name: j['name'] as String,
role: j['role'] as String,
ownerUserId: j['owner_user_id'] as int,
ownerEmail: j['owner_email'] as String?, // <— NEU
);
}
enum AccessRole { owner, editor, viewer, none }
+85
View File
@@ -0,0 +1,85 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'share.dart';
class ShareService {
ShareService({http.Client? client, required this.baseUrl})
: _client = client ?? http.Client();
final http.Client _client;
final String baseUrl; // z.B. https://api.windesign.at
Map<String, String> _auth(String token, {Map<String, String>? extra}) =>
{'Authorization': 'Bearer $token', if (extra != null) ...extra};
Future<List<Share>> listShares(
{required int igelId, required String token}) async {
final r = await _client.get(
Uri.parse('$baseUrl/hedgehogs.php?r=/igel/$igelId/shares'),
headers: _auth(token),
);
if (r.statusCode != 200)
throw Exception('listShares failed (${r.statusCode}) ${r.body}');
final List data = jsonDecode(r.body) as List;
return data.map((e) => Share.fromJson(e as Map<String, dynamic>)).toList();
}
Future<void> invite(
{required int igelId,
required String email,
required String role,
required String token}) async {
final r = await _client.post(
Uri.parse('$baseUrl/hedgehogs.php?r=/igel/$igelId/shares'),
headers: _auth(token, extra: {'Content-Type': 'application/json'}),
body: jsonEncode({'email': email, 'role': role}),
);
if (r.statusCode != 201)
throw Exception('invite failed (${r.statusCode}) ${r.body}');
}
Future<void> updateRole(
{required int shareId,
required String role,
required String token}) async {
final r = await _client.patch(
Uri.parse('$baseUrl/hedgehogs.php?r=/shares/$shareId'),
headers: _auth(token, extra: {'Content-Type': 'application/json'}),
body: jsonEncode({'role': role}),
);
if (r.statusCode != 200)
throw Exception('updateRole failed (${r.statusCode}) ${r.body}');
}
Future<void> revoke({required int shareId, required String token}) async {
final r = await _client.delete(
Uri.parse('$baseUrl/hedgehogs.php?r=/shares/$shareId'),
headers: _auth(token),
);
if (r.statusCode != 200)
throw Exception('revoke failed (${r.statusCode}) ${r.body}');
}
Future<void> acceptInvite(
{required String tokenJwt, required String inviteToken}) async {
final r = await _client.post(
Uri.parse('$baseUrl/hedgehogs.php?r=/invites/accept'),
headers: _auth(tokenJwt, extra: {'Content-Type': 'application/json'}),
body: jsonEncode({'token': inviteToken}),
);
if (r.statusCode != 200)
throw Exception('acceptInvite failed (${r.statusCode}) ${r.body}');
}
Future<List<SharedIgelItem>> listSharedWithMe({required String token}) async {
final r = await _client.get(
Uri.parse('$baseUrl/hedgehogs.php?r=/me/shared'),
headers: _auth(token),
);
if (r.statusCode != 200)
throw Exception('listSharedWithMe failed (${r.statusCode}) ${r.body}');
final List data = jsonDecode(r.body) as List;
return data
.map((e) => SharedIgelItem.fromJson(e as Map<String, dynamic>))
.toList();
}
}
@@ -11,6 +11,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../data/share.dart';
import '../data/share_service.dart';
import 'share_bottom_sheet.dart';
import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart' show MediaType;
import 'package:image_picker/image_picker.dart';
@@ -30,9 +33,17 @@ import '../../messwerte/data/messwerte_repository.dart';
import '../../messwerte/domain/messwert.dart';
class IgelDetailScreen extends ConsumerStatefulWidget {
const IgelDetailScreen({super.key, required this.igelId});
final String? initialRole; // "viewer" | "editor" | null
final String? from; // "shared" | null
final int igelId;
const IgelDetailScreen({
super.key,
required this.igelId,
this.initialRole,
this.from,
});
@override
ConsumerState<IgelDetailScreen> createState() => _IgelDetailState();
}
@@ -41,54 +52,75 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
late final IgelImagesRepository imagesRepo;
late final IgelRepository igelRepo;
late final MesswerteRepository messRepo;
ShareService? _shareService;
String? _accessToken;
Igel? igel;
bool busy = true;
String? err;
Igel? igel;
// --- Zugriff / Rolle ---
late final AccessRole _role;
late final bool canEdit; // owner || editor
late final bool cameFromShared; // from=shared
// Basisdaten (editierbar via AppBar-Dialog)
AccessRole _mapRole(String? r) {
switch (r) {
case 'viewer':
return AccessRole.viewer;
case 'editor':
return AccessRole.editor;
default:
return AccessRole.owner;
}
}
// --- Controller / State ---
final nameC = TextEditingController();
final featureC = TextEditingController();
final noteC = TextEditingController();
// Ort/Fundstelle + Gerettet am
final locationC = TextEditingController();
DateTime? rescuedAt;
DateTime? _initRescuedAt;
String? _initLocation;
String? gender;
String? gender; // 'männlich' | 'weiblich' | 'unbekannt' | null
// Bilder
List<IgelImage> images = [];
// Messwerte
List<Messwert> messwerte = [];
// Messwert-Form
bool mwShowForm = false;
DateTime mwDatum = DateTime.now();
final mwGewichtC = TextEditingController();
final mwBehandlungC = TextEditingController();
final mwBemerkungC = TextEditingController();
// Upload-Overlay
// Upload
bool _uploading = false;
int _uploadDone = 0;
int _uploadTotal = 0;
// Export: Chart als Bild rendern
final GlobalKey _chartKey = GlobalKey();
@override
void initState() {
super.initState();
_role = _mapRole(widget.initialRole);
canEdit = _role == AccessRole.owner || _role == AccessRole.editor;
cameFromShared = widget.from == 'shared';
imagesRepo = ref.read(igelImagesRepoProvider);
igelRepo = ref.read(igelRepoProvider);
messRepo = ref.read(messwerteRepoProvider);
featureC.addListener(_markDirtyBasics);
noteC.addListener(_markDirtyBasics);
locationC.addListener(_markDirtyBasics);
final ts = ref.read(tokenStorageProvider);
ts.access.then((tok) {
if (mounted) setState(() => _accessToken = tok);
});
final base = kApiBase.replaceFirst('/hedgehogs.php?r=', '');
_shareService = ShareService(baseUrl: base);
_loadAll();
}
@@ -105,59 +137,23 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
super.dispose();
}
void _markDirtyBasics() {
if (mounted) setState(() {});
}
bool get _basicsChanged {
if (igel == null) return false;
final newName = nameC.text.trim();
final newFeature =
featureC.text.trim().isEmpty ? null : featureC.text.trim();
final newNote = noteC.text.trim().isEmpty ? null : noteC.text.trim();
bool changed = newName != igel!.name ||
newFeature != igel!.feature ||
newNote != igel!.note ||
gender != igel!.gender;
// rescuedAt & location berücksichtigen
String? fmt(DateTime? d) =>
d == null ? null : DateFormat('yyyy-MM-dd').format(d);
if (fmt(rescuedAt) != fmt(_initRescuedAt)) changed = true;
final newLoc = locationC.text.trim().isEmpty ? null : locationC.text.trim();
if (newLoc != (_initLocation?.trim())) changed = true;
return changed;
}
Future<void> _loadAll() async {
setState(() {
busy = true;
err = null;
});
try {
// Igel
final data = await igelRepo.get(widget.igelId);
igel = data;
nameC.text = data.name;
featureC.text = data.feature ?? '';
noteC.text = data.note ?? '';
gender = data.gender;
igel = await igelRepo.get(widget.igelId);
nameC.text = igel!.name;
featureC.text = igel!.feature ?? '';
noteC.text = igel!.note ?? '';
gender = igel!.gender;
rescuedAt = igel!.rescuedAt;
locationC.text = igel!.location ?? '';
_initRescuedAt = igel!.rescuedAt;
_initLocation = igel!.location;
// rescuedAt + location (sofern im Model vorhanden)
rescuedAt = data.rescuedAt;
locationC.text = data.location ?? '';
_initRescuedAt = data.rescuedAt;
_initLocation = data.location;
// Bilder
images = await imagesRepo.list(widget.igelId);
if (mounted) await _prefetchImages(context);
// Messwerte
messwerte = await messRepo.list(widget.igelId);
} catch (e) {
err = e.toString();
@@ -166,67 +162,53 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
}
}
/// Prefetcht bis zu 12 Thumbs und bis zu 3 Full-Images.
Future<void> _prefetchImages(BuildContext context) async {
if (!mounted || images.isEmpty) return;
final thumbCount = math.min(12, images.length);
for (var i = 0; i < thumbCount; i++) {
final url = images[i].thumbUrl ?? images[i].url;
try {
await precacheImage(NetworkImage(url), context);
} catch (_) {}
}
final fullCount = math.min(3, images.length);
for (var i = 0; i < fullCount; i++) {
try {
await precacheImage(NetworkImage(images[i].url), context);
} catch (_) {}
}
bool get _basicsChanged {
if (igel == null) return false;
bool changed = nameC.text.trim() != igel!.name ||
featureC.text.trim() != (igel!.feature ?? '') ||
noteC.text.trim() != (igel!.note ?? '') ||
gender != igel!.gender;
String? fmt(DateTime? d) =>
d == null ? null : DateFormat('yyyy-MM-dd').format(d);
if (fmt(rescuedAt) != fmt(_initRescuedAt)) changed = true;
if (locationC.text.trim() != (_initLocation ?? '')) changed = true;
return changed;
}
Future<void> _saveIgel() async {
if (!canEdit) return;
if (!_basicsChanged || igel == null) return;
final newName = nameC.text.trim();
final newFeature =
featureC.text.trim().isEmpty ? null : featureC.text.trim();
final newNote = noteC.text.trim().isEmpty ? null : noteC.text.trim();
if (newName.isEmpty) {
_snack('Bitte einen Namen eingeben');
if (nameC.text.trim().isEmpty) {
_snack('Bitte Namen eingeben');
return;
}
try {
await igelRepo.update(
widget.igelId,
newName,
note: newNote,
nameC.text.trim(),
note: noteC.text.trim().isEmpty ? null : noteC.text.trim(),
gender: gender,
feature: newFeature,
feature: featureC.text.trim().isEmpty ? null : featureC.text.trim(),
rescuedAt: rescuedAt,
location: locationC.text.trim().isEmpty ? null : locationC.text.trim(),
);
igel = igel!.copyWith(
name: newName,
note: newNote,
gender: gender,
feature: newFeature,
rescuedAt: rescuedAt,
location: locationC.text.trim().isEmpty ? null : locationC.text.trim(),
);
_initRescuedAt = rescuedAt;
_initLocation =
locationC.text.trim().isEmpty ? null : locationC.text.trim();
setState(() {});
_snack('Gespeichert');
_initRescuedAt = rescuedAt;
_initLocation = locationC.text.trim();
} catch (e) {
_snack('Speichern fehlgeschlagen: $e');
}
}
void _snack(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
// --- Images ---
Future<void> _pickAndUpload() async {
if (!canEdit) return;
try {
final picker = ImagePicker();
final picks = await picker.pickMultiImage(
@@ -245,7 +227,6 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
final files = <http.MultipartFile>[];
for (final x in picks) {
// Datei-Part bauen (Name MUSS 'files[]' sein)
if (kIsWeb) {
final bytes = await x.readAsBytes();
files.add(http.MultipartFile.fromBytes(
@@ -264,7 +245,6 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
setState(() => _uploadDone++);
}
// Kein takenAt mitsenden Server extrahiert EXIF und setzt taken_at
await imagesRepo.upload(widget.igelId, files);
images = await imagesRepo.list(widget.igelId);
@@ -273,7 +253,6 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
_uploadDone = _uploadTotal;
_uploading = false;
});
await _prefetchImages(context);
}
_snack('Bilder hochgeladen');
} catch (e) {
@@ -283,6 +262,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
}
Future<void> _deleteImage(IgelImage img) async {
if (!canEdit) return;
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
@@ -303,10 +283,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
try {
await imagesRepo.delete(img.id);
images = await imagesRepo.list(widget.igelId);
if (mounted) {
setState(() {});
await _prefetchImages(context);
}
if (mounted) setState(() {});
_snack('Bild gelöscht');
} catch (e) {
_snack('Löschen fehlgeschlagen: $e');
@@ -352,6 +329,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
// --- Messwerte helpers ---
Future<void> _pickMwDateTime() async {
if (!canEdit) return;
final d = await showDatePicker(
context: context,
initialDate: mwDatum,
@@ -368,6 +346,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
}
Future<void> _createMesswert() async {
if (!canEdit) return;
final gewicht = int.tryParse(mwGewichtC.text.trim());
if (gewicht == null || gewicht <= 0) {
_snack('Bitte Gewicht in Gramm angeben');
@@ -393,6 +372,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
}
Future<void> _editMesswert(Messwert m) async {
if (!canEdit) return;
final res = await showDialog<Messwert>(
context: context,
builder: (_) => _EditMesswertDialog(initial: m),
@@ -409,6 +389,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
}
Future<void> _deleteMesswert(Messwert m) async {
if (!canEdit) return;
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
@@ -436,8 +417,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
}
}
// --- Export: CSV (nur dieser Igel, denormalisiert) -------------------------
// --- Export Helfer ---
String _fmtDate(DateTime? dt) =>
dt == null ? '' : DateFormat('yyyy-MM-dd').format(dt);
@@ -460,7 +440,6 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
Future<void> _exportCsvSingleIgel() async {
if (igel == null) return;
// Sicherheits-Reload der Messwerte, damit CSV aktuell ist
final mw = await messRepo.list(widget.igelId);
const sep = ';';
@@ -529,8 +508,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
_snack('CSV exportiert');
}
// --- Export: PDF -----------------------------------------------------------
// --- Export PDF ---
Future<Uint8List?> _captureChartPngBytes() async {
try {
final ctx = _chartKey.currentContext;
@@ -595,13 +573,11 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Abbrechen'),
),
onPressed: () => Navigator.pop(context, false),
child: const Text('Abbrechen')),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Exportieren'),
),
onPressed: () => Navigator.pop(context, true),
child: const Text('Exportieren')),
],
);
},
@@ -617,14 +593,12 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
{required bool includeMesswerte, required bool includeBilder}) async {
if (igel == null) return;
// Aktuelle Messwerte/Bilderdaten holen
final mw = await messRepo.list(widget.igelId);
final chartBytes = await _captureChartPngBytes();
final imageBytesList =
includeBilder ? await _fetchImagesForPdf() : const <Uint8List?>[];
final doc = pw.Document();
final textStyle = pw.TextStyle(fontSize: 12);
final headerStyle =
pw.TextStyle(fontSize: 18, fontWeight: pw.FontWeight.bold);
@@ -641,7 +615,6 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
),
);
// Seite 1: Stammdaten + Chart + optional Messwerte/Bilder (alles synchron)
doc.addPage(
pw.MultiPage(
margin: const pw.EdgeInsets.all(24),
@@ -762,12 +735,8 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
String _fmtGramm(int g) => '$g g';
String? _emptyToNull(String s) => s.trim().isEmpty ? null : s.trim();
void _snack(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
Future<void> _openEditBasicsDialog() async {
if (!canEdit) return;
final tmpName = TextEditingController(text: nameC.text);
String? tmpGender = gender;
@@ -842,7 +811,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
if (ok == true) {
nameC.text = tmpName.text.trim();
gender = tmpGender;
_markDirtyBasics();
setState(() {});
}
tmpName.dispose();
}
@@ -857,10 +826,18 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
return Scaffold(
appBar: AppBar(
leading: BackButton(onPressed: () => context.go('/igel')),
leading: BackButton(
onPressed: () {
if (cameFromShared) {
context.go('/igel/shared');
} else {
context.go('/igel');
}
},
),
title: InkWell(
borderRadius: BorderRadius.circular(6),
onTap: _openEditBasicsDialog,
onTap: canEdit ? _openEditBasicsDialog : null,
child: Row(
children: [
if (titleIcon != null) ...[
@@ -868,36 +845,60 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
const SizedBox(width: 8),
],
Flexible(child: Text(title)),
const SizedBox(width: 8),
const Icon(Icons.edit, size: 18, color: Colors.black54),
if (canEdit) ...[
const SizedBox(width: 8),
const Icon(Icons.edit, size: 18, color: Colors.black54),
],
],
),
),
actions: [
if (_role == AccessRole.owner)
IconButton(
tooltip: 'Teilen',
icon: const Icon(Icons.share),
onPressed: () {
if (_shareService == null || _accessToken == null) return;
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => SizedBox(
height: MediaQuery.of(context).size.height * 0.85,
child: ShareBottomSheet(
igelId: widget.igelId,
shareService: _shareService!,
token: _accessToken!,
accessRole: _role,
),
),
);
},
),
IconButton(
onPressed: _basicsChanged ? _saveIgel : null,
tooltip:
_basicsChanged ? 'Änderungen speichern' : 'Keine Änderungen',
onPressed: canEdit && _basicsChanged ? _saveIgel : null,
tooltip: _basicsChanged && canEdit
? 'Änderungen speichern'
: 'Keine Änderungen',
icon: const Icon(Icons.save),
),
IconButton(
tooltip: 'CSV für diesen Igel importieren…',
icon: const Icon(Icons.upload_file),
onPressed: () async {
final service = IgelCsvImportService(
igelRepo: ref.read(igelRepoProvider),
messRepo: ref.read(messwerteRepoProvider),
);
await service.pickAndImportForIgel(
context,
igelId: widget.igelId,
igelNameHint: igel?.name, // hilft beim Name-Matching
);
// danach Messwerte neu laden
messwerte = await messRepo.list(widget.igelId);
if (mounted) setState(() {});
},
),
if (canEdit)
IconButton(
tooltip: 'CSV für diesen Igel importieren…',
icon: const Icon(Icons.upload_file),
onPressed: () async {
final service = IgelCsvImportService(
igelRepo: ref.read(igelRepoProvider),
messRepo: ref.read(messwerteRepoProvider),
);
await service.pickAndImportForIgel(
context,
igelId: widget.igelId,
igelNameHint: igel?.name,
);
messwerte = await messRepo.list(widget.igelId);
if (mounted) setState(() {});
},
),
PopupMenuButton<String>(
tooltip: 'Export',
onSelected: (v) async {
@@ -913,11 +914,12 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
],
icon: const Icon(Icons.ios_share),
),
IconButton(
onPressed: _pickAndUpload,
tooltip: 'Bilder hinzufügen',
icon: const Icon(Icons.add_a_photo),
),
if (canEdit)
IconButton(
onPressed: _pickAndUpload,
tooltip: 'Bilder hinzufügen',
icon: const Icon(Icons.add_a_photo),
),
],
),
body: Stack(
@@ -943,15 +945,17 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
children: [
TextField(
controller: featureC,
enabled: canEdit,
readOnly: !canEdit,
decoration: const InputDecoration(
labelText: 'Merkmal (optional)',
prefixIcon: Icon(Icons.style),
),
),
const SizedBox(height: 12),
// Gerettet am (Datum)
// Gerettet am
ListTile(
enabled: canEdit,
contentPadding: EdgeInsets.zero,
leading:
const Icon(Icons.calendar_today),
@@ -962,28 +966,35 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
: DateFormat('dd.MM.yyyy')
.format(rescuedAt!),
),
onTap: () async {
final now = DateTime.now();
final init = rescuedAt ?? now;
final picked = await showDatePicker(
context: context,
initialDate: init,
firstDate: DateTime(2000),
lastDate:
DateTime(now.year + 1, 12, 31),
);
if (picked != null) {
setState(() => rescuedAt = picked);
}
},
onLongPress: () =>
setState(() => rescuedAt = null),
onTap: !canEdit
? null
: () async {
final now = DateTime.now();
final init = rescuedAt ?? now;
final picked =
await showDatePicker(
context: context,
initialDate: init,
firstDate: DateTime(2000),
lastDate: DateTime(
now.year + 1, 12, 31),
);
if (picked != null) {
setState(
() => rescuedAt = picked);
}
},
onLongPress: !canEdit
? null
: () => setState(
() => rescuedAt = null),
),
const SizedBox(height: 12),
// Ort / Fundstelle
TextField(
controller: locationC,
enabled: canEdit,
readOnly: !canEdit,
decoration: const InputDecoration(
labelText:
'Ort / Fundstelle (optional)',
@@ -991,10 +1002,11 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
Icon(Icons.place_outlined),
),
),
const SizedBox(height: 12),
TextField(
controller: noteC,
enabled: canEdit,
readOnly: !canEdit,
minLines: 2,
maxLines: 5,
decoration: const InputDecoration(
@@ -1006,10 +1018,8 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
),
),
),
const SizedBox(height: 12),
// ----- Gewicht-Chart (mit Achsen)
// ----- Gewicht-Chart
Card(
child: Padding(
padding: const EdgeInsets.all(12),
@@ -1028,9 +1038,10 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
const Spacer(),
if (chartData.isNotEmpty)
Text(
'${chartData.first.datum.year}${chartData.last.datum.year}',
style: const TextStyle(
color: Colors.black54)),
'${chartData.first.datum.year}${chartData.last.datum.year}',
style: const TextStyle(
color: Colors.black54),
),
],
),
const SizedBox(height: 8),
@@ -1045,10 +1056,8 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
),
),
),
const SizedBox(height: 12),
// ----- Messwerte (Form + Tabelle)
// ----- Messwerte
Card(
child: Padding(
padding: const EdgeInsets.all(12),
@@ -1066,22 +1075,23 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
fontSize: 16,
fontWeight: FontWeight.w600)),
const Spacer(),
TextButton.icon(
onPressed: () => setState(
() => mwShowForm = !mwShowForm),
icon: Icon(mwShowForm
? Icons.close
: Icons.add),
label: Text(mwShowForm
? 'Abbrechen'
: 'Neu'),
),
if (canEdit)
TextButton.icon(
onPressed: () => setState(() =>
mwShowForm = !mwShowForm),
icon: Icon(mwShowForm
? Icons.close
: Icons.add),
label: Text(mwShowForm
? 'Abbrechen'
: 'Neu'),
),
],
),
AnimatedCrossFade(
duration:
const Duration(milliseconds: 180),
crossFadeState: mwShowForm
crossFadeState: mwShowForm && canEdit
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: Padding(
@@ -1163,50 +1173,60 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columns: const [
DataColumn(
columns: [
const DataColumn(
label: Text('Datum/Uhrzeit')),
DataColumn(
const DataColumn(
label: Text('Gewicht (g)')),
DataColumn(
const DataColumn(
label: Text('Behandlung')),
DataColumn(
const DataColumn(
label: Text('Bemerkung')),
DataColumn(
label: Text('Aktionen')),
if (canEdit)
const DataColumn(
label: Text('Aktionen')),
],
rows: [
for (final m in messwerte)
DataRow(cells: [
DataCell(Text(
_fmtDateTime(m.datum))),
DataCell(
Text('${m.gewicht}')),
DataCell(
Text(m.behandlung ?? '')),
DataCell(
Text(m.bemerkung ?? '')),
DataCell(Row(
mainAxisSize:
MainAxisSize.min,
children: [
IconButton(
tooltip: 'Bearbeiten',
icon: const Icon(
Icons.edit),
onPressed: () =>
_editMesswert(m),
DataRow(
cells: [
DataCell(Text(
_fmtDateTime(m.datum))),
DataCell(
Text('${m.gewicht}')),
DataCell(Text(
m.behandlung ?? '')),
DataCell(Text(
m.bemerkung ?? '')),
if (canEdit)
DataCell(
Row(
mainAxisSize:
MainAxisSize.min,
children: [
IconButton(
tooltip:
'Bearbeiten',
icon: const Icon(
Icons.edit),
onPressed: () =>
_editMesswert(
m),
),
IconButton(
tooltip:
'Löschen',
icon: const Icon(Icons
.delete_outline),
onPressed: () =>
_deleteMesswert(
m),
),
],
),
),
IconButton(
tooltip: 'Löschen',
icon: const Icon(Icons
.delete_outline),
onPressed: () =>
_deleteMesswert(m),
),
],
)),
]), // for
],
),
],
),
),
@@ -1214,10 +1234,8 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
),
),
),
const SizedBox(height: 12),
// ----- Bilder-Grid (GANZ UNTEN)
// ----- Bilder-Grid
if (images.isEmpty)
Card(
child: SizedBox(
@@ -1231,13 +1249,16 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
size: 48),
const SizedBox(height: 8),
const Text('Noch keine Bilder'),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: _pickAndUpload,
icon: const Icon(Icons.add_a_photo),
label:
const Text('Bilder hochladen'),
),
if (canEdit) ...[
const SizedBox(height: 8),
FilledButton.icon(
onPressed: _pickAndUpload,
icon:
const Icon(Icons.add_a_photo),
label: const Text(
'Bilder hochladen'),
),
],
],
),
),
@@ -1251,29 +1272,25 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
// Header mit Button OBERHALB der Thumbs
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
'Bilder',
style: Theme.of(context)
.textTheme
.titleMedium,
),
OutlinedButton.icon(
onPressed: _pickAndUpload,
icon:
const Icon(Icons.add_a_photo),
label: const Text(
'Bilder hochladen'),
),
Text('Bilder',
style: Theme.of(context)
.textTheme
.titleMedium),
if (canEdit)
OutlinedButton.icon(
onPressed: _pickAndUpload,
icon: const Icon(
Icons.add_a_photo),
label: const Text(
'Bilder hochladen'),
),
],
),
const SizedBox(height: 8),
// Thumbs kleiner + Datum/Uhrzeit (takenAt bevorzugt)
GridView.builder(
physics:
const NeverScrollableScrollPhysics(),
@@ -1290,7 +1307,6 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
final img = images[i];
final thumb =
img.thumbUrl ?? img.url;
final String? ts = (() {
final dt =
img.takenAt ?? img.createdAt;
@@ -1299,7 +1315,6 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
'dd.MM.yyyy, HH:mm')
.format(dt);
})();
return GestureDetector(
onTap: () async {
final res = await context.push(
@@ -1308,13 +1323,12 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
if (res == true) {
images = await imagesRepo
.list(widget.igelId);
if (mounted) {
setState(() {});
}
if (mounted) setState(() {});
}
},
onLongPress: () =>
_deleteImage(img),
onLongPress: canEdit
? () => _deleteImage(img)
: null,
child: Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
@@ -1335,9 +1349,8 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
color: Color(
0x11000000),
child: Center(
child: Icon(Icons
.broken_image),
),
child: Icon(Icons
.broken_image)),
),
),
),
@@ -1366,7 +1379,6 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
],
),
),
// Upload-Overlay
if (_uploading)
Container(
@@ -1376,15 +1388,14 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 60,
height: 60,
child: CircularProgressIndicator(strokeWidth: 4),
),
width: 60,
height: 60,
child: CircularProgressIndicator(strokeWidth: 4)),
const SizedBox(height: 12),
Text(
_uploadTotal <= 1
? 'Lade Bild hoch …'
: 'Lade Bilder hoch ($_uploadDone/$_uploadTotal) …',
: 'Lade Bilder hoch (${_uploadDone}/${_uploadTotal}) …',
style: const TextStyle(color: Colors.white, fontSize: 16),
),
],
@@ -1502,8 +1513,9 @@ class _EditMesswertDialogState extends State<_EditMesswertDialog> {
onPressed: () {
final gewicht = int.tryParse(gewichtC.text.trim());
if (gewicht == null || gewicht <= 0) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Bitte Gewicht in Gramm angeben')));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Bitte Gewicht in Gramm angeben')),
);
return;
}
Navigator.pop(
@@ -1604,20 +1616,18 @@ class _WeightChartPainter extends CustomPainter {
size.height - paddingTop - paddingBottom,
);
// Achsen
final axisPaint = Paint()
..color = _axisColor
..strokeWidth = 1;
// Y-Achse links
// Achsen
canvas.drawLine(
Offset(area.left, area.top), Offset(area.left, area.bottom), axisPaint);
// X-Achse unten
canvas.drawLine(Offset(area.left, area.bottom),
Offset(area.right, area.bottom), axisPaint);
if (data.isEmpty) return;
// Wertebereiche
int minG = data.map((e) => e.gewicht).reduce(math.min);
int maxG = data.map((e) => e.gewicht).reduce(math.max);
if (minG == maxG) {
@@ -289,7 +289,14 @@ class _IgelListState extends ConsumerState<IgelListScreen> {
await service.pickAndImport(context);
await _load(); // nach Import Liste aktualisieren
},
), // ⬇️ Export-Menü
),
// NEU: Freigegebene Igel
IconButton(
tooltip: 'Freigegebene Igel',
icon: const Icon(Icons.people_alt),
onPressed: () => context.push('/igel/shared'),
),
// ⬇️ Export-Menü
PopupMenuButton<String>(
onSelected: (v) async {
if (v == 'export_csv_denorm') {
@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import '../data/share_service.dart';
import '../data/share.dart';
class ShareBottomSheet extends StatefulWidget {
const ShareBottomSheet({
super.key,
required this.igelId,
required this.shareService,
required this.accessRole, // AccessRole.owner/editor/viewer
required this.token,
});
final int igelId;
final ShareService shareService;
final String token;
final AccessRole accessRole;
@override
State<ShareBottomSheet> createState() => _ShareBottomSheetState();
}
class _ShareBottomSheetState extends State<ShareBottomSheet> {
final _emailCtrl = TextEditingController();
String _role = 'viewer';
Future<List<Share>>? _future;
@override
void initState() {
super.initState();
_future = widget.shareService
.listShares(igelId: widget.igelId, token: widget.token);
}
Future<void> _reload() async {
setState(() {
_future = widget.shareService
.listShares(igelId: widget.igelId, token: widget.token);
});
}
Future<void> _invite() async {
final email = _emailCtrl.text.trim();
if (email.isEmpty) return;
await widget.shareService.invite(
igelId: widget.igelId,
email: email,
role: _role,
token: widget.token,
);
_emailCtrl.clear();
await _reload();
}
Future<void> _updateRole(Share s, String newRole) async {
await widget.shareService
.updateRole(shareId: s.id, role: newRole, token: widget.token);
await _reload();
}
Future<void> _revoke(Share s) async {
await widget.shareService.revoke(shareId: s.id, token: widget.token);
await _reload();
}
@override
Widget build(BuildContext context) {
final canInvite = widget.accessRole == AccessRole.owner;
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(children: [
const Icon(Icons.share),
const SizedBox(width: 8),
Text('Igel teilen',
style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
]),
const SizedBox(height: 12),
// Einladen (nur Owner)
if (canInvite)
_InviteRow(
emailCtrl: _emailCtrl,
role: _role,
onRoleChanged: (v) => setState(() => _role = v),
onInvite: _invite,
),
if (canInvite) const SizedBox(height: 16),
// Liste der Freigaben
Expanded(
child: FutureBuilder<List<Share>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(child: Text('Fehler: ${snap.error}'));
}
final items = snap.data ?? const [];
if (items.isEmpty) {
return const Center(child: Text('Keine Freigaben'));
}
return ListView.separated(
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (ctx, i) {
final s = items[i];
// Priorität: pending -> invitedEmail, accepted -> targetEmail
final label =
(s.invitedEmail != null && s.invitedEmail!.isNotEmpty)
? s.invitedEmail!
: ((s.targetEmail != null &&
s.targetEmail!.isNotEmpty)
? s.targetEmail!
: 'Unbekannt');
return ListTile(
leading: Icon(
s.status == 'accepted'
? Icons.check_circle_outline
: Icons.hourglass_bottom,
),
title: Text(label),
subtitle:
Text('Rolle: ${s.role} · Status: ${s.status}'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (canInvite && s.status == 'accepted')
PopupMenuButton<String>(
onSelected: (v) => _updateRole(s, v),
itemBuilder: (_) => const [
PopupMenuItem(
value: 'viewer', child: Text('Viewer')),
PopupMenuItem(
value: 'editor', child: Text('Editor')),
],
child: const Icon(Icons.admin_panel_settings),
),
if (canInvite)
IconButton(
icon: const Icon(Icons.remove_circle_outline),
tooltip: 'Zugriff entfernen',
onPressed: () => _revoke(s),
),
],
),
);
},
);
},
),
),
],
),
),
);
}
}
class _InviteRow extends StatelessWidget {
const _InviteRow({
required this.emailCtrl,
required this.role,
required this.onRoleChanged,
required this.onInvite,
});
final TextEditingController emailCtrl;
final String role;
final ValueChanged<String> onRoleChanged;
final VoidCallback onInvite;
@override
Widget build(BuildContext context) {
return Row(children: [
Expanded(
child: TextField(
controller: emailCtrl,
decoration: const InputDecoration(labelText: 'E-Mail einladen'),
keyboardType: TextInputType.emailAddress,
),
),
const SizedBox(width: 8),
DropdownButton<String>(
value: role,
items: const [
DropdownMenuItem(value: 'viewer', child: Text('Viewer')),
DropdownMenuItem(value: 'editor', child: Text('Editor')),
],
onChanged: (v) {
if (v != null) onRoleChanged(v);
},
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: onInvite,
icon: const Icon(Icons.send),
label: const Text('Einladen'),
),
]);
}
}
@@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../main.dart';
import '../data/share_service.dart';
import '../data/share.dart';
import 'package:go_router/go_router.dart';
class SharedIgelPage extends StatefulWidget {
const SharedIgelPage({super.key, required this.service, required this.token});
final ShareService service;
final String token;
@override
State<SharedIgelPage> createState() => _SharedIgelPageState();
}
class _SharedIgelPageState extends State<SharedIgelPage> {
late Future<List<SharedIgelItem>> _future;
@override
void initState() {
super.initState();
_future = widget.service.listSharedWithMe(token: widget.token);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: BackButton(
onPressed: () => context.go('/igel')), // ⬅️ zurück zu „Meine Igel“
title: const Text('Für mich freigegeben'),
actions: [
IconButton(
tooltip: 'Meine Igel',
icon: const Icon(Icons.pets),
onPressed: () => context.go('/igel'),
),
],
),
body: FutureBuilder<List<SharedIgelItem>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(child: Text('Fehler: ${snap.error}'));
}
final items = snap.data ?? const [];
if (items.isEmpty) {
return const Center(child: Text('Keine freigegebenen Igel'));
}
return ListView.separated(
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (ctx, i) {
final it = items[i];
// Owner-Anzeige: bevorzugt E-Mail, sonst Fallback auf #ID
final ownerLabel =
(it.ownerEmail != null && it.ownerEmail!.isNotEmpty)
? it.ownerEmail!
: '#${it.ownerUserId}';
return ListTile(
leading: const CircleAvatar(child: Icon(Icons.people_alt)),
title: Text(it.name),
subtitle: Text('Rolle: ${it.role} · Owner: $ownerLabel'),
onTap: () {
// Detail öffnen; 'from=shared' sorgt dafür, dass der Back-Button wieder hierher führt
context.push('/igel/${it.id}?role=${it.role}&from=shared}');
},
);
},
);
},
),
);
}
}
class SharedIgelRoute extends ConsumerWidget {
const SharedIgelRoute({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final ts = ref.read(tokenStorageProvider);
return FutureBuilder<String?>(
future: ts.access,
builder: (context, snap) {
if (!snap.hasData) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final token = snap.data!;
final base = kApiBase.replaceFirst('/hedgehogs.php?r=', '');
final service = ShareService(baseUrl: base);
return SharedIgelPage(service: service, token: token);
},
);
}
}