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