From bede0000a8a58977167a622640a166229d41cc1d Mon Sep 17 00:00:00 2001 From: Herwig Birke Date: Thu, 23 Oct 2025 20:16:29 +0200 Subject: [PATCH] Upload geht --- .../igel/data/igel_images_repository.dart | 95 +++++++++--- lib/features/igel/domain/igel_image.dart | 38 +++++ .../igel/presentation/igel_detail_screen.dart | 145 +++++++++++++----- pubspec.lock | 18 ++- pubspec.yaml | 2 + 5 files changed, 239 insertions(+), 59 deletions(-) create mode 100644 lib/features/igel/domain/igel_image.dart diff --git a/lib/features/igel/data/igel_images_repository.dart b/lib/features/igel/data/igel_images_repository.dart index 6deba21..83b8244 100644 --- a/lib/features/igel/data/igel_images_repository.dart +++ b/lib/features/igel/data/igel_images_repository.dart @@ -1,3 +1,4 @@ +// lib/features/igel/data/igel_images_repository.dart import 'dart:convert'; import 'package:http/http.dart' as http; import '../../../shared/api_client.dart'; @@ -5,10 +6,11 @@ import '../../../shared/api_client.dart'; class IgelImage { final int id; final String url; - final String? thumbUrl; // ✅ Thumbnail-Feld ergänzt + final String? thumbUrl; final String? originalName; final String? mime; final int? sizeBytes; + final DateTime? createdAt; // 🆕 IgelImage({ required this.id, @@ -17,54 +19,109 @@ class IgelImage { this.originalName, this.mime, this.sizeBytes, + this.createdAt, }); - factory IgelImage.fromMap(Map 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(), - ); + factory IgelImage.fromMap(Map m) { + // created_at kann als "YYYY-MM-DD HH:mm:ss" kommen + DateTime? parsed; + final raw = m['created_at']; + if (raw is String && raw.isNotEmpty) { + parsed = DateTime.tryParse(raw) ?? + DateTime.tryParse(raw.replaceFirst(' ', 'T')); + } + + return IgelImage( + id: (m['id'] as num).toInt(), + url: (m['url'] ?? '') as String, + thumbUrl: (m['thumb_url'] ?? m['thumbUrl']) as String?, + originalName: m['original_name'] as String?, + mime: m['mime'] as String?, + sizeBytes: (m['size_bytes'] as num?)?.toInt(), + createdAt: parsed, + ); + } } class IgelImagesRepository { - IgelImagesRepository(this.api, {required this.getAccessToken}); final ApiClient api; final Future Function() getAccessToken; + IgelImagesRepository( + this.api, { + required this.getAccessToken, + }); + Future> list(int igelId) async { final res = await api.get('/igel/$igelId/images'); final list = (res as List).cast>(); return list.map(IgelImage.fromMap).toList(); } + /// Multipart-Upload. Gibt die neu hochgeladenen Bilder zurück. Future> upload( int igelId, List files) async { - final uri = Uri.parse( - '${api.baseUrl}/igel/$igelId/images'); // api.baseUrl endet auf ?r= + final uri = Uri.parse('${api.baseUrl}/igel/$igelId/images'); 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 + if (token != null) { + req.headers['Authorization'] = 'Bearer $token'; } + + // WICHTIG: PHP erwartet ein Array-Feld: "files[]" + // Wir bauen für jedes geleiferte MultipartFile ein NEUES mit dem Namen "files[]". + for (final f in files) { + final mf = http.MultipartFile( + 'files[]', // <-- entscheidend! + f.finalize(), // Stream vom bestehenden MultipartFile übernehmen + f.length, // Länge übernehmen (ist bei http >=1.x ein int) + filename: f.filename, + contentType: f.contentType, + ); + req.files.add(mf); + } + 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 list = (decoded is List) ? decoded : [decoded]; - final uploaded = list.cast>(); - return uploaded.map(IgelImage.fromMap).toList(); + final uploaded = list + .cast>() + .map(IgelImage.fromMap) + // Fallback, wenn der Upload-Response kein created_at enthält: + .map((img) => img.createdAt == null + ? IgelImage( + id: img.id, + url: img.url, + thumbUrl: img.thumbUrl, + originalName: img.originalName, + mime: img.mime, + sizeBytes: img.sizeBytes, + createdAt: DateTime.now(), + ) + : img) + .toList(); + + // Sicherheitsleine: Wenn der Server nichts verarbeitet hat, als Fehler behandeln. + if (uploaded.isEmpty) { + throw ApiException( + 500, + 'Upload fehlgeschlagen: Server hat keine Dateien empfangen (prüfe Feldname "files[]").', + ); + } + + return uploaded; } Future delete(int imageId) async { + // PHP: DELETE /images/{id} await api.delete('/images/$imageId'); } } diff --git a/lib/features/igel/domain/igel_image.dart b/lib/features/igel/domain/igel_image.dart new file mode 100644 index 0000000..87c83df --- /dev/null +++ b/lib/features/igel/domain/igel_image.dart @@ -0,0 +1,38 @@ +class IgelImage { + final int id; + final String url; + final String? thumbUrl; + final String? originalName; + final String? mime; + final int? sizeBytes; + final DateTime? createdAt; // 🆕 + + IgelImage({ + required this.id, + required this.url, + this.thumbUrl, + this.originalName, + this.mime, + this.sizeBytes, + this.createdAt, + }); + + factory IgelImage.fromMap(Map m) => IgelImage( + id: (m['id'] as num).toInt(), + url: (m['url'] ?? '') as String, + thumbUrl: (m['thumb_url'] ?? m['thumbUrl']) as String?, + originalName: m['original_name'] as String?, + mime: m['mime'] as String?, + sizeBytes: (m['size_bytes'] as num?)?.toInt(), + // PHP liefert created_at (TIMESTAMP) bei GET /igel/{id}/images + createdAt: (() { + final s = m['created_at']; + if (s is String && s.isNotEmpty) { + // z.B. "2025-10-21 12:34:56" + return DateTime.tryParse(s) ?? + DateTime.tryParse(s.replaceFirst(' ', 'T')); + } + return null; + })(), + ); +} diff --git a/lib/features/igel/presentation/igel_detail_screen.dart b/lib/features/igel/presentation/igel_detail_screen.dart index 8bd7a33..f17ae85 100644 --- a/lib/features/igel/presentation/igel_detail_screen.dart +++ b/lib/features/igel/presentation/igel_detail_screen.dart @@ -7,7 +7,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; import 'package:http/http.dart' as http; -import 'package:http_parser/http_parser.dart'; +import 'package:intl/intl.dart'; +import 'package:http_parser/http_parser.dart' show MediaType; +import 'dart:ui' as ui show TextDirection; import '../../../main.dart'; import '../../igel/data/igel_images_repository.dart'; @@ -742,43 +744,104 @@ class _IgelDetailState extends ConsumerState { Card( child: Padding( padding: const EdgeInsets.all(8.0), - child: GridView.builder( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - itemCount: images.length, - itemBuilder: (_, i) { - final img = images[i]; - final thumb = img.thumbUrl ?? img.url; - return GestureDetector( - onTap: () => context.push( - '/igel/${widget.igelId}/gallery?index=$i'), - onLongPress: () => _deleteImage(img), - child: Hero( - tag: 'igimg-${img.id}', - child: ClipRRect( - borderRadius: - BorderRadius.circular(12), - child: Image.network( - thumb, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => - const ColoredBox( - color: Color(0x11000000), - child: Center( - child: - Icon(Icons.broken_image)), - ), - ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + // --- Header-Zeile: Button OBERHALB der Thumbnails --- + 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'), + ), + ], + ), + const SizedBox(height: 8), + + // --- Grid mit kleineren Thumbnails + Datum --- + GridView.builder( + physics: + const NeverScrollableScrollPhysics(), + shrinkWrap: true, + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: + 120, // ~120px Kachelbreite -> dezent + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: + 0.85, // Platz für Datum unter Bild ), - ); - }, + itemCount: images.length, + itemBuilder: (_, i) { + final img = images[i]; + final thumb = img.thumbUrl ?? img.url; + + final String? ts = (() { + final dt = img.createdAt; + if (dt == null) return null; + // z.B. 23.10.2025, 14:05 + return DateFormat('dd.MM.yyyy, HH:mm') + .format(dt); + })(); + + return GestureDetector( + onTap: () => context.push( + '/igel/${widget.igelId}/gallery?index=$i'), + onLongPress: () => _deleteImage(img), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Hero( + tag: 'igimg-${img.id}', + child: ClipRRect( + borderRadius: + BorderRadius.circular( + 10), + child: Image.network( + thumb, + fit: BoxFit.cover, + errorBuilder: + (_, __, ___) => + const ColoredBox( + color: + Color(0x11000000), + child: Center( + child: Icon(Icons + .broken_image)), + ), + ), + ), + ), + ), + const SizedBox(height: 4), + Text( + ts ?? '–', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .labelSmall, + ), + ], + ), + ); + }, + ), + ], ), ), ), @@ -1089,10 +1152,14 @@ class _WeightChartPainter extends CustomPainter { void _drawText(Canvas canvas, String text, Offset pos, {TextAlign align = TextAlign.left, Offset anchor = Offset.zero}) { final span = TextSpan( - text: text, - style: const TextStyle(fontSize: 11, color: Colors.black87)); + text: text, + style: const TextStyle(fontSize: 11, color: Colors.black87), + ); final tp = TextPainter( - text: span, textAlign: align, textDirection: TextDirection.ltr); + text: span, + textAlign: align, + textDirection: ui.TextDirection.ltr, // ✅ korrekte Schreibweise + ); tp.layout(); final offset = Offset(pos.dx - anchor.dx * tp.width, pos.dy - anchor.dy * tp.height); diff --git a/pubspec.lock b/pubspec.lock index 8bb26cc..6c71707 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -126,6 +126,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" code_builder: dependency: transitive description: @@ -361,7 +369,7 @@ packages: source: hosted version: "3.2.2" http_parser: - dependency: transitive + dependency: "direct main" description: name: http_parser sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" @@ -432,6 +440,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.1+1" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" io: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1cefcb6..3bd184d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,8 @@ dependencies: flutter_secure_storage: ^9.2.2 json_annotation: ^4.9.0 image_picker: ^1.0.7 + intl: ^0.20.2 + http_parser: ^4.0.2 dev_dependencies: build_runner: ^2.4.11