From b9722106ff76c76b99583611b8880d11b149d01b Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sun, 1 Mar 2026 22:11:21 +0800 Subject: [PATCH] Task file attachments --- lib/providers/tasks_provider.dart | 59 + lib/screens/tasks/task_detail_screen.dart | 1244 ++++++++++++++++----- 2 files changed, 1009 insertions(+), 294 deletions(-) diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index d00c461e..1eefc7f2 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -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 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 _notifyCreated({ required String taskId, required String? actorId, diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index 6e3dc9d7..006155dc 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -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 String? _mentionQuery; int? _mentionStart; List _mentionResults = []; + // Attachments state + List? _attachments; + bool _loadingAttachments = false; @override void initState() { @@ -265,6 +269,10 @@ class _TaskDetailScreenState extends ConsumerState _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 childrenPadding: const EdgeInsets.symmetric(horizontal: 0), children: [ DefaultTabController( - length: 4, + length: 5, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -502,19 +510,28 @@ class _TaskDetailScreenState extends ConsumerState indicatorColor: Theme.of( context, ).colorScheme.primary, - tabs: const [ - Tab(text: 'Assignees'), - Tab(text: 'Type & Category'), - Tab(text: 'Signatories'), - Tab(text: 'Action taken'), - ], + 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), SizedBox( height: isWide ? 360 : 300, child: TabBarView( children: [ - // Assignees + // Assignees (Tab 1) SingleChildScrollView( child: Padding( padding: const EdgeInsets.only(top: 8.0), @@ -1557,297 +1574,622 @@ class _TaskDetailScreenState extends ConsumerState children: [ Column( children: [ - 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( - context: context, - builder: (ctx) => AlertDialog( - title: const Text( - 'Insert link', + isWide + ? Row( + children: [ + IconButton( + tooltip: 'Bold', + icon: const Icon( + Icons.format_bold, ), - content: TextField( - controller: - urlCtrl, - decoration: - const InputDecoration( - hintText: - 'https://', - ), - ), - actions: [ - TextButton( - onPressed: () => - Navigator.of( - ctx, - ).pop(), - child: - const Text( - 'Cancel', + onPressed: () => + _actionController + ?.formatSelection( + quill + .Attribute + .bold, ), - ), - 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 - .baseOffset ?? - 0; - // ignore: avoid_print - print( - 'inserting image embed idx=$idx url=$trimmedUrl', - ); - _actionController - ?.document - .insert( - idx, - quill - .BlockEmbed.image( - trimmedUrl, + 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( + 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', + ), + ), + ], ), ); - } catch (_) {} - }, - ), - ], - ), - const SizedBox(height: 6), + 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 + .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( + 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 + .baseOffset ?? + 0; + // ignore: avoid_print + print( + 'inserting image embed idx=$idx url=$trimmedUrl', + ); + _actionController + ?.document + .insert( + idx, + quill + .BlockEmbed.image( + trimmedUrl, + ), + ); + } catch (_) {} + }, + ), + ], + ), + ), Expanded( child: MouseRegion( cursor: @@ -1921,6 +2263,105 @@ class _TaskDetailScreenState extends ConsumerState ], ), ), + // 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 ); } + Future _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 _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 _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 _deleteTaskAttachment(String taskId, String fileName) async { + try { + final confirmed = await showDialog( + 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`. }