Task file attachments
This commit is contained in:
parent
3950f3ee94
commit
b9722106ff
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'dart:convert';
|
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({
|
Future<void> _notifyCreated({
|
||||||
required String taskId,
|
required String taskId,
|
||||||
required String? actorId,
|
required String? actorId,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import '../../models/office.dart';
|
||||||
import '../../providers/notifications_provider.dart';
|
import '../../providers/notifications_provider.dart';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
||||||
import '../../providers/services_provider.dart';
|
import '../../providers/services_provider.dart';
|
||||||
|
|
@ -124,6 +125,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
String? _mentionQuery;
|
String? _mentionQuery;
|
||||||
int? _mentionStart;
|
int? _mentionStart;
|
||||||
List<Profile> _mentionResults = [];
|
List<Profile> _mentionResults = [];
|
||||||
|
// Attachments state
|
||||||
|
List<String>? _attachments;
|
||||||
|
bool _loadingAttachments = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -265,6 +269,10 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
_notedSaved = _notedController.text.isNotEmpty;
|
_notedSaved = _notedController.text.isNotEmpty;
|
||||||
_receivedSaved = _receivedController.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
|
// Seed action taken plain text controller from persisted JSON or raw text
|
||||||
try {
|
try {
|
||||||
_actionDebounce?.cancel();
|
_actionDebounce?.cancel();
|
||||||
|
|
@ -493,7 +501,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
childrenPadding: const EdgeInsets.symmetric(horizontal: 0),
|
childrenPadding: const EdgeInsets.symmetric(horizontal: 0),
|
||||||
children: [
|
children: [
|
||||||
DefaultTabController(
|
DefaultTabController(
|
||||||
length: 4,
|
length: 5,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -502,11 +510,20 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
indicatorColor: Theme.of(
|
indicatorColor: Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.primary,
|
).colorScheme.primary,
|
||||||
tabs: const [
|
tabs: isWide
|
||||||
|
? const [
|
||||||
Tab(text: 'Assignees'),
|
Tab(text: 'Assignees'),
|
||||||
Tab(text: 'Type & Category'),
|
Tab(text: 'Type & Category'),
|
||||||
Tab(text: 'Signatories'),
|
Tab(text: 'Signatories'),
|
||||||
Tab(text: 'Action taken'),
|
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),
|
const SizedBox(height: 8),
|
||||||
|
|
@ -514,7 +531,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
height: isWide ? 360 : 300,
|
height: isWide ? 360 : 300,
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
children: [
|
children: [
|
||||||
// Assignees
|
// Assignees (Tab 1)
|
||||||
SingleChildScrollView(
|
SingleChildScrollView(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
|
@ -1557,7 +1574,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
isWide
|
||||||
|
? Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Bold',
|
tooltip: 'Bold',
|
||||||
|
|
@ -1575,7 +1593,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Italic',
|
tooltip: 'Italic',
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.format_italic,
|
Icons
|
||||||
|
.format_italic,
|
||||||
),
|
),
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
_actionController
|
_actionController
|
||||||
|
|
@ -1586,9 +1605,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Underline',
|
tooltip:
|
||||||
|
'Underline',
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.format_underlined,
|
Icons
|
||||||
|
.format_underlined,
|
||||||
),
|
),
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
_actionController
|
_actionController
|
||||||
|
|
@ -1599,7 +1620,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Bullet list',
|
tooltip:
|
||||||
|
'Bullet list',
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons
|
Icons
|
||||||
.format_list_bulleted,
|
.format_list_bulleted,
|
||||||
|
|
@ -1613,7 +1635,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Numbered list',
|
tooltip:
|
||||||
|
'Numbered list',
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons
|
Icons
|
||||||
.format_list_numbered,
|
.format_list_numbered,
|
||||||
|
|
@ -1626,9 +1649,12 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
.ol,
|
.ol,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(
|
||||||
|
width: 8,
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Heading 2',
|
tooltip:
|
||||||
|
'Heading 2',
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.format_size,
|
Icons.format_size,
|
||||||
),
|
),
|
||||||
|
|
@ -1641,7 +1667,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Heading 3',
|
tooltip:
|
||||||
|
'Heading 3',
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.format_size,
|
Icons.format_size,
|
||||||
size: 18,
|
size: 18,
|
||||||
|
|
@ -1673,7 +1700,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
?.redo(),
|
?.redo(),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Insert link',
|
tooltip:
|
||||||
|
'Insert link',
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.link,
|
Icons.link,
|
||||||
),
|
),
|
||||||
|
|
@ -1681,7 +1709,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
final urlCtrl =
|
final urlCtrl =
|
||||||
TextEditingController();
|
TextEditingController();
|
||||||
final res = await showDialog<String?>(
|
final res = await showDialog<String?>(
|
||||||
context: context,
|
context:
|
||||||
|
context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text(
|
title: const Text(
|
||||||
'Insert link',
|
'Insert link',
|
||||||
|
|
@ -1701,8 +1730,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
Navigator.of(
|
Navigator.of(
|
||||||
ctx,
|
ctx,
|
||||||
).pop(),
|
).pop(),
|
||||||
child:
|
child: const Text(
|
||||||
const Text(
|
|
||||||
'Cancel',
|
'Cancel',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1711,12 +1739,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
Navigator.of(
|
Navigator.of(
|
||||||
ctx,
|
ctx,
|
||||||
).pop(
|
).pop(
|
||||||
urlCtrl
|
urlCtrl.text.trim(),
|
||||||
.text
|
|
||||||
.trim(),
|
|
||||||
),
|
),
|
||||||
child:
|
child: const Text(
|
||||||
const Text(
|
|
||||||
'Insert',
|
'Insert',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1733,10 +1758,10 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
const TextSelection.collapsed(
|
const TextSelection.collapsed(
|
||||||
offset: 0,
|
offset: 0,
|
||||||
);
|
);
|
||||||
final start =
|
final start = sel
|
||||||
sel.baseOffset;
|
.baseOffset;
|
||||||
final end =
|
final end = sel
|
||||||
sel.extentOffset;
|
.extentOffset;
|
||||||
if (!sel.isCollapsed &&
|
if (!sel.isCollapsed &&
|
||||||
end > start) {
|
end > start) {
|
||||||
final len =
|
final len =
|
||||||
|
|
@ -1766,14 +1791,14 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Insert image',
|
tooltip:
|
||||||
|
'Insert image',
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.image,
|
Icons.image,
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
try {
|
try {
|
||||||
final r =
|
final r = await FilePicker
|
||||||
await FilePicker
|
|
||||||
.platform
|
.platform
|
||||||
.pickFiles(
|
.pickFiles(
|
||||||
withData:
|
withData:
|
||||||
|
|
@ -1782,14 +1807,18 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
.image,
|
.image,
|
||||||
);
|
);
|
||||||
if (r == null ||
|
if (r == null ||
|
||||||
r.files.isEmpty) {
|
r
|
||||||
|
.files
|
||||||
|
.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final file =
|
final file = r
|
||||||
r.files.first;
|
.files
|
||||||
|
.first;
|
||||||
final bytes =
|
final bytes =
|
||||||
file.bytes;
|
file.bytes;
|
||||||
if (bytes == null) {
|
if (bytes ==
|
||||||
|
null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final ext =
|
final ext =
|
||||||
|
|
@ -1804,7 +1833,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
.uploadActionImage(
|
.uploadActionImage(
|
||||||
taskId:
|
taskId:
|
||||||
task.id,
|
task.id,
|
||||||
bytes: bytes,
|
bytes:
|
||||||
|
bytes,
|
||||||
extension:
|
extension:
|
||||||
ext,
|
ext,
|
||||||
);
|
);
|
||||||
|
|
@ -1815,15 +1845,327 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (url == null) {
|
if (url ==
|
||||||
|
null) {
|
||||||
showErrorSnackBar(
|
showErrorSnackBar(
|
||||||
context,
|
context,
|
||||||
'Image upload failed (no URL returned)',
|
'Image upload failed (no URL returned)',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final trimmedUrl = url
|
final trimmedUrl =
|
||||||
.trim();
|
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 =
|
final idx =
|
||||||
_actionController
|
_actionController
|
||||||
?.selection
|
?.selection
|
||||||
|
|
@ -1847,7 +2189,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: MouseRegion(
|
child: MouseRegion(
|
||||||
cursor:
|
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`.
|
// PDF preview/building moved to `task_pdf.dart`.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user