Added additional toolbar icons
This commit is contained in:
parent
2aeb73d5de
commit
1b2b89d506
|
|
@ -10,6 +10,7 @@ import '../../models/ticket_message.dart';
|
||||||
import '../../providers/notifications_provider.dart';
|
import '../../providers/notifications_provider.dart';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
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/supabase_provider.dart';
|
import '../../providers/supabase_provider.dart';
|
||||||
import 'package:flutter_typeahead/flutter_typeahead.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/task_assignment_section.dart';
|
||||||
import '../../widgets/typing_dots.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)
|
// Local request metadata options (kept consistent with other screens)
|
||||||
const List<String> requestTypeOptions = [
|
const List<String> requestTypeOptions = [
|
||||||
'Install',
|
'Install',
|
||||||
|
|
@ -56,6 +87,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
// Rich text editor for Action taken
|
// Rich text editor for Action taken
|
||||||
quill.QuillController? _actionController;
|
quill.QuillController? _actionController;
|
||||||
Timer? _actionDebounce;
|
Timer? _actionDebounce;
|
||||||
|
late final FocusNode _actionFocusNode;
|
||||||
|
late final ScrollController _actionScrollController;
|
||||||
Timer? _requestedDebounce;
|
Timer? _requestedDebounce;
|
||||||
Timer? _notedDebounce;
|
Timer? _notedDebounce;
|
||||||
Timer? _receivedDebounce;
|
Timer? _receivedDebounce;
|
||||||
|
|
@ -101,6 +134,10 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
);
|
);
|
||||||
// create an empty action controller by default; will seed per-task later
|
// create an empty action controller by default; will seed per-task later
|
||||||
_actionController = quill.QuillController.basic();
|
_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
|
@override
|
||||||
|
|
@ -114,6 +151,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
_receivedDebounce?.cancel();
|
_receivedDebounce?.cancel();
|
||||||
_actionDebounce?.cancel();
|
_actionDebounce?.cancel();
|
||||||
_actionController?.dispose();
|
_actionController?.dispose();
|
||||||
|
_actionFocusNode.dispose();
|
||||||
|
_actionScrollController.dispose();
|
||||||
_saveAnimController.dispose();
|
_saveAnimController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -1326,166 +1365,350 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
),
|
),
|
||||||
|
|
||||||
// Action taken (rich text)
|
// Action taken (rich text)
|
||||||
SingleChildScrollView(
|
Padding(
|
||||||
child: Padding(
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
child: Column(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment:
|
children: [
|
||||||
CrossAxisAlignment.start,
|
const Text('Action taken'),
|
||||||
children: [
|
const SizedBox(height: 6),
|
||||||
const Text('Action taken'),
|
// Toolbar + editor with inline save indicator
|
||||||
const SizedBox(height: 6),
|
Container(
|
||||||
// Toolbar + editor with inline save indicator
|
height: isWide ? 260 : 220,
|
||||||
Container(
|
padding: const EdgeInsets.all(8),
|
||||||
height: isWide ? 260 : 220,
|
decoration: BoxDecoration(
|
||||||
padding: const EdgeInsets.all(8),
|
border: Border.all(
|
||||||
decoration: BoxDecoration(
|
color: Theme.of(
|
||||||
border: Border.all(
|
context,
|
||||||
color: Theme.of(
|
).colorScheme.outline,
|
||||||
context,
|
|
||||||
).colorScheme.outline,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(
|
|
||||||
8,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Stack(
|
borderRadius: BorderRadius.circular(8),
|
||||||
children: [
|
),
|
||||||
Column(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
Row(
|
||||||
tooltip: 'Bold',
|
children: [
|
||||||
icon: const Icon(
|
IconButton(
|
||||||
Icons.format_bold,
|
tooltip: 'Bold',
|
||||||
),
|
icon: const Icon(
|
||||||
onPressed: () =>
|
Icons.format_bold,
|
||||||
_actionController
|
|
||||||
?.formatSelection(
|
|
||||||
quill
|
|
||||||
.Attribute
|
|
||||||
.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
IconButton(
|
onPressed: () =>
|
||||||
tooltip: 'Italic',
|
_actionController
|
||||||
icon: const Icon(
|
?.formatSelection(
|
||||||
Icons.format_italic,
|
quill
|
||||||
),
|
.Attribute
|
||||||
onPressed: () =>
|
.bold,
|
||||||
_actionController
|
),
|
||||||
?.formatSelection(
|
),
|
||||||
quill
|
IconButton(
|
||||||
.Attribute
|
tooltip: 'Italic',
|
||||||
.italic,
|
icon: const Icon(
|
||||||
),
|
Icons.format_italic,
|
||||||
),
|
),
|
||||||
IconButton(
|
onPressed: () =>
|
||||||
tooltip: 'Underline',
|
_actionController
|
||||||
icon: const Icon(
|
?.formatSelection(
|
||||||
Icons.format_underlined,
|
quill
|
||||||
),
|
.Attribute
|
||||||
onPressed: () =>
|
.italic,
|
||||||
_actionController
|
),
|
||||||
?.formatSelection(
|
),
|
||||||
quill
|
IconButton(
|
||||||
.Attribute
|
tooltip: 'Underline',
|
||||||
.underline,
|
icon: const Icon(
|
||||||
),
|
Icons.format_underlined,
|
||||||
),
|
),
|
||||||
IconButton(
|
onPressed: () =>
|
||||||
tooltip: 'Bullet list',
|
_actionController
|
||||||
icon: const Icon(
|
?.formatSelection(
|
||||||
Icons
|
quill
|
||||||
.format_list_bulleted,
|
.Attribute
|
||||||
),
|
.underline,
|
||||||
onPressed: () =>
|
),
|
||||||
_actionController
|
),
|
||||||
?.formatSelection(
|
IconButton(
|
||||||
quill
|
tooltip: 'Bullet list',
|
||||||
.Attribute
|
icon: const Icon(
|
||||||
.ul,
|
Icons
|
||||||
),
|
.format_list_bulleted,
|
||||||
),
|
),
|
||||||
IconButton(
|
onPressed: () =>
|
||||||
tooltip: 'Numbered list',
|
_actionController
|
||||||
icon: const Icon(
|
?.formatSelection(
|
||||||
Icons
|
quill
|
||||||
.format_list_numbered,
|
.Attribute
|
||||||
),
|
.ul,
|
||||||
onPressed: () =>
|
),
|
||||||
_actionController
|
),
|
||||||
?.formatSelection(
|
IconButton(
|
||||||
quill
|
tooltip: 'Numbered list',
|
||||||
.Attribute
|
icon: const Icon(
|
||||||
.ol,
|
Icons
|
||||||
),
|
.format_list_numbered,
|
||||||
),
|
),
|
||||||
],
|
onPressed: () =>
|
||||||
),
|
_actionController
|
||||||
const SizedBox(height: 6),
|
?.formatSelection(
|
||||||
Expanded(
|
quill
|
||||||
child: MouseRegion(
|
.Attribute
|
||||||
cursor:
|
.ol,
|
||||||
SystemMouseCursors.text,
|
),
|
||||||
child:
|
),
|
||||||
quill.QuillEditor.basic(
|
const SizedBox(width: 8),
|
||||||
controller:
|
IconButton(
|
||||||
_actionController!,
|
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,
|
Positioned(
|
||||||
bottom: 6,
|
right: 6,
|
||||||
child: _actionSaving
|
bottom: 6,
|
||||||
? SizedBox(
|
child: _actionSaving
|
||||||
width: 20,
|
? SizedBox(
|
||||||
height: 20,
|
width: 20,
|
||||||
child: ScaleTransition(
|
height: 20,
|
||||||
scale: _savePulse,
|
child: ScaleTransition(
|
||||||
child: const Icon(
|
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,
|
Icons.save,
|
||||||
size: 16,
|
size: 16,
|
||||||
|
color: Colors.green,
|
||||||
),
|
),
|
||||||
),
|
Positioned(
|
||||||
)
|
right: -2,
|
||||||
: _actionSaved
|
bottom: -2,
|
||||||
? SizedBox(
|
child: Icon(
|
||||||
width: 20,
|
Icons.check,
|
||||||
height: 20,
|
size: 10,
|
||||||
child: Stack(
|
color: Colors.white,
|
||||||
alignment:
|
|
||||||
Alignment.center,
|
|
||||||
children: const [
|
|
||||||
Icon(
|
|
||||||
Icons.save,
|
|
||||||
size: 16,
|
|
||||||
color: Colors.green,
|
|
||||||
),
|
),
|
||||||
Positioned(
|
),
|
||||||
right: -2,
|
],
|
||||||
bottom: -2,
|
),
|
||||||
child: Icon(
|
)
|
||||||
Icons.check,
|
: const SizedBox.shrink(),
|
||||||
size: 10,
|
),
|
||||||
color:
|
],
|
||||||
Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
24
pubspec.lock
24
pubspec.lock
|
|
@ -249,6 +249,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.8.3"
|
version: "10.8.3"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.12"
|
||||||
diff_match_patch:
|
diff_match_patch:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -289,6 +297,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.4"
|
version: "6.1.4"
|
||||||
|
file_picker:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: file_picker
|
||||||
|
sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.3.10"
|
||||||
file_selector_platform_interface:
|
file_selector_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -419,6 +435,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.2.2"
|
version: "8.2.2"
|
||||||
|
flutter_plugin_android_lifecycle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_plugin_android_lifecycle
|
||||||
|
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.33"
|
||||||
flutter_quill:
|
flutter_quill:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ dependencies:
|
||||||
latlong2: ^0.9.0
|
latlong2: ^0.9.0
|
||||||
flutter_typeahead: ^4.1.0
|
flutter_typeahead: ^4.1.0
|
||||||
flutter_quill: ^11.5.0
|
flutter_quill: ^11.5.0
|
||||||
|
file_picker: ^10.3.10
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user