Added Action Taken on Task
This commit is contained in:
parent
d2f1bcf9b3
commit
1478667bbf
|
|
@ -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();
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
10
supabase/migrations/20260221_add_action_taken_to_tasks.sql
Normal file
10
supabase/migrations/20260221_add_action_taken_to_tasks.sql
Normal 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
|
||||
$$;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user