diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index 0ae627be..a4ce5c92 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -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 requestTypeOptions = [ 'Install', @@ -56,6 +87,8 @@ class _TaskDetailScreenState extends ConsumerState // 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 ); // 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 _receivedDebounce?.cancel(); _actionDebounce?.cancel(); _actionController?.dispose(); + _actionFocusNode.dispose(); + _actionScrollController.dispose(); _saveAnimController.dispose(); super.dispose(); } @@ -1326,166 +1365,350 @@ class _TaskDetailScreenState extends ConsumerState ), // 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( + 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(), + ), + ], ), - ], - ), + ), + ], ), ), ], diff --git a/pubspec.lock b/pubspec.lock index 6d2fcaee..768c96aa 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -249,6 +249,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -289,6 +297,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -419,6 +435,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index cfe6c044..57351827 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: latlong2: ^0.9.0 flutter_typeahead: ^4.1.0 flutter_quill: ^11.5.0 + file_picker: ^10.3.10 dev_dependencies: flutter_test: