import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/programmer_day_sheet.model.dart'; import '../models/programmer_day_sheet_event.model.dart'; import '../utils/app_time.dart'; import '../utils/programmer_daily_time.dart'; import 'notifications_provider.dart'; import 'profile_provider.dart'; import 'supabase_provider.dart'; // --------------------------------------------------------------------------- // Shared types // --------------------------------------------------------------------------- /// Callback type for sending push notifications. typedef PushFn = Future Function({ required List userIds, required String title, required String body, Map? data, }); // --------------------------------------------------------------------------- // Error types // --------------------------------------------------------------------------- enum DaySheetError { alreadyReviewed, alreadySubmitted, notAuthorized, remarksRequired, bodyRequired, workDateLocked, invalidSnapshot, unknown, } class DaySheetActionException implements Exception { const DaySheetActionException(this.code); final DaySheetError code; String get message { switch (code) { case DaySheetError.alreadyReviewed: return 'This sheet was already reviewed.'; case DaySheetError.alreadySubmitted: return 'This day sheet was already submitted.'; case DaySheetError.notAuthorized: return 'You are not allowed to do that.'; case DaySheetError.remarksRequired: return 'Remarks are required to disapprove.'; case DaySheetError.bodyRequired: return 'Please write a justification.'; case DaySheetError.workDateLocked: return 'That day is locked — you can only add entries to pending or disapproved days.'; case DaySheetError.invalidSnapshot: return 'Could not approve: the sheet data was invalid.'; case DaySheetError.unknown: return 'Something went wrong. Please try again.'; } } @override String toString() => 'DaySheetActionException(${code.name}): $message'; } /// Maps an RPC error to a [DaySheetError] by substring-matching the lowercased /// error string against the known database error codes. DaySheetError mapDaySheetError(Object error) { final s = error.toString().toLowerCase(); if (s.contains('not_authorized')) return DaySheetError.notAuthorized; if (s.contains('already_reviewed')) return DaySheetError.alreadyReviewed; if (s.contains('already_submitted')) return DaySheetError.alreadySubmitted; if (s.contains('remarks_required')) return DaySheetError.remarksRequired; if (s.contains('body_required')) return DaySheetError.bodyRequired; if (s.contains('work_date_locked')) return DaySheetError.workDateLocked; if (s.contains('invalid_snapshot')) return DaySheetError.invalidSnapshot; return DaySheetError.unknown; } // --------------------------------------------------------------------------- // Result type // --------------------------------------------------------------------------- class BulkApproveResult { const BulkApproveResult({ required this.approvedIds, required this.skippedIds, }); final List approvedIds; final List skippedIds; } // --------------------------------------------------------------------------- // Controller // --------------------------------------------------------------------------- class DaySheetsController { DaySheetsController(this._client, {required PushFn push}) : _push = push; final dynamic _client; final PushFn _push; // ------------------------------------------------------------------------- // Submit // ------------------------------------------------------------------------- Future submit(DateTime dayKey) async { try { final dateStr = formatDayKey(dayKey); final result = await _client.rpc( 'submit_day_sheet', params: {'p_date': dateStr}, ) as Map; final sheetId = result['sheet_id'] as String; final workDate = parseDayKey(result['work_date'] as String); final programmerName = result['programmer_name'] as String; final notifyUserIds = List.from(result['notify_user_ids'] as List? ?? []); if (notifyUserIds.isNotEmpty) { try { await _push( userIds: notifyUserIds, title: 'Day sheet submitted', body: '$programmerName submitted their day sheet for ${AppTime.formatDate(workDate)}', data: { 'type': 'day_sheet_submitted', 'navigate_to': '/programmer-tasks/day-sheets/$sheetId', }, ); } catch (e) { debugPrint('DaySheetsController.submit push error: $e'); } } return sheetId; } catch (e) { if (e is DaySheetActionException) rethrow; throw DaySheetActionException(mapDaySheetError(e)); } } // ------------------------------------------------------------------------- // Disapprove // ------------------------------------------------------------------------- Future disapprove({ required String sheetId, required String remarks, required List flaggedTaskIds, }) async { if (remarks.trim().isEmpty) { throw DaySheetActionException(DaySheetError.remarksRequired); } try { final result = await _client.rpc( 'disapprove_day_sheet', params: { 'p_sheet': sheetId, 'p_remarks': remarks.trim(), 'p_flagged_task_ids': flaggedTaskIds, }, ); if (result is Map) { final programmerId = result['programmer_id'] as String?; final rawDate = result['work_date'] as String?; if (programmerId != null && rawDate != null) { final workDate = parseDayKey(rawDate); final truncated = remarks.trim().length > 80 ? '${remarks.trim().substring(0, 80)}…' : remarks.trim(); try { await _push( userIds: [programmerId], title: 'Day sheet disapproved', body: 'Your day sheet for ${AppTime.formatDate(workDate)} needs changes: $truncated', data: { 'type': 'day_sheet_disapproved', 'navigate_to': '/programmer-tasks/day-sheets/$sheetId', }, ); } catch (e) { debugPrint('DaySheetsController.disapprove push error: $e'); } } } } catch (e) { if (e is DaySheetActionException) rethrow; throw DaySheetActionException(mapDaySheetError(e)); } } // ------------------------------------------------------------------------- // Justify // ------------------------------------------------------------------------- Future justify({ required String sheetId, required String body, }) async { if (body.trim().isEmpty) { throw DaySheetActionException(DaySheetError.bodyRequired); } try { final result = await _client.rpc( 'justify_day_sheet', params: { 'p_sheet': sheetId, 'p_body': body.trim(), }, ); if (result is Map) { final notifyUserIds = List.from(result['notify_user_ids'] as List? ?? []); final programmerName = result['programmer_name'] as String?; final rawDate = result['work_date'] as String?; if (notifyUserIds.isNotEmpty && programmerName != null && rawDate != null) { final workDate = parseDayKey(rawDate); try { await _push( userIds: notifyUserIds, title: 'Day sheet resubmitted', body: '$programmerName justified their day sheet for ${AppTime.formatDate(workDate)}', data: { 'type': 'day_sheet_resubmitted', 'navigate_to': '/programmer-tasks/day-sheets/$sheetId', }, ); } catch (e) { debugPrint('DaySheetsController.justify push error: $e'); } } } } catch (e) { if (e is DaySheetActionException) rethrow; throw DaySheetActionException(mapDaySheetError(e)); } } // ------------------------------------------------------------------------- // Approve (bulk) // ------------------------------------------------------------------------- Future approve( List<({String sheetId, DaySheetSnapshot snapshot})> items, ) async { try { final params = items .map((e) => { 'sheet_id': e.sheetId, 'snapshot': e.snapshot.toMap(), }) .toList(); final result = await _client.rpc( 'approve_day_sheets', params: {'p_items': params}, ) as Map; final approved = List>.from( (result['approved'] as List? ?? []).cast>(), ); final skippedIds = List.from(result['skipped_ids'] as List? ?? []); for (final item in approved) { final approvedSheetId = item['sheet_id'] as String; final programmerId = item['programmer_id'] as String?; final rawDate = item['work_date'] as String?; if (programmerId != null && rawDate != null) { final workDate = parseDayKey(rawDate); try { await _push( userIds: [programmerId], title: 'Day sheet approved', body: 'Your day sheet for ${AppTime.formatDate(workDate)} was approved', data: { 'type': 'day_sheet_approved', 'navigate_to': '/programmer-tasks/day-sheets/$approvedSheetId', }, ); } catch (e) { debugPrint('DaySheetsController.approve push error: $e'); } } } return BulkApproveResult( approvedIds: approved.map((e) => e['sheet_id'] as String).toList(), skippedIds: skippedIds, ); } catch (e) { if (e is DaySheetActionException) rethrow; throw DaySheetActionException(mapDaySheetError(e)); } } } // --------------------------------------------------------------------------- // Riverpod providers // --------------------------------------------------------------------------- final daySheetsControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); final notifications = ref.read(notificationsControllerProvider); return DaySheetsController( client, push: notifications.sendPush, ); }); // --------------------------------------------------------------------------- // Stream providers // --------------------------------------------------------------------------- /// Day sheets for the currently authenticated programmer, newest first. final myDaySheetsProvider = StreamProvider>((ref) { final userId = ref.watch(currentUserIdProvider); if (userId == null) return const Stream.empty(); final client = ref.watch(supabaseClientProvider); return client .from('programmer_day_sheets') .stream(primaryKey: ['id']) .eq('programmer_id', userId) .order('work_date', ascending: false) .map((rows) => rows.map(ProgrammerDaySheet.fromMap).toList()); }); /// Day sheets for a specific user (admin view), newest first. final daySheetsForUserProvider = StreamProvider.family, String>((ref, userId) { final client = ref.watch(supabaseClientProvider); return client .from('programmer_day_sheets') .stream(primaryKey: ['id']) .eq('programmer_id', userId) .order('work_date', ascending: false) .map((rows) => rows.map(ProgrammerDaySheet.fromMap).toList()); }); /// All day sheets visible to the current user (RLS-scoped), newest first. final allDaySheetsProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); return client .from('programmer_day_sheets') .stream(primaryKey: ['id']) .order('work_date', ascending: false) .map((rows) => rows.map(ProgrammerDaySheet.fromMap).toList()); }); /// A single day sheet by its ID, or null if not found. final daySheetByIdProvider = StreamProvider.family((ref, id) { final client = ref.watch(supabaseClientProvider); return client .from('programmer_day_sheets') .stream(primaryKey: ['id']) .eq('id', id) .map((rows) => rows.isEmpty ? null : ProgrammerDaySheet.fromMap(rows.first)); }); /// Events for a day sheet, ordered by creation time ascending. final daySheetEventsProvider = StreamProvider.family, String>((ref, sheetId) { final client = ref.watch(supabaseClientProvider); return client .from('programmer_day_sheet_events') .stream(primaryKey: ['id']) .eq('sheet_id', sheetId) .order('created_at', ascending: true) .map((rows) => rows.map(ProgrammerDaySheetEvent.fromMap).toList()); });