2851 lines
98 KiB
Dart
2851 lines
98 KiB
Dart
import 'dart:async';
|
||
import 'dart:io';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'dart:convert';
|
||
import 'package:uuid/uuid.dart';
|
||
import '../utils/snackbar.dart' show isOfflineSaveError;
|
||
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||
import '../brick/cache_helpers.dart';
|
||
import '../brick/repository.dart';
|
||
import '../models/task.dart';
|
||
import '../models/task_activity_log.dart';
|
||
import 'connectivity_provider.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||
import '../models/task_assignment.dart';
|
||
import 'profile_provider.dart';
|
||
import 'supabase_provider.dart';
|
||
import 'tickets_provider.dart';
|
||
import 'user_offices_provider.dart';
|
||
import 'stream_recovery.dart';
|
||
import 'realtime_controller.dart';
|
||
import '../utils/app_time.dart';
|
||
|
||
/// Maximum rows fetched in a single seed/poll request.
|
||
/// If the server returns exactly this many rows the dataset may be truncated;
|
||
/// a debug warning is logged so it is caught during development.
|
||
const int kTaskPageLimit = 200;
|
||
|
||
String _stableJson(dynamic value) {
|
||
if (value is Map) {
|
||
final keys = value.keys.map((k) => k.toString()).toList()..sort();
|
||
final entries = <String, dynamic>{};
|
||
for (final key in keys) {
|
||
entries[key] = _stableJson(value[key]);
|
||
}
|
||
return jsonEncode(entries);
|
||
}
|
||
if (value is List) {
|
||
return jsonEncode(value.map(_stableJson).toList(growable: false));
|
||
}
|
||
return jsonEncode(value);
|
||
}
|
||
|
||
String _activityFingerprint(Map<String, dynamic> row) {
|
||
final taskId = row['task_id']?.toString() ?? '';
|
||
final actorId = row['actor_id']?.toString() ?? '';
|
||
final actionType = row['action_type']?.toString() ?? '';
|
||
final meta = row['meta'];
|
||
return '$taskId|$actorId|$actionType|${_stableJson(meta)}';
|
||
}
|
||
|
||
/// Build a UI-display fingerprint for a log entry so that entries that would
|
||
/// render identically are collapsed to a single row.
|
||
///
|
||
/// For auto-save fields (filled_*), the actual text value changes every millisecond
|
||
/// as the user types, creating hundreds of entries. By ignoring the text value and
|
||
/// grouping strictly by "Task + Actor + Action + Visual Minute", all auto-save
|
||
/// logs within the exact same minute collapse into a single display row.
|
||
String _uiFingerprint(TaskActivityLog log) {
|
||
const oncePerTask = {'created', 'started', 'completed'};
|
||
if (oncePerTask.contains(log.actionType)) {
|
||
return '${log.taskId}|${log.actionType}';
|
||
}
|
||
|
||
// Pause/resume events are sequence-sensitive for execution-time calculations.
|
||
// Keep each row distinct to avoid collapsing multiple toggles in the same minute.
|
||
if (log.actionType == 'paused' || log.actionType == 'resumed') {
|
||
return '${log.taskId}|${log.id}';
|
||
}
|
||
|
||
// The UI displays time as "MMM dd, yyyy hh:mm AA". Grouping by year, month,
|
||
// day, hour, and minute ensures we collapse duplicates that look identical on screen.
|
||
final visualMinute =
|
||
'${log.createdAt.year}-${log.createdAt.month}-${log.createdAt.day}-${log.createdAt.hour}-${log.createdAt.minute}';
|
||
|
||
if (log.actionType.startsWith('filled_')) {
|
||
// DO NOT include the meta/value here! If the user types "A", "AB", "ABC" in the
|
||
// same minute, including the value would make them mathematically "unique" and
|
||
// bypass the deduplication. Grouping by time+action solves the auto-save spam.
|
||
return '${log.taskId}|${log.actorId ?? ''}|${log.actionType}|$visualMinute';
|
||
}
|
||
|
||
// For other actions, we also include the normalized meta to be safe.
|
||
final metaStr = _normaliseMeta(log.meta);
|
||
return '${log.taskId}|${log.actorId ?? ''}|${log.actionType}|$metaStr|$visualMinute';
|
||
}
|
||
|
||
List<TaskActivityLog> _dedupeActivityLogs(List<TaskActivityLog> logs) {
|
||
if (logs.length < 2) return logs;
|
||
// Sort newest first so we always keep the most recent copy of a duplicate.
|
||
final sorted = [...logs]..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||
final seen = <String>{};
|
||
final deduped = <TaskActivityLog>[];
|
||
for (final log in sorted) {
|
||
final fp = _uiFingerprint(log);
|
||
if (seen.add(fp)) {
|
||
deduped.add(log);
|
||
}
|
||
}
|
||
return deduped;
|
||
}
|
||
|
||
/// Normalise a meta value for comparison: treat null and empty-map
|
||
/// identically, and sort map keys for stability.
|
||
String _normaliseMeta(dynamic meta) {
|
||
if (meta == null) return '';
|
||
final s = _stableJson(meta);
|
||
return (s == 'null' || s == '{}') ? '' : s;
|
||
}
|
||
|
||
// Helper to insert activity log rows while sanitizing nulls and
|
||
// avoiding exceptions from malformed payloads. Accepts either a Map
|
||
// or a List<Map>.
|
||
Future<void> _insertActivityRows(dynamic client, dynamic rows) async {
|
||
try {
|
||
if (rows == null) return;
|
||
if (rows is List) {
|
||
final sanitized = rows
|
||
.map((r) {
|
||
if (r is Map) {
|
||
final m = Map<String, dynamic>.from(r);
|
||
m.removeWhere((k, v) => v == null);
|
||
return m;
|
||
}
|
||
return null;
|
||
})
|
||
.whereType<Map<String, dynamic>>()
|
||
.toList();
|
||
if (sanitized.isEmpty) return;
|
||
|
||
final seen = <String>{};
|
||
final dedupedBatch = <Map<String, dynamic>>[];
|
||
for (final row in sanitized) {
|
||
final fingerprint = _activityFingerprint(row);
|
||
if (seen.add(fingerprint)) {
|
||
dedupedBatch.add(row);
|
||
}
|
||
}
|
||
|
||
if (dedupedBatch.isEmpty) return;
|
||
await client.from('task_activity_logs').insert(dedupedBatch);
|
||
} else if (rows is Map) {
|
||
final m = Map<String, dynamic>.from(rows);
|
||
m.removeWhere((k, v) => v == null);
|
||
await client.from('task_activity_logs').insert(m);
|
||
}
|
||
} catch (e) {
|
||
// Log for debugging but don't rethrow to avoid breaking caller flows
|
||
try {
|
||
debugPrint('[insertActivityRows] insert failed: $e');
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
|
||
/// Task query parameters for server-side pagination and filtering.
|
||
class TaskQuery {
|
||
/// Creates task query parameters.
|
||
const TaskQuery({
|
||
this.offset = 0,
|
||
this.limit = 50,
|
||
this.searchQuery = '',
|
||
this.officeId,
|
||
this.status,
|
||
this.taskNumber,
|
||
this.dateRange,
|
||
});
|
||
|
||
/// Offset for pagination.
|
||
final int offset;
|
||
|
||
/// Number of items per page (default: 50).
|
||
final int limit;
|
||
|
||
/// Full text search query.
|
||
final String searchQuery;
|
||
|
||
/// Filter by office ID.
|
||
final String? officeId;
|
||
|
||
/// Filter by status.
|
||
final String? status;
|
||
|
||
/// Filter by task number (partial match, case-insensitive).
|
||
final String? taskNumber;
|
||
|
||
/// Filter by date range.
|
||
final DateTimeRange? dateRange;
|
||
|
||
TaskQuery copyWith({
|
||
int? offset,
|
||
int? limit,
|
||
String? searchQuery,
|
||
String? officeId,
|
||
String? status,
|
||
String? taskNumber,
|
||
DateTimeRange? dateRange,
|
||
}) {
|
||
return TaskQuery(
|
||
offset: offset ?? this.offset,
|
||
limit: limit ?? this.limit,
|
||
searchQuery: searchQuery ?? this.searchQuery,
|
||
officeId: officeId ?? this.officeId,
|
||
status: status ?? this.status,
|
||
taskNumber: taskNumber ?? this.taskNumber,
|
||
dateRange: dateRange ?? this.dateRange,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Builds the isolate payload from a list of [Task] objects and the current
|
||
/// query/access context. Extracted so the initial REST seed and the realtime
|
||
/// stream listener can share the same logic without duplication.
|
||
Map<String, dynamic> _buildTaskPayload({
|
||
required List<Task> tasks,
|
||
required bool isGlobal,
|
||
required List<String> allowedTicketIds,
|
||
required List<String> allowedOfficeIds,
|
||
required TaskQuery query,
|
||
}) {
|
||
final rowsList = tasks
|
||
.map(
|
||
(task) => <String, dynamic>{
|
||
'id': task.id,
|
||
'task_number': task.taskNumber,
|
||
'office_id': task.officeId,
|
||
'ticket_id': task.ticketId,
|
||
'title': task.title,
|
||
'description': task.description,
|
||
'status': task.status,
|
||
'priority': task.priority,
|
||
'creator_id': task.creatorId,
|
||
'created_at': task.createdAt.toIso8601String(),
|
||
'started_at': task.startedAt?.toIso8601String(),
|
||
'completed_at': task.completedAt?.toIso8601String(),
|
||
'requested_by': task.requestedBy,
|
||
'noted_by': task.notedBy,
|
||
'received_by': task.receivedBy,
|
||
'queue_order': task.queueOrder,
|
||
'request_type': task.requestType,
|
||
'request_type_other': task.requestTypeOther,
|
||
'request_category': task.requestCategory,
|
||
'action_taken': task.actionTaken,
|
||
'cancellation_reason': task.cancellationReason,
|
||
'cancelled_at': task.cancelledAt?.toIso8601String(),
|
||
'it_job_printed': task.itJobPrinted,
|
||
'it_job_printed_at': task.itJobPrintedAt?.toIso8601String(),
|
||
'it_job_received_by_id': task.itJobReceivedById,
|
||
},
|
||
)
|
||
.toList();
|
||
|
||
return {
|
||
'rows': rowsList,
|
||
'isGlobal': isGlobal,
|
||
'allowedTicketIds': allowedTicketIds,
|
||
'allowedOfficeIds': allowedOfficeIds,
|
||
'officeId': query.officeId,
|
||
'status': query.status,
|
||
'searchQuery': query.searchQuery,
|
||
'taskNumber': query.taskNumber,
|
||
'dateStart': query.dateRange?.start.millisecondsSinceEpoch,
|
||
'dateEnd': query.dateRange?.end.millisecondsSinceEpoch,
|
||
};
|
||
}
|
||
|
||
final tasksProvider = StreamProvider<List<Task>>((ref) {
|
||
final client = ref.watch(supabaseClientProvider);
|
||
final profileAsync = ref.watch(currentProfileProvider);
|
||
// Watch only the ticket ID set — prevents tasksProvider from rebuilding
|
||
// when ticket *details* change (status, messages, etc.), only on ID changes.
|
||
final ticketIds = ref.watch(
|
||
ticketsProvider.select(
|
||
(async) => async.valueOrNull?.map((t) => t.id).toSet() ?? const <String>{},
|
||
),
|
||
);
|
||
final assignmentsAsync = ref.watch(userOfficesProvider);
|
||
final query = ref.watch(tasksQueryProvider);
|
||
|
||
final profile = profileAsync.valueOrNull;
|
||
if (profile == null) {
|
||
return Stream.value(const <Task>[]);
|
||
}
|
||
|
||
final isGlobal =
|
||
profile.role == 'admin' ||
|
||
profile.role == 'programmer' ||
|
||
profile.role == 'dispatcher' ||
|
||
profile.role == 'it_staff';
|
||
|
||
final allowedTicketIds = ticketIds.toList();
|
||
final allowedOfficeIds =
|
||
assignmentsAsync.valueOrNull
|
||
?.where((a) => a.userId == profile.id)
|
||
.map((a) => a.officeId)
|
||
.toSet()
|
||
.toList() ??
|
||
<String>[];
|
||
|
||
// For non-global users with no assigned offices/tickets, skip subscribing.
|
||
if (!isGlobal && allowedTicketIds.isEmpty && allowedOfficeIds.isEmpty) {
|
||
return Stream.value(const <Task>[]);
|
||
}
|
||
|
||
// Wrap realtime stream with recovery logic
|
||
final wrapper = StreamRecoveryWrapper<Task>(
|
||
stream: client.from('tasks').stream(primaryKey: ['id']),
|
||
onPollData: () async {
|
||
// Apply server-side filters to reduce poll payload.
|
||
// Role-based filtering and complex searches still run in the isolate.
|
||
var q = client.from('tasks').select();
|
||
if (query.status != null) q = q.eq('status', query.status!);
|
||
if (query.searchQuery.isNotEmpty) {
|
||
q = q.or(
|
||
'title.ilike.%${query.searchQuery}%,'
|
||
'description.ilike.%${query.searchQuery}%',
|
||
);
|
||
}
|
||
final data = await q
|
||
.order('created_at', ascending: false)
|
||
.range(0, kTaskPageLimit - 1);
|
||
return data.cast<Map<String, dynamic>>().map(Task.fromMap).toList();
|
||
},
|
||
fromMap: Task.fromMap,
|
||
channelName: 'tasks',
|
||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||
onOfflineData: () async {
|
||
if (!AppRepository.isConfigured) return [];
|
||
try {
|
||
final all = await AppRepository.instance.get<Task>(
|
||
policy: OfflineFirstGetPolicy.localOnly,
|
||
);
|
||
return {for (final t in all) t.id: t}.values.toList();
|
||
} catch (_) {
|
||
return [];
|
||
}
|
||
},
|
||
onCacheMirror: (rows) => mirrorBatchToBrick<Task>(rows, tag: 'tasks'),
|
||
);
|
||
|
||
ref.onDispose(wrapper.dispose);
|
||
|
||
// When the device reconnects, directly INSERT any pending offline tasks into
|
||
// Supabase rather than waiting for Brick's HTTP queue. The queue may be
|
||
// blocked by stale entries that keep returning 504 (connection pool timeout),
|
||
// preventing the newly-queued INSERT from ever replaying. This listener is a
|
||
// reliable bypass: it fires exactly once on the false→true transition and
|
||
// sends a fresh POST directly to the Supabase REST API.
|
||
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
|
||
// wasOnline is null on the first call after every provider rebuild, not the
|
||
// value at setup time. Use != true so that both null and false trigger the
|
||
// replay, matching the intent "we just came back online".
|
||
if (wasOnline != true && isNowOnline) {
|
||
final pending = ref.read(offlinePendingTasksProvider);
|
||
final pendingUpdates = Map<String, Map<String, dynamic>>.from(
|
||
ref.read(offlinePendingTaskUpdatesProvider),
|
||
);
|
||
final pendingTaskIds = pending.map((t) => t.id).toSet();
|
||
|
||
// ── 1. Sync offline-created tasks (INSERT) ──────────────────────────
|
||
if (pending.isNotEmpty) {
|
||
debugPrint(
|
||
'[tasksProvider] reconnected — syncing ${pending.length} pending task(s)',
|
||
);
|
||
}
|
||
final failedTaskIds = <String>{};
|
||
for (final task in pending) {
|
||
try {
|
||
final syncPayload = <String, dynamic>{
|
||
'id': task.id,
|
||
'title': task.title,
|
||
'description': task.description,
|
||
};
|
||
if (task.officeId != null) syncPayload['office_id'] = task.officeId;
|
||
if (task.ticketId != null) syncPayload['ticket_id'] = task.ticketId;
|
||
if (task.requestType != null) {
|
||
syncPayload['request_type'] = task.requestType;
|
||
}
|
||
if (task.requestTypeOther != null) {
|
||
syncPayload['request_type_other'] = task.requestTypeOther;
|
||
}
|
||
if (task.requestCategory != null) {
|
||
syncPayload['request_category'] = task.requestCategory;
|
||
}
|
||
// Merge any field edits made to this pending task while offline so
|
||
// they are included in the INSERT rather than a follow-up PATCH.
|
||
final accumulated = pendingUpdates[task.id];
|
||
if (accumulated != null && accumulated.isNotEmpty) {
|
||
syncPayload.addAll(accumulated);
|
||
}
|
||
await client.from('tasks').insert(syncPayload);
|
||
pendingUpdates.remove(task.id); // INSERT carried the edits
|
||
debugPrint('[tasksProvider] synced pending task id=${task.id}');
|
||
} catch (e) {
|
||
final msg = e.toString();
|
||
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
||
// Task already in DB — treat as success so it's cleared from pending.
|
||
debugPrint(
|
||
'[tasksProvider] pending task already in DB id=${task.id}',
|
||
);
|
||
} else {
|
||
failedTaskIds.add(task.id);
|
||
debugPrint(
|
||
'[tasksProvider] failed to sync pending task id=${task.id}: $e',
|
||
);
|
||
}
|
||
}
|
||
}
|
||
// Clear successfully synced pending tasks. Don't rely on the
|
||
// tasks_list_screen listener alone — it only fires when that screen is
|
||
// active, leaving the banner stuck on other screens indefinitely.
|
||
if (pending.isNotEmpty) {
|
||
final remaining =
|
||
pending.where((t) => failedTaskIds.contains(t.id)).toList();
|
||
ref.read(offlinePendingTasksProvider.notifier).state = remaining;
|
||
}
|
||
|
||
// ── 2. Sync offline field edits for EXISTING tasks (PATCH) ──────────
|
||
// pendingUpdates already had pending-task entries removed in step 1.
|
||
// What remains are edits to tasks that were already in the DB.
|
||
final updatesForExisting = pendingUpdates.entries
|
||
.where((e) => !pendingTaskIds.contains(e.key))
|
||
.toList();
|
||
if (updatesForExisting.isNotEmpty) {
|
||
debugPrint(
|
||
'[tasksProvider] reconnected — syncing ${updatesForExisting.length} pending edit(s)',
|
||
);
|
||
final synced = <String>[];
|
||
for (final entry in updatesForExisting) {
|
||
try {
|
||
// action_taken may be a string (Quill delta JSON); convert for DB.
|
||
final dbPayload = Map<String, dynamic>.from(entry.value);
|
||
if (dbPayload['action_taken'] is String) {
|
||
try {
|
||
dbPayload['action_taken'] =
|
||
jsonDecode(dbPayload['action_taken'] as String);
|
||
} catch (_) {}
|
||
}
|
||
await client.from('tasks').update(dbPayload).eq('id', entry.key);
|
||
synced.add(entry.key);
|
||
debugPrint(
|
||
'[tasksProvider] synced pending edit for task id=${entry.key}',
|
||
);
|
||
} catch (e) {
|
||
debugPrint(
|
||
'[tasksProvider] failed to sync edit for task id=${entry.key}: $e',
|
||
);
|
||
}
|
||
}
|
||
// Clear successfully synced edits.
|
||
if (synced.isNotEmpty) {
|
||
final remaining = Map<String, Map<String, dynamic>>.from(
|
||
ref.read(offlinePendingTaskUpdatesProvider),
|
||
);
|
||
for (final id in synced) { remaining.remove(id); }
|
||
ref.read(offlinePendingTaskUpdatesProvider.notifier).state = remaining;
|
||
}
|
||
}
|
||
|
||
// ── 3. Sync offline-queued messages ────────────────────────────────
|
||
final pendingMsgs = Map<String, List<Map<String, dynamic>>>.from(
|
||
ref.read(offlinePendingMessagesProvider),
|
||
);
|
||
if (pendingMsgs.isNotEmpty) {
|
||
debugPrint(
|
||
'[tasksProvider] reconnected — syncing messages for '
|
||
'${pendingMsgs.length} task(s)',
|
||
);
|
||
final syncedTaskIds = <String>[];
|
||
for (final entry in pendingMsgs.entries) {
|
||
bool allOk = true;
|
||
for (final msgPayload in entry.value) {
|
||
try {
|
||
await client.from('ticket_messages').insert(msgPayload);
|
||
} catch (e) {
|
||
debugPrint(
|
||
'[tasksProvider] failed to sync message for task '
|
||
'${entry.key}: $e',
|
||
);
|
||
allOk = false;
|
||
}
|
||
}
|
||
if (allOk) syncedTaskIds.add(entry.key);
|
||
}
|
||
if (syncedTaskIds.isNotEmpty) {
|
||
final remaining = Map<String, List<Map<String, dynamic>>>.from(
|
||
ref.read(offlinePendingMessagesProvider),
|
||
);
|
||
for (final id in syncedTaskIds) { remaining.remove(id); }
|
||
ref.read(offlinePendingMessagesProvider.notifier).state = remaining;
|
||
}
|
||
}
|
||
|
||
// ── 4. Sync offline assignment replacements ─────────────────────────
|
||
final pendingAssigns = Map<String, List<String>>.from(
|
||
ref.read(offlinePendingAssignmentsProvider),
|
||
);
|
||
if (pendingAssigns.isNotEmpty) {
|
||
debugPrint(
|
||
'[tasksProvider] reconnected — syncing assignments for '
|
||
'${pendingAssigns.length} task(s)',
|
||
);
|
||
final syncedAssignIds = <String>[];
|
||
for (final entry in pendingAssigns.entries) {
|
||
try {
|
||
await client
|
||
.from('task_assignments')
|
||
.delete()
|
||
.eq('task_id', entry.key);
|
||
if (entry.value.isNotEmpty) {
|
||
final rows = entry.value
|
||
.map((uid) => {'task_id': entry.key, 'user_id': uid})
|
||
.toList();
|
||
await client.from('task_assignments').insert(rows);
|
||
}
|
||
syncedAssignIds.add(entry.key);
|
||
debugPrint(
|
||
'[tasksProvider] synced assignments for task ${entry.key}',
|
||
);
|
||
} catch (e) {
|
||
debugPrint(
|
||
'[tasksProvider] failed to sync assignments for task '
|
||
'${entry.key}: $e',
|
||
);
|
||
}
|
||
}
|
||
if (syncedAssignIds.isNotEmpty) {
|
||
final remaining = Map<String, List<String>>.from(
|
||
ref.read(offlinePendingAssignmentsProvider),
|
||
);
|
||
for (final id in syncedAssignIds) { remaining.remove(id); }
|
||
ref.read(offlinePendingAssignmentsProvider.notifier).state = remaining;
|
||
}
|
||
}
|
||
|
||
// ── 5. Replay queued activity logs ──────────────────────────────────
|
||
final pendingLogs = List<Map<String, dynamic>>.from(
|
||
ref.read(offlinePendingActivityLogsProvider),
|
||
);
|
||
if (pendingLogs.isNotEmpty) {
|
||
debugPrint(
|
||
'[tasksProvider] reconnected — syncing ${pendingLogs.length} activity log(s)',
|
||
);
|
||
try {
|
||
await client.from('task_activity_logs').insert(pendingLogs);
|
||
ref.read(offlinePendingActivityLogsProvider.notifier).state = const [];
|
||
// Clear the typed items provider so merged offline logs no longer
|
||
// appear as extras once the server copy is confirmed.
|
||
ref.read(offlinePendingActivityLogItemsProvider.notifier).state =
|
||
const [];
|
||
} catch (e) {
|
||
debugPrint('[tasksProvider] failed to sync activity logs: $e');
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
var lastResultHash = '';
|
||
Timer? debounceTimer;
|
||
// broadcast() so Riverpod and any other listener can both receive events.
|
||
final controller = StreamController<List<Task>>.broadcast();
|
||
|
||
void emitDebounced(List<Task> tasks) {
|
||
debounceTimer?.cancel();
|
||
debounceTimer = Timer(const Duration(milliseconds: 150), () {
|
||
if (!controller.isClosed) controller.add(tasks);
|
||
});
|
||
}
|
||
|
||
ref.onDispose(() {
|
||
debounceTimer?.cancel();
|
||
controller.close();
|
||
});
|
||
|
||
// ── Immediate REST seed ───────────────────────────────────────────────────
|
||
// Fire a one-shot HTTP fetch right now so the UI can render before the
|
||
// WebSocket realtime channel is fully established. Eliminates loading delay
|
||
// on web and initial flash on mobile. Hash check prevents a duplicate
|
||
// rebuild if both the seed and the realtime stream arrive with the same data.
|
||
unawaited(
|
||
Future(() async {
|
||
// When offline, seed from Brick local SQLite cache instead of network.
|
||
if (!ref.read(isOnlineProvider) && AppRepository.isConfigured) {
|
||
try {
|
||
final all = await AppRepository.instance.get<Task>(
|
||
policy: OfflineFirstGetPolicy.localOnly,
|
||
);
|
||
final cached = {for (final t in all) t.id: t}.values.toList();
|
||
debugPrint('[tasksProvider] offline seed: ${cached.length} tasks from local cache');
|
||
final payload = _buildTaskPayload(
|
||
tasks: cached,
|
||
isGlobal: isGlobal,
|
||
allowedTicketIds: allowedTicketIds,
|
||
allowedOfficeIds: allowedOfficeIds,
|
||
query: query,
|
||
);
|
||
final processed = await compute(_processTasksInIsolate, payload);
|
||
final tasks = (processed as List<dynamic>)
|
||
.cast<Map<String, dynamic>>()
|
||
.map(Task.fromMap)
|
||
.toList();
|
||
final hash = tasks.fold('', (h, t) => '$h${t.hashCode}');
|
||
if (!controller.isClosed && hash != lastResultHash) {
|
||
lastResultHash = hash;
|
||
controller.add(tasks);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[tasksProvider] offline seed error: $e');
|
||
}
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Seed fetch: order newest-first and limit to 200 rows to reduce
|
||
// initial payload. The realtime stream delivers subsequent changes.
|
||
var seedQ = client.from('tasks').select();
|
||
if (query.status != null) seedQ = seedQ.eq('status', query.status!);
|
||
final data = await seedQ
|
||
.order('created_at', ascending: false)
|
||
.range(0, kTaskPageLimit - 1);
|
||
final raw = data
|
||
.cast<Map<String, dynamic>>()
|
||
.map(Task.fromMap)
|
||
.toList();
|
||
if (raw.length >= kTaskPageLimit) {
|
||
debugPrint(
|
||
'[tasksProvider] seed hit $kTaskPageLimit-row limit; '
|
||
'older tasks arrive via realtime stream.',
|
||
);
|
||
}
|
||
final payload = _buildTaskPayload(
|
||
tasks: raw,
|
||
isGlobal: isGlobal,
|
||
allowedTicketIds: allowedTicketIds,
|
||
allowedOfficeIds: allowedOfficeIds,
|
||
query: query,
|
||
);
|
||
final processed = await compute(_processTasksInIsolate, payload);
|
||
final tasks = (processed as List<dynamic>)
|
||
.cast<Map<String, dynamic>>()
|
||
.map(Task.fromMap)
|
||
.toList();
|
||
final hash = tasks.fold('', (h, t) => '$h${t.hashCode}');
|
||
if (!controller.isClosed && hash != lastResultHash) {
|
||
lastResultHash = hash;
|
||
controller.add(tasks); // emit immediately – no debounce
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[tasksProvider] initial seed error: $e');
|
||
}
|
||
}),
|
||
);
|
||
|
||
// ── Realtime stream ───────────────────────────────────────────────────────
|
||
// Processes every realtime event through the same isolate. Debounced so
|
||
// rapid consecutive events (e.g. bulk inserts) don't cause repeated renders.
|
||
final wrapperSub = wrapper.stream
|
||
.asyncMap((result) async {
|
||
final payload = _buildTaskPayload(
|
||
tasks: result.data,
|
||
isGlobal: isGlobal,
|
||
allowedTicketIds: allowedTicketIds,
|
||
allowedOfficeIds: allowedOfficeIds,
|
||
query: query,
|
||
);
|
||
final processed = await compute(_processTasksInIsolate, payload);
|
||
return (processed as List<dynamic>)
|
||
.cast<Map<String, dynamic>>()
|
||
.map(Task.fromMap)
|
||
.toList();
|
||
})
|
||
.listen(
|
||
(tasks) {
|
||
final hash = tasks.fold('', (h, t) => '$h${t.hashCode}');
|
||
if (hash != lastResultHash) {
|
||
lastResultHash = hash;
|
||
emitDebounced(tasks);
|
||
}
|
||
},
|
||
onError: (Object e) {
|
||
debugPrint('[tasksProvider] stream error: $e');
|
||
// Don't forward errors — the wrapper handles recovery internally.
|
||
},
|
||
);
|
||
|
||
ref.onDispose(wrapperSub.cancel);
|
||
|
||
return controller.stream;
|
||
});
|
||
|
||
// Runs inside a background isolate to filter/sort tasks represented as
|
||
// plain maps. Returns a List<Map<String,dynamic>> suitable for
|
||
// reconstruction with `Task.fromMap` on the main isolate.
|
||
List<Map<String, dynamic>> _processTasksInIsolate(
|
||
Map<String, dynamic> payload,
|
||
) {
|
||
var list = List<Map<String, dynamic>>.from(
|
||
(payload['rows'] as List).cast<Map<String, dynamic>>(),
|
||
);
|
||
|
||
final isGlobal = payload['isGlobal'] as bool? ?? false;
|
||
final allowedTicketIds =
|
||
(payload['allowedTicketIds'] as List?)?.cast<String>().toSet() ??
|
||
<String>{};
|
||
final allowedOfficeIds =
|
||
(payload['allowedOfficeIds'] as List?)?.cast<String>().toSet() ??
|
||
<String>{};
|
||
|
||
if (!isGlobal) {
|
||
if (allowedTicketIds.isEmpty && allowedOfficeIds.isEmpty) {
|
||
return <Map<String, dynamic>>[];
|
||
}
|
||
list = list.where((t) {
|
||
final tid = t['ticket_id'] as String?;
|
||
final oid = t['office_id'] as String?;
|
||
return (tid != null && allowedTicketIds.contains(tid)) ||
|
||
(oid != null && allowedOfficeIds.contains(oid));
|
||
}).toList();
|
||
}
|
||
|
||
final officeId = payload['officeId'] as String?;
|
||
if (officeId != null) {
|
||
list = list.where((t) => t['office_id'] == officeId).toList();
|
||
}
|
||
final status = payload['status'] as String?;
|
||
if (status != null) {
|
||
list = list.where((t) => t['status'] == status).toList();
|
||
}
|
||
|
||
final searchQuery = (payload['searchQuery'] as String?) ?? '';
|
||
if (searchQuery.isNotEmpty) {
|
||
final q = searchQuery.toLowerCase();
|
||
list = list.where((t) {
|
||
final title = (t['title'] as String?)?.toLowerCase() ?? '';
|
||
final desc = (t['description'] as String?)?.toLowerCase() ?? '';
|
||
final tn = (t['task_number'] as String?)?.toLowerCase() ?? '';
|
||
return title.contains(q) || desc.contains(q) || tn.contains(q);
|
||
}).toList();
|
||
}
|
||
|
||
final taskNumberFilter = (payload['taskNumber'] as String?)?.trim();
|
||
if (taskNumberFilter != null && taskNumberFilter.isNotEmpty) {
|
||
final tnLow = taskNumberFilter.toLowerCase();
|
||
list = list
|
||
.where(
|
||
(t) => ((t['task_number'] as String?) ?? '').toLowerCase().contains(
|
||
tnLow,
|
||
),
|
||
)
|
||
.toList();
|
||
}
|
||
|
||
int statusRank(String s) {
|
||
switch (s) {
|
||
case 'queued':
|
||
return 0;
|
||
case 'in_progress':
|
||
return 1;
|
||
case 'completed':
|
||
return 2;
|
||
case 'cancelled':
|
||
return 3;
|
||
default:
|
||
return 4;
|
||
}
|
||
}
|
||
|
||
int parseCreatedAt(Map<String, dynamic> m) {
|
||
final v = m['created_at'];
|
||
if (v == null) return 0;
|
||
if (v is int) return v;
|
||
if (v is double) return v.toInt();
|
||
if (v is String) {
|
||
try {
|
||
return DateTime.parse(v).millisecondsSinceEpoch;
|
||
} catch (_) {
|
||
return 0;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
list.sort((a, b) {
|
||
final ra = statusRank((a['status'] as String?) ?? '');
|
||
final rb = statusRank((b['status'] as String?) ?? '');
|
||
final rcmp = ra.compareTo(rb);
|
||
if (rcmp != 0) return rcmp;
|
||
|
||
if (ra == 0) {
|
||
// queued: higher priority first (lower number = higher priority),
|
||
// then created_at descending (newest first)
|
||
final pa = (a['priority'] as num?)?.toInt() ?? 1;
|
||
final pb = (b['priority'] as num?)?.toInt() ?? 1;
|
||
final pcmp = pb.compareTo(pa);
|
||
if (pcmp != 0) return pcmp;
|
||
// Same priority: sort by created_at descending (newest first)
|
||
return parseCreatedAt(b).compareTo(parseCreatedAt(a));
|
||
}
|
||
|
||
if (ra == 1) {
|
||
// in_progress: higher priority first (lower number = higher priority),
|
||
// then created_at descending (newest first)
|
||
final pa = (a['priority'] as num?)?.toInt() ?? 1;
|
||
final pb = (b['priority'] as num?)?.toInt() ?? 1;
|
||
final pcmp = pb.compareTo(pa);
|
||
if (pcmp != 0) return pcmp;
|
||
// Same priority: sort by created_at descending (newest first)
|
||
return parseCreatedAt(b).compareTo(parseCreatedAt(a));
|
||
}
|
||
|
||
// completed and cancelled: sort by created_at descending (newest first)
|
||
return parseCreatedAt(b).compareTo(parseCreatedAt(a));
|
||
});
|
||
|
||
return list;
|
||
}
|
||
|
||
/// Provider for task query parameters.
|
||
final tasksQueryProvider = StateProvider<TaskQuery>((ref) => const TaskQuery());
|
||
|
||
/// Derived provider that selects a single [Task] by ID from the tasks list.
|
||
///
|
||
/// Checks the live Supabase stream first, then falls back to the
|
||
/// [offlinePendingTasksProvider] so that tasks created while offline are
|
||
/// visible in the detail screen before they sync.
|
||
/// Pending offline field updates keyed by task ID.
|
||
/// Merged into [taskByIdProvider] and replayed on reconnect.
|
||
final offlinePendingTaskUpdatesProvider =
|
||
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
|
||
|
||
final taskByIdProvider = Provider.family<Task?, String>((ref, taskId) {
|
||
Task? task = ref
|
||
.watch(tasksProvider)
|
||
.valueOrNull
|
||
?.where((t) => t.id == taskId)
|
||
.firstOrNull;
|
||
task ??= ref
|
||
.watch(offlinePendingTasksProvider)
|
||
.where((t) => t.id == taskId)
|
||
.firstOrNull;
|
||
if (task == null) return null;
|
||
// Merge any pending offline field edits so the UI stays current.
|
||
final updates = ref.watch(offlinePendingTaskUpdatesProvider)[taskId];
|
||
if (updates == null || updates.isEmpty) return task;
|
||
return Task(
|
||
id: task.id,
|
||
ticketId: task.ticketId,
|
||
taskNumber: task.taskNumber,
|
||
title: updates['title'] as String? ?? task.title,
|
||
description: updates['description'] as String? ?? task.description,
|
||
officeId: updates['office_id'] as String? ?? task.officeId,
|
||
status: updates['status'] as String? ?? task.status,
|
||
priority: task.priority,
|
||
queueOrder: task.queueOrder,
|
||
createdAt: task.createdAt,
|
||
creatorId: task.creatorId,
|
||
startedAt: updates.containsKey('started_at')
|
||
? (updates['started_at'] == null
|
||
? null
|
||
: DateTime.tryParse(updates['started_at'] as String))
|
||
: task.startedAt,
|
||
completedAt: updates.containsKey('completed_at')
|
||
? (updates['completed_at'] == null
|
||
? null
|
||
: DateTime.tryParse(updates['completed_at'] as String))
|
||
: task.completedAt,
|
||
requestedBy: updates['requested_by'] as String? ?? task.requestedBy,
|
||
notedBy: updates['noted_by'] as String? ?? task.notedBy,
|
||
receivedBy: updates['received_by'] as String? ?? task.receivedBy,
|
||
requestType: updates['request_type'] as String? ?? task.requestType,
|
||
requestTypeOther:
|
||
updates['request_type_other'] as String? ?? task.requestTypeOther,
|
||
requestCategory:
|
||
updates['request_category'] as String? ?? task.requestCategory,
|
||
actionTaken: updates['action_taken'] as String? ?? task.actionTaken,
|
||
cancellationReason:
|
||
updates['cancellation_reason'] as String? ?? task.cancellationReason,
|
||
cancelledAt: updates.containsKey('cancelled_at')
|
||
? (updates['cancelled_at'] == null
|
||
? null
|
||
: DateTime.tryParse(updates['cancelled_at'] as String))
|
||
: task.cancelledAt,
|
||
itJobPrinted: task.itJobPrinted,
|
||
itJobPrintedAt: task.itJobPrintedAt,
|
||
itJobReceivedById: task.itJobReceivedById,
|
||
);
|
||
});
|
||
|
||
/// Returns true when [taskId] exists in the offline pending list (not yet synced).
|
||
///
|
||
/// Use this in the detail screen to show a "pending sync" banner and to know
|
||
/// whether editing operations are hitting a not-yet-created DB row.
|
||
final isTaskPendingProvider = Provider.family<bool, String>((ref, taskId) {
|
||
return ref.watch(offlinePendingTasksProvider).any((t) => t.id == taskId);
|
||
});
|
||
|
||
/// Derived provider that selects a [Task] linked to a given ticket ID.
|
||
///
|
||
/// Returns the first task whose `ticketId` matches [ticketId], or null.
|
||
final taskByTicketIdProvider = Provider.family<Task?, String>((ref, ticketId) {
|
||
return ref
|
||
.watch(tasksProvider)
|
||
.valueOrNull
|
||
?.where((t) => t.ticketId == ticketId)
|
||
.firstOrNull;
|
||
});
|
||
|
||
final taskAssignmentsProvider = StreamProvider<List<TaskAssignment>>((ref) {
|
||
final client = ref.watch(supabaseClientProvider);
|
||
|
||
final wrapper = StreamRecoveryWrapper<TaskAssignment>(
|
||
stream: client
|
||
.from('task_assignments')
|
||
.stream(primaryKey: ['task_id', 'user_id']),
|
||
onPollData: () async {
|
||
final data = await client.from('task_assignments').select();
|
||
return data.map(TaskAssignment.fromMap).toList();
|
||
},
|
||
fromMap: TaskAssignment.fromMap,
|
||
channelName: 'task_assignments',
|
||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||
);
|
||
|
||
ref.onDispose(wrapper.dispose);
|
||
return wrapper.stream.map((result) => result.data);
|
||
});
|
||
|
||
/// Stream of activity logs for a single task.
|
||
final taskActivityLogsProvider =
|
||
StreamProvider.family<List<TaskActivityLog>, String>((ref, taskId) {
|
||
final client = ref.watch(supabaseClientProvider);
|
||
|
||
// Watch offline-pending typed items. When _queueActivityLog adds a new
|
||
// entry this provider rebuilds, creating a fresh StreamRecoveryWrapper
|
||
// whose _hasEmittedData=false — so _emitOfflineData() fires immediately
|
||
// and the closed-over pendingItems list already contains the new log.
|
||
final pendingItems = ref
|
||
.watch(offlinePendingActivityLogItemsProvider)
|
||
.where((l) => l.taskId == taskId)
|
||
.toList();
|
||
|
||
final wrapper = StreamRecoveryWrapper<TaskActivityLog>(
|
||
stream: client
|
||
.from('task_activity_logs')
|
||
.stream(primaryKey: ['id'])
|
||
.eq('task_id', taskId)
|
||
.order('created_at', ascending: false),
|
||
onPollData: () async {
|
||
final data = await client
|
||
.from('task_activity_logs')
|
||
.select()
|
||
.eq('task_id', taskId)
|
||
.order('created_at', ascending: false);
|
||
return data.map((r) => TaskActivityLog.fromMap(r)).toList();
|
||
},
|
||
fromMap: TaskActivityLog.fromMap,
|
||
channelName: 'task_activity_logs:$taskId',
|
||
onStatusChanged: ref
|
||
.read(realtimeControllerProvider)
|
||
.handleChannelStatus,
|
||
onOfflineData: () async {
|
||
// Brick SQLite (cold-launch cache) + any in-memory pending logs
|
||
// that haven't been written to Brick yet (async timing gap).
|
||
final all = await cachedListFromBrick<TaskActivityLog>();
|
||
final filtered =
|
||
all.where((l) => l.taskId == taskId).toList();
|
||
// Dedupe: pendingItems may already be in Brick after a hot-restart
|
||
final brickIds = filtered.map((l) => l.id).toSet();
|
||
final extras =
|
||
pendingItems.where((l) => !brickIds.contains(l.id)).toList();
|
||
return [...extras, ...filtered]
|
||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||
},
|
||
onCacheMirror: (rows) => mirrorBatchToBrick<TaskActivityLog>(
|
||
rows,
|
||
tag: 'task_activity_logs',
|
||
),
|
||
);
|
||
|
||
ref.onDispose(wrapper.dispose);
|
||
return wrapper.stream.map((result) {
|
||
// For live/polling emissions, also merge any pending offline logs not
|
||
// yet echoed back from Supabase realtime (avoids a brief flash where
|
||
// the log disappears between queue and server confirmation).
|
||
final liveIds = result.data.map((l) => l.id).toSet();
|
||
final extras =
|
||
pendingItems.where((l) => !liveIds.contains(l.id)).toList();
|
||
final merged = [...extras, ...result.data]
|
||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||
return _dedupeActivityLogs(merged);
|
||
});
|
||
});
|
||
|
||
final taskAssignmentsControllerProvider = Provider<TaskAssignmentsController>((
|
||
ref,
|
||
) {
|
||
final client = ref.watch(supabaseClientProvider);
|
||
return TaskAssignmentsController(client, ref);
|
||
});
|
||
|
||
/// Tasks created while offline that are pending sync to Supabase.
|
||
/// Merged with the live [tasksProvider] stream in the UI so the new task
|
||
/// appears immediately without invalidating (and destroying) the stream.
|
||
/// Entries are automatically removed when the realtime stream returns a task
|
||
/// with the same UUID (meaning the offline write synced successfully).
|
||
final offlinePendingTasksProvider = StateProvider<List<Task>>((ref) => const []);
|
||
|
||
/// Offline-queued task messages: task ID → list of raw INSERT payloads
|
||
/// {task_id, ticket_id?, content, sender_id, created_at}.
|
||
/// Shown optimistically in the UI and replayed on reconnect via the
|
||
/// [tasksProvider] listener (which bypasses Brick's potentially-stale queue).
|
||
final offlinePendingMessagesProvider =
|
||
StateProvider<Map<String, List<Map<String, dynamic>>>>((ref) => const {});
|
||
|
||
/// Offline-pending IT staff assignment replacements: task ID → full new user ID list.
|
||
/// Replayed as a DELETE + INSERT pair on reconnect to avoid partial diffs.
|
||
final offlinePendingAssignmentsProvider =
|
||
StateProvider<Map<String, List<String>>>((ref) => const {});
|
||
|
||
/// Offline-queued activity log rows. Each entry is a complete INSERT payload
|
||
/// including a [created_at] timestamp stamped at the time of the offline action
|
||
/// (not at replay time) so the audit trail reflects when the action occurred.
|
||
final offlinePendingActivityLogsProvider =
|
||
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
|
||
|
||
/// Typed offline-pending activity log objects. Watched by
|
||
/// [taskActivityLogsProvider] so the UI updates immediately when an activity
|
||
/// is logged offline — without waiting for Brick SQLite async writes.
|
||
final offlinePendingActivityLogItemsProvider =
|
||
StateProvider<List<TaskActivityLog>>((ref) => const []);
|
||
|
||
final tasksControllerProvider = Provider<TasksController>((ref) {
|
||
final client = ref.watch(supabaseClientProvider);
|
||
return TasksController(client, ref);
|
||
});
|
||
|
||
class TasksController {
|
||
TasksController(this._client, [this._ref]);
|
||
|
||
// Supabase storage bucket for task action images. Ensure this bucket exists
|
||
// with public read access.
|
||
static const String _actionImageBucket = 'task_action_taken_images';
|
||
|
||
// _client is declared dynamic allowing test doubles that mimic only the
|
||
// subset of methods used by this class. In production it will be a
|
||
// SupabaseClient instance.
|
||
final dynamic _client;
|
||
// Optional — only provided when constructed via [tasksControllerProvider].
|
||
// Used to update [offlinePendingTasksProvider] after an offline upsert.
|
||
final Ref? _ref;
|
||
|
||
Future<bool> _isTaskCurrentlyPaused(String taskId) async {
|
||
try {
|
||
final rows = await _client
|
||
.from('task_activity_logs')
|
||
.select('action_type, created_at')
|
||
.eq('task_id', taskId)
|
||
.inFilter('action_type', [
|
||
'started',
|
||
'paused',
|
||
'resumed',
|
||
'completed',
|
||
'cancelled',
|
||
])
|
||
.order('created_at', ascending: false)
|
||
.limit(1);
|
||
if (rows is List && rows.isNotEmpty) {
|
||
final latest = rows.first;
|
||
final action = latest['action_type']?.toString() ?? '';
|
||
return action == 'paused';
|
||
}
|
||
} catch (_) {}
|
||
return false;
|
||
}
|
||
|
||
Future<void> pauseTask({required String taskId}) async {
|
||
final isOnline = _ref?.read(isOnlineProvider) ?? true;
|
||
if (!isOnline) {
|
||
_queueActivityLog({
|
||
'task_id': taskId,
|
||
'actor_id': _client.auth.currentUser?.id,
|
||
'action_type': 'paused',
|
||
});
|
||
return;
|
||
}
|
||
final row = await _client
|
||
.from('tasks')
|
||
.select('status')
|
||
.eq('id', taskId)
|
||
.maybeSingle();
|
||
if (row is! Map<String, dynamic>) {
|
||
throw Exception('Task not found');
|
||
}
|
||
|
||
final status = (row['status'] as String?)?.trim() ?? '';
|
||
if (status == 'completed' || status == 'cancelled' || status == 'closed') {
|
||
throw Exception('Cannot pause a terminal task.');
|
||
}
|
||
if (status != 'in_progress') {
|
||
throw Exception('Only in-progress tasks can be paused.');
|
||
}
|
||
|
||
final alreadyPaused = await _isTaskCurrentlyPaused(taskId);
|
||
if (alreadyPaused) {
|
||
return;
|
||
}
|
||
|
||
final actorId = _client.auth.currentUser?.id;
|
||
await _insertActivityRows(_client, {
|
||
'task_id': taskId,
|
||
'actor_id': actorId,
|
||
'action_type': 'paused',
|
||
});
|
||
}
|
||
|
||
Future<void> resumeTask({required String taskId}) async {
|
||
final isOnline = _ref?.read(isOnlineProvider) ?? true;
|
||
if (!isOnline) {
|
||
_queueActivityLog({
|
||
'task_id': taskId,
|
||
'actor_id': _client.auth.currentUser?.id,
|
||
'action_type': 'resumed',
|
||
});
|
||
return;
|
||
}
|
||
final row = await _client
|
||
.from('tasks')
|
||
.select('status')
|
||
.eq('id', taskId)
|
||
.maybeSingle();
|
||
if (row is! Map<String, dynamic>) {
|
||
throw Exception('Task not found');
|
||
}
|
||
|
||
final status = (row['status'] as String?)?.trim() ?? '';
|
||
if (status == 'completed' || status == 'cancelled' || status == 'closed') {
|
||
throw Exception('Cannot resume a terminal task.');
|
||
}
|
||
if (status != 'in_progress') {
|
||
throw Exception('Only in-progress tasks can be resumed.');
|
||
}
|
||
|
||
final isPaused = await _isTaskCurrentlyPaused(taskId);
|
||
if (!isPaused) {
|
||
return;
|
||
}
|
||
|
||
final actorId = _client.auth.currentUser?.id;
|
||
await _insertActivityRows(_client, {
|
||
'task_id': taskId,
|
||
'actor_id': actorId,
|
||
'action_type': 'resumed',
|
||
});
|
||
}
|
||
|
||
Future<void> createTask({
|
||
required String title,
|
||
required String description,
|
||
String? officeId,
|
||
String? ticketId,
|
||
// optional request metadata when creating a task
|
||
String? requestType,
|
||
String? requestTypeOther,
|
||
String? requestCategory,
|
||
}) async {
|
||
final actorId = _client.auth.currentUser?.id;
|
||
final payload = <String, dynamic>{
|
||
'title': title,
|
||
'description': description,
|
||
};
|
||
if (officeId != null) {
|
||
payload['office_id'] = officeId;
|
||
}
|
||
if (ticketId != null) {
|
||
payload['ticket_id'] = ticketId;
|
||
}
|
||
if (requestType != null) {
|
||
payload['request_type'] = requestType;
|
||
}
|
||
if (requestTypeOther != null) {
|
||
payload['request_type_other'] = requestTypeOther;
|
||
}
|
||
if (requestCategory != null) {
|
||
payload['request_category'] = requestCategory;
|
||
}
|
||
|
||
// Prefer server RPC that atomically generates `task_number` and inserts
|
||
// the task; fallback to client-side insert with retry on duplicate-key.
|
||
// Generate UUID before the network call so Brick's offline queue captures
|
||
// the same UUID in the queued request — preventing a UUID mismatch on replay.
|
||
final offlineId = const Uuid().v4();
|
||
String? taskId;
|
||
String? assignedNumber;
|
||
|
||
// Optimistic update: add the pending task BEFORE any network call so it
|
||
// appears in the UI immediately. On online success the live Supabase stream
|
||
// delivers the task with the same offlineId and ref.listen in
|
||
// tasks_list_screen removes it from the pending list automatically.
|
||
// On offline the task stays pending until the queued INSERT syncs.
|
||
final offlineTask = Task(
|
||
id: offlineId,
|
||
title: title,
|
||
description: description,
|
||
officeId: officeId,
|
||
ticketId: ticketId,
|
||
taskNumber: null,
|
||
status: 'queued',
|
||
priority: 1,
|
||
queueOrder: null,
|
||
createdAt: DateTime.now(),
|
||
creatorId: actorId,
|
||
startedAt: null,
|
||
completedAt: null,
|
||
requestType: requestType,
|
||
requestTypeOther: requestTypeOther,
|
||
requestCategory: requestCategory,
|
||
);
|
||
final ref = _ref;
|
||
if (ref != null && AppRepository.isConfigured) {
|
||
ref.read(offlinePendingTasksProvider.notifier).state = [
|
||
offlineTask,
|
||
...ref.read(offlinePendingTasksProvider),
|
||
];
|
||
}
|
||
|
||
// When offline, skip the RPC entirely to avoid queuing a request with
|
||
// wrong param names (title vs p_title). A queued RPC that gets 504s on
|
||
// replay blocks Brick's queue indefinitely, preventing the INSERT below
|
||
// from ever syncing. Going straight to the INSERT-only path means only
|
||
// one clean entry sits in the queue.
|
||
final isOnline = ref?.read(isOnlineProvider) ?? true;
|
||
if (!isOnline && AppRepository.isConfigured) {
|
||
try {
|
||
await AppRepository.instance.sqliteProvider
|
||
.upsert<Task>(offlineTask, repository: AppRepository.instance);
|
||
debugPrint('createTask offline — saved to local SQLite id=$offlineId');
|
||
} catch (localErr) {
|
||
debugPrint('createTask offline local SQLite write failed: $localErr');
|
||
}
|
||
payload['id'] = offlineId;
|
||
try {
|
||
await _client
|
||
.from('tasks')
|
||
.insert(payload)
|
||
.select('id, task_number')
|
||
.single();
|
||
} catch (insertErr) {
|
||
if (isOfflineSaveError(insertErr)) {
|
||
debugPrint(
|
||
'createTask offline — INSERT queued for sync id=$offlineId',
|
||
);
|
||
} else {
|
||
debugPrint('createTask offline INSERT queue error: $insertErr');
|
||
}
|
||
}
|
||
// Queue the 'created' activity log — the online path inserts it after the
|
||
// RPC/INSERT returns a taskId, which never happens here in the offline path.
|
||
_queueActivityLog({
|
||
'task_id': offlineId,
|
||
'actor_id': actorId,
|
||
'action_type': 'created',
|
||
});
|
||
throw const PostgrestException(
|
||
message: 'unknown internal error',
|
||
code: '501',
|
||
details: 'queued offline',
|
||
);
|
||
}
|
||
|
||
try {
|
||
final rpcParams = Map<String, dynamic>.from(payload);
|
||
rpcParams['id'] = offlineId;
|
||
// Retry RPC on duplicate-key (23505) errors which may occur
|
||
// transiently due to concurrent inserts; prefer RPC always.
|
||
const int rpcMaxAttempts = 3;
|
||
Map<String, dynamic>? rpcRow;
|
||
for (var attempt = 0; attempt < rpcMaxAttempts; attempt++) {
|
||
try {
|
||
final rpcRes = await _client
|
||
.rpc('insert_task_with_number', rpcParams)
|
||
.single();
|
||
if (rpcRes is Map) {
|
||
rpcRow = Map<String, dynamic>.from(rpcRes);
|
||
} else if (rpcRes is List &&
|
||
rpcRes.isNotEmpty &&
|
||
rpcRes.first is Map) {
|
||
rpcRow = Map<String, dynamic>.from(rpcRes.first as Map);
|
||
}
|
||
break;
|
||
} catch (err) {
|
||
final msg = err.toString();
|
||
final isDuplicateKey =
|
||
msg.contains('duplicate key value') || msg.contains('23505');
|
||
if (!isDuplicateKey || attempt == rpcMaxAttempts - 1) {
|
||
rethrow;
|
||
}
|
||
await Future.delayed(Duration(milliseconds: 150 * (attempt + 1)));
|
||
// retry
|
||
}
|
||
}
|
||
if (rpcRow != null) {
|
||
taskId = rpcRow['id'] as String?;
|
||
assignedNumber = rpcRow['task_number'] as String?;
|
||
}
|
||
debugPrint(
|
||
'createTask via RPC assigned number=$assignedNumber id=$taskId',
|
||
);
|
||
} catch (e) {
|
||
debugPrint('createTask error: ${e.runtimeType}: $e');
|
||
if (isOfflineSaveError(e) && AppRepository.isConfigured) {
|
||
// Pending list already updated above. Write to local SQLite and queue
|
||
// a direct INSERT (the path that works online via trigger).
|
||
try {
|
||
await AppRepository.instance.sqliteProvider
|
||
.upsert<Task>(offlineTask, repository: AppRepository.instance);
|
||
debugPrint('createTask offline — saved to local SQLite id=$offlineId');
|
||
} catch (localErr) {
|
||
debugPrint('createTask offline local SQLite write failed: $localErr');
|
||
}
|
||
payload['id'] = offlineId;
|
||
try {
|
||
await _client
|
||
.from('tasks')
|
||
.insert(payload)
|
||
.select('id, task_number')
|
||
.single();
|
||
} catch (insertErr) {
|
||
if (isOfflineSaveError(insertErr)) {
|
||
debugPrint(
|
||
'createTask offline — direct INSERT queued for sync id=$offlineId',
|
||
);
|
||
} else {
|
||
debugPrint('createTask offline INSERT queue error: $insertErr');
|
||
}
|
||
}
|
||
rethrow;
|
||
}
|
||
|
||
// Include the pre-generated UUID in the fallback insert so the server uses
|
||
// the same ID whether we go through the RPC or the direct insert path.
|
||
payload['id'] = offlineId;
|
||
// RPC not available or failed online; fallback to client insert with retry
|
||
const int maxAttempts = 3;
|
||
Map<String, dynamic>? insertData;
|
||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||
try {
|
||
insertData = await _client
|
||
.from('tasks')
|
||
.insert(payload)
|
||
.select('id, task_number')
|
||
.single();
|
||
break;
|
||
} catch (err) {
|
||
final msg = err.toString();
|
||
final isDuplicateKey =
|
||
msg.contains('duplicate key value') || msg.contains('23505');
|
||
if (isOfflineSaveError(err)) {
|
||
// Offline caught at fallback — pending already updated above,
|
||
// Brick has queued the INSERT for replay on reconnect.
|
||
debugPrint(
|
||
'createTask offline at fallback INSERT id=$offlineId',
|
||
);
|
||
rethrow;
|
||
}
|
||
if (!isDuplicateKey || attempt == maxAttempts - 1) {
|
||
// Server error — remove the optimistic pending entry (it won't sync).
|
||
if (ref != null && AppRepository.isConfigured) {
|
||
ref.read(offlinePendingTasksProvider.notifier).state = ref
|
||
.read(offlinePendingTasksProvider)
|
||
.where((t) => t.id != offlineId)
|
||
.toList();
|
||
}
|
||
rethrow;
|
||
}
|
||
await Future.delayed(Duration(milliseconds: 150 * (attempt + 1)));
|
||
}
|
||
}
|
||
taskId = insertData == null ? null : insertData['id'] as String?;
|
||
assignedNumber = insertData == null
|
||
? null
|
||
: insertData['task_number'] as String?;
|
||
debugPrint(
|
||
'createTask fallback assigned number=$assignedNumber id=$taskId',
|
||
);
|
||
}
|
||
|
||
if (taskId == null) return;
|
||
|
||
try {
|
||
await _insertActivityRows(_client, {
|
||
'task_id': taskId,
|
||
'actor_id': actorId,
|
||
'action_type': 'created',
|
||
});
|
||
} catch (_) {
|
||
// non-fatal
|
||
}
|
||
|
||
// Auto-assignment should run once on creation (best-effort).
|
||
try {
|
||
await _autoAssignTask(taskId: taskId, officeId: officeId ?? '');
|
||
} catch (e, st) {
|
||
// keep creation successful but surface the error in logs for debugging
|
||
debugPrint('autoAssignTask failed for task=$taskId: $e\n$st');
|
||
}
|
||
|
||
unawaited(_notifyCreated(taskId: taskId, actorId: actorId));
|
||
}
|
||
|
||
/// Uploads an image for a task's action field and returns the public URL.
|
||
///
|
||
/// [bytes] should contain the file data and [extension] the file extension
|
||
/// (e.g. 'png' or 'jpg'). The image will be stored under a path that
|
||
/// includes the task ID and a timestamp to avoid collisions. Returns `null`
|
||
/// if the upload fails.
|
||
Future<String?> uploadActionImage({
|
||
required String taskId,
|
||
required Uint8List bytes,
|
||
required String extension,
|
||
}) async {
|
||
final path =
|
||
'tasks/$taskId/${DateTime.now().millisecondsSinceEpoch}.$extension';
|
||
try {
|
||
// debug: show upload path
|
||
debugPrint('uploadActionImage uploading to path: $path');
|
||
// perform the upload and capture whatever the SDK returns (it varies by platform)
|
||
final dynamic res;
|
||
if (kIsWeb) {
|
||
// on web, upload binary data
|
||
res = await _client.storage
|
||
.from(_actionImageBucket)
|
||
.uploadBinary(path, bytes);
|
||
} else {
|
||
// write bytes to a simple temp file (no nested folders)
|
||
final tmpDir = Directory.systemTemp;
|
||
final localFile = File(
|
||
'${tmpDir.path}/${DateTime.now().millisecondsSinceEpoch}.$extension',
|
||
);
|
||
try {
|
||
await localFile.create();
|
||
await localFile.writeAsBytes(bytes);
|
||
} catch (e) {
|
||
debugPrint('uploadActionImage failed writing temp file: $e');
|
||
return null;
|
||
}
|
||
res = await _client.storage
|
||
.from(_actionImageBucket)
|
||
.upload(path, localFile);
|
||
try {
|
||
await localFile.delete();
|
||
} catch (_) {}
|
||
}
|
||
|
||
debugPrint(
|
||
'uploadActionImage response type=${res.runtimeType} value=$res',
|
||
);
|
||
|
||
// Some SDK methods return a simple String (path) on success, others
|
||
// return a StorageResponse with an error field. Avoid calling .error on a
|
||
// String to prevent NoSuchMethodError as seen in logs earlier.
|
||
if (res is String) {
|
||
// treat as success
|
||
} else if (res is Map && res['error'] != null) {
|
||
// older versions might return a plain map
|
||
debugPrint('uploadActionImage upload error: ${res['error']}');
|
||
return null;
|
||
} else if (res != null && res.error != null) {
|
||
// StorageResponse case
|
||
debugPrint('uploadActionImage upload error: ${res.error}');
|
||
return null;
|
||
}
|
||
} catch (e) {
|
||
debugPrint('uploadActionImage failed upload: $e');
|
||
return null;
|
||
}
|
||
try {
|
||
final urlRes = await _client.storage
|
||
.from(_actionImageBucket)
|
||
.getPublicUrl(path);
|
||
// debug: log full response
|
||
debugPrint('uploadActionImage getPublicUrl response: $urlRes');
|
||
|
||
String? url;
|
||
if (urlRes is String) {
|
||
url = urlRes;
|
||
} else if (urlRes is Map && urlRes['data'] is String) {
|
||
url = urlRes['data'] as String;
|
||
} else if (urlRes != null) {
|
||
try {
|
||
url = urlRes.data as String?;
|
||
} catch (_) {
|
||
url = null;
|
||
}
|
||
}
|
||
|
||
if (url != null && url.isNotEmpty) {
|
||
// trim whitespace/newline which may be added by SDK or logging
|
||
return url.trim();
|
||
}
|
||
// fallback: construct URL manually using env variable
|
||
final supabaseUrl = dotenv.env['SUPABASE_URL'] ?? '';
|
||
if (supabaseUrl.isEmpty) return null;
|
||
return '$supabaseUrl/storage/v1/object/public/$_actionImageBucket/$path'
|
||
.trim();
|
||
} catch (e) {
|
||
debugPrint('uploadActionImage getPublicUrl error: $e');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
Future<void> uploadTaskAttachment({
|
||
required String taskId,
|
||
required String fileName,
|
||
required Uint8List bytes,
|
||
}) async {
|
||
final path = '$taskId/$fileName';
|
||
try {
|
||
debugPrint('uploadTaskAttachment uploading to path: $path');
|
||
|
||
final dynamic res;
|
||
if (kIsWeb) {
|
||
// on web, upload binary data
|
||
res = await _client.storage
|
||
.from('task_attachments')
|
||
.uploadBinary(path, bytes);
|
||
} else {
|
||
// write bytes to a simple temp file (no nested folders)
|
||
final tmpDir = Directory.systemTemp;
|
||
final localFile = File(
|
||
'${tmpDir.path}/${DateTime.now().millisecondsSinceEpoch}_$fileName',
|
||
);
|
||
try {
|
||
await localFile.create();
|
||
await localFile.writeAsBytes(bytes);
|
||
} catch (e) {
|
||
debugPrint('uploadTaskAttachment failed writing temp file: $e');
|
||
rethrow;
|
||
}
|
||
|
||
res = await _client.storage
|
||
.from('task_attachments')
|
||
.upload(path, localFile);
|
||
|
||
try {
|
||
await localFile.delete();
|
||
} catch (_) {}
|
||
}
|
||
|
||
debugPrint(
|
||
'uploadTaskAttachment response type=${res.runtimeType} value=$res',
|
||
);
|
||
|
||
// Check for errors
|
||
if (res is String) {
|
||
// treat as success
|
||
} else if (res is Map && res['error'] != null) {
|
||
debugPrint('uploadTaskAttachment upload error: ${res['error']}');
|
||
throw Exception('Upload error: ${res['error']}');
|
||
} else if (res != null && res.error != null) {
|
||
debugPrint('uploadTaskAttachment upload error: ${res.error}');
|
||
throw Exception('Upload error: ${res.error}');
|
||
}
|
||
} catch (e) {
|
||
debugPrint('uploadTaskAttachment failed: $e');
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
Future<void> _notifyCreated({
|
||
required String taskId,
|
||
required String? actorId,
|
||
}) async {
|
||
try {
|
||
final recipients = await _fetchRoleUserIds(
|
||
roles: const ['dispatcher', 'it_staff'],
|
||
excludeUserId: actorId,
|
||
);
|
||
if (recipients.isEmpty) return;
|
||
final rows = recipients
|
||
.map(
|
||
(userId) => {
|
||
'user_id': userId,
|
||
'actor_id': actorId,
|
||
'task_id': taskId,
|
||
'type': 'created',
|
||
},
|
||
)
|
||
.toList();
|
||
await _client.from('notifications').insert(rows);
|
||
// Send FCM pushes with meaningful text
|
||
try {
|
||
// resolve actor display name if possible
|
||
String actorName = 'Someone';
|
||
if (actorId != null && actorId.isNotEmpty) {
|
||
try {
|
||
final p = await _client
|
||
.from('profiles')
|
||
.select('full_name,display_name,name')
|
||
.eq('id', actorId)
|
||
.maybeSingle();
|
||
if (p != null) {
|
||
if (p['full_name'] != null) {
|
||
actorName = p['full_name'].toString();
|
||
} else if (p['display_name'] != null) {
|
||
actorName = p['display_name'].toString();
|
||
} else if (p['name'] != null) {
|
||
actorName = p['name'].toString();
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
// fetch task_number and office (try embedding office.name); fallback to offices lookup
|
||
String? taskNumber;
|
||
String? officeId;
|
||
String? officeName;
|
||
try {
|
||
final t = await _client
|
||
.from('tasks')
|
||
.select('task_number, office_id, offices(name)')
|
||
.eq('id', taskId)
|
||
.maybeSingle();
|
||
if (t != null) {
|
||
if (t['task_number'] != null) {
|
||
taskNumber = t['task_number'].toString();
|
||
}
|
||
if (t['office_id'] != null) officeId = t['office_id'].toString();
|
||
final dynOffices = t['offices'];
|
||
if (dynOffices != null) {
|
||
if (dynOffices is List &&
|
||
dynOffices.isNotEmpty &&
|
||
dynOffices.first['name'] != null) {
|
||
officeName = dynOffices.first['name'].toString();
|
||
} else if (dynOffices is Map && dynOffices['name'] != null) {
|
||
officeName = dynOffices['name'].toString();
|
||
}
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
|
||
if ((officeName == null || officeName.isEmpty) &&
|
||
officeId != null &&
|
||
officeId.isNotEmpty) {
|
||
try {
|
||
final o = await _client
|
||
.from('offices')
|
||
.select('name')
|
||
.eq('id', officeId)
|
||
.maybeSingle();
|
||
if (o != null && o['name'] != null) {
|
||
officeName = o['name'].toString();
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
final title = 'New task';
|
||
final body = taskNumber != null
|
||
? (officeName != null
|
||
? '$actorName created task #$taskNumber in $officeName.'
|
||
: '$actorName created task #$taskNumber.')
|
||
: (officeName != null
|
||
? '$actorName created a new task in $officeName.'
|
||
: '$actorName created a new task.');
|
||
|
||
final dataPayload = <String, dynamic>{
|
||
'type': 'created',
|
||
'task_id': taskId,
|
||
'task_number': ?taskNumber,
|
||
'office_id': ?officeId,
|
||
'office_name': ?officeName,
|
||
};
|
||
|
||
await _client.functions.invoke(
|
||
'send_fcm',
|
||
body: {
|
||
'user_ids': recipients,
|
||
'title': title,
|
||
'body': body,
|
||
'data': dataPayload,
|
||
},
|
||
);
|
||
} catch (e) {
|
||
// non-fatal: push failure should not break flow
|
||
debugPrint('notifyCreated push error: $e');
|
||
}
|
||
} catch (_) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
Future<List<String>> _fetchRoleUserIds({
|
||
required List<String> roles,
|
||
required String? excludeUserId,
|
||
}) async {
|
||
try {
|
||
final data = await _client
|
||
.from('profiles')
|
||
.select('id, role')
|
||
.inFilter('role', roles);
|
||
final rows = data;
|
||
final ids = rows
|
||
.map((row) => row['id'] as String?)
|
||
.whereType<String>()
|
||
.where((id) => id.isNotEmpty && id != excludeUserId)
|
||
.toList();
|
||
return ids;
|
||
} catch (_) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/// Update only the status of a task.
|
||
///
|
||
/// Before marking a task as **completed** we enforce that the
|
||
/// request type/category metadata have been provided. This protects the
|
||
/// business rule that details must be specified before closing.
|
||
Future<void> updateTaskStatus({
|
||
required String taskId,
|
||
required String status,
|
||
String? reason,
|
||
}) async {
|
||
// Short-circuit when offline: skip server-side validation and queue the update.
|
||
final isOnline = _ref?.read(isOnlineProvider) ?? true;
|
||
if (!isOnline) {
|
||
if (status == 'cancelled' && (reason == null || reason.trim().isEmpty)) {
|
||
throw Exception('Cancellation requires a reason.');
|
||
}
|
||
final payload = <String, dynamic>{'status': status};
|
||
if (status == 'cancelled') {
|
||
payload['cancellation_reason'] = reason;
|
||
payload['cancelled_at'] = AppTime.now().toIso8601String();
|
||
}
|
||
if (status == 'in_progress') {
|
||
payload['started_at'] = AppTime.now().toIso8601String();
|
||
}
|
||
if (status == 'completed') {
|
||
payload['completed_at'] = AppTime.now().toIso8601String();
|
||
}
|
||
_trackOfflineUpdate(taskId, payload);
|
||
// Queue the corresponding activity log so it replays on reconnect.
|
||
final actionType = status == 'in_progress'
|
||
? 'started'
|
||
: status == 'completed'
|
||
? 'completed'
|
||
: status == 'cancelled'
|
||
? 'cancelled'
|
||
: null;
|
||
if (actionType != null) {
|
||
_queueActivityLog({
|
||
'task_id': taskId,
|
||
'actor_id': _client.auth.currentUser?.id,
|
||
'action_type': actionType,
|
||
if (actionType == 'cancelled' && reason != null)
|
||
'meta': {'reason': reason},
|
||
});
|
||
}
|
||
throw const SocketException('Offline – status queued for sync');
|
||
}
|
||
|
||
Map<String, dynamic>? taskRow;
|
||
try {
|
||
final row = await _client
|
||
.from('tasks')
|
||
.select('status, request_type, request_category, action_taken')
|
||
.eq('id', taskId)
|
||
.maybeSingle();
|
||
if (row is! Map<String, dynamic>) {
|
||
throw Exception('Task not found');
|
||
}
|
||
taskRow = row;
|
||
} catch (e) {
|
||
rethrow;
|
||
}
|
||
|
||
final currentStatus = (taskRow['status'] as String?)?.trim() ?? '';
|
||
if (currentStatus == 'closed' ||
|
||
currentStatus == 'cancelled' ||
|
||
currentStatus == 'completed') {
|
||
if (currentStatus != status) {
|
||
throw Exception(
|
||
'Status cannot be changed after a task is $currentStatus.',
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (status == 'cancelled') {
|
||
if (reason == null || reason.trim().isEmpty) {
|
||
throw Exception('Cancellation requires a reason.');
|
||
}
|
||
}
|
||
|
||
if (status == 'in_progress') {
|
||
final assignmentRows = await _client
|
||
.from('task_assignments')
|
||
.select('user_id')
|
||
.eq('task_id', taskId);
|
||
final assignedUserIds = (assignmentRows as List)
|
||
.map((row) => row['user_id']?.toString())
|
||
.whereType<String>()
|
||
.where((id) => id.isNotEmpty)
|
||
.toSet()
|
||
.toList();
|
||
if (assignedUserIds.isEmpty) {
|
||
throw Exception(
|
||
'Assign at least one IT Staff before starting this task.',
|
||
);
|
||
}
|
||
|
||
final itStaffRows = await _client
|
||
.from('profiles')
|
||
.select('id')
|
||
.inFilter('id', assignedUserIds)
|
||
.eq('role', 'it_staff');
|
||
final hasItStaff = (itStaffRows as List).isNotEmpty;
|
||
if (!hasItStaff) {
|
||
throw Exception(
|
||
'Assign at least one IT Staff before starting this task.',
|
||
);
|
||
}
|
||
}
|
||
|
||
if (status == 'completed') {
|
||
// Check for IT staff assignment
|
||
final assignmentRows = await _client
|
||
.from('task_assignments')
|
||
.select('user_id')
|
||
.eq('task_id', taskId);
|
||
final assignedUserIds = (assignmentRows as List)
|
||
.map((row) => row['user_id']?.toString())
|
||
.whereType<String>()
|
||
.where((id) => id.isNotEmpty)
|
||
.toSet()
|
||
.toList();
|
||
if (assignedUserIds.isEmpty) {
|
||
throw Exception(
|
||
'Assign at least one IT Staff before completing this task.',
|
||
);
|
||
}
|
||
|
||
final itStaffRows = await _client
|
||
.from('profiles')
|
||
.select('id')
|
||
.inFilter('id', assignedUserIds)
|
||
.eq('role', 'it_staff');
|
||
final hasItStaff = (itStaffRows as List).isNotEmpty;
|
||
if (!hasItStaff) {
|
||
throw Exception(
|
||
'Assign at least one IT Staff before completing this task.',
|
||
);
|
||
}
|
||
|
||
// Check required fields
|
||
try {
|
||
final rt = taskRow['request_type'];
|
||
final rc = taskRow['request_category'];
|
||
final action = taskRow['action_taken'];
|
||
|
||
final missing = <String>[];
|
||
if (rt == null || (rt is String && rt.trim().isEmpty)) {
|
||
missing.add('request type');
|
||
}
|
||
if (rc == null || (rc is String && rc.trim().isEmpty)) {
|
||
missing.add('request category');
|
||
}
|
||
// signatories are no longer required for completion; they can be
|
||
// filled in later. we still require action taken to document what
|
||
// was done.
|
||
|
||
// if you want to enforce action taken you can uncomment this block,
|
||
// but current business rule only mandates request metadata. we keep
|
||
// action taken non-null for clarity.
|
||
if (action == null || (action is String && action.trim().isEmpty)) {
|
||
missing.add('action taken');
|
||
}
|
||
|
||
if (missing.isNotEmpty) {
|
||
throw Exception(
|
||
'The following fields must be set before completing a task: ${missing.join(', ')}.',
|
||
);
|
||
}
|
||
} catch (e) {
|
||
// rethrow so callers can handle (UI will display message)
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
// persist status and cancellation reason (when provided)
|
||
final payload = <String, dynamic>{'status': status};
|
||
if (status == 'cancelled') {
|
||
payload['cancellation_reason'] = reason;
|
||
}
|
||
await _client.from('tasks').update(payload).eq('id', taskId);
|
||
|
||
// if cancelled, also set cancelled_at timestamp
|
||
if (status == 'cancelled') {
|
||
try {
|
||
final cancelledAt = AppTime.now().toIso8601String();
|
||
await _client
|
||
.from('tasks')
|
||
.update({'cancelled_at': cancelledAt})
|
||
.eq('id', taskId);
|
||
} catch (_) {}
|
||
}
|
||
|
||
// Log important status transitions
|
||
try {
|
||
final actorId = _client.auth.currentUser?.id;
|
||
if (status == 'in_progress') {
|
||
await _insertActivityRows(_client, {
|
||
'task_id': taskId,
|
||
'actor_id': actorId,
|
||
'action_type': 'started',
|
||
});
|
||
} else if (status == 'completed') {
|
||
await _insertActivityRows(_client, {
|
||
'task_id': taskId,
|
||
'actor_id': actorId,
|
||
'action_type': 'completed',
|
||
});
|
||
} else if (status == 'cancelled') {
|
||
await _insertActivityRows(_client, {
|
||
'task_id': taskId,
|
||
'actor_id': actorId,
|
||
'action_type': 'cancelled',
|
||
'meta': {'reason': reason},
|
||
});
|
||
}
|
||
} catch (_) {
|
||
// ignore logging failures
|
||
}
|
||
}
|
||
|
||
/// Update arbitrary fields on a task row.
|
||
///
|
||
/// Primarily used to set request metadata after creation or during
|
||
/// status transitions.
|
||
Future<void> updateTask({
|
||
required String taskId,
|
||
String? requestType,
|
||
String? requestTypeOther,
|
||
String? requestCategory,
|
||
String? status,
|
||
String? requestedBy,
|
||
String? notedBy,
|
||
String? receivedBy,
|
||
String? actionTaken,
|
||
}) async {
|
||
final payload = <String, dynamic>{};
|
||
if (requestType != null) {
|
||
payload['request_type'] = requestType;
|
||
}
|
||
if (requestTypeOther != null) {
|
||
payload['request_type_other'] = requestTypeOther;
|
||
}
|
||
if (requestCategory != null) {
|
||
payload['request_category'] = requestCategory;
|
||
}
|
||
if (requestedBy != null) {
|
||
payload['requested_by'] = requestedBy;
|
||
}
|
||
if (notedBy != null) {
|
||
payload['noted_by'] = notedBy;
|
||
}
|
||
// `performed_by` is derived from task assignments; we don't persist it here.
|
||
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;
|
||
}
|
||
if (payload.isEmpty) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await _client.from('tasks').update(payload).eq('id', taskId);
|
||
// Clear any pending offline updates for this task now that the server has them.
|
||
_clearPendingUpdate(taskId);
|
||
} catch (e) {
|
||
if (isOfflineSaveError(e)) {
|
||
// Store the raw action_taken string (not the decoded JSON) so that
|
||
// taskByIdProvider can reconstruct the Task.actionTaken field correctly.
|
||
final pendingPayload = Map<String, dynamic>.from(payload);
|
||
if (actionTaken != null) pendingPayload['action_taken'] = actionTaken;
|
||
_trackOfflineUpdate(taskId, pendingPayload);
|
||
_queueActivityLog({
|
||
'task_id': taskId,
|
||
'actor_id': _client.auth.currentUser?.id,
|
||
'action_type': 'updated',
|
||
});
|
||
}
|
||
rethrow;
|
||
}
|
||
|
||
// Record activity logs for any metadata/signatory fields that were changed
|
||
try {
|
||
final actorId = _client.auth.currentUser?.id;
|
||
final List<Map<String, dynamic>> logRows = [];
|
||
if (requestType != null) {
|
||
logRows.add({
|
||
'task_id': taskId,
|
||
'actor_id': actorId,
|
||
'action_type': 'filled_request_type',
|
||
'meta': {'value': requestType},
|
||
});
|
||
}
|
||
if (requestCategory != null) {
|
||
logRows.add({
|
||
'task_id': taskId,
|
||
'actor_id': actorId,
|
||
'action_type': 'filled_request_category',
|
||
'meta': {'value': requestCategory},
|
||
});
|
||
}
|
||
if (requestedBy != null) {
|
||
logRows.add({
|
||
'task_id': taskId,
|
||
'actor_id': actorId,
|
||
'action_type': 'filled_requested_by',
|
||
'meta': {'value': requestedBy},
|
||
});
|
||
}
|
||
if (notedBy != null) {
|
||
logRows.add({
|
||
'task_id': taskId,
|
||
'actor_id': actorId,
|
||
'action_type': 'filled_noted_by',
|
||
'meta': {'value': notedBy},
|
||
});
|
||
}
|
||
if (receivedBy != null) {
|
||
logRows.add({
|
||
'task_id': taskId,
|
||
'actor_id': actorId,
|
||
'action_type': 'filled_received_by',
|
||
'meta': {'value': receivedBy},
|
||
});
|
||
}
|
||
if (actionTaken != null) {
|
||
logRows.add({
|
||
'task_id': taskId,
|
||
'actor_id': actorId,
|
||
'action_type': 'filled_action_taken',
|
||
'meta': {'value': actionTaken},
|
||
});
|
||
}
|
||
|
||
if (logRows.isNotEmpty) {
|
||
await _insertActivityRows(_client, logRows);
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
/// Update editable task fields such as title, description, office or linked ticket.
|
||
Future<void> updateTaskFields({
|
||
required String taskId,
|
||
String? title,
|
||
String? description,
|
||
String? officeId,
|
||
String? ticketId,
|
||
}) async {
|
||
final payload = <String, dynamic>{};
|
||
if (title != null) payload['title'] = title;
|
||
if (description != null) payload['description'] = description;
|
||
if (officeId != null) payload['office_id'] = officeId;
|
||
if (ticketId != null) payload['ticket_id'] = ticketId;
|
||
if (payload.isEmpty) return;
|
||
try {
|
||
await _client.from('tasks').update(payload).eq('id', taskId);
|
||
_clearPendingUpdate(taskId);
|
||
} catch (e) {
|
||
if (isOfflineSaveError(e)) {
|
||
_trackOfflineUpdate(taskId, payload);
|
||
_queueActivityLog({
|
||
'task_id': taskId,
|
||
'actor_id': _client.auth.currentUser?.id,
|
||
'action_type': 'updated',
|
||
});
|
||
}
|
||
rethrow;
|
||
}
|
||
|
||
// record an activity log for edit operations (best-effort)
|
||
try {
|
||
final actorId = _client.auth.currentUser?.id;
|
||
await _insertActivityRows(_client, {
|
||
'task_id': taskId,
|
||
'actor_id': actorId,
|
||
'action_type': 'updated',
|
||
'meta': payload,
|
||
});
|
||
} catch (_) {}
|
||
}
|
||
|
||
/// Accumulate offline field changes so [taskByIdProvider] can merge them
|
||
/// into the displayed task and [tasksProvider]'s reconnect listener can
|
||
/// replay them directly to Supabase REST (bypassing Brick's stale queue).
|
||
void _trackOfflineUpdate(String taskId, Map<String, dynamic> payload) {
|
||
final ref = _ref;
|
||
if (ref == null) return;
|
||
final current = Map<String, Map<String, dynamic>>.from(
|
||
ref.read(offlinePendingTaskUpdatesProvider),
|
||
);
|
||
final existing = Map<String, dynamic>.from(current[taskId] ?? {});
|
||
existing.addAll(payload);
|
||
current[taskId] = existing;
|
||
ref.read(offlinePendingTaskUpdatesProvider.notifier).state = current;
|
||
}
|
||
|
||
/// Remove pending updates for [taskId] after a successful server write.
|
||
void _clearPendingUpdate(String taskId) {
|
||
final ref = _ref;
|
||
if (ref == null) return;
|
||
final current = Map<String, Map<String, dynamic>>.from(
|
||
ref.read(offlinePendingTaskUpdatesProvider),
|
||
);
|
||
if (current.remove(taskId) != null) {
|
||
ref.read(offlinePendingTaskUpdatesProvider.notifier).state = current;
|
||
}
|
||
}
|
||
|
||
/// Queue an activity log row for offline-then-reconnect replay.
|
||
/// Stamps [created_at] NOW so the audit trail reflects the offline action time.
|
||
/// Also writes to Brick SQLite so the log is visible in the offline UI immediately
|
||
/// (taskActivityLogsProvider.onOfflineData reads from Brick, not the RAM queue).
|
||
void _queueActivityLog(Map<String, dynamic> row) {
|
||
final ref = _ref;
|
||
if (ref == null) return;
|
||
final fullRow = {
|
||
'id': const Uuid().v4(), // pre-generate so server INSERT uses same UUID
|
||
...row,
|
||
'created_at': AppTime.now().toIso8601String(),
|
||
};
|
||
// RAM queue for reconnect-replay step 5
|
||
final current = List<Map<String, dynamic>>.from(
|
||
ref.read(offlinePendingActivityLogsProvider),
|
||
);
|
||
current.add(fullRow);
|
||
ref.read(offlinePendingActivityLogsProvider.notifier).state =
|
||
List.unmodifiable(current);
|
||
// Typed state — watched by taskActivityLogsProvider so the UI rebuilds
|
||
// immediately (synchronous write, no Brick async timing race).
|
||
final logModel = TaskActivityLog.fromMap(fullRow);
|
||
final currentItems = List<TaskActivityLog>.from(
|
||
ref.read(offlinePendingActivityLogItemsProvider),
|
||
);
|
||
currentItems.add(logModel);
|
||
ref.read(offlinePendingActivityLogItemsProvider.notifier).state =
|
||
List.unmodifiable(currentItems);
|
||
// Brick SQLite write — ensures the log survives app restart (cold-launch).
|
||
mirrorBatchToBrick<TaskActivityLog>(
|
||
[logModel],
|
||
tag: 'offline_activity_log',
|
||
);
|
||
}
|
||
|
||
// Auto-assignment logic executed once on creation.
|
||
Future<void> _autoAssignTask({
|
||
required String taskId,
|
||
required String officeId,
|
||
}) async {
|
||
if (officeId.isEmpty) return;
|
||
|
||
final now = AppTime.now();
|
||
final startOfDay = DateTime(now.year, now.month, now.day);
|
||
final nextDay = startOfDay.add(const Duration(days: 1));
|
||
|
||
try {
|
||
// 1) Find teams covering the office
|
||
final teamsRows =
|
||
(await _client.from('teams').select()) as List<dynamic>? ?? [];
|
||
final teamIds = teamsRows
|
||
.where((r) => (r['office_ids'] as List?)?.contains(officeId) == true)
|
||
.map((r) => r['id'] as String)
|
||
.toSet()
|
||
.toList();
|
||
if (teamIds.isEmpty) return;
|
||
|
||
// 2) Get members of those teams
|
||
final memberRows =
|
||
(await _client
|
||
.from('team_members')
|
||
.select('user_id')
|
||
.inFilter('team_id', teamIds))
|
||
as List<dynamic>? ??
|
||
[];
|
||
final candidateIds = memberRows
|
||
.map((r) => r['user_id'] as String)
|
||
.toSet()
|
||
.toList();
|
||
if (candidateIds.isEmpty) return;
|
||
|
||
// 3) Filter by "On Duty" (have a check-in record for today)
|
||
final dsRows =
|
||
(await _client
|
||
.from('duty_schedules')
|
||
.select('user_id, check_in_at')
|
||
.inFilter('user_id', candidateIds))
|
||
as List<dynamic>? ??
|
||
[];
|
||
|
||
final Map<String, DateTime> onDuty = {};
|
||
for (final r in dsRows) {
|
||
final userId = r['user_id'] as String?;
|
||
final checkIn = r['check_in_at'] as String?;
|
||
if (userId == null || checkIn == null) continue;
|
||
final dt = DateTime.tryParse(checkIn);
|
||
if (dt == null) continue;
|
||
if (dt.isAfter(startOfDay.subtract(const Duration(seconds: 1))) &&
|
||
dt.isBefore(nextDay.add(const Duration(seconds: 1)))) {
|
||
onDuty[userId] = dt;
|
||
}
|
||
}
|
||
if (onDuty.isEmpty) {
|
||
// record a failed auto-assign attempt for observability
|
||
try {
|
||
await _insertActivityRows(_client, {
|
||
'task_id': taskId,
|
||
'actor_id': null,
|
||
'action_type': 'auto_assign_failed',
|
||
'meta': {'reason': 'no_on_duty_candidates'},
|
||
});
|
||
} catch (_) {}
|
||
return;
|
||
}
|
||
|
||
// 4) For each on-duty user compute completed_tasks_count for today
|
||
final List<_Candidate> candidates = [];
|
||
for (final userId in onDuty.keys) {
|
||
// get task ids assigned to user
|
||
final taRows =
|
||
(await _client
|
||
.from('task_assignments')
|
||
.select('task_id')
|
||
.eq('user_id', userId))
|
||
as List<dynamic>? ??
|
||
[];
|
||
final assignedTaskIds = taRows
|
||
.map((r) => r['task_id'] as String)
|
||
.toList();
|
||
int completedCount = 0;
|
||
if (assignedTaskIds.isNotEmpty) {
|
||
final tasksRows =
|
||
(await _client
|
||
.from('tasks')
|
||
.select('id')
|
||
.inFilter('id', assignedTaskIds)
|
||
.gte('completed_at', startOfDay.toIso8601String())
|
||
.lt('completed_at', nextDay.toIso8601String()))
|
||
as List<dynamic>? ??
|
||
[];
|
||
completedCount = tasksRows.length;
|
||
}
|
||
candidates.add(
|
||
_Candidate(
|
||
userId: userId,
|
||
checkInAt: onDuty[userId]!,
|
||
completedToday: completedCount,
|
||
),
|
||
);
|
||
}
|
||
|
||
if (candidates.isEmpty) {
|
||
try {
|
||
await _insertActivityRows(_client, {
|
||
'task_id': taskId,
|
||
'actor_id': null,
|
||
'action_type': 'auto_assign_failed',
|
||
'meta': {'reason': 'no_eligible_candidates'},
|
||
});
|
||
} catch (_) {}
|
||
return;
|
||
}
|
||
|
||
// 5) Sort: latest check-in first (desc), then lowest completed_today
|
||
candidates.sort((a, b) {
|
||
final c = b.checkInAt.compareTo(a.checkInAt);
|
||
if (c != 0) return c;
|
||
return a.completedToday.compareTo(b.completedToday);
|
||
});
|
||
|
||
final chosen = candidates.first;
|
||
|
||
// 6) Insert assignment + activity log + notification
|
||
await _client.from('task_assignments').insert({
|
||
'task_id': taskId,
|
||
'user_id': chosen.userId,
|
||
});
|
||
|
||
try {
|
||
await _insertActivityRows(_client, {
|
||
'task_id': taskId,
|
||
'actor_id': null,
|
||
'action_type': 'assigned',
|
||
'meta': {'auto': true, 'user_id': chosen.userId},
|
||
});
|
||
} catch (_) {}
|
||
|
||
try {
|
||
await _client.from('notifications').insert({
|
||
'user_id': chosen.userId,
|
||
'actor_id': null,
|
||
'task_id': taskId,
|
||
'type': 'assignment',
|
||
});
|
||
// send push for auto-assignment
|
||
try {
|
||
final actorName = 'Dispatcher';
|
||
// fetch task_number and office for nicer deep-linking when available
|
||
String? taskNumber;
|
||
String? officeId;
|
||
String? officeName;
|
||
try {
|
||
final t = await _client
|
||
.from('tasks')
|
||
.select('task_number, office_id, offices(name)')
|
||
.eq('id', taskId)
|
||
.maybeSingle();
|
||
if (t != null) {
|
||
if (t['task_number'] != null) {
|
||
taskNumber = t['task_number'].toString();
|
||
}
|
||
if (t['office_id'] != null) officeId = t['office_id'].toString();
|
||
final dynOffices = t['offices'];
|
||
if (dynOffices != null) {
|
||
if (dynOffices is List &&
|
||
dynOffices.isNotEmpty &&
|
||
dynOffices.first['name'] != null) {
|
||
officeName = dynOffices.first['name'].toString();
|
||
} else if (dynOffices is Map && dynOffices['name'] != null) {
|
||
officeName = dynOffices['name'].toString();
|
||
}
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
|
||
if ((officeName == null || officeName.isEmpty) &&
|
||
officeId != null &&
|
||
officeId.isNotEmpty) {
|
||
try {
|
||
final o = await _client
|
||
.from('offices')
|
||
.select('name')
|
||
.eq('id', officeId)
|
||
.maybeSingle();
|
||
if (o != null && o['name'] != null) {
|
||
officeName = o['name'].toString();
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
final dataPayload = <String, dynamic>{
|
||
'type': 'assignment',
|
||
'task_id': taskId,
|
||
'task_number': ?taskNumber,
|
||
'office_id': ?officeId,
|
||
'office_name': ?officeName,
|
||
};
|
||
|
||
final title = 'Task assigned';
|
||
final body = taskNumber != null
|
||
? (officeName != null
|
||
? '$actorName assigned you task #$taskNumber in $officeName.'
|
||
: '$actorName assigned you task #$taskNumber.')
|
||
: (officeName != null
|
||
? '$actorName assigned you a task in $officeName.'
|
||
: '$actorName assigned you a task.');
|
||
|
||
await _client.functions.invoke(
|
||
'send_fcm',
|
||
body: {
|
||
'user_ids': [chosen.userId],
|
||
'title': title,
|
||
'body': body,
|
||
'data': dataPayload,
|
||
},
|
||
);
|
||
} catch (e) {
|
||
debugPrint('autoAssign push error: $e');
|
||
}
|
||
} catch (_) {}
|
||
} catch (e, st) {
|
||
// Log error for visibility and record a failed auto-assign activity
|
||
debugPrint('autoAssignTask error for task=$taskId: $e\n$st');
|
||
try {
|
||
await _insertActivityRows(_client, {
|
||
'task_id': taskId,
|
||
'actor_id': null,
|
||
'action_type': 'auto_assign_failed',
|
||
'meta': {'reason': 'exception', 'error': e.toString()},
|
||
});
|
||
} catch (_) {}
|
||
return;
|
||
}
|
||
}
|
||
|
||
/// Mark a task's printed IT Job as received by [receivedById].
|
||
///
|
||
/// Uses a SECURITY DEFINER RPC to bypass RLS UPDATE restrictions
|
||
/// that would otherwise silently block the dispatcher's update.
|
||
Future<void> markItJobPrinted(String taskId,
|
||
{required String receivedById}) async {
|
||
await _client.rpc('mark_it_job_printed', params: {
|
||
'p_task_id': taskId,
|
||
'p_receiver_id': receivedById,
|
||
});
|
||
}
|
||
|
||
/// Unmark a task's printed IT Job submission.
|
||
Future<void> markItJobNotPrinted(String taskId) async {
|
||
await _client.rpc('unmark_it_job_printed', params: {
|
||
'p_task_id': taskId,
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Public DTO used by unit tests to validate selection logic.
|
||
class AutoAssignCandidate {
|
||
AutoAssignCandidate({
|
||
required this.userId,
|
||
required this.checkInAt,
|
||
required this.completedToday,
|
||
});
|
||
|
||
final String userId;
|
||
final DateTime checkInAt;
|
||
final int completedToday;
|
||
}
|
||
|
||
/// Choose the best candidate according to auto-assignment rules:
|
||
/// - latest check-in first (late-comer priority)
|
||
/// - tie-breaker: lowest completedTasks (today)
|
||
/// Returns the chosen userId or null when candidates is empty.
|
||
String? chooseAutoAssignCandidate(List<AutoAssignCandidate> candidates) {
|
||
if (candidates.isEmpty) return null;
|
||
final list = List<AutoAssignCandidate>.from(candidates);
|
||
list.sort((a, b) {
|
||
final c = b.checkInAt.compareTo(a.checkInAt);
|
||
if (c != 0) return c;
|
||
return a.completedToday.compareTo(b.completedToday);
|
||
});
|
||
return list.first.userId;
|
||
}
|
||
|
||
class _Candidate {
|
||
_Candidate({
|
||
required this.userId,
|
||
required this.checkInAt,
|
||
required this.completedToday,
|
||
});
|
||
final String userId;
|
||
final DateTime checkInAt;
|
||
final int completedToday;
|
||
}
|
||
|
||
class TaskAssignmentsController {
|
||
TaskAssignmentsController(this._client, [this._ref]);
|
||
|
||
final SupabaseClient _client;
|
||
final Ref? _ref;
|
||
|
||
void _trackOfflineAssignment(String taskId, List<String> userIds) {
|
||
final ref = _ref;
|
||
if (ref == null) return;
|
||
final current = Map<String, List<String>>.from(
|
||
ref.read(offlinePendingAssignmentsProvider),
|
||
);
|
||
current[taskId] = userIds;
|
||
ref.read(offlinePendingAssignmentsProvider.notifier).state = current;
|
||
}
|
||
|
||
void _clearOfflineAssignment(String taskId) {
|
||
final ref = _ref;
|
||
if (ref == null) return;
|
||
final current = Map<String, List<String>>.from(
|
||
ref.read(offlinePendingAssignmentsProvider),
|
||
);
|
||
if (current.remove(taskId) != null) {
|
||
ref.read(offlinePendingAssignmentsProvider.notifier).state = current;
|
||
}
|
||
}
|
||
|
||
Future<void> replaceAssignments({
|
||
required String taskId,
|
||
required String? ticketId,
|
||
required List<String> newUserIds,
|
||
required List<String> currentUserIds,
|
||
}) async {
|
||
final nextIds = newUserIds.toSet();
|
||
final currentIds = currentUserIds.toSet();
|
||
final toAdd = nextIds.difference(currentIds).toList();
|
||
final toRemove = currentIds.difference(nextIds).toList();
|
||
|
||
// Short-circuit: caller's currentUserIds already matches the desired state.
|
||
// If a pending offline entry exists for this task, leave it alone — wiping
|
||
// it would silently drop the assignment before the reconnect-replay runs.
|
||
if (toAdd.isEmpty && toRemove.isEmpty) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (toAdd.isNotEmpty) {
|
||
final rows = toAdd
|
||
.map((userId) => {'task_id': taskId, 'user_id': userId})
|
||
.toList();
|
||
await _client.from('task_assignments').insert(rows);
|
||
}
|
||
if (toRemove.isNotEmpty) {
|
||
await _client
|
||
.from('task_assignments')
|
||
.delete()
|
||
.eq('task_id', taskId)
|
||
.inFilter('user_id', toRemove);
|
||
}
|
||
_clearOfflineAssignment(taskId);
|
||
} catch (e) {
|
||
if (isOfflineSaveError(e)) {
|
||
_trackOfflineAssignment(taskId, newUserIds);
|
||
// Queue an activity log for the offline assignment change so it syncs
|
||
// on reconnect and is visible in the offline UI immediately.
|
||
final logRef = _ref;
|
||
if (logRef != null) {
|
||
final actorId = _client.auth.currentUser?.id;
|
||
final logRow = {
|
||
'id': const Uuid().v4(),
|
||
'task_id': taskId,
|
||
'actor_id': actorId,
|
||
'action_type': toRemove.isEmpty ? 'assigned' : 'reassigned',
|
||
'meta': {'to': toAdd, 'from': toRemove},
|
||
'created_at': AppTime.now().toIso8601String(),
|
||
};
|
||
final pending = List<Map<String, dynamic>>.from(
|
||
logRef.read(offlinePendingActivityLogsProvider),
|
||
);
|
||
pending.add(logRow);
|
||
logRef.read(offlinePendingActivityLogsProvider.notifier).state =
|
||
List.unmodifiable(pending);
|
||
// Typed items provider — watched by taskActivityLogsProvider for
|
||
// immediate UI update (synchronous, no Brick timing race).
|
||
final logModel = TaskActivityLog.fromMap(logRow);
|
||
final currentItems = List<TaskActivityLog>.from(
|
||
logRef.read(offlinePendingActivityLogItemsProvider),
|
||
);
|
||
currentItems.add(logModel);
|
||
logRef.read(offlinePendingActivityLogItemsProvider.notifier).state =
|
||
List.unmodifiable(currentItems);
|
||
mirrorBatchToBrick<TaskActivityLog>(
|
||
[logModel],
|
||
tag: 'offline_assignment_log',
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
rethrow;
|
||
}
|
||
|
||
// Best-effort side effects — these run AFTER the transactional block so
|
||
// an offline error here cannot retrack the assignment as pending.
|
||
unawaited(_safeLogAssignmentChange(
|
||
taskId: taskId,
|
||
toAdd: toAdd,
|
||
toRemove: toRemove,
|
||
));
|
||
if (toAdd.isNotEmpty) {
|
||
unawaited(_safeNotifyAssigned(
|
||
taskId: taskId,
|
||
ticketId: ticketId,
|
||
userIds: toAdd,
|
||
));
|
||
}
|
||
}
|
||
|
||
Future<void> _safeLogAssignmentChange({
|
||
required String taskId,
|
||
required List<String> toAdd,
|
||
required List<String> toRemove,
|
||
}) async {
|
||
try {
|
||
final actorId = _client.auth.currentUser?.id;
|
||
if (toAdd.isNotEmpty) {
|
||
final logRows = toAdd
|
||
.map(
|
||
(userId) => {
|
||
'task_id': taskId,
|
||
'actor_id': actorId,
|
||
'action_type': 'assigned',
|
||
'meta': {'user_id': userId},
|
||
},
|
||
)
|
||
.toList();
|
||
await _insertActivityRows(_client, logRows);
|
||
}
|
||
if (toRemove.isNotEmpty) {
|
||
await _insertActivityRows(_client, {
|
||
'task_id': taskId,
|
||
'actor_id': actorId,
|
||
'action_type': 'reassigned',
|
||
'meta': {'from': toRemove, 'to': toAdd},
|
||
});
|
||
}
|
||
} catch (_) {
|
||
// non-fatal
|
||
}
|
||
}
|
||
|
||
Future<void> _safeNotifyAssigned({
|
||
required String taskId,
|
||
required String? ticketId,
|
||
required List<String> userIds,
|
||
}) async {
|
||
try {
|
||
await _notifyAssigned(taskId: taskId, ticketId: ticketId, userIds: userIds);
|
||
} catch (_) {
|
||
// non-fatal — never let notify errors propagate
|
||
}
|
||
}
|
||
|
||
Future<void> _notifyAssigned({
|
||
required String taskId,
|
||
required String? ticketId,
|
||
required List<String> userIds,
|
||
}) async {
|
||
if (userIds.isEmpty) return;
|
||
try {
|
||
final actorId = _client.auth.currentUser?.id;
|
||
final rows = userIds
|
||
.map(
|
||
(userId) => {
|
||
'user_id': userId,
|
||
'actor_id': actorId,
|
||
'task_id': taskId,
|
||
'ticket_id': ticketId,
|
||
'type': 'assignment',
|
||
},
|
||
)
|
||
.toList();
|
||
await _client.from('notifications').insert(rows);
|
||
// send FCM pushes for explicit assignments
|
||
try {
|
||
String actorName = 'Someone';
|
||
if (actorId != null && actorId.isNotEmpty) {
|
||
try {
|
||
final p = await _client
|
||
.from('profiles')
|
||
.select('full_name,display_name,name')
|
||
.eq('id', actorId)
|
||
.maybeSingle();
|
||
if (p != null) {
|
||
if (p['full_name'] != null) {
|
||
actorName = p['full_name'].toString();
|
||
} else if (p['display_name'] != null) {
|
||
actorName = p['display_name'].toString();
|
||
} else if (p['name'] != null) {
|
||
actorName = p['name'].toString();
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
// fetch task_number and office (try embedding office.name); fallback to offices lookup
|
||
String? taskNumber;
|
||
String? officeId;
|
||
String? officeName;
|
||
try {
|
||
final t = await _client
|
||
.from('tasks')
|
||
.select('task_number, office_id, offices(name)')
|
||
.eq('id', taskId)
|
||
.maybeSingle();
|
||
if (t != null) {
|
||
if (t['task_number'] != null) {
|
||
taskNumber = t['task_number'].toString();
|
||
}
|
||
if (t['office_id'] != null) officeId = t['office_id'].toString();
|
||
final dynOffices = t['offices'];
|
||
if (dynOffices != null) {
|
||
if (dynOffices is List &&
|
||
dynOffices.isNotEmpty &&
|
||
dynOffices.first['name'] != null) {
|
||
officeName = dynOffices.first['name'].toString();
|
||
} else if (dynOffices is Map && dynOffices['name'] != null) {
|
||
officeName = dynOffices['name'].toString();
|
||
}
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
|
||
if ((officeName == null || officeName.isEmpty) &&
|
||
officeId != null &&
|
||
officeId.isNotEmpty) {
|
||
try {
|
||
final o = await _client
|
||
.from('offices')
|
||
.select('name')
|
||
.eq('id', officeId)
|
||
.maybeSingle();
|
||
if (o != null && o['name'] != null) {
|
||
officeName = o['name'].toString();
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
final dataPayload = <String, dynamic>{
|
||
'type': 'assignment',
|
||
'task_id': taskId,
|
||
'task_number': ?taskNumber,
|
||
'ticket_id': ?ticketId,
|
||
'office_id': ?officeId,
|
||
'office_name': ?officeName,
|
||
};
|
||
|
||
final title = 'Task assigned';
|
||
final body = taskNumber != null
|
||
? (officeName != null
|
||
? '$actorName assigned you task #$taskNumber in $officeName.'
|
||
: '$actorName assigned you task #$taskNumber.')
|
||
: (officeName != null
|
||
? '$actorName assigned you a task in $officeName.'
|
||
: '$actorName assigned you a task.');
|
||
|
||
await _client.functions.invoke(
|
||
'send_fcm',
|
||
body: {
|
||
'user_ids': userIds,
|
||
'title': title,
|
||
'body': body,
|
||
'data': dataPayload,
|
||
},
|
||
);
|
||
} catch (e) {
|
||
debugPrint('notifyAssigned push error: $e');
|
||
}
|
||
} catch (_) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
Future<void> removeAssignment({
|
||
required String taskId,
|
||
required String userId,
|
||
}) async {
|
||
try {
|
||
await _client
|
||
.from('task_assignments')
|
||
.delete()
|
||
.eq('task_id', taskId)
|
||
.eq('user_id', userId);
|
||
// Also drop from any pending entry so reconnect-replay doesn't re-add.
|
||
_removeUserFromOfflinePending(taskId, userId);
|
||
} catch (e) {
|
||
if (isOfflineSaveError(e)) {
|
||
_removeUserFromOfflinePending(taskId, userId);
|
||
// If the user was a LIVE assignment (no pending entry yet), seed an
|
||
// empty pending list so the reconnect-replay deletes ALL live rows
|
||
// for this task and inserts none — effectively removing them.
|
||
_ensureOfflineReplacementWithoutUser(taskId);
|
||
return;
|
||
}
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
void _removeUserFromOfflinePending(String taskId, String userId) {
|
||
final ref = _ref;
|
||
if (ref == null) return;
|
||
final current = Map<String, List<String>>.from(
|
||
ref.read(offlinePendingAssignmentsProvider),
|
||
);
|
||
final list = current[taskId];
|
||
if (list == null) return;
|
||
final updated = list.where((u) => u != userId).toList();
|
||
if (updated.isEmpty) {
|
||
// Keep the empty list as a "remove all" intent for the replay.
|
||
current[taskId] = const <String>[];
|
||
} else {
|
||
current[taskId] = updated;
|
||
}
|
||
ref.read(offlinePendingAssignmentsProvider.notifier).state = current;
|
||
}
|
||
|
||
void _ensureOfflineReplacementWithoutUser(String taskId) {
|
||
final ref = _ref;
|
||
if (ref == null) return;
|
||
final current = Map<String, List<String>>.from(
|
||
ref.read(offlinePendingAssignmentsProvider),
|
||
);
|
||
if (current.containsKey(taskId)) return;
|
||
current[taskId] = const <String>[];
|
||
ref.read(offlinePendingAssignmentsProvider.notifier).state = current;
|
||
}
|
||
}
|
||
|
||
// ─── Work Log helpers ────────────────────────────────────────────────────────
|
||
|
||
/// All task_assignments rows where user_id = [userId].
|
||
/// Used by the Work Log tab to identify which tasks the user is the assignee of.
|
||
final taskAssignmentsForUserProvider =
|
||
FutureProvider.autoDispose.family<List<TaskAssignment>, String>(
|
||
(ref, userId) async {
|
||
try {
|
||
final data = await Supabase.instance.client
|
||
.from('task_assignments')
|
||
.select()
|
||
.eq('user_id', userId);
|
||
return data.map(TaskAssignment.fromMap).toList();
|
||
} catch (_) {
|
||
return [];
|
||
}
|
||
},
|
||
);
|
||
|
||
/// All task_activity_logs rows for the given task IDs — single batch query.
|
||
/// Key is a sorted, comma-joined string of task IDs so Riverpod caches the
|
||
/// provider correctly across rebuilds (List equality is reference-based in Dart).
|
||
/// Returns logs in DESCENDING order (newest first).
|
||
final taskActivityLogsForTasksProvider =
|
||
FutureProvider.autoDispose.family<List<TaskActivityLog>, String>(
|
||
(ref, taskIdsKey) async {
|
||
if (taskIdsKey.isEmpty) return [];
|
||
final taskIds = taskIdsKey.split(',');
|
||
try {
|
||
final data = await Supabase.instance.client
|
||
.from('task_activity_logs')
|
||
.select()
|
||
.inFilter('task_id', taskIds)
|
||
.order('created_at', ascending: false);
|
||
return data.map(TaskActivityLog.fromMap).toList();
|
||
} catch (_) {
|
||
try {
|
||
final all = await cachedListFromBrick<TaskActivityLog>();
|
||
final idSet = taskIds.toSet();
|
||
final filtered = all.where((l) => idSet.contains(l.taskId)).toList();
|
||
filtered.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||
return filtered;
|
||
} catch (_) {
|
||
return [];
|
||
}
|
||
}
|
||
},
|
||
);
|