From 1478667bbffb9c138ef2102f0bfaba938084f4d2 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sat, 21 Feb 2026 15:44:12 +0800 Subject: [PATCH] Added Action Taken on Task --- lib/models/task.dart | 15 ++ lib/providers/tasks_provider.dart | 10 + lib/screens/tasks/task_detail_screen.dart | 187 +++++++++++++++++- .../20260221_add_action_taken_to_tasks.sql | 10 + test/task_detail_screen_test.dart | 4 + 5 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 supabase/migrations/20260221_add_action_taken_to_tasks.sql diff --git a/lib/models/task.dart b/lib/models/task.dart index 7ea60791..2d5e8a01 100644 --- a/lib/models/task.dart +++ b/lib/models/task.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import '../utils/app_time.dart'; class Task { @@ -21,6 +23,7 @@ class Task { this.requestType, this.requestTypeOther, this.requestCategory, + this.actionTaken, }); final String id; @@ -45,6 +48,8 @@ class Task { final String? requestType; final String? requestTypeOther; final String? requestCategory; + // JSON serialized rich text for action taken (Quill Delta JSON encoded) + final String? actionTaken; factory Task.fromMap(Map map) { return Task( @@ -70,6 +75,16 @@ class Task { requestedBy: map['requested_by'] as String?, notedBy: map['noted_by'] as String?, receivedBy: map['received_by'] as String?, + actionTaken: (() { + final at = map['action_taken']; + if (at == null) return null; + if (at is String) return at; + try { + return jsonEncode(at); + } catch (_) { + return at.toString(); + } + })(), ); } } diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index 09f502b0..38d44794 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -389,6 +390,7 @@ class TasksController { String? requestedBy, String? notedBy, String? receivedBy, + String? actionTaken, }) async { final payload = {}; if (requestType != null) { @@ -410,6 +412,14 @@ class TasksController { if (receivedBy != null) { payload['received_by'] = receivedBy; } + if (actionTaken != null) { + try { + payload['action_taken'] = jsonDecode(actionTaken); + } catch (_) { + // fallback: store raw string + payload['action_taken'] = actionTaken; + } + } if (status != null) { payload['status'] = status; } diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index 3342eb7b..17355d1a 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -9,6 +9,7 @@ import '../../models/ticket.dart'; import '../../models/ticket_message.dart'; import '../../providers/notifications_provider.dart'; import 'dart:async'; +import 'dart:convert'; import '../../providers/supabase_provider.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import '../../providers/profile_provider.dart'; @@ -51,6 +52,9 @@ class _TaskDetailScreenState extends ConsumerState final _requestedController = TextEditingController(); final _notedController = TextEditingController(); final _receivedController = TextEditingController(); + // Plain text controller for Action taken (fallback from rich editor) + TextEditingController? _actionController; + Timer? _actionDebounce; Timer? _requestedDebounce; Timer? _notedDebounce; Timer? _receivedDebounce; @@ -66,6 +70,8 @@ class _TaskDetailScreenState extends ConsumerState bool _typeSaved = false; bool _categorySaving = false; bool _categorySaved = false; + bool _actionSaving = false; + bool _actionSaved = false; late final AnimationController _saveAnimController; late final Animation _savePulse; static const List _statusOptions = [ @@ -92,6 +98,8 @@ class _TaskDetailScreenState extends ConsumerState _savePulse = Tween(begin: 1.0, end: 0.78).animate( CurvedAnimation(parent: _saveAnimController, curve: Curves.easeInOut), ); + // create an empty action controller by default; will seed per-task later + _actionController = TextEditingController(); } @override @@ -103,6 +111,8 @@ class _TaskDetailScreenState extends ConsumerState _requestedDebounce?.cancel(); _notedDebounce?.cancel(); _receivedDebounce?.cancel(); + _actionDebounce?.cancel(); + _actionController?.dispose(); _saveAnimController.dispose(); super.dispose(); } @@ -112,7 +122,8 @@ class _TaskDetailScreenState extends ConsumerState _notedSaving || _receivedSaving || _typeSaving || - _categorySaving; + _categorySaving || + _actionSaving; void _updateSaveAnim() { if (_anySaving) { @@ -127,6 +138,26 @@ class _TaskDetailScreenState extends ConsumerState } } + // Convert stored Quill delta JSON (or other simple structures) to plain text. + String _deltaJsonToPlainText(dynamic json) { + try { + if (json is String) return json; + if (json is List) { + final buffer = StringBuffer(); + for (final item in json) { + if (item is Map && item.containsKey('insert')) { + final ins = item['insert']; + if (ins is String) buffer.write(ins); + } + } + return buffer.toString(); + } + } catch (_) { + // ignore and fallthrough + } + return ''; + } + @override Widget build(BuildContext context) { final tasksAsync = ref.watch(tasksProvider); @@ -192,6 +223,60 @@ class _TaskDetailScreenState extends ConsumerState _requestedSaved = _requestedController.text.isNotEmpty; _notedSaved = _notedController.text.isNotEmpty; _receivedSaved = _receivedController.text.isNotEmpty; + + // Seed action taken plain text controller from persisted JSON or raw text + try { + _actionDebounce?.cancel(); + _actionController?.dispose(); + _actionController = TextEditingController(); + if (task.actionTaken != null && task.actionTaken!.isNotEmpty) { + try { + final decoded = jsonDecode(task.actionTaken!); + final plain = _deltaJsonToPlainText(decoded); + _actionController!.text = plain; + } catch (_) { + _actionController!.text = task.actionTaken!; + } + } + } catch (_) { + _actionController = TextEditingController(); + } + + // Attach auto-save listener for action taken (debounced) + _actionController?.addListener(() { + _actionDebounce?.cancel(); + _actionDebounce = Timer( + const Duration(milliseconds: 700), + () async { + final plain = _actionController?.text.trim() ?? ''; + setState(() { + _actionSaving = true; + _actionSaved = false; + }); + try { + await ref + .read(tasksControllerProvider) + .updateTask(taskId: task.id, actionTaken: plain); + setState(() { + _actionSaved = plain.isNotEmpty; + }); + } catch (_) { + // ignore + } finally { + setState(() { + _actionSaving = false; + }); + if (_actionSaved) { + Future.delayed(const Duration(seconds: 2), () { + if (mounted) { + setState(() => _actionSaved = false); + } + }); + } + } + }, + ); + }); } final detailsContent = Column( @@ -239,7 +324,7 @@ class _TaskDetailScreenState extends ConsumerState childrenPadding: const EdgeInsets.symmetric(horizontal: 0), children: [ DefaultTabController( - length: 3, + length: 4, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -250,6 +335,7 @@ class _TaskDetailScreenState extends ConsumerState Tab(text: 'Assignees'), Tab(text: 'Type & Category'), Tab(text: 'Signatories'), + Tab(text: 'Action taken'), ], ), const SizedBox(height: 8), @@ -1248,6 +1334,103 @@ 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, + ), + ), + child: Stack( + children: [ + Column( + children: [ + // Plain multiline editor for Action taken + const SizedBox(height: 6), + Expanded( + child: TextFormField( + controller: + _actionController, + readOnly: !canUpdateStatus, + maxLines: null, + expands: true, + decoration: + const InputDecoration.collapsed( + hintText: + 'Describe the action taken...', + ), + ), + ), + ], + ), + 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, + ), + Positioned( + right: -2, + bottom: -2, + child: Icon( + Icons.check, + size: 10, + color: + Colors.white, + ), + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + ), + ], + ), + ), + ), ], ), ), diff --git a/supabase/migrations/20260221_add_action_taken_to_tasks.sql b/supabase/migrations/20260221_add_action_taken_to_tasks.sql new file mode 100644 index 00000000..c8d0a4e1 --- /dev/null +++ b/supabase/migrations/20260221_add_action_taken_to_tasks.sql @@ -0,0 +1,10 @@ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name='tasks' AND column_name='action_taken' + ) THEN + ALTER TABLE public.tasks ADD COLUMN action_taken jsonb; + END IF; +END +$$; \ No newline at end of file diff --git a/test/task_detail_screen_test.dart b/test/task_detail_screen_test.dart index 43af5601..b813c4dd 100644 --- a/test/task_detail_screen_test.dart +++ b/test/task_detail_screen_test.dart @@ -38,6 +38,7 @@ class FakeTasksController extends TasksController { String? requestedBy, String? notedBy, String? receivedBy, + String? actionTaken, }) async { final m = {}; if (requestType != null) { @@ -58,6 +59,9 @@ class FakeTasksController extends TasksController { if (receivedBy != null) { m['receivedBy'] = receivedBy; } + if (actionTaken != null) { + m['actionTaken'] = actionTaken; + } if (status != null) { m['status'] = status; }