igel share
This commit is contained in:
+686
-230
File diff suppressed because it is too large
Load Diff
+21
-8
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user