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 <qwen-coder@alibabacloud.com>
This commit is contained in:
Thomas Güttler
2026-04-22 10:47:11 +02:00
co-authored by Qwen-Coder
parent 16607f7ea0
commit a51c2dad9c
6 changed files with 117 additions and 33 deletions
+1
View File
@@ -54,3 +54,4 @@ linux/flutter/generated_plugins.cmake
# FVM — .fvmrc is committed; .fvm/ contains the downloaded SDK (not committed)
.fvm/
.qwen
-19
View File
@@ -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
+7
View File
@@ -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.
+90 -11
View File
@@ -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<ComposeScreen> {
String? _accountId;
List<Account> _accounts = [];
bool _sending = false;
final List<String> _attachmentPaths = [];
final List<_AttachmentInfo> _attachments = [];
bool _opening = false;
int? _draftId;
bool _draftDirty = false;
@@ -156,13 +160,59 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
Future<void> _pickAttachments() async {
final result = await FilePicker.platform.pickFiles(allowMultiple: true);
if (result == null) return;
final paths = result.files.map((f) => f.path).whereType<String>().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<void> _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<void> _send() async {
@@ -192,7 +242,8 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
.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<ComposeScreen> {
alignLabelWithHint: true,
),
),
if (_attachmentPaths.isNotEmpty) ...[
if (_attachments.isNotEmpty) ...[
const SizedBox(height: 8),
const Divider(),
Padding(
@@ -302,14 +353,28 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
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<ComposeScreen> {
}
}
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<void> _) {}
+18 -3
View File
@@ -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<EmailDetailScreen> {
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));
}
+1
View File
@@ -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: