Matthias Nott
3 days ago cda5ac96c4802f7c33f8f0099a8c9c34423dde4a
lib/widgets/message_bubble.dart
....@@ -1,10 +1,13 @@
11 import 'dart:convert';
2
+import 'dart:io';
23 import 'dart:math';
34 import 'dart:typed_data';
45
56 import 'package:flutter/material.dart';
67 import 'package:flutter/services.dart';
78 import 'package:flutter_markdown/flutter_markdown.dart';
9
+import 'package:path_provider/path_provider.dart';
10
+import 'package:share_plus/share_plus.dart';
811 import 'package:url_launcher/url_launcher.dart';
912 import 'package:intl/intl.dart';
1013
....@@ -263,9 +266,20 @@
263266 );
264267 }
265268
269
+ /// True if the mimeType is a renderable image format.
270
+ bool get _isImageMime {
271
+ final mime = message.mimeType?.toLowerCase() ?? 'image/jpeg';
272
+ return mime.startsWith('image/');
273
+ }
274
+
266275 Widget _buildImageContent(BuildContext context) {
267276 if (message.imageBase64 == null || message.imageBase64!.isEmpty) {
268277 return const Text('Image unavailable');
278
+ }
279
+
280
+ // Non-image files (PDF, CSV, etc.) — show file card instead of Image.memory
281
+ if (!_isImageMime) {
282
+ return _buildFileCard(context);
269283 }
270284
271285 // Cache decoded bytes to prevent flicker on rebuild; evict oldest if over 50 entries
....@@ -322,6 +336,130 @@
322336 );
323337 }
324338
339
+ Widget _buildFileCard(BuildContext context) {
340
+ final mime = message.mimeType ?? 'application/octet-stream';
341
+ final caption = message.content.isNotEmpty ? message.content : 'File';
342
+ final isPdf = mime == 'application/pdf';
343
+
344
+ // File type icon
345
+ IconData icon;
346
+ Color iconColor;
347
+ if (isPdf) {
348
+ icon = Icons.picture_as_pdf;
349
+ iconColor = Colors.red;
350
+ } else if (mime.contains('spreadsheet') || mime.contains('excel') || mime == 'text/csv') {
351
+ icon = Icons.table_chart;
352
+ iconColor = Colors.green;
353
+ } else if (mime.contains('word') || mime.contains('document')) {
354
+ icon = Icons.description;
355
+ iconColor = Colors.blue;
356
+ } else if (mime == 'text/plain' || mime == 'application/json') {
357
+ icon = Icons.text_snippet;
358
+ iconColor = Colors.grey;
359
+ } else {
360
+ icon = Icons.insert_drive_file;
361
+ iconColor = Colors.blueGrey;
362
+ }
363
+
364
+ final sizeKB = ((message.imageBase64?.length ?? 0) * 3 / 4 / 1024).round();
365
+
366
+ return GestureDetector(
367
+ onTap: () => _openFile(context),
368
+ child: Container(
369
+ width: 260,
370
+ padding: const EdgeInsets.all(12),
371
+ decoration: BoxDecoration(
372
+ color: (_isUser ? Colors.white : Theme.of(context).colorScheme.primary).withAlpha(25),
373
+ borderRadius: BorderRadius.circular(8),
374
+ border: Border.all(
375
+ color: (_isUser ? Colors.white : Colors.grey).withAlpha(50),
376
+ ),
377
+ ),
378
+ child: Column(
379
+ crossAxisAlignment: CrossAxisAlignment.start,
380
+ children: [
381
+ Row(
382
+ children: [
383
+ Icon(icon, size: 32, color: iconColor),
384
+ const SizedBox(width: 10),
385
+ Expanded(
386
+ child: Column(
387
+ crossAxisAlignment: CrossAxisAlignment.start,
388
+ children: [
389
+ Text(
390
+ caption,
391
+ style: TextStyle(
392
+ fontSize: 14,
393
+ fontWeight: FontWeight.w600,
394
+ color: _isUser ? Colors.white : null,
395
+ ),
396
+ maxLines: 2,
397
+ overflow: TextOverflow.ellipsis,
398
+ ),
399
+ const SizedBox(height: 2),
400
+ Text(
401
+ '${mime.split('/').last.toUpperCase()} - ${sizeKB} KB',
402
+ style: TextStyle(
403
+ fontSize: 11,
404
+ color: (_isUser ? Colors.white : Colors.grey).withAlpha(180),
405
+ ),
406
+ ),
407
+ ],
408
+ ),
409
+ ),
410
+ Icon(
411
+ Icons.open_in_new,
412
+ size: 20,
413
+ color: (_isUser ? Colors.white : Colors.grey).withAlpha(150),
414
+ ),
415
+ ],
416
+ ),
417
+ ],
418
+ ),
419
+ ),
420
+ );
421
+ }
422
+
423
+ Future<void> _openFile(BuildContext context) async {
424
+ final data = message.imageBase64;
425
+ if (data == null || data.isEmpty) return;
426
+
427
+ try {
428
+ final bytes = base64Decode(data.contains(',') ? data.split(',').last : data);
429
+ final mime = message.mimeType ?? 'application/octet-stream';
430
+ final ext = _mimeToExt(mime);
431
+ final dir = await getTemporaryDirectory();
432
+ final fileName = '${message.content.isNotEmpty ? message.content.replaceAll(RegExp(r'[^\w\s.-]'), '').trim() : 'file'}.$ext';
433
+ final file = File('${dir.path}/$fileName');
434
+ await file.writeAsBytes(bytes);
435
+
436
+ // Use share sheet — works for PDFs, Office docs, and all file types on iOS
437
+ await SharePlus.instance.share(
438
+ ShareParams(files: [XFile(file.path, mimeType: mime)]),
439
+ );
440
+ } catch (e) {
441
+ if (context.mounted) {
442
+ ScaffoldMessenger.of(context).showSnackBar(
443
+ SnackBar(content: Text('Could not open file: $e')),
444
+ );
445
+ }
446
+ }
447
+ }
448
+
449
+ String _mimeToExt(String mime) {
450
+ const map = {
451
+ 'application/pdf': 'pdf',
452
+ 'application/msword': 'doc',
453
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
454
+ 'application/vnd.ms-excel': 'xls',
455
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
456
+ 'text/plain': 'txt',
457
+ 'text/csv': 'csv',
458
+ 'application/json': 'json',
459
+ };
460
+ return map[mime] ?? 'bin';
461
+ }
462
+
325463 Widget _buildFooter(BuildContext context) {
326464 final isDark = Theme.of(context).brightness == Brightness.dark;
327465 final dt = DateTime.fromMillisecondsSinceEpoch(message.timestamp);