Upload geht

This commit is contained in:
2025-10-23 20:16:29 +02:00
parent 5d4a64a235
commit bede0000a8
5 changed files with 239 additions and 59 deletions
@@ -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<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(),
);
factory IgelImage.fromMap(Map<String, dynamic> 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<String?> Function() getAccessToken;
IgelImagesRepository(
this.api, {
required this.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();
}
/// Multipart-Upload. Gibt die neu hochgeladenen Bilder zurück.
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 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<Map<String, dynamic>>();
return uploaded.map(IgelImage.fromMap).toList();
final uploaded = list
.cast<Map<String, dynamic>>()
.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<void> delete(int imageId) async {
// PHP: DELETE /images/{id}
await api.delete<dynamic>('/images/$imageId');
}
}
+38
View File
@@ -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<String, dynamic> 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;
})(),
);
}
@@ -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<IgelDetailScreen> {
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);
+17 -1
View File
@@ -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:
+2
View File
@@ -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