Task file attachments
This commit is contained in:
parent
3950f3ee94
commit
b9722106ff
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user