627645056b
Separate feature for the `programmer` role to record dev work by category, distinct from the IT help-desk `tasks` feature. Modeled on the IT Service Requests pattern (Brick offline-first model -> Riverpod stream/controller -> list/detail screens); seed of a future Project Management workflow. - programmer_tasks + programmer_task_activity_logs (category enum, RLS, realtime) via supabase migration - single assignee, self-logged or lead-assigned; assign/reassign logged - pause-on-switch: starting a task while another runs prompts to pause the running one, keeping one active task per user for accurate time attribution - worked-duration math shared with help-desk tasks via a generic core in task_duration.dart (existing callers unchanged) - list distinguishes running vs paused with a per-task Resume shortcut - accessible dialog labels; route /programmer-tasks + nav gated to admin/programmer - unit/widget tests: controller lifecycle, duration math, pause-switch dialog Co-Authored-By: claude-flow <ruv@ruv.net>
593 lines
20 KiB
Dart
593 lines
20 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import '../brick/cache_helpers.dart';
|
|
import '../models/programmer_task.model.dart';
|
|
import '../models/programmer_task_activity_log.model.dart';
|
|
import '../utils/snackbar.dart' show isOfflineSaveError;
|
|
import 'connectivity_provider.dart';
|
|
import 'profile_provider.dart';
|
|
import 'realtime_controller.dart';
|
|
import 'stream_recovery.dart';
|
|
import 'supabase_provider.dart';
|
|
|
|
/// Lifecycle action types that determine whether a task is currently paused.
|
|
const _pauseStateActions = ['started', 'paused', 'resumed', 'completed', 'cancelled'];
|
|
|
|
int _statusRank(String status) {
|
|
switch (status) {
|
|
case ProgrammerTaskStatus.inProgress:
|
|
return 0;
|
|
case ProgrammerTaskStatus.queued:
|
|
return 1;
|
|
case ProgrammerTaskStatus.completed:
|
|
return 2;
|
|
case ProgrammerTaskStatus.cancelled:
|
|
return 3;
|
|
default:
|
|
return 4;
|
|
}
|
|
}
|
|
|
|
void _sortTasks(List<ProgrammerTask> tasks) {
|
|
tasks.sort((a, b) {
|
|
final r = _statusRank(a.status).compareTo(_statusRank(b.status));
|
|
if (r != 0) return r;
|
|
return b.createdAt.compareTo(a.createdAt);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Offline-pending state (merged into the live stream; replayed on reconnect)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
final offlinePendingProgrammerTasksProvider =
|
|
StateProvider<List<ProgrammerTask>>((ref) => const []);
|
|
|
|
final offlinePendingProgrammerTaskRawProvider =
|
|
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
|
|
|
|
final offlinePendingProgrammerTaskUpdatesProvider =
|
|
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
|
|
|
|
final offlinePendingProgrammerTaskLogsProvider =
|
|
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stream providers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
final programmerTasksProvider = StreamProvider<List<ProgrammerTask>>((ref) {
|
|
final userId = ref.watch(currentUserIdProvider);
|
|
if (userId == null) return const Stream.empty();
|
|
final client = ref.watch(supabaseClientProvider);
|
|
|
|
final pendingNew = ref.watch(offlinePendingProgrammerTasksProvider);
|
|
final pendingUpdates = ref.watch(offlinePendingProgrammerTaskUpdatesProvider);
|
|
|
|
List<ProgrammerTask> applyPending(List<ProgrammerTask> rows) {
|
|
final byId = {for (final r in rows) r.id: r};
|
|
pendingUpdates.forEach((id, fields) {
|
|
final existing = byId[id];
|
|
if (existing == null) return;
|
|
byId[id] = _applyUpdateFields(existing, fields);
|
|
});
|
|
for (final p in pendingNew) {
|
|
byId.putIfAbsent(p.id, () => p);
|
|
}
|
|
final result = byId.values.toList();
|
|
_sortTasks(result);
|
|
return result;
|
|
}
|
|
|
|
final wrapper = StreamRecoveryWrapper<ProgrammerTask>(
|
|
stream: client
|
|
.from('programmer_tasks')
|
|
.stream(primaryKey: ['id'])
|
|
.order('created_at', ascending: false),
|
|
onPollData: () async {
|
|
final data = await client
|
|
.from('programmer_tasks')
|
|
.select()
|
|
.order('created_at', ascending: false)
|
|
.range(0, 199);
|
|
return data.map(ProgrammerTask.fromMap).toList();
|
|
},
|
|
fromMap: ProgrammerTask.fromMap,
|
|
channelName: 'programmer_tasks',
|
|
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
|
onOfflineData: () async {
|
|
final all = await cachedListFromBrick<ProgrammerTask>();
|
|
return applyPending(all);
|
|
},
|
|
onCacheMirror: (rows) =>
|
|
mirrorBatchToBrick<ProgrammerTask>(rows, tag: 'programmer_tasks'),
|
|
);
|
|
|
|
ref.onDispose(wrapper.dispose);
|
|
|
|
// Reconnect-replay: POST queued items directly, bypassing Brick's queue.
|
|
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
|
|
if (wasOnline == true || !isNowOnline) return;
|
|
|
|
// 1. Replay offline-created tasks.
|
|
final pendingRaw = List<Map<String, dynamic>>.from(
|
|
ref.read(offlinePendingProgrammerTaskRawProvider),
|
|
);
|
|
if (pendingRaw.isNotEmpty) {
|
|
final syncedIds = <String>[];
|
|
for (final payload in pendingRaw) {
|
|
final id = payload['id'] as String;
|
|
try {
|
|
await client.from('programmer_tasks').insert(payload);
|
|
try {
|
|
await client.from('programmer_task_activity_logs').insert({
|
|
'task_id': id,
|
|
'actor_id': payload['creator_id'],
|
|
'action_type': 'created',
|
|
});
|
|
} catch (_) {}
|
|
syncedIds.add(id);
|
|
} catch (e) {
|
|
final msg = e.toString();
|
|
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
|
syncedIds.add(id);
|
|
} else {
|
|
debugPrint('[programmerTasksProvider] failed to sync id=$id: $e');
|
|
}
|
|
}
|
|
}
|
|
if (syncedIds.isNotEmpty) {
|
|
ref.read(offlinePendingProgrammerTaskRawProvider.notifier).state =
|
|
List.unmodifiable(
|
|
ref
|
|
.read(offlinePendingProgrammerTaskRawProvider)
|
|
.where((p) => !syncedIds.contains(p['id'] as String)),
|
|
);
|
|
ref.read(offlinePendingProgrammerTasksProvider.notifier).state =
|
|
List.unmodifiable(
|
|
ref
|
|
.read(offlinePendingProgrammerTasksProvider)
|
|
.where((t) => !syncedIds.contains(t.id)),
|
|
);
|
|
}
|
|
}
|
|
|
|
// 2. Replay offline field/status updates.
|
|
final pendingUpdatesMap = Map<String, Map<String, dynamic>>.from(
|
|
ref.read(offlinePendingProgrammerTaskUpdatesProvider),
|
|
);
|
|
if (pendingUpdatesMap.isNotEmpty) {
|
|
final syncedIds = <String>[];
|
|
for (final entry in pendingUpdatesMap.entries) {
|
|
try {
|
|
await client
|
|
.from('programmer_tasks')
|
|
.update(entry.value)
|
|
.eq('id', entry.key);
|
|
syncedIds.add(entry.key);
|
|
} catch (e) {
|
|
debugPrint(
|
|
'[programmerTasksProvider] failed to sync update id=${entry.key}: $e',
|
|
);
|
|
}
|
|
}
|
|
if (syncedIds.isNotEmpty) {
|
|
final remaining = Map<String, Map<String, dynamic>>.from(
|
|
ref.read(offlinePendingProgrammerTaskUpdatesProvider),
|
|
);
|
|
for (final id in syncedIds) {
|
|
remaining.remove(id);
|
|
}
|
|
ref.read(offlinePendingProgrammerTaskUpdatesProvider.notifier).state =
|
|
remaining;
|
|
}
|
|
}
|
|
|
|
// 3. Replay queued activity logs (started/paused/resumed/etc.).
|
|
final pendingLogs = List<Map<String, dynamic>>.from(
|
|
ref.read(offlinePendingProgrammerTaskLogsProvider),
|
|
);
|
|
if (pendingLogs.isNotEmpty) {
|
|
final synced = <Map<String, dynamic>>[];
|
|
for (final log in pendingLogs) {
|
|
try {
|
|
await client.from('programmer_task_activity_logs').insert(log);
|
|
synced.add(log);
|
|
} catch (e) {
|
|
debugPrint('[programmerTasksProvider] failed to sync log: $e');
|
|
}
|
|
}
|
|
if (synced.isNotEmpty) {
|
|
ref.read(offlinePendingProgrammerTaskLogsProvider.notifier).state =
|
|
List.unmodifiable(
|
|
ref
|
|
.read(offlinePendingProgrammerTaskLogsProvider)
|
|
.where((l) => !synced.contains(l)),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
return wrapper.stream.map((result) => applyPending(result.data));
|
|
});
|
|
|
|
final programmerTaskByIdProvider = Provider.family<ProgrammerTask?, String>((
|
|
ref,
|
|
id,
|
|
) {
|
|
final tasks = ref.watch(programmerTasksProvider).valueOrNull;
|
|
if (tasks == null) return null;
|
|
try {
|
|
return tasks.firstWhere((t) => t.id == id);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
});
|
|
|
|
/// The current user's actively-running task id (status in_progress AND latest
|
|
/// activity log is not a pause), or null. Lets the list distinguish a *running*
|
|
/// task from a *paused* one (both share the `in_progress` status). Re-queries
|
|
/// whenever the task list changes. Given the single-active invariant, at most
|
|
/// one task is running per user.
|
|
final myRunningProgrammerTaskIdProvider = FutureProvider<String?>((ref) async {
|
|
// Re-run whenever tasks change (a start/pause/resume mutates a row).
|
|
ref.watch(programmerTasksProvider);
|
|
final running =
|
|
await ref.watch(programmerTasksControllerProvider).findRunningTaskForCurrentUser();
|
|
return running?.id;
|
|
});
|
|
|
|
final programmerTaskActivityLogsProvider =
|
|
StreamProvider.family<List<ProgrammerTaskActivityLog>, String>((ref, taskId) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
|
|
final wrapper = StreamRecoveryWrapper<ProgrammerTaskActivityLog>(
|
|
stream: client
|
|
.from('programmer_task_activity_logs')
|
|
.stream(primaryKey: ['id'])
|
|
.eq('task_id', taskId)
|
|
.order('created_at', ascending: false),
|
|
onPollData: () async {
|
|
final data = await client
|
|
.from('programmer_task_activity_logs')
|
|
.select()
|
|
.eq('task_id', taskId)
|
|
.order('created_at', ascending: false);
|
|
return data.map(ProgrammerTaskActivityLog.fromMap).toList();
|
|
},
|
|
fromMap: ProgrammerTaskActivityLog.fromMap,
|
|
channelName: 'programmer_task_activity_logs_$taskId',
|
|
onStatusChanged: ref
|
|
.read(realtimeControllerProvider)
|
|
.handleChannelStatus,
|
|
onOfflineData: () async {
|
|
final all = await cachedListFromBrick<ProgrammerTaskActivityLog>();
|
|
return all.where((l) => l.taskId == taskId).toList()
|
|
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
|
},
|
|
onCacheMirror: (rows) => mirrorBatchToBrick<ProgrammerTaskActivityLog>(
|
|
rows,
|
|
tag: 'programmer_task_activity_logs',
|
|
),
|
|
);
|
|
|
|
ref.onDispose(wrapper.dispose);
|
|
return wrapper.stream.map((result) => result.data);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Controller
|
|
// ---------------------------------------------------------------------------
|
|
|
|
final programmerTasksControllerProvider = Provider<ProgrammerTasksController>((
|
|
ref,
|
|
) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
return ProgrammerTasksController(client, ref);
|
|
});
|
|
|
|
class ProgrammerTasksController {
|
|
ProgrammerTasksController(this._client, [this._ref]);
|
|
|
|
final dynamic _client;
|
|
final Ref? _ref;
|
|
|
|
bool get _isOnline => _ref?.read(isOnlineProvider) ?? true;
|
|
|
|
/// Whether [taskId]'s most recent lifecycle event is a pause (i.e. the task
|
|
/// is in_progress but not actively running).
|
|
Future<bool> _isCurrentlyPaused(String taskId) async {
|
|
try {
|
|
final rows = await _client
|
|
.from('programmer_task_activity_logs')
|
|
.select('action_type, created_at')
|
|
.eq('task_id', taskId)
|
|
.inFilter('action_type', _pauseStateActions)
|
|
.order('created_at', ascending: false)
|
|
.limit(1);
|
|
if (rows is List && rows.isNotEmpty) {
|
|
return (rows.first['action_type']?.toString() ?? '') == 'paused';
|
|
}
|
|
} catch (_) {}
|
|
return false;
|
|
}
|
|
|
|
Future<void> _insertLog(
|
|
String taskId,
|
|
String actionType, {
|
|
Map<String, dynamic>? meta,
|
|
}) async {
|
|
final row = <String, dynamic>{
|
|
'task_id': taskId,
|
|
'actor_id': _client.auth.currentUser?.id,
|
|
'action_type': actionType,
|
|
'meta': ?meta,
|
|
};
|
|
if (!_isOnline) {
|
|
_queueLog(row);
|
|
return;
|
|
}
|
|
try {
|
|
await _client.from('programmer_task_activity_logs').insert(row);
|
|
} catch (e) {
|
|
if (isOfflineSaveError(e)) {
|
|
_queueLog(row);
|
|
} else {
|
|
rethrow;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Creates a task. [assigneeId] defaults to the current user (self-logged);
|
|
/// pass another programmer's id to assign it to them. Returns the task id.
|
|
Future<String> createTask({
|
|
required String title,
|
|
required String category,
|
|
String? description,
|
|
String? assigneeId,
|
|
int priority = 1,
|
|
}) async {
|
|
final userId = _client.auth.currentUser?.id;
|
|
if (userId == null) throw Exception('Not authenticated');
|
|
|
|
final id = const Uuid().v4();
|
|
final assignee = assigneeId ?? userId;
|
|
final payload = <String, dynamic>{
|
|
'id': id,
|
|
'title': title,
|
|
'description': description,
|
|
'category': category,
|
|
'status': ProgrammerTaskStatus.queued,
|
|
'priority': priority,
|
|
'assignee_id': assignee,
|
|
'creator_id': userId,
|
|
};
|
|
|
|
try {
|
|
await _client.from('programmer_tasks').insert(payload);
|
|
await _insertLog(id, 'created');
|
|
if (assignee != userId) {
|
|
await _insertLog(id, 'assigned', meta: {'assignee_id': assignee});
|
|
}
|
|
return id;
|
|
} catch (e) {
|
|
if (!isOfflineSaveError(e)) rethrow;
|
|
final now = DateTime.now().toUtc();
|
|
final local = ProgrammerTask(
|
|
id: id,
|
|
title: title,
|
|
description: description,
|
|
category: category,
|
|
status: ProgrammerTaskStatus.queued,
|
|
priority: priority,
|
|
assigneeId: assignee,
|
|
creatorId: userId,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
);
|
|
_queueNewTask(local, payload);
|
|
return id;
|
|
}
|
|
}
|
|
|
|
/// The current user's actively-running task (status in_progress, latest event
|
|
/// not a pause), or null. Used to drive the pause-on-switch prompt. Online
|
|
/// only — returns null offline (the prompt is a best-effort UX aid).
|
|
Future<ProgrammerTask?> findRunningTaskForCurrentUser() async {
|
|
final userId = _client.auth.currentUser?.id;
|
|
if (userId == null || !_isOnline) return null;
|
|
try {
|
|
final rows = await _client
|
|
.from('programmer_tasks')
|
|
.select()
|
|
.eq('assignee_id', userId)
|
|
.eq('status', ProgrammerTaskStatus.inProgress);
|
|
if (rows is! List) return null;
|
|
for (final row in rows) {
|
|
final task = ProgrammerTask.fromMap(row as Map<String, dynamic>);
|
|
if (!await _isCurrentlyPaused(task.id)) return task;
|
|
}
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
/// Moves a queued task to in_progress (sets started_at on first start) and
|
|
/// logs a `started` event.
|
|
Future<void> startTask({required String taskId}) async {
|
|
final updates = <String, dynamic>{
|
|
'status': ProgrammerTaskStatus.inProgress,
|
|
'started_at': DateTime.now().toUtc().toIso8601String(),
|
|
};
|
|
await _updateTaskRow(taskId, updates, onlyIfStartNull: true);
|
|
await _insertLog(taskId, 'started');
|
|
}
|
|
|
|
/// Logs a `paused` event for a running task (idempotent).
|
|
Future<void> pauseTask({required String taskId}) async {
|
|
if (_isOnline && await _isCurrentlyPaused(taskId)) return;
|
|
await _insertLog(taskId, 'paused');
|
|
}
|
|
|
|
/// Logs a `resumed` event for a paused task (idempotent).
|
|
Future<void> resumeTask({required String taskId}) async {
|
|
if (_isOnline && !await _isCurrentlyPaused(taskId)) return;
|
|
await _insertLog(taskId, 'resumed');
|
|
}
|
|
|
|
Future<void> completeTask({required String taskId}) async {
|
|
await _updateTaskRow(taskId, {
|
|
'status': ProgrammerTaskStatus.completed,
|
|
'completed_at': DateTime.now().toUtc().toIso8601String(),
|
|
});
|
|
await _insertLog(taskId, 'completed');
|
|
}
|
|
|
|
Future<void> cancelTask({
|
|
required String taskId,
|
|
required String reason,
|
|
}) async {
|
|
await _updateTaskRow(taskId, {
|
|
'status': ProgrammerTaskStatus.cancelled,
|
|
'cancelled_at': DateTime.now().toUtc().toIso8601String(),
|
|
'cancellation_reason': reason,
|
|
});
|
|
await _insertLog(taskId, 'cancelled', meta: {'reason': reason});
|
|
}
|
|
|
|
Future<void> reassign({
|
|
required String taskId,
|
|
required String newAssigneeId,
|
|
}) async {
|
|
await _updateTaskRow(taskId, {'assignee_id': newAssigneeId});
|
|
await _insertLog(taskId, 'reassigned', meta: {'assignee_id': newAssigneeId});
|
|
}
|
|
|
|
Future<void> updateTask({
|
|
required String taskId,
|
|
String? title,
|
|
String? description,
|
|
String? category,
|
|
int? priority,
|
|
}) async {
|
|
final updates = <String, dynamic>{};
|
|
if (title != null) updates['title'] = title;
|
|
if (description != null) updates['description'] = description;
|
|
if (category != null) updates['category'] = category;
|
|
if (priority != null) updates['priority'] = priority;
|
|
if (updates.isEmpty) return;
|
|
await _updateTaskRow(taskId, updates);
|
|
await _insertLog(taskId, 'updated', meta: {'fields': updates.keys.toList()});
|
|
}
|
|
|
|
/// Applies a field patch to a task row. When [onlyIfStartNull] is set,
|
|
/// `started_at` is dropped if the task already has one (preserves the first
|
|
/// execution start across pause/resume cycles).
|
|
Future<void> _updateTaskRow(
|
|
String taskId,
|
|
Map<String, dynamic> updates, {
|
|
bool onlyIfStartNull = false,
|
|
}) async {
|
|
var payload = updates;
|
|
if (!_isOnline) {
|
|
_queueUpdate(taskId, payload);
|
|
return;
|
|
}
|
|
try {
|
|
if (onlyIfStartNull && payload.containsKey('started_at')) {
|
|
final existing = await _client
|
|
.from('programmer_tasks')
|
|
.select('started_at')
|
|
.eq('id', taskId)
|
|
.maybeSingle();
|
|
if (existing is Map && existing['started_at'] != null) {
|
|
payload = Map<String, dynamic>.from(payload)..remove('started_at');
|
|
}
|
|
}
|
|
await _client.from('programmer_tasks').update(payload).eq('id', taskId);
|
|
} catch (e) {
|
|
if (isOfflineSaveError(e)) {
|
|
_queueUpdate(taskId, payload);
|
|
} else {
|
|
rethrow;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ queues
|
|
|
|
void _queueNewTask(ProgrammerTask model, Map<String, dynamic> payload) {
|
|
final ref = _ref;
|
|
if (ref == null) return;
|
|
ref.read(offlinePendingProgrammerTasksProvider.notifier).state =
|
|
List.unmodifiable([
|
|
...ref.read(offlinePendingProgrammerTasksProvider),
|
|
model,
|
|
]);
|
|
ref.read(offlinePendingProgrammerTaskRawProvider.notifier).state =
|
|
List.unmodifiable([
|
|
...ref.read(offlinePendingProgrammerTaskRawProvider),
|
|
payload,
|
|
]);
|
|
mirrorBatchToBrick<ProgrammerTask>([model], tag: 'offline_programmer_task');
|
|
}
|
|
|
|
void _queueUpdate(String taskId, Map<String, dynamic> fields) {
|
|
final ref = _ref;
|
|
if (ref == null) return;
|
|
final current = Map<String, Map<String, dynamic>>.from(
|
|
ref.read(offlinePendingProgrammerTaskUpdatesProvider),
|
|
);
|
|
current[taskId] = {...?current[taskId], ...fields};
|
|
ref.read(offlinePendingProgrammerTaskUpdatesProvider.notifier).state =
|
|
current;
|
|
}
|
|
|
|
void _queueLog(Map<String, dynamic> row) {
|
|
final ref = _ref;
|
|
if (ref == null) return;
|
|
ref.read(offlinePendingProgrammerTaskLogsProvider.notifier).state =
|
|
List.unmodifiable([
|
|
...ref.read(offlinePendingProgrammerTaskLogsProvider),
|
|
row,
|
|
]);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Field-patch helper for optimistic offline updates
|
|
// ---------------------------------------------------------------------------
|
|
|
|
ProgrammerTask _applyUpdateFields(ProgrammerTask t, Map<String, dynamic> f) {
|
|
DateTime? parseOpt(String key) {
|
|
if (!f.containsKey(key)) return null;
|
|
final v = f[key];
|
|
return v == null ? null : DateTime.parse(v as String).toUtc();
|
|
}
|
|
|
|
return ProgrammerTask(
|
|
id: t.id,
|
|
title: (f['title'] as String?) ?? t.title,
|
|
description: f.containsKey('description')
|
|
? f['description'] as String?
|
|
: t.description,
|
|
category: (f['category'] as String?) ?? t.category,
|
|
status: (f['status'] as String?) ?? t.status,
|
|
priority: (f['priority'] as num?)?.toInt() ?? t.priority,
|
|
assigneeId:
|
|
f.containsKey('assignee_id') ? f['assignee_id'] as String? : t.assigneeId,
|
|
creatorId: t.creatorId,
|
|
createdAt: t.createdAt,
|
|
startedAt: f.containsKey('started_at') ? parseOpt('started_at') : t.startedAt,
|
|
completedAt:
|
|
f.containsKey('completed_at') ? parseOpt('completed_at') : t.completedAt,
|
|
cancelledAt:
|
|
f.containsKey('cancelled_at') ? parseOpt('cancelled_at') : t.cancelledAt,
|
|
cancellationReason: f.containsKey('cancellation_reason')
|
|
? f['cancellation_reason'] as String?
|
|
: t.cancellationReason,
|
|
updatedAt: DateTime.now().toUtc(),
|
|
);
|
|
}
|