Upload geht
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user