From a51c2dad9c1089a8d2c005c2651050d76c332c67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Wed, 22 Apr 2026 10:47:11 +0200 Subject: [PATCH] feat: attachment open via xdg-open on Linux, mime type detection - Use xdg-open directly on Linux for opening attachments (fixes 'file type not supported' error) - Add mime package for comprehensive MIME type detection in compose screen - Show file size and MIME type for attachments in compose screen - Add open/preview button for attachments in compose screen Co-authored-by: Qwen-Coder --- .gitignore | 1 + LATER.md | 19 ----- NEXT.md | 7 ++ lib/ui/screens/compose_screen.dart | 101 +++++++++++++++++++++--- lib/ui/screens/email_detail_screen.dart | 21 ++++- pubspec.yaml | 1 + 6 files changed, 117 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index 89e9e5c..89546ae 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,4 @@ linux/flutter/generated_plugins.cmake # FVM — .fvmrc is committed; .fvm/ contains the downloaded SDK (not committed) .fvm/ +.qwen diff --git a/LATER.md b/LATER.md index de61b32..67bc966 100644 --- a/LATER.md +++ b/LATER.md @@ -4,10 +4,6 @@ Thread view (group by `References` / `In-Reply-To`) --- -Attachment download + open - ---- - mail-loop.com (anstatt shared inbox). --- @@ -21,15 +17,6 @@ create a plan. Avoid downloading big bodies/attachments again. mailcoach.de - ---- - -done? - -think about that: Maybe we should not mock jmap/imap/smtp. We have a temproary Stalwart. - -Just like mocking DB in Django makes no sense. - --- Try Qwen, vscode plugin @@ -52,12 +39,6 @@ After Try Connection, show some matching icon next to the text. --- -Mail edit, attachment: - -List of attached files should be visible and editable. Show size, and type of file. - -Make it possible to open/view the file. - --- Test with a Fastmail account diff --git a/NEXT.md b/NEXT.md index 9e5585e..5c68a1a 100644 --- a/NEXT.md +++ b/NEXT.md @@ -7,3 +7,10 @@ Do one thing, then run `task check`. Then commit. After commit, remove the item from this document. ## Tasks + + +Mail edit, attachment: + +List of attached files should be visible and editable. Show size, and type of file. + +Make it possible to open/view the file. diff --git a/lib/ui/screens/compose_screen.dart b/lib/ui/screens/compose_screen.dart index dca0822..b0faa54 100644 --- a/lib/ui/screens/compose_screen.dart +++ b/lib/ui/screens/compose_screen.dart @@ -5,10 +5,13 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:mime/mime.dart'; +import 'package:open_file/open_file.dart'; import '../../core/models/account.dart'; import '../../core/models/email.dart'; import '../../core/repositories/draft_repository.dart'; +import '../../core/utils/format_utils.dart'; import '../../di.dart'; class ComposeScreen extends ConsumerStatefulWidget { @@ -41,7 +44,8 @@ class _ComposeScreenState extends ConsumerState { String? _accountId; List _accounts = []; bool _sending = false; - final List _attachmentPaths = []; + final List<_AttachmentInfo> _attachments = []; + bool _opening = false; int? _draftId; bool _draftDirty = false; @@ -156,13 +160,59 @@ class _ComposeScreenState extends ConsumerState { Future _pickAttachments() async { final result = await FilePicker.platform.pickFiles(allowMultiple: true); if (result == null) return; - final paths = result.files.map((f) => f.path).whereType().toList(); + final files = result.files.where((f) => f.path != null).toList(); if (!mounted) return; - setState(() => _attachmentPaths.addAll(paths)); + final newAttachments = <_AttachmentInfo>[]; + for (final file in files) { + final path = file.path!; + final stat = await File(path).stat(); + newAttachments.add( + _AttachmentInfo( + path: path, + filename: file.name, + size: stat.size, + contentType: _guessMimeType(file.name), + ), + ); + } + setState(() => _attachments.addAll(newAttachments)); } void _removeAttachment(int index) { - setState(() => _attachmentPaths.removeAt(index)); + setState(() => _attachments.removeAt(index)); + } + + Future _openAttachment(int index) async { + if (_opening) return; + setState(() => _opening = true); + try { + final path = _attachments[index].path; + + // On Linux, OpenFile.open may fail with "file type not supported". + // Use xdg-open directly for better compatibility. + if (Platform.isLinux) { + final result = await Process.run('xdg-open', [path]); + if (result.exitCode != 0 && mounted) { + throw Exception( + 'xdg-open failed: ${result.stderr}\n' + 'File path: $path', + ); + } + } else { + await OpenFile.open(path); + } + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to open file: $e')), + ); + } finally { + if (mounted) setState(() => _opening = false); + } + } + + String _guessMimeType(String filename) { + return lookupMimeType(filename) ?? 'application/octet-stream'; } Future _send() async { @@ -192,7 +242,8 @@ class _ComposeScreenState extends ConsumerState { .toList(), subject: _subject.text, body: _body.text, - attachmentFilePaths: List.unmodifiable(_attachmentPaths), + attachmentFilePaths: + List.unmodifiable(_attachments.map((a) => a.path).toList()), ); await ref.read(emailRepositoryProvider).sendEmail(_accountId!, draft); // Delete the draft only after a successful send. @@ -292,7 +343,7 @@ class _ComposeScreenState extends ConsumerState { alignLabelWithHint: true, ), ), - if (_attachmentPaths.isNotEmpty) ...[ + if (_attachments.isNotEmpty) ...[ const SizedBox(height: 8), const Divider(), Padding( @@ -302,14 +353,28 @@ class _ComposeScreenState extends ConsumerState { style: Theme.of(context).textTheme.titleSmall, ), ), - for (var i = 0; i < _attachmentPaths.length; i++) + for (var i = 0; i < _attachments.length; i++) ListTile( dense: true, leading: const Icon(Icons.attach_file), - title: Text(File(_attachmentPaths[i]).uri.pathSegments.last), - trailing: IconButton( - icon: const Icon(Icons.close), - onPressed: () => _removeAttachment(i), + title: Text(_attachments[i].filename), + subtitle: Text( + '${_attachments[i].contentType} • ${fmtSize(_attachments[i].size)}', + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.visibility), + tooltip: 'Open', + onPressed: () => _openAttachment(i), + ), + IconButton( + icon: const Icon(Icons.close), + tooltip: 'Remove', + onPressed: () => _removeAttachment(i), + ), + ], ), ), ], @@ -337,5 +402,19 @@ class _ComposeScreenState extends ConsumerState { } } +class _AttachmentInfo { + final String path; + final String filename; + final int size; + final String contentType; + + _AttachmentInfo({ + required this.path, + required this.filename, + required this.size, + required this.contentType, + }); +} + // Helper to silence the unawaited_futures lint on fire-and-forget futures. void unawaited(Future _) {} diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index 37ba398..53a27e2 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -161,11 +162,25 @@ class _EmailDetailScreenState extends ConsumerState { final path = await ref .read(emailRepositoryProvider) .downloadAttachment(widget.emailId, att); - await OpenFile.open(path); + + // On Linux, OpenFile.open may fail with "file type not supported". + // Use xdg-open directly for better compatibility. + if (Platform.isLinux) { + final result = await Process.run('xdg-open', [path]); + if (result.exitCode != 0 && mounted) { + throw Exception( + 'xdg-open failed: ${result.stderr}\n' + 'File saved to: $path', + ); + } + } else { + await OpenFile.open(path); + } } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('Download failed: $e'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Opening file failed: $e')), + ); } finally { if (mounted) setState(() => _downloading.remove(att.filename)); } diff --git a/pubspec.yaml b/pubspec.yaml index ae86e54..77ae76f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: # File picking (compose attachments) and opening downloaded attachments file_picker: ^8.0.0 open_file: ^3.3.2 + mime: ^2.0.0 dev_dependencies: flutter_test: