Added additional toolbar icons
This commit is contained in:
@@ -10,6 +10,7 @@ import '../../models/ticket_message.dart';
|
||||
import '../../providers/notifications_provider.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
||||
import '../../providers/supabase_provider.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
@@ -26,6 +27,36 @@ import '../../theme/app_surfaces.dart';
|
||||
import '../../widgets/task_assignment_section.dart';
|
||||
import '../../widgets/typing_dots.dart';
|
||||
|
||||
// Simple image embed builder to support data-URI and network images
|
||||
class _ImageEmbedBuilder extends quill.EmbedBuilder {
|
||||
const _ImageEmbedBuilder();
|
||||
|
||||
@override
|
||||
String get key => quill.BlockEmbed.imageType;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, quill.EmbedContext embedContext) {
|
||||
final data = embedContext.node.value.data as String;
|
||||
if (data.startsWith('data:image/')) {
|
||||
try {
|
||||
final base64Str = data.split(',').last;
|
||||
final bytes = base64Decode(base64Str);
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 240),
|
||||
child: Image.memory(bytes, fit: BoxFit.contain),
|
||||
);
|
||||
} catch (_) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
// Fallback to network image
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 240),
|
||||
child: Image.network(data, fit: BoxFit.contain),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Local request metadata options (kept consistent with other screens)
|
||||
const List<String> requestTypeOptions = [
|
||||
'Install',
|
||||
@@ -56,6 +87,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
// Rich text editor for Action taken
|
||||
quill.QuillController? _actionController;
|
||||
Timer? _actionDebounce;
|
||||
late final FocusNode _actionFocusNode;
|
||||
late final ScrollController _actionScrollController;
|
||||
Timer? _requestedDebounce;
|
||||
Timer? _notedDebounce;
|
||||
Timer? _receivedDebounce;
|
||||
@@ -101,6 +134,10 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
);
|
||||
// create an empty action controller by default; will seed per-task later
|
||||
_actionController = quill.QuillController.basic();
|
||||
_actionFocusNode = FocusNode();
|
||||
_actionScrollController = ScrollController();
|
||||
// Debugging: to enable a scroll jump detector, add a listener here.
|
||||
// Keep it disabled in production to avoid analyzer dead_code warnings.
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -114,6 +151,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
_receivedDebounce?.cancel();
|
||||
_actionDebounce?.cancel();
|
||||
_actionController?.dispose();
|
||||
_actionFocusNode.dispose();
|
||||
_actionScrollController.dispose();
|
||||
_saveAnimController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -1326,166 +1365,350 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
),
|
||||
|
||||
// Action taken (rich text)
|
||||
SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Action taken'),
|
||||
const SizedBox(height: 6),
|
||||
// Toolbar + editor with inline save indicator
|
||||
Container(
|
||||
height: isWide ? 260 : 220,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
8,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Action taken'),
|
||||
const SizedBox(height: 6),
|
||||
// Toolbar + editor with inline save indicator
|
||||
Container(
|
||||
height: isWide ? 260 : 220,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Bold',
|
||||
icon: const Icon(
|
||||
Icons.format_bold,
|
||||
),
|
||||
onPressed: () =>
|
||||
_actionController
|
||||
?.formatSelection(
|
||||
quill
|
||||
.Attribute
|
||||
.bold,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Bold',
|
||||
icon: const Icon(
|
||||
Icons.format_bold,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Italic',
|
||||
icon: const Icon(
|
||||
Icons.format_italic,
|
||||
),
|
||||
onPressed: () =>
|
||||
_actionController
|
||||
?.formatSelection(
|
||||
quill
|
||||
.Attribute
|
||||
.italic,
|
||||
),
|
||||
onPressed: () =>
|
||||
_actionController
|
||||
?.formatSelection(
|
||||
quill
|
||||
.Attribute
|
||||
.bold,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Italic',
|
||||
icon: const Icon(
|
||||
Icons.format_italic,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Underline',
|
||||
icon: const Icon(
|
||||
Icons.format_underlined,
|
||||
),
|
||||
onPressed: () =>
|
||||
_actionController
|
||||
?.formatSelection(
|
||||
quill
|
||||
.Attribute
|
||||
.underline,
|
||||
),
|
||||
onPressed: () =>
|
||||
_actionController
|
||||
?.formatSelection(
|
||||
quill
|
||||
.Attribute
|
||||
.italic,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Underline',
|
||||
icon: const Icon(
|
||||
Icons.format_underlined,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Bullet list',
|
||||
icon: const Icon(
|
||||
Icons
|
||||
.format_list_bulleted,
|
||||
),
|
||||
onPressed: () =>
|
||||
_actionController
|
||||
?.formatSelection(
|
||||
quill
|
||||
.Attribute
|
||||
.ul,
|
||||
),
|
||||
onPressed: () =>
|
||||
_actionController
|
||||
?.formatSelection(
|
||||
quill
|
||||
.Attribute
|
||||
.underline,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Bullet list',
|
||||
icon: const Icon(
|
||||
Icons
|
||||
.format_list_bulleted,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Numbered list',
|
||||
icon: const Icon(
|
||||
Icons
|
||||
.format_list_numbered,
|
||||
),
|
||||
onPressed: () =>
|
||||
_actionController
|
||||
?.formatSelection(
|
||||
quill
|
||||
.Attribute
|
||||
.ol,
|
||||
),
|
||||
onPressed: () =>
|
||||
_actionController
|
||||
?.formatSelection(
|
||||
quill
|
||||
.Attribute
|
||||
.ul,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Numbered list',
|
||||
icon: const Icon(
|
||||
Icons
|
||||
.format_list_numbered,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Expanded(
|
||||
child: MouseRegion(
|
||||
cursor:
|
||||
SystemMouseCursors.text,
|
||||
child:
|
||||
quill.QuillEditor.basic(
|
||||
controller:
|
||||
_actionController!,
|
||||
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 b64 =
|
||||
base64Encode(bytes);
|
||||
final ext =
|
||||
file.extension ??
|
||||
'png';
|
||||
final dataUri =
|
||||
'data:image/$ext;base64,$b64';
|
||||
final idx =
|
||||
_actionController
|
||||
?.selection
|
||||
.baseOffset ??
|
||||
0;
|
||||
_actionController
|
||||
?.document
|
||||
.insert(
|
||||
idx,
|
||||
quill
|
||||
.BlockEmbed.image(
|
||||
dataUri,
|
||||
),
|
||||
);
|
||||
} catch (_) {}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Expanded(
|
||||
child: MouseRegion(
|
||||
cursor:
|
||||
SystemMouseCursors.text,
|
||||
child: quill.QuillEditor.basic(
|
||||
controller:
|
||||
_actionController!,
|
||||
focusNode: _actionFocusNode,
|
||||
scrollController:
|
||||
_actionScrollController,
|
||||
config:
|
||||
quill.QuillEditorConfig(
|
||||
embedBuilders: const [
|
||||
_ImageEmbedBuilder(),
|
||||
],
|
||||
scrollable: true,
|
||||
padding:
|
||||
EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: 6,
|
||||
bottom: 6,
|
||||
child: _actionSaving
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: ScaleTransition(
|
||||
scale: _savePulse,
|
||||
child: const Icon(
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: 6,
|
||||
bottom: 6,
|
||||
child: _actionSaving
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: ScaleTransition(
|
||||
scale: _savePulse,
|
||||
child: const Icon(
|
||||
Icons.save,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
)
|
||||
: _actionSaved
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: Stack(
|
||||
alignment:
|
||||
Alignment.center,
|
||||
children: const [
|
||||
Icon(
|
||||
Icons.save,
|
||||
size: 16,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
)
|
||||
: _actionSaved
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: Stack(
|
||||
alignment:
|
||||
Alignment.center,
|
||||
children: const [
|
||||
Icon(
|
||||
Icons.save,
|
||||
size: 16,
|
||||
color: Colors.green,
|
||||
Positioned(
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
size: 10,
|
||||
color: Colors.white,
|
||||
),
|
||||
Positioned(
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
size: 10,
|
||||
color:
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user