Files
tasq/lib/providers/tasks_provider.dart
T

2851 lines
98 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 [];
}
}
},
);