| .. | .. |
|---|
| 1 | 1 | import 'dart:convert'; |
|---|
| 2 | +import 'dart:io'; |
|---|
| 2 | 3 | import 'dart:math'; |
|---|
| 3 | 4 | import 'dart:typed_data'; |
|---|
| 4 | 5 | |
|---|
| 5 | 6 | import 'package:flutter/material.dart'; |
|---|
| 6 | 7 | import 'package:flutter/services.dart'; |
|---|
| 7 | 8 | import 'package:flutter_markdown/flutter_markdown.dart'; |
|---|
| 9 | +import 'package:path_provider/path_provider.dart'; |
|---|
| 10 | +import 'package:share_plus/share_plus.dart'; |
|---|
| 8 | 11 | import 'package:url_launcher/url_launcher.dart'; |
|---|
| 9 | 12 | import 'package:intl/intl.dart'; |
|---|
| 10 | 13 | |
|---|
| .. | .. |
|---|
| 263 | 266 | ); |
|---|
| 264 | 267 | } |
|---|
| 265 | 268 | |
|---|
| 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 | + |
|---|
| 266 | 275 | Widget _buildImageContent(BuildContext context) { |
|---|
| 267 | 276 | if (message.imageBase64 == null || message.imageBase64!.isEmpty) { |
|---|
| 268 | 277 | 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); |
|---|
| 269 | 283 | } |
|---|
| 270 | 284 | |
|---|
| 271 | 285 | // Cache decoded bytes to prevent flicker on rebuild; evict oldest if over 50 entries |
|---|
| .. | .. |
|---|
| 322 | 336 | ); |
|---|
| 323 | 337 | } |
|---|
| 324 | 338 | |
|---|
| 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 | + |
|---|
| 325 | 463 | Widget _buildFooter(BuildContext context) { |
|---|
| 326 | 464 | final isDark = Theme.of(context).brightness == Brightness.dark; |
|---|
| 327 | 465 | final dt = DateTime.fromMillisecondsSinceEpoch(message.timestamp); |
|---|