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';
|
import '../utils/app_time.dart';
|
||||||
|
|
||||||
class Task {
|
class Task {
|
||||||
|
|
@ -21,6 +23,7 @@ class Task {
|
||||||
this.requestType,
|
this.requestType,
|
||||||
this.requestTypeOther,
|
this.requestTypeOther,
|
||||||
this.requestCategory,
|
this.requestCategory,
|
||||||
|
this.actionTaken,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
|
|
@ -45,6 +48,8 @@ class Task {
|
||||||
final String? requestType;
|
final String? requestType;
|
||||||
final String? requestTypeOther;
|
final String? requestTypeOther;
|
||||||
final String? requestCategory;
|
final String? requestCategory;
|
||||||
|
// JSON serialized rich text for action taken (Quill Delta JSON encoded)
|
||||||
|
final String? actionTaken;
|
||||||
|
|
||||||
factory Task.fromMap(Map<String, dynamic> map) {
|
factory Task.fromMap(Map<String, dynamic> map) {
|
||||||
return Task(
|
return Task(
|
||||||
|
|
@ -70,6 +75,16 @@ class Task {
|
||||||
requestedBy: map['requested_by'] as String?,
|
requestedBy: map['requested_by'] as String?,
|
||||||
notedBy: map['noted_by'] as String?,
|
notedBy: map['noted_by'] as String?,
|
||||||
receivedBy: map['received_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:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
@ -389,6 +390,7 @@ class TasksController {
|
||||||
String? requestedBy,
|
String? requestedBy,
|
||||||
String? notedBy,
|
String? notedBy,
|
||||||
String? receivedBy,
|
String? receivedBy,
|
||||||
|
String? actionTaken,
|
||||||
}) async {
|
}) async {
|
||||||
final payload = <String, dynamic>{};
|
final payload = <String, dynamic>{};
|
||||||
if (requestType != null) {
|
if (requestType != null) {
|
||||||
|
|
@ -410,6 +412,14 @@ class TasksController {
|
||||||
if (receivedBy != null) {
|
if (receivedBy != null) {
|
||||||
payload['received_by'] = receivedBy;
|
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) {
|
if (status != null) {
|
||||||
payload['status'] = status;
|
payload['status'] = status;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import '../../models/ticket.dart';
|
||||||
import '../../models/ticket_message.dart';
|
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 '../../providers/supabase_provider.dart';
|
import '../../providers/supabase_provider.dart';
|
||||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||||
import '../../providers/profile_provider.dart';
|
import '../../providers/profile_provider.dart';
|
||||||
|
|
@ -51,6 +52,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
final _requestedController = TextEditingController();
|
final _requestedController = TextEditingController();
|
||||||
final _notedController = TextEditingController();
|
final _notedController = TextEditingController();
|
||||||
final _receivedController = TextEditingController();
|
final _receivedController = TextEditingController();
|
||||||
|
// Plain text controller for Action taken (fallback from rich editor)
|
||||||
|
TextEditingController? _actionController;
|
||||||
|
Timer? _actionDebounce;
|
||||||
Timer? _requestedDebounce;
|
Timer? _requestedDebounce;
|
||||||
Timer? _notedDebounce;
|
Timer? _notedDebounce;
|
||||||
Timer? _receivedDebounce;
|
Timer? _receivedDebounce;
|
||||||
|
|
@ -66,6 +70,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
bool _typeSaved = false;
|
bool _typeSaved = false;
|
||||||
bool _categorySaving = false;
|
bool _categorySaving = false;
|
||||||
bool _categorySaved = false;
|
bool _categorySaved = false;
|
||||||
|
bool _actionSaving = false;
|
||||||
|
bool _actionSaved = false;
|
||||||
late final AnimationController _saveAnimController;
|
late final AnimationController _saveAnimController;
|
||||||
late final Animation<double> _savePulse;
|
late final Animation<double> _savePulse;
|
||||||
static const List<String> _statusOptions = [
|
static const List<String> _statusOptions = [
|
||||||
|
|
@ -92,6 +98,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
_savePulse = Tween(begin: 1.0, end: 0.78).animate(
|
_savePulse = Tween(begin: 1.0, end: 0.78).animate(
|
||||||
CurvedAnimation(parent: _saveAnimController, curve: Curves.easeInOut),
|
CurvedAnimation(parent: _saveAnimController, curve: Curves.easeInOut),
|
||||||
);
|
);
|
||||||
|
// create an empty action controller by default; will seed per-task later
|
||||||
|
_actionController = TextEditingController();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -103,6 +111,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
_requestedDebounce?.cancel();
|
_requestedDebounce?.cancel();
|
||||||
_notedDebounce?.cancel();
|
_notedDebounce?.cancel();
|
||||||
_receivedDebounce?.cancel();
|
_receivedDebounce?.cancel();
|
||||||
|
_actionDebounce?.cancel();
|
||||||
|
_actionController?.dispose();
|
||||||
_saveAnimController.dispose();
|
_saveAnimController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -112,7 +122,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
_notedSaving ||
|
_notedSaving ||
|
||||||
_receivedSaving ||
|
_receivedSaving ||
|
||||||
_typeSaving ||
|
_typeSaving ||
|
||||||
_categorySaving;
|
_categorySaving ||
|
||||||
|
_actionSaving;
|
||||||
|
|
||||||
void _updateSaveAnim() {
|
void _updateSaveAnim() {
|
||||||
if (_anySaving) {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tasksAsync = ref.watch(tasksProvider);
|
final tasksAsync = ref.watch(tasksProvider);
|
||||||
|
|
@ -192,6 +223,60 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
_requestedSaved = _requestedController.text.isNotEmpty;
|
_requestedSaved = _requestedController.text.isNotEmpty;
|
||||||
_notedSaved = _notedController.text.isNotEmpty;
|
_notedSaved = _notedController.text.isNotEmpty;
|
||||||
_receivedSaved = _receivedController.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(
|
final detailsContent = Column(
|
||||||
|
|
@ -239,7 +324,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
childrenPadding: const EdgeInsets.symmetric(horizontal: 0),
|
childrenPadding: const EdgeInsets.symmetric(horizontal: 0),
|
||||||
children: [
|
children: [
|
||||||
DefaultTabController(
|
DefaultTabController(
|
||||||
length: 3,
|
length: 4,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -250,6 +335,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
Tab(text: 'Assignees'),
|
Tab(text: 'Assignees'),
|
||||||
Tab(text: 'Type & Category'),
|
Tab(text: 'Type & Category'),
|
||||||
Tab(text: 'Signatories'),
|
Tab(text: 'Signatories'),
|
||||||
|
Tab(text: 'Action taken'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
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? requestedBy,
|
||||||
String? notedBy,
|
String? notedBy,
|
||||||
String? receivedBy,
|
String? receivedBy,
|
||||||
|
String? actionTaken,
|
||||||
}) async {
|
}) async {
|
||||||
final m = <String, dynamic>{};
|
final m = <String, dynamic>{};
|
||||||
if (requestType != null) {
|
if (requestType != null) {
|
||||||
|
|
@ -58,6 +59,9 @@ class FakeTasksController extends TasksController {
|
||||||
if (receivedBy != null) {
|
if (receivedBy != null) {
|
||||||
m['receivedBy'] = receivedBy;
|
m['receivedBy'] = receivedBy;
|
||||||
}
|
}
|
||||||
|
if (actionTaken != null) {
|
||||||
|
m['actionTaken'] = actionTaken;
|
||||||
|
}
|
||||||
if (status != null) {
|
if (status != null) {
|
||||||
m['status'] = status;
|
m['status'] = status;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user