Added Action Taken on Task

This commit is contained in:
Marc Rejohn Castillano 2026-02-21 15:44:12 +08:00
parent d2f1bcf9b3
commit 1478667bbf
5 changed files with 224 additions and 2 deletions

View File

@ -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<String, dynamic> 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();
}
})(),
);
}
}

View File

@ -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 = <String, dynamic>{};
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;
}

View File

@ -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<TaskDetailScreen>
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<TaskDetailScreen>
bool _typeSaved = false;
bool _categorySaving = false;
bool _categorySaved = false;
bool _actionSaving = false;
bool _actionSaved = false;
late final AnimationController _saveAnimController;
late final Animation<double> _savePulse;
static const List<String> _statusOptions = [
@ -92,6 +98,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
_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<TaskDetailScreen>
_requestedDebounce?.cancel();
_notedDebounce?.cancel();
_receivedDebounce?.cancel();
_actionDebounce?.cancel();
_actionController?.dispose();
_saveAnimController.dispose();
super.dispose();
}
@ -112,7 +122,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
_notedSaving ||
_receivedSaving ||
_typeSaving ||
_categorySaving;
_categorySaving ||
_actionSaving;
void _updateSaveAnim() {
if (_anySaving) {
@ -127,6 +138,26 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
}
}
// 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<TaskDetailScreen>
_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<TaskDetailScreen>
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<TaskDetailScreen>
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<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,
),
),
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(),
),
],
),
),
],
),
),
),
],
),
),

View File

@ -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
$$;

View File

@ -38,6 +38,7 @@ class FakeTasksController extends TasksController {
String? requestedBy,
String? notedBy,
String? receivedBy,
String? actionTaken,
}) async {
final m = <String, dynamic>{};
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;
}