From cda5ac96c4802f7c33f8f0099a8c9c34423dde4a Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 11 Apr 2026 09:27:07 +0200
Subject: [PATCH] feat: PDF and document file viewing support

---
 lib/widgets/message_bubble.dart |  138 ++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 138 insertions(+), 0 deletions(-)

diff --git a/lib/widgets/message_bubble.dart b/lib/widgets/message_bubble.dart
index da6238a..df9e238 100644
--- a/lib/widgets/message_bubble.dart
+++ b/lib/widgets/message_bubble.dart
@@ -1,10 +1,13 @@
 import 'dart:convert';
+import 'dart:io';
 import 'dart:math';
 import 'dart:typed_data';
 
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_markdown/flutter_markdown.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:share_plus/share_plus.dart';
 import 'package:url_launcher/url_launcher.dart';
 import 'package:intl/intl.dart';
 
@@ -263,9 +266,20 @@
     );
   }
 
+  /// True if the mimeType is a renderable image format.
+  bool get _isImageMime {
+    final mime = message.mimeType?.toLowerCase() ?? 'image/jpeg';
+    return mime.startsWith('image/');
+  }
+
   Widget _buildImageContent(BuildContext context) {
     if (message.imageBase64 == null || message.imageBase64!.isEmpty) {
       return const Text('Image unavailable');
+    }
+
+    // Non-image files (PDF, CSV, etc.) — show file card instead of Image.memory
+    if (!_isImageMime) {
+      return _buildFileCard(context);
     }
 
     // Cache decoded bytes to prevent flicker on rebuild; evict oldest if over 50 entries
@@ -322,6 +336,130 @@
     );
   }
 
+  Widget _buildFileCard(BuildContext context) {
+    final mime = message.mimeType ?? 'application/octet-stream';
+    final caption = message.content.isNotEmpty ? message.content : 'File';
+    final isPdf = mime == 'application/pdf';
+
+    // File type icon
+    IconData icon;
+    Color iconColor;
+    if (isPdf) {
+      icon = Icons.picture_as_pdf;
+      iconColor = Colors.red;
+    } else if (mime.contains('spreadsheet') || mime.contains('excel') || mime == 'text/csv') {
+      icon = Icons.table_chart;
+      iconColor = Colors.green;
+    } else if (mime.contains('word') || mime.contains('document')) {
+      icon = Icons.description;
+      iconColor = Colors.blue;
+    } else if (mime == 'text/plain' || mime == 'application/json') {
+      icon = Icons.text_snippet;
+      iconColor = Colors.grey;
+    } else {
+      icon = Icons.insert_drive_file;
+      iconColor = Colors.blueGrey;
+    }
+
+    final sizeKB = ((message.imageBase64?.length ?? 0) * 3 / 4 / 1024).round();
+
+    return GestureDetector(
+      onTap: () => _openFile(context),
+      child: Container(
+        width: 260,
+        padding: const EdgeInsets.all(12),
+        decoration: BoxDecoration(
+          color: (_isUser ? Colors.white : Theme.of(context).colorScheme.primary).withAlpha(25),
+          borderRadius: BorderRadius.circular(8),
+          border: Border.all(
+            color: (_isUser ? Colors.white : Colors.grey).withAlpha(50),
+          ),
+        ),
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            Row(
+              children: [
+                Icon(icon, size: 32, color: iconColor),
+                const SizedBox(width: 10),
+                Expanded(
+                  child: Column(
+                    crossAxisAlignment: CrossAxisAlignment.start,
+                    children: [
+                      Text(
+                        caption,
+                        style: TextStyle(
+                          fontSize: 14,
+                          fontWeight: FontWeight.w600,
+                          color: _isUser ? Colors.white : null,
+                        ),
+                        maxLines: 2,
+                        overflow: TextOverflow.ellipsis,
+                      ),
+                      const SizedBox(height: 2),
+                      Text(
+                        '${mime.split('/').last.toUpperCase()} - ${sizeKB} KB',
+                        style: TextStyle(
+                          fontSize: 11,
+                          color: (_isUser ? Colors.white : Colors.grey).withAlpha(180),
+                        ),
+                      ),
+                    ],
+                  ),
+                ),
+                Icon(
+                  Icons.open_in_new,
+                  size: 20,
+                  color: (_isUser ? Colors.white : Colors.grey).withAlpha(150),
+                ),
+              ],
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+
+  Future<void> _openFile(BuildContext context) async {
+    final data = message.imageBase64;
+    if (data == null || data.isEmpty) return;
+
+    try {
+      final bytes = base64Decode(data.contains(',') ? data.split(',').last : data);
+      final mime = message.mimeType ?? 'application/octet-stream';
+      final ext = _mimeToExt(mime);
+      final dir = await getTemporaryDirectory();
+      final fileName = '${message.content.isNotEmpty ? message.content.replaceAll(RegExp(r'[^\w\s.-]'), '').trim() : 'file'}.$ext';
+      final file = File('${dir.path}/$fileName');
+      await file.writeAsBytes(bytes);
+
+      // Use share sheet — works for PDFs, Office docs, and all file types on iOS
+      await SharePlus.instance.share(
+        ShareParams(files: [XFile(file.path, mimeType: mime)]),
+      );
+    } catch (e) {
+      if (context.mounted) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          SnackBar(content: Text('Could not open file: $e')),
+        );
+      }
+    }
+  }
+
+  String _mimeToExt(String mime) {
+    const map = {
+      'application/pdf': 'pdf',
+      'application/msword': 'doc',
+      'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
+      'application/vnd.ms-excel': 'xls',
+      'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
+      'text/plain': 'txt',
+      'text/csv': 'csv',
+      'application/json': 'json',
+    };
+    return map[mime] ?? 'bin';
+  }
+
   Widget _buildFooter(BuildContext context) {
     final isDark = Theme.of(context).brightness == Brightness.dark;
     final dt = DateTime.fromMillisecondsSinceEpoch(message.timestamp);

--
Gitblit v1.3.1