Task file attachments

This commit is contained in:
Marc Rejohn Castillano 2026-03-01 22:11:21 +08:00
parent 3950f3ee94
commit b9722106ff
2 changed files with 1009 additions and 294 deletions

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'dart:convert';
@ -741,6 +742,64 @@ class TasksController {
}
}
Future<void> uploadTaskAttachment({
required String taskId,
required String fileName,
required Uint8List bytes,
}) async {
final path = '$taskId/$fileName';
try {
debugPrint('uploadTaskAttachment uploading to path: $path');
final dynamic res;
if (kIsWeb) {
// on web, upload binary data
res = await _client.storage
.from('task_attachments')
.uploadBinary(path, bytes);
} else {
// write bytes to a simple temp file (no nested folders)
final tmpDir = Directory.systemTemp;
final localFile = File(
'${tmpDir.path}/${DateTime.now().millisecondsSinceEpoch}_$fileName',
);
try {
await localFile.create();
await localFile.writeAsBytes(bytes);
} catch (e) {
debugPrint('uploadTaskAttachment failed writing temp file: $e');
rethrow;
}
res = await _client.storage
.from('task_attachments')
.upload(path, localFile);
try {
await localFile.delete();
} catch (_) {}
}
debugPrint(
'uploadTaskAttachment response type=${res.runtimeType} value=$res',
);
// Check for errors
if (res is String) {
// treat as success
} else if (res is Map && res['error'] != null) {
debugPrint('uploadTaskAttachment upload error: ${res['error']}');
throw Exception('Upload error: ${res['error']}');
} else if (res != null && res.error != null) {
debugPrint('uploadTaskAttachment upload error: ${res.error}');
throw Exception('Upload error: ${res.error}');
}
} catch (e) {
debugPrint('uploadTaskAttachment failed: $e');
rethrow;
}
}
Future<void> _notifyCreated({
required String taskId,
required String? actorId,

View File

@ -12,6 +12,7 @@ import '../../models/office.dart';
import '../../providers/notifications_provider.dart';
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill;
import '../../providers/services_provider.dart';
@ -124,6 +125,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
String? _mentionQuery;
int? _mentionStart;
List<Profile> _mentionResults = [];
// Attachments state
List<String>? _attachments;
bool _loadingAttachments = false;
@override
void initState() {
@ -265,6 +269,10 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
_notedSaved = _notedController.text.isNotEmpty;
_receivedSaved = _receivedController.text.isNotEmpty;
// Reset attachments for new task
_attachments = null;
_loadingAttachments = false;
// Seed action taken plain text controller from persisted JSON or raw text
try {
_actionDebounce?.cancel();
@ -493,7 +501,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
childrenPadding: const EdgeInsets.symmetric(horizontal: 0),
children: [
DefaultTabController(
length: 4,
length: 5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -502,11 +510,20 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
indicatorColor: Theme.of(
context,
).colorScheme.primary,
tabs: const [
tabs: isWide
? const [
Tab(text: 'Assignees'),
Tab(text: 'Type & Category'),
Tab(text: 'Signatories'),
Tab(text: 'Action taken'),
Tab(text: 'Attachments'),
]
: const [
Tab(icon: Icon(Icons.person)),
Tab(icon: Icon(Icons.category)),
Tab(icon: Icon(Icons.check_circle)),
Tab(icon: Icon(Icons.description)),
Tab(icon: Icon(Icons.attach_file)),
],
),
const SizedBox(height: 8),
@ -514,7 +531,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
height: isWide ? 360 : 300,
child: TabBarView(
children: [
// Assignees
// Assignees (Tab 1)
SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
@ -1557,7 +1574,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
children: [
Column(
children: [
Row(
isWide
? Row(
children: [
IconButton(
tooltip: 'Bold',
@ -1575,7 +1593,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
IconButton(
tooltip: 'Italic',
icon: const Icon(
Icons.format_italic,
Icons
.format_italic,
),
onPressed: () =>
_actionController
@ -1586,9 +1605,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
),
),
IconButton(
tooltip: 'Underline',
tooltip:
'Underline',
icon: const Icon(
Icons.format_underlined,
Icons
.format_underlined,
),
onPressed: () =>
_actionController
@ -1599,7 +1620,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
),
),
IconButton(
tooltip: 'Bullet list',
tooltip:
'Bullet list',
icon: const Icon(
Icons
.format_list_bulleted,
@ -1613,7 +1635,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
),
),
IconButton(
tooltip: 'Numbered list',
tooltip:
'Numbered list',
icon: const Icon(
Icons
.format_list_numbered,
@ -1626,9 +1649,12 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
.ol,
),
),
const SizedBox(width: 8),
const SizedBox(
width: 8,
),
IconButton(
tooltip: 'Heading 2',
tooltip:
'Heading 2',
icon: const Icon(
Icons.format_size,
),
@ -1641,7 +1667,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
),
),
IconButton(
tooltip: 'Heading 3',
tooltip:
'Heading 3',
icon: const Icon(
Icons.format_size,
size: 18,
@ -1673,7 +1700,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
?.redo(),
),
IconButton(
tooltip: 'Insert link',
tooltip:
'Insert link',
icon: const Icon(
Icons.link,
),
@ -1681,7 +1709,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
final urlCtrl =
TextEditingController();
final res = await showDialog<String?>(
context: context,
context:
context,
builder: (ctx) => AlertDialog(
title: const Text(
'Insert link',
@ -1701,8 +1730,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
Navigator.of(
ctx,
).pop(),
child:
const Text(
child: const Text(
'Cancel',
),
),
@ -1711,12 +1739,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
Navigator.of(
ctx,
).pop(
urlCtrl
.text
.trim(),
urlCtrl.text.trim(),
),
child:
const Text(
child: const Text(
'Insert',
),
),
@ -1733,10 +1758,10 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
const TextSelection.collapsed(
offset: 0,
);
final start =
sel.baseOffset;
final end =
sel.extentOffset;
final start = sel
.baseOffset;
final end = sel
.extentOffset;
if (!sel.isCollapsed &&
end > start) {
final len =
@ -1766,14 +1791,14 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
},
),
IconButton(
tooltip: 'Insert image',
tooltip:
'Insert image',
icon: const Icon(
Icons.image,
),
onPressed: () async {
try {
final r =
await FilePicker
final r = await FilePicker
.platform
.pickFiles(
withData:
@ -1782,14 +1807,18 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
.image,
);
if (r == null ||
r.files.isEmpty) {
r
.files
.isEmpty) {
return;
}
final file =
r.files.first;
final file = r
.files
.first;
final bytes =
file.bytes;
if (bytes == null) {
if (bytes ==
null) {
return;
}
final ext =
@ -1804,7 +1833,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
.uploadActionImage(
taskId:
task.id,
bytes: bytes,
bytes:
bytes,
extension:
ext,
);
@ -1815,15 +1845,327 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
);
return;
}
if (url == null) {
if (url ==
null) {
showErrorSnackBar(
context,
'Image upload failed (no URL returned)',
);
return;
}
final trimmedUrl = url
.trim();
final trimmedUrl =
url.trim();
final idx =
_actionController
?.selection
.baseOffset ??
0;
// ignore: avoid_print
print(
'inserting image embed idx=$idx url=$trimmedUrl',
);
_actionController
?.document
.insert(
idx,
quill
.BlockEmbed.image(
trimmedUrl,
),
);
} catch (_) {}
},
),
],
)
: SingleChildScrollView(
scrollDirection:
Axis.horizontal,
child: Row(
children: [
IconButton(
tooltip: 'Bold',
icon: const Icon(
Icons
.format_bold,
),
onPressed: () =>
_actionController
?.formatSelection(
quill
.Attribute
.bold,
),
),
IconButton(
tooltip: 'Italic',
icon: const Icon(
Icons
.format_italic,
),
onPressed: () =>
_actionController
?.formatSelection(
quill
.Attribute
.italic,
),
),
IconButton(
tooltip:
'Underline',
icon: const Icon(
Icons
.format_underlined,
),
onPressed: () =>
_actionController
?.formatSelection(
quill
.Attribute
.underline,
),
),
IconButton(
tooltip:
'Bullet list',
icon: const Icon(
Icons
.format_list_bulleted,
),
onPressed: () =>
_actionController
?.formatSelection(
quill
.Attribute
.ul,
),
),
IconButton(
tooltip:
'Numbered list',
icon: const Icon(
Icons
.format_list_numbered,
),
onPressed: () =>
_actionController
?.formatSelection(
quill
.Attribute
.ol,
),
),
const SizedBox(
width: 8,
),
IconButton(
tooltip:
'Heading 2',
icon: const Icon(
Icons
.format_size,
),
onPressed: () =>
_actionController
?.formatSelection(
quill
.Attribute
.h2,
),
),
IconButton(
tooltip:
'Heading 3',
icon: const Icon(
Icons
.format_size,
size: 18,
),
onPressed: () =>
_actionController
?.formatSelection(
quill
.Attribute
.h3,
),
),
IconButton(
tooltip: 'Undo',
icon: const Icon(
Icons.undo,
),
onPressed: () =>
_actionController
?.undo(),
),
IconButton(
tooltip: 'Redo',
icon: const Icon(
Icons.redo,
),
onPressed: () =>
_actionController
?.redo(),
),
IconButton(
tooltip:
'Insert link',
icon: const Icon(
Icons.link,
),
onPressed: () async {
final urlCtrl =
TextEditingController();
final res = await showDialog<String?>(
context:
context,
builder: (ctx) => AlertDialog(
title: const Text(
'Insert link',
),
content: TextField(
controller:
urlCtrl,
decoration: const InputDecoration(
hintText:
'https://',
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(
ctx,
).pop(),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () =>
Navigator.of(
ctx,
).pop(
urlCtrl.text.trim(),
),
child: const Text(
'Insert',
),
),
],
),
);
if (res ==
null ||
res.isEmpty) {
return;
}
final sel =
_actionController
?.selection ??
const TextSelection.collapsed(
offset: 0,
);
final start = sel
.baseOffset;
final end = sel
.extentOffset;
if (!sel.isCollapsed &&
end >
start) {
final len =
end -
start;
try {
_actionController
?.document
.delete(
start,
len,
);
} catch (_) {}
_actionController
?.document
.insert(
start,
res,
);
} else {
_actionController
?.document
.insert(
start,
res,
);
}
},
),
IconButton(
tooltip:
'Insert image',
icon: const Icon(
Icons.image,
),
onPressed: () async {
try {
final r = await FilePicker
.platform
.pickFiles(
withData:
true,
type: FileType
.image,
);
if (r ==
null ||
r
.files
.isEmpty) {
return;
}
final file = r
.files
.first;
final bytes =
file.bytes;
if (bytes ==
null) {
return;
}
final ext =
file.extension ??
'png';
String? url;
try {
url = await ref
.read(
tasksControllerProvider,
)
.uploadActionImage(
taskId:
task.id,
bytes:
bytes,
extension:
ext,
);
} catch (e) {
showErrorSnackBar(
context,
'Upload error: $e',
);
return;
}
if (url ==
null) {
showErrorSnackBar(
context,
'Image upload failed (no URL returned)',
);
return;
}
final trimmedUrl =
url.trim();
final idx =
_actionController
?.selection
@ -1847,7 +2189,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
),
],
),
const SizedBox(height: 6),
),
Expanded(
child: MouseRegion(
cursor:
@ -1921,6 +2263,105 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
],
),
),
// Attachments (Tab 5)
SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
const Text('File Attachments'),
const SizedBox(height: 12),
Center(
child: FilledButton.icon(
icon: const Icon(Icons.upload_file),
label: const Text(
'Upload File (Max 25MB)',
),
onPressed: () =>
_uploadTaskAttachment(task.id),
),
),
const SizedBox(height: 16),
Builder(
builder: (context) {
// Load attachments once per task
if (_seededTaskId != null &&
_seededTaskId == task.id &&
_attachments == null &&
!_loadingAttachments) {
WidgetsBinding.instance
.addPostFrameCallback((_) {
_loadAttachments(task.id);
});
}
if (_loadingAttachments) {
return const Center(
child:
CircularProgressIndicator(),
);
}
final files = _attachments ?? [];
if (files.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'No attachments yet',
style: TextStyle(
color: Colors.grey,
),
),
),
);
}
return ListView.separated(
shrinkWrap: true,
physics:
const NeverScrollableScrollPhysics(),
itemCount: files.length,
separatorBuilder:
(context, index) =>
const Divider(),
itemBuilder: (context, index) {
final file = files[index];
return ListTile(
leading: const Icon(
Icons.insert_drive_file,
),
title: Text(file),
subtitle: Text(
'Tap to download',
style: Theme.of(
context,
).textTheme.bodySmall,
),
trailing: IconButton(
icon: const Icon(
Icons.delete,
color: Colors.red,
),
onPressed: () =>
_deleteTaskAttachment(
task.id,
file,
),
),
onTap: () =>
_downloadTaskAttachment(
task.id,
file,
),
);
},
);
},
),
],
),
),
),
],
),
),
@ -3193,6 +3634,221 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
);
}
Future<void> _uploadTaskAttachment(String taskId) async {
try {
final result = await FilePicker.platform.pickFiles(
withData: true,
allowMultiple: false,
);
if (result == null || result.files.isEmpty) {
return;
}
final file = result.files.first;
final bytes = file.bytes;
final fileName = file.name;
if (bytes == null) {
if (mounted) {
showErrorSnackBar(context, 'Failed to read file');
}
return;
}
// Check file size (max 25MB)
const maxSizeBytes = 25 * 1024 * 1024;
if (bytes.length > maxSizeBytes) {
if (mounted) {
showErrorSnackBar(context, 'File size exceeds 25MB limit');
}
return;
}
if (!mounted) return;
// Show loading dialog
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return PopScope(
canPop: false,
child: const AlertDialog(
title: Text('Uploading...'),
content: SizedBox(
height: 50,
child: Center(child: CircularProgressIndicator()),
),
),
);
},
);
String? errorMessage;
bool uploadSuccess = false;
try {
debugPrint('Starting upload for file: $fileName');
await ref
.read(tasksControllerProvider)
.uploadTaskAttachment(
taskId: taskId,
fileName: fileName,
bytes: bytes,
);
uploadSuccess = true;
debugPrint('Upload completed successfully');
} catch (e) {
debugPrint('Upload failed: $e');
errorMessage = e.toString();
}
// Close loading dialog first, then show feedback
if (mounted && Navigator.of(context).canPop()) {
debugPrint('Closing loading dialog...');
Navigator.of(context, rootNavigator: true).pop();
debugPrint('Dialog closed');
}
// Small delay to ensure dialog is fully closed before showing snackbar
await Future.delayed(const Duration(milliseconds: 100));
if (!mounted) return;
if (uploadSuccess) {
debugPrint('Showing success message and reloading attachments');
showSuccessSnackBar(context, 'File uploaded successfully');
// Reload attachments list (non-blocking)
_loadAttachments(taskId);
debugPrint('Attachment reload triggered');
} else {
showErrorSnackBar(context, 'Upload failed: $errorMessage');
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Error: $e');
}
}
}
Future<void> _loadAttachments(String taskId) async {
if (!mounted) return;
setState(() {
_loadingAttachments = true;
});
try {
final supabase = ref.read(supabaseClientProvider);
final files = await supabase.storage
.from('task_attachments')
.list(path: taskId);
if (mounted) {
setState(() {
_attachments = files.map((f) => f.name).toList();
_loadingAttachments = false;
});
debugPrint('Attachments loaded: ${_attachments?.length ?? 0} files');
}
} catch (e) {
debugPrint('Error getting attachments list: $e');
if (mounted) {
setState(() {
_attachments = [];
_loadingAttachments = false;
});
}
}
}
Future<void> _downloadTaskAttachment(String taskId, String fileName) async {
try {
if (mounted) {
showInfoSnackBar(context, 'Downloading: $fileName');
}
final supabase = ref.read(supabaseClientProvider);
// Download file data from storage
final Uint8List bytes;
try {
bytes = await supabase.storage
.from('task_attachments')
.download('$taskId/$fileName');
debugPrint('Downloaded ${bytes.length} bytes for $fileName');
} catch (e) {
debugPrint('Storage download error: $e');
if (mounted) {
showErrorSnackBar(context, 'Failed to download: $e');
}
return;
}
if (!mounted) return;
// Use FilePicker to save file (works on all platforms)
String? savePath = await FilePicker.platform.saveFile(
dialogTitle: 'Save attachment',
fileName: fileName,
bytes: bytes,
);
if (mounted) {
if (savePath != null && savePath.isNotEmpty) {
showSuccessSnackBar(context, 'File saved to: $savePath');
} else {
showInfoSnackBar(context, 'Download cancelled');
}
}
} catch (e) {
debugPrint('Download error: $e');
if (mounted) {
showErrorSnackBar(context, 'Download error: $e');
}
}
}
Future<void> _deleteTaskAttachment(String taskId, String fileName) async {
try {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Attachment?'),
content: Text('Remove "$fileName"?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed == true) {
final supabase = ref.read(supabaseClientProvider);
await supabase.storage.from('task_attachments').remove([
'$taskId/$fileName',
]);
if (mounted) {
showSuccessSnackBar(context, 'Attachment deleted');
// Reload attachments list
await _loadAttachments(taskId);
}
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Failed to delete: $e');
}
}
}
// PDF preview/building moved to `task_pdf.dart`.
}