Gallery
This commit is contained in:
+34
-1
@@ -1,9 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'features/auth/presentation/login_screen.dart';
|
||||
import 'features/auth/presentation/register_screen.dart';
|
||||
import 'features/igel/presentation/igel_list_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_gallery_screen.dart';
|
||||
|
||||
GoRouter buildRouter() => GoRouter(
|
||||
initialLocation: '/splash',
|
||||
@@ -11,6 +14,36 @@ 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)
|
||||
GoRoute(
|
||||
path: '/igel/:id',
|
||||
builder: (ctx, st) {
|
||||
final idStr = st.pathParameters['id'] ?? '';
|
||||
final id = int.tryParse(idStr);
|
||||
if (id == null) {
|
||||
return const Scaffold(
|
||||
body: Center(child: Text('Fehlerhafte ID')),
|
||||
);
|
||||
}
|
||||
return IgelDetailScreen(igelId: id);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/igel/:id/gallery',
|
||||
builder: (ctx, st) {
|
||||
final id = int.tryParse(st.pathParameters['id'] ?? '');
|
||||
final initial =
|
||||
int.tryParse(st.uri.queryParameters['index'] ?? '0') ?? 0;
|
||||
if (id == null) {
|
||||
return const Scaffold(
|
||||
body: Center(child: Text('Fehlerhafte ID')));
|
||||
}
|
||||
return IgelGalleryScreen(igelId: id, initialIndex: initial);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../auth/data/token_storage.dart';
|
||||
import '../../../main.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../../../shared/api_client.dart';
|
||||
|
||||
class IgelImage {
|
||||
final int id;
|
||||
final String url;
|
||||
final String? thumbUrl; // ✅ Thumbnail-Feld ergänzt
|
||||
final String? originalName;
|
||||
final String? mime;
|
||||
final int? sizeBytes;
|
||||
|
||||
IgelImage({
|
||||
required this.id,
|
||||
required this.url,
|
||||
this.thumbUrl,
|
||||
this.originalName,
|
||||
this.mime,
|
||||
this.sizeBytes,
|
||||
});
|
||||
|
||||
factory IgelImage.fromMap(Map<String, dynamic> m) => IgelImage(
|
||||
id: (m['id'] as num).toInt(),
|
||||
url: (m['url'] ?? '') as String,
|
||||
thumbUrl:
|
||||
(m['thumb_url'] ?? m['thumbUrl']) as String?, // ✅ Feld gemappt
|
||||
originalName: m['original_name'] as String?,
|
||||
mime: m['mime'] as String?,
|
||||
sizeBytes: (m['size_bytes'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
class IgelImagesRepository {
|
||||
IgelImagesRepository(this.api, {required this.getAccessToken});
|
||||
final ApiClient api;
|
||||
final Future<String?> Function() getAccessToken;
|
||||
|
||||
Future<List<IgelImage>> list(int igelId) async {
|
||||
final res = await api.get<dynamic>('/igel/$igelId/images');
|
||||
final list = (res as List).cast<Map<String, dynamic>>();
|
||||
return list.map(IgelImage.fromMap).toList();
|
||||
}
|
||||
|
||||
Future<List<IgelImage>> upload(
|
||||
int igelId, List<http.MultipartFile> files) async {
|
||||
final uri = Uri.parse(
|
||||
'${api.baseUrl}/igel/$igelId/images'); // api.baseUrl endet auf ?r=
|
||||
final req = http.MultipartRequest('POST', uri);
|
||||
final token = await getAccessToken();
|
||||
if (token != null) req.headers['Authorization'] = 'Bearer $token';
|
||||
// keine Content-Type-Header setzen -> MultipartRequest macht das korrekt
|
||||
for (final f in files) {
|
||||
req.files.add(f); // Feldname egal: server sammelt alle
|
||||
}
|
||||
final streamRes = await req.send();
|
||||
final body = await streamRes.stream.bytesToString();
|
||||
if (streamRes.statusCode < 200 || streamRes.statusCode >= 300) {
|
||||
throw ApiException(streamRes.statusCode, body);
|
||||
}
|
||||
final decoded = jsonDecode(body);
|
||||
final uploaded = (decoded['uploaded'] as List).cast<Map<String, dynamic>>();
|
||||
return uploaded.map(IgelImage.fromMap).toList();
|
||||
}
|
||||
|
||||
Future<void> delete(int imageId) async {
|
||||
await api.delete<dynamic>('/igel/images/$imageId');
|
||||
}
|
||||
}
|
||||
@@ -35,4 +35,9 @@ class IgelRepository {
|
||||
Future<void> delete(int id) async {
|
||||
await api.delete<dynamic>('/igel/$id');
|
||||
}
|
||||
|
||||
Future<Igel> get(int id) async {
|
||||
final map = await api.get<Map<String, dynamic>>('/igel/$id');
|
||||
return Igel.fromMap(map);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,66 @@
|
||||
class Igel {
|
||||
final int id;
|
||||
final String name;
|
||||
final String? note;
|
||||
final String? gender; // männlich | weiblich | unbekannt
|
||||
final String? gender; // 'männlich' | 'weiblich' | 'unbekannt'
|
||||
final String? feature; // Merkmal
|
||||
final String? note; // Information
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
Igel(
|
||||
{required this.id,
|
||||
required this.name,
|
||||
this.note,
|
||||
this.gender,
|
||||
this.feature});
|
||||
Igel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.gender,
|
||||
this.feature,
|
||||
this.note,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
factory Igel.fromMap(Map<String, dynamic> m) => Igel(
|
||||
id: (m['id'] as num).toInt(),
|
||||
name: (m['name'] ?? '') as String,
|
||||
note: m['note'] as String?,
|
||||
gender: m['gender'] as String?,
|
||||
feature: m['feature'] as String?,
|
||||
// ---------- Factory fromMap ----------
|
||||
factory Igel.fromMap(Map<String, dynamic> map) => Igel(
|
||||
id: (map['id'] as num).toInt(),
|
||||
name: map['name'] as String,
|
||||
gender: map['gender'] as String?,
|
||||
feature: map['feature'] as String?,
|
||||
note: map['note'] as String?,
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'])
|
||||
: null,
|
||||
updatedAt: map['updated_at'] != null
|
||||
? DateTime.parse(map['updated_at'])
|
||||
: null,
|
||||
);
|
||||
|
||||
// ---------- toMap (optional für PUT/POST) ----------
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
if (gender != null) 'gender': gender,
|
||||
if (feature != null) 'feature': feature,
|
||||
if (note != null) 'note': note,
|
||||
if (createdAt != null) 'created_at': createdAt!.toIso8601String(),
|
||||
if (updatedAt != null) 'updated_at': updatedAt!.toIso8601String(),
|
||||
};
|
||||
|
||||
// ---------- copyWith ----------
|
||||
Igel copyWith({
|
||||
int? id,
|
||||
String? name,
|
||||
String? gender,
|
||||
String? feature,
|
||||
String? note,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return Igel(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
gender: gender ?? this.gender,
|
||||
feature: feature ?? this.feature,
|
||||
note: note ?? this.note,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,397 @@
|
||||
// lib/features/igel/presentation/igel_gallery_screen.dart
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../main.dart';
|
||||
import '../../igel/data/igel_images_repository.dart';
|
||||
|
||||
class IgelGalleryScreen extends ConsumerStatefulWidget {
|
||||
const IgelGalleryScreen(
|
||||
{super.key, required this.igelId, this.initialIndex = 0});
|
||||
final int igelId;
|
||||
final int initialIndex;
|
||||
|
||||
@override
|
||||
ConsumerState<IgelGalleryScreen> createState() => _IgelGalleryState();
|
||||
}
|
||||
|
||||
class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
late final IgelImagesRepository repo;
|
||||
List<IgelImage> images = [];
|
||||
bool busy = true;
|
||||
String? err;
|
||||
|
||||
late PageController pageC;
|
||||
int currentIndex = 0;
|
||||
|
||||
// Zoom-Handling
|
||||
final TransformationController _tc = TransformationController();
|
||||
bool _isZoomed = false;
|
||||
TapDownDetails? _doubleTapDetails;
|
||||
|
||||
// UI Overlays (Titel/Buttons) ein-/ausblenden
|
||||
bool _chromeVisible = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
repo = ref.read(igelImagesRepoProvider);
|
||||
pageC = PageController(initialPage: widget.initialIndex);
|
||||
currentIndex = widget.initialIndex;
|
||||
|
||||
// Nur auf Mobile/Desktop echte Immersion
|
||||
if (!kIsWeb) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
SystemChrome.setPreferredOrientations(const [
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
}
|
||||
|
||||
_tc.addListener(_onMatrixChanged);
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tc.removeListener(_onMatrixChanged);
|
||||
_tc.dispose();
|
||||
pageC.dispose();
|
||||
|
||||
if (!kIsWeb) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onMatrixChanged() {
|
||||
final m = _tc.value;
|
||||
final zoomedNow = !_matrixIsIdentity(m);
|
||||
if (zoomedNow != _isZoomed && mounted) {
|
||||
setState(() => _isZoomed = zoomedNow);
|
||||
}
|
||||
}
|
||||
|
||||
bool _matrixIsIdentity(Matrix4 m, {double eps = 1e-3}) {
|
||||
// Tolerante Prüfung auf Identität
|
||||
final s = m.storage;
|
||||
bool eq(double a, double b) => (a - b).abs() <= eps;
|
||||
return eq(s[0], 1) &&
|
||||
eq(s[5], 1) &&
|
||||
eq(s[10], 1) &&
|
||||
eq(s[15], 1) &&
|
||||
eq(s[1], 0) &&
|
||||
eq(s[2], 0) &&
|
||||
eq(s[3], 0) &&
|
||||
eq(s[4], 0) &&
|
||||
eq(s[6], 0) &&
|
||||
eq(s[7], 0) &&
|
||||
eq(s[8], 0) &&
|
||||
eq(s[9], 0) &&
|
||||
eq(s[11], 0) &&
|
||||
eq(s[12], 0) &&
|
||||
eq(s[13], 0) &&
|
||||
eq(s[14], 0);
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
busy = true;
|
||||
err = null;
|
||||
});
|
||||
try {
|
||||
images = await repo.list(widget.igelId);
|
||||
|
||||
// initialIndex clampen
|
||||
final newIndex =
|
||||
images.isEmpty ? 0 : math.min(widget.initialIndex, images.length - 1);
|
||||
if (newIndex != currentIndex) {
|
||||
currentIndex = newIndex;
|
||||
pageC = PageController(initialPage: currentIndex);
|
||||
}
|
||||
|
||||
if (mounted && images.isNotEmpty) {
|
||||
await _prefetchAround(currentIndex);
|
||||
}
|
||||
} catch (e) {
|
||||
err = e.toString();
|
||||
} finally {
|
||||
if (mounted) setState(() => busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _prefetchAround(int index) async {
|
||||
if (!mounted || images.isEmpty) return;
|
||||
Future<void> pre(String url) async {
|
||||
try {
|
||||
await precacheImage(NetworkImage(url), context);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
await pre(images[index].url);
|
||||
if (index - 1 >= 0) await pre(images[index - 1].url);
|
||||
if (index + 1 < images.length) await pre(images[index + 1].url);
|
||||
}
|
||||
|
||||
void _onPageChanged(int i) async {
|
||||
setState(() {
|
||||
currentIndex = i;
|
||||
_tc.value = Matrix4.identity();
|
||||
_isZoomed = false;
|
||||
});
|
||||
await _prefetchAround(i);
|
||||
}
|
||||
|
||||
void _onDoubleTap() {
|
||||
// Toggle Zoom (2x) um Tap-Position
|
||||
if (!_isZoomed && _tc.value == Matrix4.identity()) {
|
||||
final pos = _doubleTapDetails?.localPosition ?? const Offset(0, 0);
|
||||
const scale = 2.0;
|
||||
final z = Matrix4.identity()
|
||||
..translate(-pos.dx * (scale - 1), -pos.dy * (scale - 1))
|
||||
..scale(scale);
|
||||
_tc.value = z;
|
||||
} else {
|
||||
_tc.value = Matrix4.identity();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteCurrent() async {
|
||||
if (images.isEmpty) return;
|
||||
final img = images[currentIndex];
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Bild löschen?'),
|
||||
content: const Text('Dieses Bild wirklich löschen?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Abbrechen')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Löschen')),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await repo.delete(img.id);
|
||||
final oldIndex = currentIndex;
|
||||
await _load();
|
||||
if (images.isEmpty) {
|
||||
if (mounted) context.pop();
|
||||
return;
|
||||
}
|
||||
final nextIndex = oldIndex.clamp(0, images.length - 1);
|
||||
pageC.jumpToPage(nextIndex);
|
||||
_onPageChanged(nextIndex);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Bild gelöscht')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Löschen fehlgeschlagen: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _downloadCurrent() async {
|
||||
if (images.isEmpty) return;
|
||||
final url = images[currentIndex].url;
|
||||
if (kIsWeb) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Im Web per Rechtsklick/Neuer Tab speichern.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final res = await http.get(Uri.parse(url));
|
||||
if (res.statusCode == 200) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Bild heruntergeladen (im Speicher).')),
|
||||
);
|
||||
} else {
|
||||
throw Exception('HTTP ${res.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Download fehlgeschlagen: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleChrome() => setState(() => _chromeVisible = !_chromeVisible);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final total = images.length;
|
||||
final canSwipe = !_isZoomed; // Wischen nur, wenn nicht gezoomt
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
// keine AppBar -> echtes Fullscreen. Overlays bauen wir selbst.
|
||||
body: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _toggleChrome, // Overlay ein-/ausblenden
|
||||
child: SafeArea(
|
||||
// SafeArea, damit Notch nicht überlagert; wir blenden UI überlagert ein.
|
||||
child: Stack(
|
||||
children: [
|
||||
// Seiten
|
||||
if (busy)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (err != null)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child:
|
||||
Text(err!, style: const TextStyle(color: Colors.white)),
|
||||
),
|
||||
)
|
||||
else if (total == 0)
|
||||
const Center(
|
||||
child: Text('Keine Bilder',
|
||||
style: TextStyle(color: Colors.white70)))
|
||||
else
|
||||
PageView.builder(
|
||||
controller: pageC,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: canSwipe
|
||||
? const PageScrollPhysics()
|
||||
: const NeverScrollableScrollPhysics(),
|
||||
itemCount: total,
|
||||
itemBuilder: (_, i) {
|
||||
final img = images[i];
|
||||
final full = img.url;
|
||||
return Listener(
|
||||
// Maus/Touch-Events nicht "schlucken", wenn nicht gezoomt, damit PageView scrollt
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
onTapDown: (d) => _doubleTapDetails = d,
|
||||
onDoubleTap: _onDoubleTap,
|
||||
child: InteractiveViewer(
|
||||
transformationController: _tc,
|
||||
minScale: 1,
|
||||
maxScale: 4,
|
||||
panEnabled: _isZoomed, // Pan nur im Zoom
|
||||
scaleEnabled: true,
|
||||
clipBehavior: Clip.none,
|
||||
child: Image.network(
|
||||
full,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => const Icon(
|
||||
Icons.broken_image,
|
||||
color: Colors.white70,
|
||||
size: 64),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Top-Bar (Back, Index, Löschen) – ein-/ausblendbar
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
top: _chromeVisible ? 0 : -80,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
color: const Color(0x66000000),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
color: Colors.white,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
final r = GoRouter.of(context);
|
||||
if (r.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/igel/${widget.igelId}');
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
total == 0
|
||||
? 'Galerie'
|
||||
: '${currentIndex + 1} / $total',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Löschen',
|
||||
color: Colors.white,
|
||||
onPressed: total == 0 ? null : _deleteCurrent,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom-Bar (Dateiname + Download) – ein-/ausblendbar
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
bottom: _chromeVisible ? 0 : -72,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
color: const Color(0x66000000),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
total == 0
|
||||
? ''
|
||||
: (images[currentIndex].originalName ?? ''),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: 'Download',
|
||||
color: Colors.white,
|
||||
onPressed: total == 0 ? null : _downloadCurrent,
|
||||
icon: const Icon(Icons.download),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../main.dart';
|
||||
import '../../igel/data/igel_repository.dart';
|
||||
import '../domain/igel.dart';
|
||||
@@ -9,25 +10,30 @@ import '../domain/igel.dart';
|
||||
class IgelListScreen extends ConsumerStatefulWidget {
|
||||
const IgelListScreen({super.key});
|
||||
@override
|
||||
ConsumerState<IgelListScreen> createState() => _State();
|
||||
ConsumerState<IgelListScreen> createState() => _IgelListState();
|
||||
}
|
||||
|
||||
class _State extends ConsumerState<IgelListScreen> {
|
||||
class _IgelListState extends ConsumerState<IgelListScreen> {
|
||||
late final IgelRepository repo;
|
||||
|
||||
List<Igel> items = [];
|
||||
bool busy = true;
|
||||
String? err;
|
||||
|
||||
// Suche
|
||||
final searchC = TextEditingController();
|
||||
String query = '';
|
||||
Timer? _debounce;
|
||||
|
||||
// Inline-Form
|
||||
bool showForm = false;
|
||||
final nameC = TextEditingController();
|
||||
final noteC = TextEditingController();
|
||||
final noteC =
|
||||
TextEditingController(); // Notiz bleibt im Edit/Erstellen, wird aber NICHT in der Liste gezeigt
|
||||
final featureC = TextEditingController();
|
||||
String? gender;
|
||||
String? gender; // 'männlich' | 'weiblich' | 'unbekannt' | null (keine Angabe)
|
||||
|
||||
// Undo
|
||||
Igel? _lastDeleted;
|
||||
|
||||
@override
|
||||
@@ -97,7 +103,7 @@ class _State extends ConsumerState<IgelListScreen> {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(msg),
|
||||
action: onAction != null && actionLabel != null
|
||||
action: (onAction != null && actionLabel != null)
|
||||
? SnackBarAction(label: actionLabel, onPressed: onAction)
|
||||
: null,
|
||||
),
|
||||
@@ -146,6 +152,7 @@ class _State extends ConsumerState<IgelListScreen> {
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
// Suche
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||
@@ -159,6 +166,7 @@ class _State extends ConsumerState<IgelListScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Neu-Button
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -176,6 +184,7 @@ class _State extends ConsumerState<IgelListScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Inline-Form
|
||||
if (showForm)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
@@ -266,6 +275,7 @@ class _State extends ConsumerState<IgelListScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Liste
|
||||
SliverList.separated(
|
||||
itemCount: visible.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
@@ -274,6 +284,8 @@ class _State extends ConsumerState<IgelListScreen> {
|
||||
return _IgelTile(
|
||||
key: ValueKey('igel-${x.id}'),
|
||||
igel: x,
|
||||
onTap: () =>
|
||||
context.go('/igel/${x.id}'), // Detailseite
|
||||
onEditAll: (result) async {
|
||||
await repo.update(
|
||||
x.id,
|
||||
@@ -329,11 +341,13 @@ class _IgelTile extends StatelessWidget {
|
||||
const _IgelTile({
|
||||
super.key,
|
||||
required this.igel,
|
||||
required this.onTap,
|
||||
required this.onEditAll,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final Igel igel;
|
||||
final VoidCallback onTap;
|
||||
final Future<void> Function(_IgelEditResult updated) onEditAll;
|
||||
final Future<void> Function() onDelete;
|
||||
|
||||
@@ -375,6 +389,7 @@ class _IgelTile extends StatelessWidget {
|
||||
?.copyWith(fontWeight: FontWeight.w600);
|
||||
|
||||
return ListTile(
|
||||
onTap: onTap,
|
||||
leading: CircleAvatar(
|
||||
child: Text(igel.name.isNotEmpty ? igel.name[0].toUpperCase() : '?')),
|
||||
title: Row(
|
||||
@@ -437,6 +452,8 @@ class _IgelTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Edit-Dialog: Name, Geschlecht (optional), Merkmal, Notiz --------------
|
||||
|
||||
class _IgelEditResult {
|
||||
final String name;
|
||||
final String? gender;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../main.dart'; // kApiBase
|
||||
import '../../auth/data/token_storage.dart'; // tokenStorageProvider
|
||||
import '../domain/messwert.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class MesswerteRepository {
|
||||
MesswerteRepository(this._tokens);
|
||||
final TokenStorage _tokens;
|
||||
|
||||
Future<List<Messwert>> list(int igelId) async {
|
||||
final t = await _tokens.access;
|
||||
final r = await http.get(
|
||||
Uri.parse('$kApiBase/igel/$igelId/messwerte'),
|
||||
headers: {'Authorization': 'Bearer $t'},
|
||||
);
|
||||
if (r.statusCode != 200) {
|
||||
throw Exception('API ${r.statusCode}: ${r.body}');
|
||||
}
|
||||
final List data = jsonDecode(r.body) as List;
|
||||
return data
|
||||
.map((e) => Messwert.fromMap(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> create({
|
||||
required int igelId,
|
||||
required DateTime datum,
|
||||
required int gewicht,
|
||||
String? behandlung,
|
||||
String? bemerkung,
|
||||
}) async {
|
||||
final t = await _tokens.access;
|
||||
final r = await http.post(
|
||||
Uri.parse('$kApiBase/igel/$igelId/messwerte'),
|
||||
headers: {
|
||||
'Authorization': 'Bearer $t',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'datum': datum.toUtc().toIso8601String(),
|
||||
'gewicht': gewicht,
|
||||
'behandlung': behandlung,
|
||||
'bemerkung': bemerkung,
|
||||
}),
|
||||
);
|
||||
if (r.statusCode != 201) {
|
||||
throw Exception('API ${r.statusCode}: ${r.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> update(int id, Messwert m) async {
|
||||
final t = await _tokens.access;
|
||||
final r = await http.put(
|
||||
Uri.parse('$kApiBase/messwerte/$id'),
|
||||
headers: {
|
||||
'Authorization': 'Bearer $t',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode({
|
||||
// Wichtig: KEIN 'id' im Body mitschicken – der steht bereits in der URL!
|
||||
'datum': m.datum.toUtc().toIso8601String(),
|
||||
'gewicht': m.gewicht,
|
||||
'behandlung': m.behandlung,
|
||||
'bemerkung': m.bemerkung,
|
||||
}),
|
||||
);
|
||||
if (r.statusCode != 200) {
|
||||
throw Exception('API ${r.statusCode}: ${r.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
final t = await _tokens.access;
|
||||
final r = await http.delete(
|
||||
Uri.parse('$kApiBase/messwerte/$id'),
|
||||
headers: {'Authorization': 'Bearer $t'},
|
||||
);
|
||||
if (r.statusCode != 200) {
|
||||
throw Exception('API ${r.statusCode}: ${r.body}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Riverpod Provider
|
||||
final messwerteRepoProvider = Provider<MesswerteRepository>((ref) {
|
||||
final tokens = ref.read(tokenStorageProvider);
|
||||
return MesswerteRepository(tokens);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
class Messwert {
|
||||
final int id;
|
||||
final DateTime datum;
|
||||
final int gewicht;
|
||||
final String? behandlung;
|
||||
final String? bemerkung;
|
||||
|
||||
Messwert({
|
||||
required this.id,
|
||||
required this.datum,
|
||||
required this.gewicht,
|
||||
this.behandlung,
|
||||
this.bemerkung,
|
||||
});
|
||||
|
||||
factory Messwert.fromMap(Map<String, dynamic> m) => Messwert(
|
||||
id: (m['id'] as num).toInt(),
|
||||
datum: DateTime.parse(m['datum'] as String).toLocal(),
|
||||
gewicht: (m['gewicht'] as num).toInt(),
|
||||
behandlung: m['behandlung'] as String?,
|
||||
bemerkung: m['bemerkung'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'datum': datum.toIso8601String(),
|
||||
'gewicht': gewicht,
|
||||
'behandlung': behandlung,
|
||||
'bemerkung': bemerkung,
|
||||
};
|
||||
|
||||
Messwert copyWith({
|
||||
int? id,
|
||||
DateTime? datum,
|
||||
int? gewicht,
|
||||
String? behandlung,
|
||||
String? bemerkung,
|
||||
}) {
|
||||
return Messwert(
|
||||
id: id ?? this.id,
|
||||
datum: datum ?? this.datum,
|
||||
gewicht: gewicht ?? this.gewicht,
|
||||
behandlung: behandlung ?? this.behandlung,
|
||||
bemerkung: bemerkung ?? this.bemerkung,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'app_router.dart';
|
||||
import 'features/auth/data/token_storage.dart';
|
||||
import 'shared/api_client.dart';
|
||||
import 'features/igel/data/igel_repository.dart';
|
||||
import 'features/igel/data/igel_images_repository.dart';
|
||||
|
||||
const kApiBase = String.fromEnvironment(
|
||||
'API_BASE',
|
||||
@@ -20,6 +21,12 @@ final apiClientProvider = Provider<ApiClient>((ref) => ApiClient(
|
||||
));
|
||||
final igelRepoProvider = Provider<IgelRepository>(
|
||||
(ref) => IgelRepository(ref.read(apiClientProvider)));
|
||||
final igelImagesRepoProvider =
|
||||
Provider<IgelImagesRepository>((ref) => IgelImagesRepository(
|
||||
ref.read(apiClientProvider),
|
||||
getAccessToken: () =>
|
||||
ref.read(tokenStorageProvider).getValidAccessToken(),
|
||||
));
|
||||
|
||||
void main() {
|
||||
runApp(const ProviderScope(child: IgelApp()));
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
flutter_secure_storage_linux
|
||||
)
|
||||
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import file_selector_macos
|
||||
import flutter_secure_storage_macos
|
||||
import path_provider_foundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
}
|
||||
|
||||
+128
@@ -150,6 +150,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.4+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -182,6 +190,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
file_selector_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_linux
|
||||
sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3+2"
|
||||
file_selector_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_macos
|
||||
sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4+2"
|
||||
file_selector_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_platform_interface
|
||||
sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.2"
|
||||
file_selector_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_windows
|
||||
sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3+4"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -195,6 +235,22 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: "1c2b787f99bdca1f3718543f81d38aa1b124817dfeb9fb196201bea85b6134bf"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.26"
|
||||
flutter_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -312,6 +368,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
image_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: image_picker
|
||||
sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
image_picker_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_android
|
||||
sha256: "82652a75e3dd667a91187769a6a2cc81bd8c111bbead698d8e938d2b63e5e89a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.12+21"
|
||||
image_picker_for_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_for_web
|
||||
sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.6"
|
||||
image_picker_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_ios
|
||||
sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.12+2"
|
||||
image_picker_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_linux
|
||||
sha256: "34a65f6740df08bbbeb0a1abd8e6d32107941fd4868f67a507b25601651022c9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1+2"
|
||||
image_picker_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_macos
|
||||
sha256: "1b90ebbd9dcf98fb6c1d01427e49a55bd96b5d67b8c67cf955d60a5de74207c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1+2"
|
||||
image_picker_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_platform_interface
|
||||
sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.10.1"
|
||||
image_picker_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_windows
|
||||
sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1+1"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -344,6 +464,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.9.0"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -10,10 +10,12 @@ dependencies:
|
||||
http: ^1.2.2
|
||||
flutter_secure_storage: ^9.2.2
|
||||
json_annotation: ^4.9.0
|
||||
image_picker: ^1.0.7
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.4.11
|
||||
json_serializable: ^6.9.0
|
||||
flutter_lints: ^3.0.2
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
@@ -6,9 +6,12 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
flutter_secure_storage_windows
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user