From 48cd3bcd600090c0a6eb4297284e47f5224b5f86 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Mon, 27 Apr 2026 06:20:39 +0800 Subject: [PATCH] Offline Support --- .claude-flow/swarm/swarm-state.json | 18 + .claude/settings.json | 20 + .gitignore | 12 + analysis_options.yaml | 7 + build.yaml | 14 + lib/app.dart | 13 +- .../adapters/announcement_adapter.g.dart | 319 +++++++ .../announcement_comment_adapter.g.dart | 183 ++++ .../adapters/attendance_log_adapter.g.dart | 357 +++++++ .../adapters/chat_message_adapter.g.dart | 182 ++++ .../adapters/duty_schedule_adapter.g.dart | 265 ++++++ .../it_service_request_action_adapter.g.dart | 201 ++++ ...ervice_request_activity_log_adapter.g.dart | 197 ++++ .../it_service_request_adapter.g.dart | 635 +++++++++++++ ..._service_request_assignment_adapter.g.dart | 169 ++++ .../adapters/leave_of_absence_adapter.g.dart | 239 +++++ .../adapters/notification_item_adapter.g.dart | 315 +++++++ lib/brick/adapters/office_adapter.g.dart | 155 +++ lib/brick/adapters/pass_slip_adapter.g.dart | 302 ++++++ lib/brick/adapters/profile_adapter.g.dart | 240 +++++ lib/brick/adapters/service_adapter.g.dart | 129 +++ .../adapters/swap_request_adapter.g.dart | 332 +++++++ .../adapters/task_activity_log_adapter.g.dart | 197 ++++ lib/brick/adapters/task_adapter.g.dart | 529 +++++++++++ lib/brick/adapters/team_adapter.g.dart | 187 ++++ lib/brick/adapters/ticket_adapter.g.dart | 277 ++++++ lib/brick/brick.g.dart | 114 +++ lib/brick/cache_helpers.dart | 63 ++ lib/brick/cache_warmer.dart | 360 +++++++ lib/brick/db/20260411041808.migration.dart | 502 ++++++++++ lib/brick/db/20260411042152.migration.dart | 50 + lib/brick/db/schema.g.dart | 478 ++++++++++ lib/brick/repository.dart | 160 ++++ lib/main.dart | 39 + lib/models/announcement.dart | 112 +-- lib/models/announcement.model.dart | 95 ++ lib/models/announcement_comment.dart | 48 +- lib/models/announcement_comment.model.dart | 51 + lib/models/attendance_log.dart | 69 +- lib/models/attendance_log.model.dart | 79 ++ lib/models/chat_message.dart | 34 +- lib/models/chat_message.model.dart | 39 + lib/models/duty_schedule.dart | 56 +- lib/models/duty_schedule.model.dart | 67 ++ lib/models/it_service_request.dart | 260 +---- lib/models/it_service_request.model.dart | 268 ++++++ lib/models/it_service_request_action.dart | 43 +- .../it_service_request_action.model.dart | 49 + .../it_service_request_activity_log.dart | 75 +- ...it_service_request_activity_log.model.dart | 81 ++ lib/models/it_service_request_assignment.dart | 25 +- .../it_service_request_assignment.model.dart | 30 + lib/models/leave_of_absence.dart | 55 +- lib/models/leave_of_absence.model.dart | 59 ++ lib/models/notification_item.dart | 56 +- lib/models/notification_item.model.dart | 65 ++ lib/models/office.dart | 16 +- lib/models/office.model.dart | 21 + lib/models/pass_slip.dart | 63 +- lib/models/pass_slip.model.dart | 71 ++ lib/models/profile.dart | 39 +- lib/models/profile.model.dart | 47 + lib/models/service.dart | 11 +- lib/models/service.model.dart | 19 + lib/models/swap_request.dart | 63 +- lib/models/swap_request.model.dart | 72 ++ lib/models/task.dart | 187 +--- lib/models/task.model.dart | 159 ++++ lib/models/task_activity_log.dart | 107 +-- lib/models/task_activity_log.model.dart | 102 ++ lib/models/team.dart | 43 +- lib/models/team.model.dart | 47 + lib/models/ticket.dart | 79 +- lib/models/ticket.model.dart | 71 ++ lib/providers/announcements_provider.dart | 432 +++++++-- lib/providers/attendance_provider.dart | 395 +++++++- lib/providers/chat_provider.dart | 142 ++- lib/providers/connectivity_provider.dart | 87 ++ .../it_service_request_provider.dart | 574 ++++++++++-- lib/providers/leave_provider.dart | 309 ++++-- lib/providers/notifications_provider.dart | 257 ++++- lib/providers/pass_slip_provider.dart | 338 +++++-- lib/providers/profile_provider.dart | 87 +- lib/providers/realtime_controller.dart | 55 +- lib/providers/services_provider.dart | 8 + lib/providers/stream_recovery.dart | 225 ++++- lib/providers/sync_queue_provider.dart | 107 +++ lib/providers/tasks_provider.dart | 886 +++++++++++++++++- lib/providers/tickets_provider.dart | 367 +++++++- lib/providers/typing_provider.dart | 71 +- lib/providers/user_offices_provider.dart | 155 ++- lib/providers/workforce_provider.dart | 22 + lib/screens/admin/offices_screen.dart | 64 +- .../announcement_comments_section.dart | 9 +- .../announcements/announcements_screen.dart | 27 +- .../create_announcement_dialog.dart | 18 +- lib/screens/attendance/attendance_screen.dart | 92 +- .../it_service_request_detail_screen.dart | 28 +- .../it_service_requests_list_screen.dart | 80 +- lib/screens/tasks/it_job_checklist_tab.dart | 11 +- lib/screens/tasks/task_detail_screen.dart | 180 +++- lib/screens/tasks/tasks_list_screen.dart | 244 +++-- lib/screens/teams/teams_screen.dart | 22 +- lib/screens/tickets/ticket_detail_screen.dart | 18 +- lib/screens/tickets/tickets_list_screen.dart | 158 ++-- lib/screens/workforce/workforce_screen.dart | 6 +- lib/utils/snackbar.dart | 53 ++ lib/widgets/offline_banner.dart | 185 ++++ lib/widgets/reconnect_overlay.dart | 12 +- lib/widgets/sync_pending_badge.dart | 144 +++ lib/widgets/task_assignment_section.dart | 64 +- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 304 ++++++ pubspec.yaml | 8 + ...23000000_fix_enqueue_all_notifications.sql | 68 +- ...000_fix_enum_cast_in_enqueue_functions.sql | 156 +++ ...0418000000_attendance_log_sync_trigger.sql | 86 ++ test/announcements_test.dart | 17 +- test/layout_smoke_test.dart | 3 +- test/offline_sync_test.dart | 227 +++++ test/realtime_controller_test.dart | 16 +- 121 files changed, 14798 insertions(+), 2214 deletions(-) create mode 100644 build.yaml create mode 100644 lib/brick/adapters/announcement_adapter.g.dart create mode 100644 lib/brick/adapters/announcement_comment_adapter.g.dart create mode 100644 lib/brick/adapters/attendance_log_adapter.g.dart create mode 100644 lib/brick/adapters/chat_message_adapter.g.dart create mode 100644 lib/brick/adapters/duty_schedule_adapter.g.dart create mode 100644 lib/brick/adapters/it_service_request_action_adapter.g.dart create mode 100644 lib/brick/adapters/it_service_request_activity_log_adapter.g.dart create mode 100644 lib/brick/adapters/it_service_request_adapter.g.dart create mode 100644 lib/brick/adapters/it_service_request_assignment_adapter.g.dart create mode 100644 lib/brick/adapters/leave_of_absence_adapter.g.dart create mode 100644 lib/brick/adapters/notification_item_adapter.g.dart create mode 100644 lib/brick/adapters/office_adapter.g.dart create mode 100644 lib/brick/adapters/pass_slip_adapter.g.dart create mode 100644 lib/brick/adapters/profile_adapter.g.dart create mode 100644 lib/brick/adapters/service_adapter.g.dart create mode 100644 lib/brick/adapters/swap_request_adapter.g.dart create mode 100644 lib/brick/adapters/task_activity_log_adapter.g.dart create mode 100644 lib/brick/adapters/task_adapter.g.dart create mode 100644 lib/brick/adapters/team_adapter.g.dart create mode 100644 lib/brick/adapters/ticket_adapter.g.dart create mode 100644 lib/brick/brick.g.dart create mode 100644 lib/brick/cache_helpers.dart create mode 100644 lib/brick/cache_warmer.dart create mode 100644 lib/brick/db/20260411041808.migration.dart create mode 100644 lib/brick/db/20260411042152.migration.dart create mode 100644 lib/brick/db/schema.g.dart create mode 100644 lib/brick/repository.dart create mode 100644 lib/models/announcement.model.dart create mode 100644 lib/models/announcement_comment.model.dart create mode 100644 lib/models/attendance_log.model.dart create mode 100644 lib/models/chat_message.model.dart create mode 100644 lib/models/duty_schedule.model.dart create mode 100644 lib/models/it_service_request.model.dart create mode 100644 lib/models/it_service_request_action.model.dart create mode 100644 lib/models/it_service_request_activity_log.model.dart create mode 100644 lib/models/it_service_request_assignment.model.dart create mode 100644 lib/models/leave_of_absence.model.dart create mode 100644 lib/models/notification_item.model.dart create mode 100644 lib/models/office.model.dart create mode 100644 lib/models/pass_slip.model.dart create mode 100644 lib/models/profile.model.dart create mode 100644 lib/models/service.model.dart create mode 100644 lib/models/swap_request.model.dart create mode 100644 lib/models/task.model.dart create mode 100644 lib/models/task_activity_log.model.dart create mode 100644 lib/models/team.model.dart create mode 100644 lib/models/ticket.model.dart create mode 100644 lib/providers/connectivity_provider.dart create mode 100644 lib/providers/sync_queue_provider.dart create mode 100644 lib/widgets/offline_banner.dart create mode 100644 lib/widgets/sync_pending_badge.dart create mode 100644 supabase/migrations/20260323010000_fix_enum_cast_in_enqueue_functions.sql create mode 100644 supabase/migrations/20260418000000_attendance_log_sync_trigger.sql create mode 100644 test/offline_sync_test.dart diff --git a/.claude-flow/swarm/swarm-state.json b/.claude-flow/swarm/swarm-state.json index 6d4446fe..823b75a8 100644 --- a/.claude-flow/swarm/swarm-state.json +++ b/.claude-flow/swarm/swarm-state.json @@ -35,6 +35,24 @@ }, "createdAt": "2026-04-08T22:52:50.281Z", "updatedAt": "2026-04-08T22:52:50.281Z" + }, + "swarm-1777077971732-eh7t50": { + "swarmId": "swarm-1777077971732-eh7t50", + "topology": "hierarchical-mesh", + "maxAgents": 15, + "status": "running", + "agents": [], + "tasks": [], + "config": { + "topology": "hierarchical-mesh", + "maxAgents": 15, + "strategy": "specialized", + "communicationProtocol": "message-bus", + "autoScaling": true, + "consensusMechanism": "majority" + }, + "createdAt": "2026-04-25T00:46:11.732Z", + "updatedAt": "2026-04-25T00:46:11.732Z" } }, "version": "3.0.0" diff --git a/.claude/settings.json b/.claude/settings.json index 7544951b..ca757caa 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -51,6 +51,12 @@ "type": "command", "command": "cmd /c node %CLAUDE_PROJECT_DIR%/.claude/helpers/hook-handler.cjs route", "timeout": 10000 + }, + { + "type": "command", + "shell": "powershell", + "statusMessage": "Loading architecture context...", + "command": "$f = \"$env:CLAUDE_PROJECT_DIR/graphify-out/GRAPH_REPORT.md\"; if (Test-Path $f) { $c = Get-Content -Raw $f; $o = @{ hookSpecificOutput = @{ hookEventName = 'UserPromptSubmit'; additionalContext = \"=== GRAPHIFY ARCHITECTURE CONTEXT ===`n$c`n=== END GRAPHIFY CONTEXT ===\" } } | ConvertTo-Json -Depth 5 -Compress; Write-Output $o }" } ] } @@ -67,6 +73,13 @@ "type": "command", "command": "cmd /c node %CLAUDE_PROJECT_DIR%/.claude/helpers/auto-memory-hook.mjs import", "timeout": 8000 + }, + { + "type": "command", + "shell": "powershell", + "async": true, + "statusMessage": "Starting workers and graphify...", + "command": "$branch = git branch --show-current; Write-Host \"[Hook] Branch: $branch\"; if (!(Get-NetTCPConnection -LocalPort 37777 -ErrorAction SilentlyContinue)) { Start-Process cmd -ArgumentList '/c','npx','claude-mem','start' -WindowStyle Hidden -ErrorAction SilentlyContinue }; Start-Process claude-flow -ArgumentList 'daemon','start' -WindowStyle Hidden -ErrorAction SilentlyContinue; Start-Process graphify -ArgumentList '--watch' -WindowStyle Minimized -ErrorAction SilentlyContinue; $t=0; while (!(Get-NetTCPConnection -LocalPort 37777 -ErrorAction SilentlyContinue) -and $t -lt 20) { Start-Sleep 1; $t++ }; if (Get-NetTCPConnection -LocalPort 37777 -ErrorAction SilentlyContinue) { Start-Process cmd -ArgumentList '/c','start','\"\"','\"http://localhost:37777\"' -WindowStyle Hidden; Write-Host \"[Hook] Opened claude-mem dashboard\" } else { Write-Host \"[Hook] claude-mem not running on :37777 (needs Bun + plugin install — run 'npx claude-mem install' outside Claude Code)\" }; $graphHtml = \"$env:CLAUDE_PROJECT_DIR\\graphify-out\\graph.html\"; if (Test-Path $graphHtml) { Start-Process cmd -ArgumentList '/c','start','\"\"',\"`\"$graphHtml`\"\" -WindowStyle Hidden; Write-Host \"[Hook] Opened graphify visualization\" }" } ] } @@ -89,6 +102,13 @@ "type": "command", "command": "cmd /c node %CLAUDE_PROJECT_DIR%/.claude/helpers/auto-memory-hook.mjs sync", "timeout": 10000 + }, + { + "type": "command", + "shell": "powershell", + "async": true, + "statusMessage": "Updating architecture graph...", + "command": "Write-Host \"[Hook] Regenerating graphify...\"; graphify 2>$null; $wshell = New-Object -ComObject WScript.Shell; $wshell.Popup(\"Claude finished. Architecture graph updated.\", 5, \"Claude Code Task Complete\", 64)" } ] } diff --git a/.gitignore b/.gitignore index f1bdb205..e4e5100d 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,15 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Firebase service account credentials (never commit) +supabase/functions/**/service-account.json +supabase/functions/service-account.json + +# graphify knowledge graph output +graphify-out/ + +# claude-flow agent DB artifacts +.swarm/ +agentdb.rvf +ruvector.db diff --git a/analysis_options.yaml b/analysis_options.yaml index f9b30346..8716ac97 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1 +1,8 @@ include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + # Auto-generated Brick files — suppress lint warnings from external generators. + - lib/brick/*.g.dart + - lib/brick/**/*.g.dart + - lib/brick/db/*.dart diff --git a/build.yaml b/build.yaml new file mode 100644 index 00000000..71589ce1 --- /dev/null +++ b/build.yaml @@ -0,0 +1,14 @@ +targets: + $default: + builders: + brick_offline_first_with_supabase_build|offline_first_with_supabase_generator: + enabled: true + generate_for: + - lib/models/**.dart + options: + # SQLite migrations are placed in lib/brick/db/ + sqlite_migration_path: lib/brick/db + # Adapters are placed in lib/brick/adapters/ + adapter_path: lib/brick/adapters + # The generated model dictionary file + output_path: lib/brick diff --git a/lib/app.dart b/lib/app.dart index 96f16c85..e77364cc 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -5,17 +5,25 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'routing/app_router.dart'; import 'models/profile.dart'; +import 'providers/connectivity_provider.dart'; import 'providers/profile_provider.dart'; import 'services/background_location_service.dart'; import 'theme/app_theme.dart'; import 'utils/snackbar.dart'; import 'widgets/ios_install_prompt.dart'; +import 'widgets/offline_banner.dart'; class TasqApp extends ConsumerWidget { const TasqApp({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + // Keep the connectivity monitor alive at the root so the global online + // check is wired into StreamRecoveryWrapper before any provider subscribes + // to a Supabase realtime stream. Without this watch the autoDispose + // provider only stays alive while OfflineBanner is mounted, which is too + // late for offline-cold-launch detection. + ref.watch(connectivityMonitorProvider); final router = ref.watch(appRouterProvider); // Ensure background service is running if the profile indicates tracking. @@ -48,8 +56,9 @@ class TasqApp extends ConsumerWidget { GlobalCupertinoLocalizations.delegate, FlutterQuillLocalizations.delegate, ], - builder: (context, child) => - IosInstallPrompt(child: child ?? const SizedBox.shrink()), + builder: (context, child) => OfflineBanner( + child: IosInstallPrompt(child: child ?? const SizedBox.shrink()), + ), ); } } diff --git a/lib/brick/adapters/announcement_adapter.g.dart b/lib/brick/adapters/announcement_adapter.g.dart new file mode 100644 index 00000000..49275c5a --- /dev/null +++ b/lib/brick/adapters/announcement_adapter.g.dart @@ -0,0 +1,319 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$AnnouncementFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return Announcement( + id: data['id'] as String, + authorId: data['author_id'] as String, + title: data['title'] as String, + body: data['body'] as String, + visibleRoles: data['visible_roles'].toList().cast(), + isTemplate: data['is_template'] as bool, + templateId: data['template_id'] == null + ? null + : data['template_id'] as String?, + createdAt: DateTime.parse(data['created_at'] as String), + updatedAt: DateTime.parse(data['updated_at'] as String), + bannerEnabled: data['banner_enabled'] as bool, + bannerShowAt: data['banner_show_at'] == null + ? null + : data['banner_show_at'] == null + ? null + : DateTime.tryParse(data['banner_show_at'] as String), + bannerHideAt: data['banner_hide_at'] == null + ? null + : data['banner_hide_at'] == null + ? null + : DateTime.tryParse(data['banner_hide_at'] as String), + pushIntervalMinutes: data['push_interval_minutes'] == null + ? null + : data['push_interval_minutes'] as int?, + ); +} + +Future> _$AnnouncementToSupabase( + Announcement instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'author_id': instance.authorId, + 'title': instance.title, + 'body': instance.body, + 'visible_roles': instance.visibleRoles, + 'is_template': instance.isTemplate, + 'template_id': instance.templateId, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'banner_enabled': instance.bannerEnabled, + 'banner_show_at': instance.bannerShowAt?.toIso8601String(), + 'banner_hide_at': instance.bannerHideAt?.toIso8601String(), + 'push_interval_minutes': instance.pushIntervalMinutes, + }; +} + +Future _$AnnouncementFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return Announcement( + id: data['id'] as String, + authorId: data['author_id'] as String, + title: data['title'] as String, + body: data['body'] as String, + visibleRoles: jsonDecode(data['visible_roles']).toList().cast(), + isTemplate: data['is_template'] == 1, + templateId: data['template_id'] == null + ? null + : data['template_id'] as String?, + createdAt: DateTime.parse(data['created_at'] as String), + updatedAt: DateTime.parse(data['updated_at'] as String), + bannerEnabled: data['banner_enabled'] == 1, + bannerShowAt: data['banner_show_at'] == null + ? null + : data['banner_show_at'] == null + ? null + : DateTime.tryParse(data['banner_show_at'] as String), + bannerHideAt: data['banner_hide_at'] == null + ? null + : data['banner_hide_at'] == null + ? null + : DateTime.tryParse(data['banner_hide_at'] as String), + pushIntervalMinutes: data['push_interval_minutes'] == null + ? null + : data['push_interval_minutes'] as int?, + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$AnnouncementToSqlite( + Announcement instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'author_id': instance.authorId, + 'title': instance.title, + 'body': instance.body, + 'visible_roles': jsonEncode(instance.visibleRoles), + 'is_template': instance.isTemplate ? 1 : 0, + 'template_id': instance.templateId, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'banner_enabled': instance.bannerEnabled ? 1 : 0, + 'banner_show_at': instance.bannerShowAt?.toIso8601String(), + 'banner_hide_at': instance.bannerHideAt?.toIso8601String(), + 'push_interval_minutes': instance.pushIntervalMinutes, + }; +} + +/// Construct a [Announcement] +class AnnouncementAdapter + extends OfflineFirstWithSupabaseAdapter { + AnnouncementAdapter(); + + @override + final supabaseTableName = 'announcements'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'authorId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'author_id', + ), + 'title': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'title', + ), + 'body': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'body', + ), + 'visibleRoles': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'visible_roles', + ), + 'isTemplate': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'is_template', + ), + 'templateId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'template_id', + ), + 'createdAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'created_at', + ), + 'updatedAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'updated_at', + ), + 'bannerEnabled': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'banner_enabled', + ), + 'bannerShowAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'banner_show_at', + ), + 'bannerHideAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'banner_hide_at', + ), + 'pushIntervalMinutes': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'push_interval_minutes', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'authorId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'author_id', + iterable: false, + type: String, + ), + 'title': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'title', + iterable: false, + type: String, + ), + 'body': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'body', + iterable: false, + type: String, + ), + 'visibleRoles': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'visible_roles', + iterable: true, + type: String, + ), + 'isTemplate': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'is_template', + iterable: false, + type: bool, + ), + 'templateId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'template_id', + iterable: false, + type: String, + ), + 'createdAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'created_at', + iterable: false, + type: DateTime, + ), + 'updatedAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'updated_at', + iterable: false, + type: DateTime, + ), + 'bannerEnabled': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'banner_enabled', + iterable: false, + type: bool, + ), + 'bannerShowAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'banner_show_at', + iterable: false, + type: DateTime, + ), + 'bannerHideAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'banner_hide_at', + iterable: false, + type: DateTime, + ), + 'pushIntervalMinutes': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'push_interval_minutes', + iterable: false, + type: int, + ), + }; + @override + Future primaryKeyByUniqueColumns( + Announcement instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'Announcement'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$AnnouncementFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + Announcement input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$AnnouncementToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$AnnouncementFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + Announcement input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$AnnouncementToSqlite( + input, + provider: provider, + repository: repository, + ); +} diff --git a/lib/brick/adapters/announcement_comment_adapter.g.dart b/lib/brick/adapters/announcement_comment_adapter.g.dart new file mode 100644 index 00000000..fd0cb9d8 --- /dev/null +++ b/lib/brick/adapters/announcement_comment_adapter.g.dart @@ -0,0 +1,183 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$AnnouncementCommentFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return AnnouncementComment( + id: data['id'] as String, + announcementId: data['announcement_id'] as String, + authorId: data['author_id'] as String, + body: data['body'] as String, + createdAt: DateTime.parse(data['created_at'] as String), + ); +} + +Future> _$AnnouncementCommentToSupabase( + AnnouncementComment instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'announcement_id': instance.announcementId, + 'author_id': instance.authorId, + 'body': instance.body, + 'created_at': instance.createdAt.toIso8601String(), + }; +} + +Future _$AnnouncementCommentFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return AnnouncementComment( + id: data['id'] as String, + announcementId: data['announcement_id'] as String, + authorId: data['author_id'] as String, + body: data['body'] as String, + createdAt: DateTime.parse(data['created_at'] as String), + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$AnnouncementCommentToSqlite( + AnnouncementComment instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'announcement_id': instance.announcementId, + 'author_id': instance.authorId, + 'body': instance.body, + 'created_at': instance.createdAt.toIso8601String(), + }; +} + +/// Construct a [AnnouncementComment] +class AnnouncementCommentAdapter + extends OfflineFirstWithSupabaseAdapter { + AnnouncementCommentAdapter(); + + @override + final supabaseTableName = 'announcement_comments'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'announcementId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'announcement_id', + ), + 'authorId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'author_id', + ), + 'body': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'body', + ), + 'createdAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'created_at', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'announcementId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'announcement_id', + iterable: false, + type: String, + ), + 'authorId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'author_id', + iterable: false, + type: String, + ), + 'body': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'body', + iterable: false, + type: String, + ), + 'createdAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'created_at', + iterable: false, + type: DateTime, + ), + }; + @override + Future primaryKeyByUniqueColumns( + AnnouncementComment instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'AnnouncementComment'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$AnnouncementCommentFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + AnnouncementComment input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$AnnouncementCommentToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$AnnouncementCommentFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + AnnouncementComment input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$AnnouncementCommentToSqlite( + input, + provider: provider, + repository: repository, + ); +} diff --git a/lib/brick/adapters/attendance_log_adapter.g.dart b/lib/brick/adapters/attendance_log_adapter.g.dart new file mode 100644 index 00000000..acad8b25 --- /dev/null +++ b/lib/brick/adapters/attendance_log_adapter.g.dart @@ -0,0 +1,357 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$AttendanceLogFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return AttendanceLog( + id: data['id'] as String, + userId: data['user_id'] as String, + dutyScheduleId: data['duty_schedule_id'] as String, + shiftType: data['shift_type'] as String, + checkInAt: DateTime.parse(data['check_in_at'] as String), + checkInLat: data['check_in_lat'] as double, + checkInLng: data['check_in_lng'] as double, + checkOutAt: data['check_out_at'] == null + ? null + : data['check_out_at'] == null + ? null + : DateTime.tryParse(data['check_out_at'] as String), + checkOutLat: data['check_out_lat'] == null + ? null + : data['check_out_lat'] as double?, + checkOutLng: data['check_out_lng'] == null + ? null + : data['check_out_lng'] as double?, + justification: data['justification'] == null + ? null + : data['justification'] as String?, + checkOutJustification: data['check_out_justification'] == null + ? null + : data['check_out_justification'] as String?, + verificationStatus: data['verification_status'] as String, + checkInVerificationPhotoUrl: data['check_in_verification_photo_url'] == null + ? null + : data['check_in_verification_photo_url'] as String?, + checkOutVerificationPhotoUrl: + data['check_out_verification_photo_url'] == null + ? null + : data['check_out_verification_photo_url'] as String?, + ); +} + +Future> _$AttendanceLogToSupabase( + AttendanceLog instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'user_id': instance.userId, + 'duty_schedule_id': instance.dutyScheduleId, + 'shift_type': instance.shiftType, + 'check_in_at': instance.checkInAt.toIso8601String(), + 'check_in_lat': instance.checkInLat, + 'check_in_lng': instance.checkInLng, + 'check_out_at': instance.checkOutAt?.toIso8601String(), + 'check_out_lat': instance.checkOutLat, + 'check_out_lng': instance.checkOutLng, + 'justification': instance.justification, + 'check_out_justification': instance.checkOutJustification, + 'verification_status': instance.verificationStatus, + 'check_in_verification_photo_url': instance.checkInVerificationPhotoUrl, + 'check_out_verification_photo_url': instance.checkOutVerificationPhotoUrl, + }; +} + +Future _$AttendanceLogFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return AttendanceLog( + id: data['id'] as String, + userId: data['user_id'] as String, + dutyScheduleId: data['duty_schedule_id'] as String, + shiftType: data['shift_type'] as String, + checkInAt: DateTime.parse(data['check_in_at'] as String), + checkInLat: data['check_in_lat'] as double, + checkInLng: data['check_in_lng'] as double, + checkOutAt: data['check_out_at'] == null + ? null + : data['check_out_at'] == null + ? null + : DateTime.tryParse(data['check_out_at'] as String), + checkOutLat: data['check_out_lat'] == null + ? null + : data['check_out_lat'] as double?, + checkOutLng: data['check_out_lng'] == null + ? null + : data['check_out_lng'] as double?, + justification: data['justification'] == null + ? null + : data['justification'] as String?, + checkOutJustification: data['check_out_justification'] == null + ? null + : data['check_out_justification'] as String?, + verificationStatus: data['verification_status'] as String, + checkInVerificationPhotoUrl: data['check_in_verification_photo_url'] == null + ? null + : data['check_in_verification_photo_url'] as String?, + checkOutVerificationPhotoUrl: + data['check_out_verification_photo_url'] == null + ? null + : data['check_out_verification_photo_url'] as String?, + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$AttendanceLogToSqlite( + AttendanceLog instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'user_id': instance.userId, + 'duty_schedule_id': instance.dutyScheduleId, + 'shift_type': instance.shiftType, + 'check_in_at': instance.checkInAt.toIso8601String(), + 'check_in_lat': instance.checkInLat, + 'check_in_lng': instance.checkInLng, + 'check_out_at': instance.checkOutAt?.toIso8601String(), + 'check_out_lat': instance.checkOutLat, + 'check_out_lng': instance.checkOutLng, + 'justification': instance.justification, + 'check_out_justification': instance.checkOutJustification, + 'verification_status': instance.verificationStatus, + 'check_in_verification_photo_url': instance.checkInVerificationPhotoUrl, + 'check_out_verification_photo_url': instance.checkOutVerificationPhotoUrl, + }; +} + +/// Construct a [AttendanceLog] +class AttendanceLogAdapter + extends OfflineFirstWithSupabaseAdapter { + AttendanceLogAdapter(); + + @override + final supabaseTableName = 'attendance_logs'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'userId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'user_id', + ), + 'dutyScheduleId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'duty_schedule_id', + ), + 'shiftType': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'shift_type', + ), + 'checkInAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'check_in_at', + ), + 'checkInLat': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'check_in_lat', + ), + 'checkInLng': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'check_in_lng', + ), + 'checkOutAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'check_out_at', + ), + 'checkOutLat': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'check_out_lat', + ), + 'checkOutLng': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'check_out_lng', + ), + 'justification': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'justification', + ), + 'checkOutJustification': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'check_out_justification', + ), + 'verificationStatus': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'verification_status', + ), + 'checkInVerificationPhotoUrl': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'check_in_verification_photo_url', + ), + 'checkOutVerificationPhotoUrl': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'check_out_verification_photo_url', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'userId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'user_id', + iterable: false, + type: String, + ), + 'dutyScheduleId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'duty_schedule_id', + iterable: false, + type: String, + ), + 'shiftType': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'shift_type', + iterable: false, + type: String, + ), + 'checkInAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'check_in_at', + iterable: false, + type: DateTime, + ), + 'checkInLat': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'check_in_lat', + iterable: false, + type: double, + ), + 'checkInLng': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'check_in_lng', + iterable: false, + type: double, + ), + 'checkOutAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'check_out_at', + iterable: false, + type: DateTime, + ), + 'checkOutLat': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'check_out_lat', + iterable: false, + type: double, + ), + 'checkOutLng': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'check_out_lng', + iterable: false, + type: double, + ), + 'justification': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'justification', + iterable: false, + type: String, + ), + 'checkOutJustification': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'check_out_justification', + iterable: false, + type: String, + ), + 'verificationStatus': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'verification_status', + iterable: false, + type: String, + ), + 'checkInVerificationPhotoUrl': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'check_in_verification_photo_url', + iterable: false, + type: String, + ), + 'checkOutVerificationPhotoUrl': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'check_out_verification_photo_url', + iterable: false, + type: String, + ), + }; + @override + Future primaryKeyByUniqueColumns( + AttendanceLog instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'AttendanceLog'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$AttendanceLogFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + AttendanceLog input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$AttendanceLogToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$AttendanceLogFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + AttendanceLog input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$AttendanceLogToSqlite( + input, + provider: provider, + repository: repository, + ); +} diff --git a/lib/brick/adapters/chat_message_adapter.g.dart b/lib/brick/adapters/chat_message_adapter.g.dart new file mode 100644 index 00000000..aca51815 --- /dev/null +++ b/lib/brick/adapters/chat_message_adapter.g.dart @@ -0,0 +1,182 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$ChatMessageFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return ChatMessage( + id: data['id'] as String, + threadId: data['thread_id'] as String, + senderId: data['sender_id'] as String, + body: data['body'] as String, + createdAt: DateTime.parse(data['created_at'] as String), + ); +} + +Future> _$ChatMessageToSupabase( + ChatMessage instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'thread_id': instance.threadId, + 'sender_id': instance.senderId, + 'body': instance.body, + 'created_at': instance.createdAt.toIso8601String(), + }; +} + +Future _$ChatMessageFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return ChatMessage( + id: data['id'] as String, + threadId: data['thread_id'] as String, + senderId: data['sender_id'] as String, + body: data['body'] as String, + createdAt: DateTime.parse(data['created_at'] as String), + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$ChatMessageToSqlite( + ChatMessage instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'thread_id': instance.threadId, + 'sender_id': instance.senderId, + 'body': instance.body, + 'created_at': instance.createdAt.toIso8601String(), + }; +} + +/// Construct a [ChatMessage] +class ChatMessageAdapter extends OfflineFirstWithSupabaseAdapter { + ChatMessageAdapter(); + + @override + final supabaseTableName = 'chat_messages'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'threadId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'thread_id', + ), + 'senderId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'sender_id', + ), + 'body': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'body', + ), + 'createdAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'created_at', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'threadId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'thread_id', + iterable: false, + type: String, + ), + 'senderId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'sender_id', + iterable: false, + type: String, + ), + 'body': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'body', + iterable: false, + type: String, + ), + 'createdAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'created_at', + iterable: false, + type: DateTime, + ), + }; + @override + Future primaryKeyByUniqueColumns( + ChatMessage instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'ChatMessage'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ChatMessageFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + ChatMessage input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ChatMessageToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ChatMessageFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + ChatMessage input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ChatMessageToSqlite( + input, + provider: provider, + repository: repository, + ); +} diff --git a/lib/brick/adapters/duty_schedule_adapter.g.dart b/lib/brick/adapters/duty_schedule_adapter.g.dart new file mode 100644 index 00000000..e07717c4 --- /dev/null +++ b/lib/brick/adapters/duty_schedule_adapter.g.dart @@ -0,0 +1,265 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$DutyScheduleFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return DutySchedule( + id: data['id'] as String, + userId: data['user_id'] as String, + shiftType: data['shift_type'] as String, + startTime: DateTime.parse(data['start_time'] as String), + endTime: DateTime.parse(data['end_time'] as String), + status: data['status'] as String, + createdAt: DateTime.parse(data['created_at'] as String), + checkInAt: data['check_in_at'] == null + ? null + : data['check_in_at'] == null + ? null + : DateTime.tryParse(data['check_in_at'] as String), + relieverIds: data['reliever_ids'].toList().cast(), + swapRequestId: data['swap_request_id'] == null + ? null + : data['swap_request_id'] as String?, + ); +} + +Future> _$DutyScheduleToSupabase( + DutySchedule instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'user_id': instance.userId, + 'shift_type': instance.shiftType, + 'start_time': instance.startTime.toIso8601String(), + 'end_time': instance.endTime.toIso8601String(), + 'status': instance.status, + 'created_at': instance.createdAt.toIso8601String(), + 'check_in_at': instance.checkInAt?.toIso8601String(), + 'reliever_ids': instance.relieverIds, + 'swap_request_id': instance.swapRequestId, + }; +} + +Future _$DutyScheduleFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return DutySchedule( + id: data['id'] as String, + userId: data['user_id'] as String, + shiftType: data['shift_type'] as String, + startTime: DateTime.parse(data['start_time'] as String), + endTime: DateTime.parse(data['end_time'] as String), + status: data['status'] as String, + createdAt: DateTime.parse(data['created_at'] as String), + checkInAt: data['check_in_at'] == null + ? null + : data['check_in_at'] == null + ? null + : DateTime.tryParse(data['check_in_at'] as String), + relieverIds: jsonDecode(data['reliever_ids']).toList().cast(), + swapRequestId: data['swap_request_id'] == null + ? null + : data['swap_request_id'] as String?, + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$DutyScheduleToSqlite( + DutySchedule instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'user_id': instance.userId, + 'shift_type': instance.shiftType, + 'start_time': instance.startTime.toIso8601String(), + 'end_time': instance.endTime.toIso8601String(), + 'status': instance.status, + 'created_at': instance.createdAt.toIso8601String(), + 'check_in_at': instance.checkInAt?.toIso8601String(), + 'reliever_ids': jsonEncode(instance.relieverIds), + 'swap_request_id': instance.swapRequestId, + }; +} + +/// Construct a [DutySchedule] +class DutyScheduleAdapter + extends OfflineFirstWithSupabaseAdapter { + DutyScheduleAdapter(); + + @override + final supabaseTableName = 'duty_schedules'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'userId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'user_id', + ), + 'shiftType': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'shift_type', + ), + 'startTime': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'start_time', + ), + 'endTime': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'end_time', + ), + 'status': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'status', + ), + 'createdAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'created_at', + ), + 'checkInAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'check_in_at', + ), + 'relieverIds': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'reliever_ids', + ), + 'swapRequestId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'swap_request_id', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'userId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'user_id', + iterable: false, + type: String, + ), + 'shiftType': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'shift_type', + iterable: false, + type: String, + ), + 'startTime': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'start_time', + iterable: false, + type: DateTime, + ), + 'endTime': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'end_time', + iterable: false, + type: DateTime, + ), + 'status': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'status', + iterable: false, + type: String, + ), + 'createdAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'created_at', + iterable: false, + type: DateTime, + ), + 'checkInAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'check_in_at', + iterable: false, + type: DateTime, + ), + 'relieverIds': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'reliever_ids', + iterable: true, + type: String, + ), + 'swapRequestId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'swap_request_id', + iterable: false, + type: String, + ), + }; + @override + Future primaryKeyByUniqueColumns( + DutySchedule instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'DutySchedule'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$DutyScheduleFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + DutySchedule input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$DutyScheduleToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$DutyScheduleFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + DutySchedule input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$DutyScheduleToSqlite( + input, + provider: provider, + repository: repository, + ); +} diff --git a/lib/brick/adapters/it_service_request_action_adapter.g.dart b/lib/brick/adapters/it_service_request_action_adapter.g.dart new file mode 100644 index 00000000..88450aea --- /dev/null +++ b/lib/brick/adapters/it_service_request_action_adapter.g.dart @@ -0,0 +1,201 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$ItServiceRequestActionFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return ItServiceRequestAction( + id: data['id'] as String, + requestId: data['request_id'] as String, + userId: data['user_id'] as String, + actionTaken: data['action_taken'] == null + ? null + : data['action_taken'] as String?, + createdAt: DateTime.parse(data['created_at'] as String), + updatedAt: DateTime.parse(data['updated_at'] as String), + ); +} + +Future> _$ItServiceRequestActionToSupabase( + ItServiceRequestAction instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'request_id': instance.requestId, + 'user_id': instance.userId, + 'action_taken': instance.actionTaken, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + }; +} + +Future _$ItServiceRequestActionFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return ItServiceRequestAction( + id: data['id'] as String, + requestId: data['request_id'] as String, + userId: data['user_id'] as String, + actionTaken: data['action_taken'] == null + ? null + : data['action_taken'] as String?, + createdAt: DateTime.parse(data['created_at'] as String), + updatedAt: DateTime.parse(data['updated_at'] as String), + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$ItServiceRequestActionToSqlite( + ItServiceRequestAction instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'request_id': instance.requestId, + 'user_id': instance.userId, + 'action_taken': instance.actionTaken, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + }; +} + +/// Construct a [ItServiceRequestAction] +class ItServiceRequestActionAdapter + extends OfflineFirstWithSupabaseAdapter { + ItServiceRequestActionAdapter(); + + @override + final supabaseTableName = 'it_service_request_actions'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'requestId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'request_id', + ), + 'userId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'user_id', + ), + 'actionTaken': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'action_taken', + ), + 'createdAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'created_at', + ), + 'updatedAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'updated_at', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'requestId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'request_id', + iterable: false, + type: String, + ), + 'userId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'user_id', + iterable: false, + type: String, + ), + 'actionTaken': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'action_taken', + iterable: false, + type: String, + ), + 'createdAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'created_at', + iterable: false, + type: DateTime, + ), + 'updatedAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'updated_at', + iterable: false, + type: DateTime, + ), + }; + @override + Future primaryKeyByUniqueColumns( + ItServiceRequestAction instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'ItServiceRequestAction'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ItServiceRequestActionFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + ItServiceRequestAction input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ItServiceRequestActionToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ItServiceRequestActionFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + ItServiceRequestAction input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ItServiceRequestActionToSqlite( + input, + provider: provider, + repository: repository, + ); +} diff --git a/lib/brick/adapters/it_service_request_activity_log_adapter.g.dart b/lib/brick/adapters/it_service_request_activity_log_adapter.g.dart new file mode 100644 index 00000000..65d82c29 --- /dev/null +++ b/lib/brick/adapters/it_service_request_activity_log_adapter.g.dart @@ -0,0 +1,197 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$ItServiceRequestActivityLogFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return ItServiceRequestActivityLog( + id: data['id'] as String, + requestId: data['request_id'] as String, + actorId: data['actor_id'] == null ? null : data['actor_id'] as String?, + actionType: data['action_type'] as String, + meta: data['meta'] == null ? null : data['meta'], + createdAt: DateTime.parse(data['created_at'] as String), + ); +} + +Future> _$ItServiceRequestActivityLogToSupabase( + ItServiceRequestActivityLog instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'request_id': instance.requestId, + 'actor_id': instance.actorId, + 'action_type': instance.actionType, + 'meta': instance.meta, + 'created_at': instance.createdAt.toIso8601String(), + }; +} + +Future _$ItServiceRequestActivityLogFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return ItServiceRequestActivityLog( + id: data['id'] as String, + requestId: data['request_id'] as String, + actorId: data['actor_id'] == null ? null : data['actor_id'] as String?, + actionType: data['action_type'] as String, + meta: data['meta'] == null ? null : jsonDecode(data['meta']), + createdAt: DateTime.parse(data['created_at'] as String), + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$ItServiceRequestActivityLogToSqlite( + ItServiceRequestActivityLog instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'request_id': instance.requestId, + 'actor_id': instance.actorId, + 'action_type': instance.actionType, + 'meta': instance.meta != null ? jsonEncode(instance.meta) : null, + 'created_at': instance.createdAt.toIso8601String(), + }; +} + +/// Construct a [ItServiceRequestActivityLog] +class ItServiceRequestActivityLogAdapter + extends OfflineFirstWithSupabaseAdapter { + ItServiceRequestActivityLogAdapter(); + + @override + final supabaseTableName = 'it_service_request_activity_logs'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'requestId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'request_id', + ), + 'actorId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'actor_id', + ), + 'actionType': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'action_type', + ), + 'meta': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'meta', + ), + 'createdAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'created_at', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'requestId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'request_id', + iterable: false, + type: String, + ), + 'actorId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'actor_id', + iterable: false, + type: String, + ), + 'actionType': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'action_type', + iterable: false, + type: String, + ), + 'meta': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'meta', + iterable: false, + type: Map, + ), + 'createdAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'created_at', + iterable: false, + type: DateTime, + ), + }; + @override + Future primaryKeyByUniqueColumns( + ItServiceRequestActivityLog instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'ItServiceRequestActivityLog'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ItServiceRequestActivityLogFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + ItServiceRequestActivityLog input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ItServiceRequestActivityLogToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ItServiceRequestActivityLogFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + ItServiceRequestActivityLog input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ItServiceRequestActivityLogToSqlite( + input, + provider: provider, + repository: repository, + ); +} diff --git a/lib/brick/adapters/it_service_request_adapter.g.dart b/lib/brick/adapters/it_service_request_adapter.g.dart new file mode 100644 index 00000000..682f0c8f --- /dev/null +++ b/lib/brick/adapters/it_service_request_adapter.g.dart @@ -0,0 +1,635 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$ItServiceRequestFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return ItServiceRequest( + id: data['id'] as String, + requestNumber: data['request_number'] == null + ? null + : data['request_number'] as String?, + services: data['services'].toList().cast(), + servicesOther: data['services_other'] == null + ? null + : data['services_other'] as String?, + eventName: data['event_name'] as String, + eventDetails: data['event_details'] == null + ? null + : data['event_details'] as String?, + eventDate: data['event_date'] == null + ? null + : data['event_date'] == null + ? null + : DateTime.tryParse(data['event_date'] as String), + eventEndDate: data['event_end_date'] == null + ? null + : data['event_end_date'] == null + ? null + : DateTime.tryParse(data['event_end_date'] as String), + dryRunDate: data['dry_run_date'] == null + ? null + : data['dry_run_date'] == null + ? null + : DateTime.tryParse(data['dry_run_date'] as String), + dryRunEndDate: data['dry_run_end_date'] == null + ? null + : data['dry_run_end_date'] == null + ? null + : DateTime.tryParse(data['dry_run_end_date'] as String), + contactPerson: data['contact_person'] == null + ? null + : data['contact_person'] as String?, + contactNumber: data['contact_number'] == null + ? null + : data['contact_number'] as String?, + remarks: data['remarks'] == null ? null : data['remarks'] as String?, + officeId: data['office_id'] == null ? null : data['office_id'] as String?, + requestedBy: data['requested_by'] == null + ? null + : data['requested_by'] as String?, + requestedByUserId: data['requested_by_user_id'] == null + ? null + : data['requested_by_user_id'] as String?, + approvedBy: data['approved_by'] == null + ? null + : data['approved_by'] as String?, + approvedByUserId: data['approved_by_user_id'] == null + ? null + : data['approved_by_user_id'] as String?, + approvedAt: data['approved_at'] == null + ? null + : data['approved_at'] == null + ? null + : DateTime.tryParse(data['approved_at'] as String), + status: data['status'] as String, + outsidePremiseAllowed: data['outside_premise_allowed'] as bool, + cancellationReason: data['cancellation_reason'] == null + ? null + : data['cancellation_reason'] as String?, + cancelledAt: data['cancelled_at'] == null + ? null + : data['cancelled_at'] == null + ? null + : DateTime.tryParse(data['cancelled_at'] as String), + creatorId: data['creator_id'] == null + ? null + : data['creator_id'] as String?, + createdAt: DateTime.parse(data['created_at'] as String), + updatedAt: DateTime.parse(data['updated_at'] as String), + completedAt: data['completed_at'] == null + ? null + : data['completed_at'] == null + ? null + : DateTime.tryParse(data['completed_at'] as String), + dateTimeReceived: data['date_time_received'] == null + ? null + : data['date_time_received'] == null + ? null + : DateTime.tryParse(data['date_time_received'] as String), + dateTimeChecked: data['date_time_checked'] == null + ? null + : data['date_time_checked'] == null + ? null + : DateTime.tryParse(data['date_time_checked'] as String), + ); +} + +Future> _$ItServiceRequestToSupabase( + ItServiceRequest instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'request_number': instance.requestNumber, + 'services': instance.services, + 'services_other': instance.servicesOther, + 'event_name': instance.eventName, + 'event_details': instance.eventDetails, + 'event_date': instance.eventDate?.toIso8601String(), + 'event_end_date': instance.eventEndDate?.toIso8601String(), + 'dry_run_date': instance.dryRunDate?.toIso8601String(), + 'dry_run_end_date': instance.dryRunEndDate?.toIso8601String(), + 'contact_person': instance.contactPerson, + 'contact_number': instance.contactNumber, + 'remarks': instance.remarks, + 'office_id': instance.officeId, + 'requested_by': instance.requestedBy, + 'requested_by_user_id': instance.requestedByUserId, + 'approved_by': instance.approvedBy, + 'approved_by_user_id': instance.approvedByUserId, + 'approved_at': instance.approvedAt?.toIso8601String(), + 'status': instance.status, + 'outside_premise_allowed': instance.outsidePremiseAllowed, + 'cancellation_reason': instance.cancellationReason, + 'cancelled_at': instance.cancelledAt?.toIso8601String(), + 'creator_id': instance.creatorId, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'completed_at': instance.completedAt?.toIso8601String(), + 'date_time_received': instance.dateTimeReceived?.toIso8601String(), + 'date_time_checked': instance.dateTimeChecked?.toIso8601String(), + }; +} + +Future _$ItServiceRequestFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return ItServiceRequest( + id: data['id'] as String, + requestNumber: data['request_number'] == null + ? null + : data['request_number'] as String?, + services: jsonDecode(data['services']).toList().cast(), + servicesOther: data['services_other'] == null + ? null + : data['services_other'] as String?, + eventName: data['event_name'] as String, + eventDetails: data['event_details'] == null + ? null + : data['event_details'] as String?, + eventDate: data['event_date'] == null + ? null + : data['event_date'] == null + ? null + : DateTime.tryParse(data['event_date'] as String), + eventEndDate: data['event_end_date'] == null + ? null + : data['event_end_date'] == null + ? null + : DateTime.tryParse(data['event_end_date'] as String), + dryRunDate: data['dry_run_date'] == null + ? null + : data['dry_run_date'] == null + ? null + : DateTime.tryParse(data['dry_run_date'] as String), + dryRunEndDate: data['dry_run_end_date'] == null + ? null + : data['dry_run_end_date'] == null + ? null + : DateTime.tryParse(data['dry_run_end_date'] as String), + contactPerson: data['contact_person'] == null + ? null + : data['contact_person'] as String?, + contactNumber: data['contact_number'] == null + ? null + : data['contact_number'] as String?, + remarks: data['remarks'] == null ? null : data['remarks'] as String?, + officeId: data['office_id'] == null ? null : data['office_id'] as String?, + requestedBy: data['requested_by'] == null + ? null + : data['requested_by'] as String?, + requestedByUserId: data['requested_by_user_id'] == null + ? null + : data['requested_by_user_id'] as String?, + approvedBy: data['approved_by'] == null + ? null + : data['approved_by'] as String?, + approvedByUserId: data['approved_by_user_id'] == null + ? null + : data['approved_by_user_id'] as String?, + approvedAt: data['approved_at'] == null + ? null + : data['approved_at'] == null + ? null + : DateTime.tryParse(data['approved_at'] as String), + status: data['status'] as String, + outsidePremiseAllowed: data['outside_premise_allowed'] == 1, + cancellationReason: data['cancellation_reason'] == null + ? null + : data['cancellation_reason'] as String?, + cancelledAt: data['cancelled_at'] == null + ? null + : data['cancelled_at'] == null + ? null + : DateTime.tryParse(data['cancelled_at'] as String), + creatorId: data['creator_id'] == null + ? null + : data['creator_id'] as String?, + createdAt: DateTime.parse(data['created_at'] as String), + updatedAt: DateTime.parse(data['updated_at'] as String), + completedAt: data['completed_at'] == null + ? null + : data['completed_at'] == null + ? null + : DateTime.tryParse(data['completed_at'] as String), + dateTimeReceived: data['date_time_received'] == null + ? null + : data['date_time_received'] == null + ? null + : DateTime.tryParse(data['date_time_received'] as String), + dateTimeChecked: data['date_time_checked'] == null + ? null + : data['date_time_checked'] == null + ? null + : DateTime.tryParse(data['date_time_checked'] as String), + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$ItServiceRequestToSqlite( + ItServiceRequest instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'request_number': instance.requestNumber, + 'services': jsonEncode(instance.services), + 'services_other': instance.servicesOther, + 'event_name': instance.eventName, + 'event_details': instance.eventDetails, + 'event_date': instance.eventDate?.toIso8601String(), + 'event_end_date': instance.eventEndDate?.toIso8601String(), + 'dry_run_date': instance.dryRunDate?.toIso8601String(), + 'dry_run_end_date': instance.dryRunEndDate?.toIso8601String(), + 'contact_person': instance.contactPerson, + 'contact_number': instance.contactNumber, + 'remarks': instance.remarks, + 'office_id': instance.officeId, + 'requested_by': instance.requestedBy, + 'requested_by_user_id': instance.requestedByUserId, + 'approved_by': instance.approvedBy, + 'approved_by_user_id': instance.approvedByUserId, + 'approved_at': instance.approvedAt?.toIso8601String(), + 'status': instance.status, + 'outside_premise_allowed': instance.outsidePremiseAllowed ? 1 : 0, + 'cancellation_reason': instance.cancellationReason, + 'cancelled_at': instance.cancelledAt?.toIso8601String(), + 'creator_id': instance.creatorId, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'completed_at': instance.completedAt?.toIso8601String(), + 'date_time_received': instance.dateTimeReceived?.toIso8601String(), + 'date_time_checked': instance.dateTimeChecked?.toIso8601String(), + }; +} + +/// Construct a [ItServiceRequest] +class ItServiceRequestAdapter + extends OfflineFirstWithSupabaseAdapter { + ItServiceRequestAdapter(); + + @override + final supabaseTableName = 'it_service_requests'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'requestNumber': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'request_number', + ), + 'services': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'services', + ), + 'servicesOther': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'services_other', + ), + 'eventName': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'event_name', + ), + 'eventDetails': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'event_details', + ), + 'eventDate': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'event_date', + ), + 'eventEndDate': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'event_end_date', + ), + 'dryRunDate': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'dry_run_date', + ), + 'dryRunEndDate': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'dry_run_end_date', + ), + 'contactPerson': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'contact_person', + ), + 'contactNumber': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'contact_number', + ), + 'remarks': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'remarks', + ), + 'officeId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'office_id', + ), + 'requestedBy': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'requested_by', + ), + 'requestedByUserId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'requested_by_user_id', + ), + 'approvedBy': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'approved_by', + ), + 'approvedByUserId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'approved_by_user_id', + ), + 'approvedAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'approved_at', + ), + 'status': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'status', + ), + 'outsidePremiseAllowed': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'outside_premise_allowed', + ), + 'cancellationReason': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'cancellation_reason', + ), + 'cancelledAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'cancelled_at', + ), + 'creatorId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'creator_id', + ), + 'createdAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'created_at', + ), + 'updatedAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'updated_at', + ), + 'completedAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'completed_at', + ), + 'dateTimeReceived': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'date_time_received', + ), + 'dateTimeChecked': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'date_time_checked', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'requestNumber': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'request_number', + iterable: false, + type: String, + ), + 'services': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'services', + iterable: true, + type: String, + ), + 'servicesOther': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'services_other', + iterable: false, + type: String, + ), + 'eventName': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'event_name', + iterable: false, + type: String, + ), + 'eventDetails': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'event_details', + iterable: false, + type: String, + ), + 'eventDate': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'event_date', + iterable: false, + type: DateTime, + ), + 'eventEndDate': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'event_end_date', + iterable: false, + type: DateTime, + ), + 'dryRunDate': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'dry_run_date', + iterable: false, + type: DateTime, + ), + 'dryRunEndDate': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'dry_run_end_date', + iterable: false, + type: DateTime, + ), + 'contactPerson': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'contact_person', + iterable: false, + type: String, + ), + 'contactNumber': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'contact_number', + iterable: false, + type: String, + ), + 'remarks': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'remarks', + iterable: false, + type: String, + ), + 'officeId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'office_id', + iterable: false, + type: String, + ), + 'requestedBy': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'requested_by', + iterable: false, + type: String, + ), + 'requestedByUserId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'requested_by_user_id', + iterable: false, + type: String, + ), + 'approvedBy': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'approved_by', + iterable: false, + type: String, + ), + 'approvedByUserId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'approved_by_user_id', + iterable: false, + type: String, + ), + 'approvedAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'approved_at', + iterable: false, + type: DateTime, + ), + 'status': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'status', + iterable: false, + type: String, + ), + 'outsidePremiseAllowed': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'outside_premise_allowed', + iterable: false, + type: bool, + ), + 'cancellationReason': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'cancellation_reason', + iterable: false, + type: String, + ), + 'cancelledAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'cancelled_at', + iterable: false, + type: DateTime, + ), + 'creatorId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'creator_id', + iterable: false, + type: String, + ), + 'createdAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'created_at', + iterable: false, + type: DateTime, + ), + 'updatedAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'updated_at', + iterable: false, + type: DateTime, + ), + 'completedAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'completed_at', + iterable: false, + type: DateTime, + ), + 'dateTimeReceived': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'date_time_received', + iterable: false, + type: DateTime, + ), + 'dateTimeChecked': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'date_time_checked', + iterable: false, + type: DateTime, + ), + }; + @override + Future primaryKeyByUniqueColumns( + ItServiceRequest instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'ItServiceRequest'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ItServiceRequestFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + ItServiceRequest input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ItServiceRequestToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ItServiceRequestFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + ItServiceRequest input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ItServiceRequestToSqlite( + input, + provider: provider, + repository: repository, + ); +} diff --git a/lib/brick/adapters/it_service_request_assignment_adapter.g.dart b/lib/brick/adapters/it_service_request_assignment_adapter.g.dart new file mode 100644 index 00000000..4718a8f1 --- /dev/null +++ b/lib/brick/adapters/it_service_request_assignment_adapter.g.dart @@ -0,0 +1,169 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$ItServiceRequestAssignmentFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return ItServiceRequestAssignment( + id: data['id'] as String, + requestId: data['request_id'] as String, + userId: data['user_id'] as String, + createdAt: DateTime.parse(data['created_at'] as String), + ); +} + +Future> _$ItServiceRequestAssignmentToSupabase( + ItServiceRequestAssignment instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'request_id': instance.requestId, + 'user_id': instance.userId, + 'created_at': instance.createdAt.toIso8601String(), + }; +} + +Future _$ItServiceRequestAssignmentFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return ItServiceRequestAssignment( + id: data['id'] as String, + requestId: data['request_id'] as String, + userId: data['user_id'] as String, + createdAt: DateTime.parse(data['created_at'] as String), + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$ItServiceRequestAssignmentToSqlite( + ItServiceRequestAssignment instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'request_id': instance.requestId, + 'user_id': instance.userId, + 'created_at': instance.createdAt.toIso8601String(), + }; +} + +/// Construct a [ItServiceRequestAssignment] +class ItServiceRequestAssignmentAdapter + extends OfflineFirstWithSupabaseAdapter { + ItServiceRequestAssignmentAdapter(); + + @override + final supabaseTableName = 'it_service_request_assignments'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'requestId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'request_id', + ), + 'userId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'user_id', + ), + 'createdAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'created_at', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'requestId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'request_id', + iterable: false, + type: String, + ), + 'userId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'user_id', + iterable: false, + type: String, + ), + 'createdAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'created_at', + iterable: false, + type: DateTime, + ), + }; + @override + Future primaryKeyByUniqueColumns( + ItServiceRequestAssignment instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'ItServiceRequestAssignment'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ItServiceRequestAssignmentFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + ItServiceRequestAssignment input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ItServiceRequestAssignmentToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ItServiceRequestAssignmentFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + ItServiceRequestAssignment input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ItServiceRequestAssignmentToSqlite( + input, + provider: provider, + repository: repository, + ); +} diff --git a/lib/brick/adapters/leave_of_absence_adapter.g.dart b/lib/brick/adapters/leave_of_absence_adapter.g.dart new file mode 100644 index 00000000..033798ba --- /dev/null +++ b/lib/brick/adapters/leave_of_absence_adapter.g.dart @@ -0,0 +1,239 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$LeaveOfAbsenceFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return LeaveOfAbsence( + id: data['id'] as String, + userId: data['user_id'] as String, + leaveType: data['leave_type'] as String, + justification: data['justification'] as String, + startTime: DateTime.parse(data['start_time'] as String), + endTime: DateTime.parse(data['end_time'] as String), + status: data['status'] as String, + filedBy: data['filed_by'] as String, + createdAt: DateTime.parse(data['created_at'] as String), + ); +} + +Future> _$LeaveOfAbsenceToSupabase( + LeaveOfAbsence instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'user_id': instance.userId, + 'leave_type': instance.leaveType, + 'justification': instance.justification, + 'start_time': instance.startTime.toIso8601String(), + 'end_time': instance.endTime.toIso8601String(), + 'status': instance.status, + 'filed_by': instance.filedBy, + 'created_at': instance.createdAt.toIso8601String(), + }; +} + +Future _$LeaveOfAbsenceFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return LeaveOfAbsence( + id: data['id'] as String, + userId: data['user_id'] as String, + leaveType: data['leave_type'] as String, + justification: data['justification'] as String, + startTime: DateTime.parse(data['start_time'] as String), + endTime: DateTime.parse(data['end_time'] as String), + status: data['status'] as String, + filedBy: data['filed_by'] as String, + createdAt: DateTime.parse(data['created_at'] as String), + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$LeaveOfAbsenceToSqlite( + LeaveOfAbsence instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'user_id': instance.userId, + 'leave_type': instance.leaveType, + 'justification': instance.justification, + 'start_time': instance.startTime.toIso8601String(), + 'end_time': instance.endTime.toIso8601String(), + 'status': instance.status, + 'filed_by': instance.filedBy, + 'created_at': instance.createdAt.toIso8601String(), + }; +} + +/// Construct a [LeaveOfAbsence] +class LeaveOfAbsenceAdapter + extends OfflineFirstWithSupabaseAdapter { + LeaveOfAbsenceAdapter(); + + @override + final supabaseTableName = 'leave_of_absence'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'userId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'user_id', + ), + 'leaveType': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'leave_type', + ), + 'justification': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'justification', + ), + 'startTime': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'start_time', + ), + 'endTime': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'end_time', + ), + 'status': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'status', + ), + 'filedBy': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'filed_by', + ), + 'createdAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'created_at', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'userId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'user_id', + iterable: false, + type: String, + ), + 'leaveType': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'leave_type', + iterable: false, + type: String, + ), + 'justification': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'justification', + iterable: false, + type: String, + ), + 'startTime': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'start_time', + iterable: false, + type: DateTime, + ), + 'endTime': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'end_time', + iterable: false, + type: DateTime, + ), + 'status': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'status', + iterable: false, + type: String, + ), + 'filedBy': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'filed_by', + iterable: false, + type: String, + ), + 'createdAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'created_at', + iterable: false, + type: DateTime, + ), + }; + @override + Future primaryKeyByUniqueColumns( + LeaveOfAbsence instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'LeaveOfAbsence'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$LeaveOfAbsenceFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + LeaveOfAbsence input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$LeaveOfAbsenceToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$LeaveOfAbsenceFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + LeaveOfAbsence input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$LeaveOfAbsenceToSqlite( + input, + provider: provider, + repository: repository, + ); +} diff --git a/lib/brick/adapters/notification_item_adapter.g.dart b/lib/brick/adapters/notification_item_adapter.g.dart new file mode 100644 index 00000000..f467b6af --- /dev/null +++ b/lib/brick/adapters/notification_item_adapter.g.dart @@ -0,0 +1,315 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$NotificationItemFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return NotificationItem( + id: data['id'] as String, + userId: data['user_id'] as String, + actorId: data['actor_id'] == null ? null : data['actor_id'] as String?, + ticketId: data['ticket_id'] == null ? null : data['ticket_id'] as String?, + taskId: data['task_id'] == null ? null : data['task_id'] as String?, + leaveId: data['leave_id'] == null ? null : data['leave_id'] as String?, + passSlipId: data['pass_slip_id'] == null + ? null + : data['pass_slip_id'] as String?, + itServiceRequestId: data['it_service_request_id'] == null + ? null + : data['it_service_request_id'] as String?, + announcementId: data['announcement_id'] == null + ? null + : data['announcement_id'] as String?, + messageId: data['message_id'] == null ? null : data['message_id'] as int?, + type: data['type'] as String, + createdAt: DateTime.parse(data['created_at'] as String), + readAt: data['read_at'] == null + ? null + : data['read_at'] == null + ? null + : DateTime.tryParse(data['read_at'] as String), + ); +} + +Future> _$NotificationItemToSupabase( + NotificationItem instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'user_id': instance.userId, + 'actor_id': instance.actorId, + 'ticket_id': instance.ticketId, + 'task_id': instance.taskId, + 'leave_id': instance.leaveId, + 'pass_slip_id': instance.passSlipId, + 'it_service_request_id': instance.itServiceRequestId, + 'announcement_id': instance.announcementId, + 'message_id': instance.messageId, + 'type': instance.type, + 'created_at': instance.createdAt.toIso8601String(), + 'read_at': instance.readAt?.toIso8601String(), + }; +} + +Future _$NotificationItemFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return NotificationItem( + id: data['id'] as String, + userId: data['user_id'] as String, + actorId: data['actor_id'] == null ? null : data['actor_id'] as String?, + ticketId: data['ticket_id'] == null ? null : data['ticket_id'] as String?, + taskId: data['task_id'] == null ? null : data['task_id'] as String?, + leaveId: data['leave_id'] == null ? null : data['leave_id'] as String?, + passSlipId: data['pass_slip_id'] == null + ? null + : data['pass_slip_id'] as String?, + itServiceRequestId: data['it_service_request_id'] == null + ? null + : data['it_service_request_id'] as String?, + announcementId: data['announcement_id'] == null + ? null + : data['announcement_id'] as String?, + messageId: data['message_id'] == null ? null : data['message_id'] as int?, + type: data['type'] as String, + createdAt: DateTime.parse(data['created_at'] as String), + readAt: data['read_at'] == null + ? null + : data['read_at'] == null + ? null + : DateTime.tryParse(data['read_at'] as String), + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$NotificationItemToSqlite( + NotificationItem instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'user_id': instance.userId, + 'actor_id': instance.actorId, + 'ticket_id': instance.ticketId, + 'task_id': instance.taskId, + 'leave_id': instance.leaveId, + 'pass_slip_id': instance.passSlipId, + 'it_service_request_id': instance.itServiceRequestId, + 'announcement_id': instance.announcementId, + 'message_id': instance.messageId, + 'type': instance.type, + 'created_at': instance.createdAt.toIso8601String(), + 'read_at': instance.readAt?.toIso8601String(), + }; +} + +/// Construct a [NotificationItem] +class NotificationItemAdapter + extends OfflineFirstWithSupabaseAdapter { + NotificationItemAdapter(); + + @override + final supabaseTableName = 'notifications'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'userId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'user_id', + ), + 'actorId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'actor_id', + ), + 'ticketId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'ticket_id', + ), + 'taskId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'task_id', + ), + 'leaveId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'leave_id', + ), + 'passSlipId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'pass_slip_id', + ), + 'itServiceRequestId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'it_service_request_id', + ), + 'announcementId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'announcement_id', + ), + 'messageId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'message_id', + ), + 'type': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'type', + ), + 'createdAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'created_at', + ), + 'readAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'read_at', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'userId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'user_id', + iterable: false, + type: String, + ), + 'actorId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'actor_id', + iterable: false, + type: String, + ), + 'ticketId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'ticket_id', + iterable: false, + type: String, + ), + 'taskId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'task_id', + iterable: false, + type: String, + ), + 'leaveId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'leave_id', + iterable: false, + type: String, + ), + 'passSlipId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'pass_slip_id', + iterable: false, + type: String, + ), + 'itServiceRequestId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'it_service_request_id', + iterable: false, + type: String, + ), + 'announcementId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'announcement_id', + iterable: false, + type: String, + ), + 'messageId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'message_id', + iterable: false, + type: int, + ), + 'type': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'type', + iterable: false, + type: String, + ), + 'createdAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'created_at', + iterable: false, + type: DateTime, + ), + 'readAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'read_at', + iterable: false, + type: DateTime, + ), + }; + @override + Future primaryKeyByUniqueColumns( + NotificationItem instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'NotificationItem'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$NotificationItemFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + NotificationItem input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$NotificationItemToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$NotificationItemFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + NotificationItem input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$NotificationItemToSqlite( + input, + provider: provider, + repository: repository, + ); +} diff --git a/lib/brick/adapters/office_adapter.g.dart b/lib/brick/adapters/office_adapter.g.dart new file mode 100644 index 00000000..6fcc38f0 --- /dev/null +++ b/lib/brick/adapters/office_adapter.g.dart @@ -0,0 +1,155 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$OfficeFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return Office( + id: data['id'] as String, + name: data['name'] as String, + serviceId: data['service_id'] == null + ? null + : data['service_id'] as String?, + ); +} + +Future> _$OfficeToSupabase( + Office instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'name': instance.name, + 'service_id': instance.serviceId, + }; +} + +Future _$OfficeFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return Office( + id: data['id'] as String, + name: data['name'] as String, + serviceId: data['service_id'] == null + ? null + : data['service_id'] as String?, + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$OfficeToSqlite( + Office instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'name': instance.name, + 'service_id': instance.serviceId, + }; +} + +/// Construct a [Office] +class OfficeAdapter extends OfflineFirstWithSupabaseAdapter { + OfficeAdapter(); + + @override + final supabaseTableName = 'offices'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'name': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'name', + ), + 'serviceId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'service_id', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'name': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'name', + iterable: false, + type: String, + ), + 'serviceId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'service_id', + iterable: false, + type: String, + ), + }; + @override + Future primaryKeyByUniqueColumns( + Office instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'Office'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$OfficeFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + Office input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$OfficeToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$OfficeFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + Office input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => + await _$OfficeToSqlite(input, provider: provider, repository: repository); +} diff --git a/lib/brick/adapters/pass_slip_adapter.g.dart b/lib/brick/adapters/pass_slip_adapter.g.dart new file mode 100644 index 00000000..7445ae77 --- /dev/null +++ b/lib/brick/adapters/pass_slip_adapter.g.dart @@ -0,0 +1,302 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$PassSlipFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return PassSlip( + id: data['id'] as String, + userId: data['user_id'] as String, + dutyScheduleId: data['duty_schedule_id'] as String, + reason: data['reason'] as String, + status: data['status'] as String, + requestedAt: DateTime.parse(data['requested_at'] as String), + approvedBy: data['approved_by'] == null + ? null + : data['approved_by'] as String?, + approvedAt: data['approved_at'] == null + ? null + : data['approved_at'] == null + ? null + : DateTime.tryParse(data['approved_at'] as String), + slipStart: data['slip_start'] == null + ? null + : data['slip_start'] == null + ? null + : DateTime.tryParse(data['slip_start'] as String), + slipEnd: data['slip_end'] == null + ? null + : data['slip_end'] == null + ? null + : DateTime.tryParse(data['slip_end'] as String), + requestedStart: data['requested_start'] == null + ? null + : data['requested_start'] == null + ? null + : DateTime.tryParse(data['requested_start'] as String), + ); +} + +Future> _$PassSlipToSupabase( + PassSlip instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'user_id': instance.userId, + 'duty_schedule_id': instance.dutyScheduleId, + 'reason': instance.reason, + 'status': instance.status, + 'requested_at': instance.requestedAt.toIso8601String(), + 'approved_by': instance.approvedBy, + 'approved_at': instance.approvedAt?.toIso8601String(), + 'slip_start': instance.slipStart?.toIso8601String(), + 'slip_end': instance.slipEnd?.toIso8601String(), + 'requested_start': instance.requestedStart?.toIso8601String(), + }; +} + +Future _$PassSlipFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return PassSlip( + id: data['id'] as String, + userId: data['user_id'] as String, + dutyScheduleId: data['duty_schedule_id'] as String, + reason: data['reason'] as String, + status: data['status'] as String, + requestedAt: DateTime.parse(data['requested_at'] as String), + approvedBy: data['approved_by'] == null + ? null + : data['approved_by'] as String?, + approvedAt: data['approved_at'] == null + ? null + : data['approved_at'] == null + ? null + : DateTime.tryParse(data['approved_at'] as String), + slipStart: data['slip_start'] == null + ? null + : data['slip_start'] == null + ? null + : DateTime.tryParse(data['slip_start'] as String), + slipEnd: data['slip_end'] == null + ? null + : data['slip_end'] == null + ? null + : DateTime.tryParse(data['slip_end'] as String), + requestedStart: data['requested_start'] == null + ? null + : data['requested_start'] == null + ? null + : DateTime.tryParse(data['requested_start'] as String), + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$PassSlipToSqlite( + PassSlip instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'user_id': instance.userId, + 'duty_schedule_id': instance.dutyScheduleId, + 'reason': instance.reason, + 'status': instance.status, + 'requested_at': instance.requestedAt.toIso8601String(), + 'approved_by': instance.approvedBy, + 'approved_at': instance.approvedAt?.toIso8601String(), + 'slip_start': instance.slipStart?.toIso8601String(), + 'slip_end': instance.slipEnd?.toIso8601String(), + 'requested_start': instance.requestedStart?.toIso8601String(), + }; +} + +/// Construct a [PassSlip] +class PassSlipAdapter extends OfflineFirstWithSupabaseAdapter { + PassSlipAdapter(); + + @override + final supabaseTableName = 'pass_slips'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'userId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'user_id', + ), + 'dutyScheduleId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'duty_schedule_id', + ), + 'reason': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'reason', + ), + 'status': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'status', + ), + 'requestedAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'requested_at', + ), + 'approvedBy': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'approved_by', + ), + 'approvedAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'approved_at', + ), + 'slipStart': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'slip_start', + ), + 'slipEnd': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'slip_end', + ), + 'requestedStart': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'requested_start', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'userId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'user_id', + iterable: false, + type: String, + ), + 'dutyScheduleId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'duty_schedule_id', + iterable: false, + type: String, + ), + 'reason': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'reason', + iterable: false, + type: String, + ), + 'status': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'status', + iterable: false, + type: String, + ), + 'requestedAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'requested_at', + iterable: false, + type: DateTime, + ), + 'approvedBy': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'approved_by', + iterable: false, + type: String, + ), + 'approvedAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'approved_at', + iterable: false, + type: DateTime, + ), + 'slipStart': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'slip_start', + iterable: false, + type: DateTime, + ), + 'slipEnd': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'slip_end', + iterable: false, + type: DateTime, + ), + 'requestedStart': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'requested_start', + iterable: false, + type: DateTime, + ), + }; + @override + Future primaryKeyByUniqueColumns( + PassSlip instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'PassSlip'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$PassSlipFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + PassSlip input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$PassSlipToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$PassSlipFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + PassSlip input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$PassSlipToSqlite( + input, + provider: provider, + repository: repository, + ); +} diff --git a/lib/brick/adapters/profile_adapter.g.dart b/lib/brick/adapters/profile_adapter.g.dart new file mode 100644 index 00000000..0a2f1453 --- /dev/null +++ b/lib/brick/adapters/profile_adapter.g.dart @@ -0,0 +1,240 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$ProfileFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return Profile( + id: data['id'] as String, + role: data['role'] as String, + fullName: data['full_name'] as String, + religion: data['religion'] as String, + allowTracking: data['allow_tracking'] as bool, + avatarUrl: data['avatar_url'] == null + ? null + : data['avatar_url'] as String?, + facePhotoUrl: data['face_photo_url'] == null + ? null + : data['face_photo_url'] as String?, + faceEnrolledAt: data['face_enrolled_at'] == null + ? null + : data['face_enrolled_at'] == null + ? null + : DateTime.tryParse(data['face_enrolled_at'] as String), + ); +} + +Future> _$ProfileToSupabase( + Profile instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'role': instance.role, + 'full_name': instance.fullName, + 'religion': instance.religion, + 'allow_tracking': instance.allowTracking, + 'avatar_url': instance.avatarUrl, + 'face_photo_url': instance.facePhotoUrl, + 'face_enrolled_at': instance.faceEnrolledAt?.toIso8601String(), + }; +} + +Future _$ProfileFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return Profile( + id: data['id'] as String, + role: data['role'] as String, + fullName: data['full_name'] as String, + religion: data['religion'] as String, + allowTracking: data['allow_tracking'] == 1, + avatarUrl: data['avatar_url'] == null + ? null + : data['avatar_url'] as String?, + facePhotoUrl: data['face_photo_url'] == null + ? null + : data['face_photo_url'] as String?, + faceEnrolledAt: data['face_enrolled_at'] == null + ? null + : data['face_enrolled_at'] == null + ? null + : DateTime.tryParse(data['face_enrolled_at'] as String), + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$ProfileToSqlite( + Profile instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'role': instance.role, + 'full_name': instance.fullName, + 'religion': instance.religion, + 'allow_tracking': instance.allowTracking ? 1 : 0, + 'avatar_url': instance.avatarUrl, + 'face_photo_url': instance.facePhotoUrl, + 'face_enrolled_at': instance.faceEnrolledAt?.toIso8601String(), + }; +} + +/// Construct a [Profile] +class ProfileAdapter extends OfflineFirstWithSupabaseAdapter { + ProfileAdapter(); + + @override + final supabaseTableName = 'profiles'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'role': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'role', + ), + 'fullName': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'full_name', + ), + 'religion': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'religion', + ), + 'allowTracking': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'allow_tracking', + ), + 'avatarUrl': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'avatar_url', + ), + 'facePhotoUrl': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'face_photo_url', + ), + 'faceEnrolledAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'face_enrolled_at', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'role': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'role', + iterable: false, + type: String, + ), + 'fullName': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'full_name', + iterable: false, + type: String, + ), + 'religion': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'religion', + iterable: false, + type: String, + ), + 'allowTracking': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'allow_tracking', + iterable: false, + type: bool, + ), + 'avatarUrl': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'avatar_url', + iterable: false, + type: String, + ), + 'facePhotoUrl': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'face_photo_url', + iterable: false, + type: String, + ), + 'faceEnrolledAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'face_enrolled_at', + iterable: false, + type: DateTime, + ), + }; + @override + Future primaryKeyByUniqueColumns( + Profile instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'Profile'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ProfileFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + Profile input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ProfileToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ProfileFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + Profile input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ProfileToSqlite( + input, + provider: provider, + repository: repository, + ); +} diff --git a/lib/brick/adapters/service_adapter.g.dart b/lib/brick/adapters/service_adapter.g.dart new file mode 100644 index 00000000..ac2ec57b --- /dev/null +++ b/lib/brick/adapters/service_adapter.g.dart @@ -0,0 +1,129 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$ServiceFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return Service(id: data['id'] as String, name: data['name'] as String); +} + +Future> _$ServiceToSupabase( + Service instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return {'id': instance.id, 'name': instance.name}; +} + +Future _$ServiceFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return Service(id: data['id'] as String, name: data['name'] as String) + ..primaryKey = data['_brick_id'] as int; +} + +Future> _$ServiceToSqlite( + Service instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return {'id': instance.id, 'name': instance.name}; +} + +/// Construct a [Service] +class ServiceAdapter extends OfflineFirstWithSupabaseAdapter { + ServiceAdapter(); + + @override + final supabaseTableName = 'services'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'name': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'name', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'name': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'name', + iterable: false, + type: String, + ), + }; + @override + Future primaryKeyByUniqueColumns( + Service instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'Service'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ServiceFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + Service input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ServiceToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ServiceFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + Service input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ServiceToSqlite( + input, + provider: provider, + repository: repository, + ); +} diff --git a/lib/brick/adapters/swap_request_adapter.g.dart b/lib/brick/adapters/swap_request_adapter.g.dart new file mode 100644 index 00000000..601297bd --- /dev/null +++ b/lib/brick/adapters/swap_request_adapter.g.dart @@ -0,0 +1,332 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$SwapRequestFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return SwapRequest( + id: data['id'] as String, + requesterId: data['requester_id'] as String, + recipientId: data['recipient_id'] as String, + requesterScheduleId: data['requester_schedule_id'] as String, + targetScheduleId: data['target_shift_id'] == null + ? null + : data['target_shift_id'] as String?, + status: data['status'] as String, + createdAt: DateTime.parse(data['created_at'] as String), + updatedAt: data['updated_at'] == null + ? null + : data['updated_at'] == null + ? null + : DateTime.tryParse(data['updated_at'] as String), + chatThreadId: data['chat_thread_id'] == null + ? null + : data['chat_thread_id'] as String?, + shiftType: data['shift_type'] == null + ? null + : data['shift_type'] as String?, + shiftStartTime: data['shift_start_time'] == null + ? null + : data['shift_start_time'] == null + ? null + : DateTime.tryParse(data['shift_start_time'] as String), + relieverIds: data['reliever_ids'] == null + ? null + : data['reliever_ids']?.toList().cast(), + approvedBy: data['approved_by'] == null + ? null + : data['approved_by'] as String?, + ); +} + +Future> _$SwapRequestToSupabase( + SwapRequest instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'requester_id': instance.requesterId, + 'recipient_id': instance.recipientId, + 'requester_schedule_id': instance.requesterScheduleId, + 'target_shift_id': instance.targetScheduleId, + 'status': instance.status, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt?.toIso8601String(), + 'chat_thread_id': instance.chatThreadId, + 'shift_type': instance.shiftType, + 'shift_start_time': instance.shiftStartTime?.toIso8601String(), + 'reliever_ids': instance.relieverIds, + 'approved_by': instance.approvedBy, + }; +} + +Future _$SwapRequestFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return SwapRequest( + id: data['id'] as String, + requesterId: data['requester_id'] as String, + recipientId: data['recipient_id'] as String, + requesterScheduleId: data['requester_schedule_id'] as String, + targetScheduleId: data['target_schedule_id'] == null + ? null + : data['target_schedule_id'] as String?, + status: data['status'] as String, + createdAt: DateTime.parse(data['created_at'] as String), + updatedAt: data['updated_at'] == null + ? null + : data['updated_at'] == null + ? null + : DateTime.tryParse(data['updated_at'] as String), + chatThreadId: data['chat_thread_id'] == null + ? null + : data['chat_thread_id'] as String?, + shiftType: data['shift_type'] == null + ? null + : data['shift_type'] as String?, + shiftStartTime: data['shift_start_time'] == null + ? null + : data['shift_start_time'] == null + ? null + : DateTime.tryParse(data['shift_start_time'] as String), + relieverIds: data['reliever_ids'] == null + ? null + : jsonDecode(data['reliever_ids']).toList().cast(), + approvedBy: data['approved_by'] == null + ? null + : data['approved_by'] as String?, + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$SwapRequestToSqlite( + SwapRequest instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'requester_id': instance.requesterId, + 'recipient_id': instance.recipientId, + 'requester_schedule_id': instance.requesterScheduleId, + 'target_schedule_id': instance.targetScheduleId, + 'status': instance.status, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt?.toIso8601String(), + 'chat_thread_id': instance.chatThreadId, + 'shift_type': instance.shiftType, + 'shift_start_time': instance.shiftStartTime?.toIso8601String(), + 'reliever_ids': instance.relieverIds == null + ? null + : jsonEncode(instance.relieverIds), + 'approved_by': instance.approvedBy, + }; +} + +/// Construct a [SwapRequest] +class SwapRequestAdapter extends OfflineFirstWithSupabaseAdapter { + SwapRequestAdapter(); + + @override + final supabaseTableName = 'swap_requests'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'requesterId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'requester_id', + ), + 'recipientId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'recipient_id', + ), + 'requesterScheduleId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'requester_schedule_id', + ), + 'targetScheduleId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'target_shift_id', + ), + 'status': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'status', + ), + 'createdAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'created_at', + ), + 'updatedAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'updated_at', + ), + 'chatThreadId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'chat_thread_id', + ), + 'shiftType': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'shift_type', + ), + 'shiftStartTime': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'shift_start_time', + ), + 'relieverIds': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'reliever_ids', + ), + 'approvedBy': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'approved_by', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'requesterId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'requester_id', + iterable: false, + type: String, + ), + 'recipientId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'recipient_id', + iterable: false, + type: String, + ), + 'requesterScheduleId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'requester_schedule_id', + iterable: false, + type: String, + ), + 'targetScheduleId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'target_schedule_id', + iterable: false, + type: String, + ), + 'status': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'status', + iterable: false, + type: String, + ), + 'createdAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'created_at', + iterable: false, + type: DateTime, + ), + 'updatedAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'updated_at', + iterable: false, + type: DateTime, + ), + 'chatThreadId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'chat_thread_id', + iterable: false, + type: String, + ), + 'shiftType': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'shift_type', + iterable: false, + type: String, + ), + 'shiftStartTime': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'shift_start_time', + iterable: false, + type: DateTime, + ), + 'relieverIds': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'reliever_ids', + iterable: true, + type: String, + ), + 'approvedBy': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'approved_by', + iterable: false, + type: String, + ), + }; + @override + Future primaryKeyByUniqueColumns( + SwapRequest instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'SwapRequest'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$SwapRequestFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + SwapRequest input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$SwapRequestToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$SwapRequestFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + SwapRequest input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$SwapRequestToSqlite( + input, + provider: provider, + repository: repository, + ); +} diff --git a/lib/brick/adapters/task_activity_log_adapter.g.dart b/lib/brick/adapters/task_activity_log_adapter.g.dart new file mode 100644 index 00000000..a4f720ff --- /dev/null +++ b/lib/brick/adapters/task_activity_log_adapter.g.dart @@ -0,0 +1,197 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$TaskActivityLogFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return TaskActivityLog( + id: data['id'] as String, + taskId: data['task_id'] as String, + actorId: data['actor_id'] == null ? null : data['actor_id'] as String?, + actionType: data['action_type'] as String, + meta: data['meta'] == null ? null : data['meta'], + createdAt: DateTime.parse(data['created_at'] as String), + ); +} + +Future> _$TaskActivityLogToSupabase( + TaskActivityLog instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'task_id': instance.taskId, + 'actor_id': instance.actorId, + 'action_type': instance.actionType, + 'meta': instance.meta, + 'created_at': instance.createdAt.toIso8601String(), + }; +} + +Future _$TaskActivityLogFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return TaskActivityLog( + id: data['id'] as String, + taskId: data['task_id'] as String, + actorId: data['actor_id'] == null ? null : data['actor_id'] as String?, + actionType: data['action_type'] as String, + meta: data['meta'] == null ? null : jsonDecode(data['meta']), + createdAt: DateTime.parse(data['created_at'] as String), + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$TaskActivityLogToSqlite( + TaskActivityLog instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'task_id': instance.taskId, + 'actor_id': instance.actorId, + 'action_type': instance.actionType, + 'meta': instance.meta != null ? jsonEncode(instance.meta) : null, + 'created_at': instance.createdAt.toIso8601String(), + }; +} + +/// Construct a [TaskActivityLog] +class TaskActivityLogAdapter + extends OfflineFirstWithSupabaseAdapter { + TaskActivityLogAdapter(); + + @override + final supabaseTableName = 'task_activity_logs'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'taskId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'task_id', + ), + 'actorId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'actor_id', + ), + 'actionType': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'action_type', + ), + 'meta': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'meta', + ), + 'createdAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'created_at', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'taskId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'task_id', + iterable: false, + type: String, + ), + 'actorId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'actor_id', + iterable: false, + type: String, + ), + 'actionType': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'action_type', + iterable: false, + type: String, + ), + 'meta': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'meta', + iterable: false, + type: Map, + ), + 'createdAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'created_at', + iterable: false, + type: DateTime, + ), + }; + @override + Future primaryKeyByUniqueColumns( + TaskActivityLog instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'TaskActivityLog'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$TaskActivityLogFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + TaskActivityLog input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$TaskActivityLogToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$TaskActivityLogFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + TaskActivityLog input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$TaskActivityLogToSqlite( + input, + provider: provider, + repository: repository, + ); +} diff --git a/lib/brick/adapters/task_adapter.g.dart b/lib/brick/adapters/task_adapter.g.dart new file mode 100644 index 00000000..0311194b --- /dev/null +++ b/lib/brick/adapters/task_adapter.g.dart @@ -0,0 +1,529 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$TaskFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return Task( + id: data['id'] as String, + ticketId: data['ticket_id'] == null ? null : data['ticket_id'] as String?, + taskNumber: data['task_number'] == null + ? null + : data['task_number'] as String?, + title: data['title'] as String, + description: data['description'] as String, + officeId: data['office_id'] == null ? null : data['office_id'] as String?, + status: data['status'] as String, + priority: data['priority'] as int, + queueOrder: data['queue_order'] == null + ? null + : data['queue_order'] as int?, + createdAt: DateTime.parse(data['created_at'] as String), + creatorId: data['creator_id'] == null + ? null + : data['creator_id'] as String?, + startedAt: data['started_at'] == null + ? null + : data['started_at'] == null + ? null + : DateTime.tryParse(data['started_at'] as String), + completedAt: data['completed_at'] == null + ? null + : data['completed_at'] == null + ? null + : DateTime.tryParse(data['completed_at'] as String), + requestedBy: data['requested_by'] == null + ? null + : data['requested_by'] as String?, + notedBy: data['noted_by'] == null ? null : data['noted_by'] as String?, + receivedBy: data['received_by'] == null + ? null + : data['received_by'] as String?, + requestType: data['request_type'] == null + ? null + : data['request_type'] as String?, + requestTypeOther: data['request_type_other'] == null + ? null + : data['request_type_other'] as String?, + requestCategory: data['request_category'] == null + ? null + : data['request_category'] as String?, + actionTaken: data['action_taken'] == null + ? null + : data['action_taken'] as String?, + cancellationReason: data['cancellation_reason'] == null + ? null + : data['cancellation_reason'] as String?, + cancelledAt: data['cancelled_at'] == null + ? null + : data['cancelled_at'] == null + ? null + : DateTime.tryParse(data['cancelled_at'] as String), + itJobPrinted: data['it_job_printed'] as bool, + itJobPrintedAt: data['it_job_printed_at'] == null + ? null + : data['it_job_printed_at'] == null + ? null + : DateTime.tryParse(data['it_job_printed_at'] as String), + itJobReceivedById: data['it_job_received_by_id'] == null + ? null + : data['it_job_received_by_id'] as String?, + ); +} + +Future> _$TaskToSupabase( + Task instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'ticket_id': instance.ticketId, + 'task_number': instance.taskNumber, + 'title': instance.title, + 'description': instance.description, + 'office_id': instance.officeId, + 'status': instance.status, + 'priority': instance.priority, + 'queue_order': instance.queueOrder, + 'created_at': instance.createdAt.toIso8601String(), + 'creator_id': instance.creatorId, + 'started_at': instance.startedAt?.toIso8601String(), + 'completed_at': instance.completedAt?.toIso8601String(), + 'requested_by': instance.requestedBy, + 'noted_by': instance.notedBy, + 'received_by': instance.receivedBy, + 'request_type': instance.requestType, + 'request_type_other': instance.requestTypeOther, + 'request_category': instance.requestCategory, + 'action_taken': instance.actionTaken, + 'cancellation_reason': instance.cancellationReason, + 'cancelled_at': instance.cancelledAt?.toIso8601String(), + 'it_job_printed': instance.itJobPrinted, + 'it_job_printed_at': instance.itJobPrintedAt?.toIso8601String(), + 'it_job_received_by_id': instance.itJobReceivedById, + }; +} + +Future _$TaskFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return Task( + id: data['id'] as String, + ticketId: data['ticket_id'] == null ? null : data['ticket_id'] as String?, + taskNumber: data['task_number'] == null + ? null + : data['task_number'] as String?, + title: data['title'] as String, + description: data['description'] as String, + officeId: data['office_id'] == null ? null : data['office_id'] as String?, + status: data['status'] as String, + priority: data['priority'] as int, + queueOrder: data['queue_order'] == null + ? null + : data['queue_order'] as int?, + createdAt: DateTime.parse(data['created_at'] as String), + creatorId: data['creator_id'] == null + ? null + : data['creator_id'] as String?, + startedAt: data['started_at'] == null + ? null + : data['started_at'] == null + ? null + : DateTime.tryParse(data['started_at'] as String), + completedAt: data['completed_at'] == null + ? null + : data['completed_at'] == null + ? null + : DateTime.tryParse(data['completed_at'] as String), + requestedBy: data['requested_by'] == null + ? null + : data['requested_by'] as String?, + notedBy: data['noted_by'] == null ? null : data['noted_by'] as String?, + receivedBy: data['received_by'] == null + ? null + : data['received_by'] as String?, + requestType: data['request_type'] == null + ? null + : data['request_type'] as String?, + requestTypeOther: data['request_type_other'] == null + ? null + : data['request_type_other'] as String?, + requestCategory: data['request_category'] == null + ? null + : data['request_category'] as String?, + actionTaken: data['action_taken'] == null + ? null + : data['action_taken'] as String?, + cancellationReason: data['cancellation_reason'] == null + ? null + : data['cancellation_reason'] as String?, + cancelledAt: data['cancelled_at'] == null + ? null + : data['cancelled_at'] == null + ? null + : DateTime.tryParse(data['cancelled_at'] as String), + itJobPrinted: data['it_job_printed'] == 1, + itJobPrintedAt: data['it_job_printed_at'] == null + ? null + : data['it_job_printed_at'] == null + ? null + : DateTime.tryParse(data['it_job_printed_at'] as String), + itJobReceivedById: data['it_job_received_by_id'] == null + ? null + : data['it_job_received_by_id'] as String?, + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$TaskToSqlite( + Task instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'ticket_id': instance.ticketId, + 'task_number': instance.taskNumber, + 'title': instance.title, + 'description': instance.description, + 'office_id': instance.officeId, + 'status': instance.status, + 'priority': instance.priority, + 'queue_order': instance.queueOrder, + 'created_at': instance.createdAt.toIso8601String(), + 'creator_id': instance.creatorId, + 'started_at': instance.startedAt?.toIso8601String(), + 'completed_at': instance.completedAt?.toIso8601String(), + 'requested_by': instance.requestedBy, + 'noted_by': instance.notedBy, + 'received_by': instance.receivedBy, + 'request_type': instance.requestType, + 'request_type_other': instance.requestTypeOther, + 'request_category': instance.requestCategory, + 'action_taken': instance.actionTaken, + 'cancellation_reason': instance.cancellationReason, + 'cancelled_at': instance.cancelledAt?.toIso8601String(), + 'it_job_printed': instance.itJobPrinted ? 1 : 0, + 'it_job_printed_at': instance.itJobPrintedAt?.toIso8601String(), + 'it_job_received_by_id': instance.itJobReceivedById, + }; +} + +/// Construct a [Task] +class TaskAdapter extends OfflineFirstWithSupabaseAdapter { + TaskAdapter(); + + @override + final supabaseTableName = 'tasks'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'ticketId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'ticket_id', + ), + 'taskNumber': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'task_number', + ), + 'title': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'title', + ), + 'description': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'description', + ), + 'officeId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'office_id', + ), + 'status': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'status', + ), + 'priority': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'priority', + ), + 'queueOrder': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'queue_order', + ), + 'createdAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'created_at', + ), + 'creatorId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'creator_id', + ), + 'startedAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'started_at', + ), + 'completedAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'completed_at', + ), + 'requestedBy': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'requested_by', + ), + 'notedBy': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'noted_by', + ), + 'receivedBy': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'received_by', + ), + 'requestType': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'request_type', + ), + 'requestTypeOther': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'request_type_other', + ), + 'requestCategory': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'request_category', + ), + 'actionTaken': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'action_taken', + ), + 'cancellationReason': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'cancellation_reason', + ), + 'cancelledAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'cancelled_at', + ), + 'itJobPrinted': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'it_job_printed', + ), + 'itJobPrintedAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'it_job_printed_at', + ), + 'itJobReceivedById': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'it_job_received_by_id', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'ticketId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'ticket_id', + iterable: false, + type: String, + ), + 'taskNumber': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'task_number', + iterable: false, + type: String, + ), + 'title': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'title', + iterable: false, + type: String, + ), + 'description': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'description', + iterable: false, + type: String, + ), + 'officeId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'office_id', + iterable: false, + type: String, + ), + 'status': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'status', + iterable: false, + type: String, + ), + 'priority': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'priority', + iterable: false, + type: int, + ), + 'queueOrder': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'queue_order', + iterable: false, + type: int, + ), + 'createdAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'created_at', + iterable: false, + type: DateTime, + ), + 'creatorId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'creator_id', + iterable: false, + type: String, + ), + 'startedAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'started_at', + iterable: false, + type: DateTime, + ), + 'completedAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'completed_at', + iterable: false, + type: DateTime, + ), + 'requestedBy': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'requested_by', + iterable: false, + type: String, + ), + 'notedBy': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'noted_by', + iterable: false, + type: String, + ), + 'receivedBy': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'received_by', + iterable: false, + type: String, + ), + 'requestType': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'request_type', + iterable: false, + type: String, + ), + 'requestTypeOther': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'request_type_other', + iterable: false, + type: String, + ), + 'requestCategory': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'request_category', + iterable: false, + type: String, + ), + 'actionTaken': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'action_taken', + iterable: false, + type: String, + ), + 'cancellationReason': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'cancellation_reason', + iterable: false, + type: String, + ), + 'cancelledAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'cancelled_at', + iterable: false, + type: DateTime, + ), + 'itJobPrinted': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'it_job_printed', + iterable: false, + type: bool, + ), + 'itJobPrintedAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'it_job_printed_at', + iterable: false, + type: DateTime, + ), + 'itJobReceivedById': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'it_job_received_by_id', + iterable: false, + type: String, + ), + }; + @override + Future primaryKeyByUniqueColumns( + Task instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'Task'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$TaskFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + Task input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => + await _$TaskToSupabase(input, provider: provider, repository: repository); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => + await _$TaskFromSqlite(input, provider: provider, repository: repository); + @override + Future> toSqlite( + Task input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => + await _$TaskToSqlite(input, provider: provider, repository: repository); +} diff --git a/lib/brick/adapters/team_adapter.g.dart b/lib/brick/adapters/team_adapter.g.dart new file mode 100644 index 00000000..6898fd85 --- /dev/null +++ b/lib/brick/adapters/team_adapter.g.dart @@ -0,0 +1,187 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$TeamFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return Team( + id: data['id'] as String, + name: data['name'] as String, + leaderId: data['leader_id'] as String, + officeIds: data['office_ids'].toList().cast(), + createdAt: DateTime.parse(data['created_at'] as String), + color: data['color'] == null ? null : data['color'] as String?, + ); +} + +Future> _$TeamToSupabase( + Team instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'name': instance.name, + 'leader_id': instance.leaderId, + 'office_ids': instance.officeIds, + 'created_at': instance.createdAt.toIso8601String(), + 'color': instance.color, + }; +} + +Future _$TeamFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return Team( + id: data['id'] as String, + name: data['name'] as String, + leaderId: data['leader_id'] as String, + officeIds: jsonDecode(data['office_ids']).toList().cast(), + createdAt: DateTime.parse(data['created_at'] as String), + color: data['color'] == null ? null : data['color'] as String?, + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$TeamToSqlite( + Team instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'name': instance.name, + 'leader_id': instance.leaderId, + 'office_ids': jsonEncode(instance.officeIds), + 'created_at': instance.createdAt.toIso8601String(), + 'color': instance.color, + }; +} + +/// Construct a [Team] +class TeamAdapter extends OfflineFirstWithSupabaseAdapter { + TeamAdapter(); + + @override + final supabaseTableName = 'teams'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'name': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'name', + ), + 'leaderId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'leader_id', + ), + 'officeIds': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'office_ids', + ), + 'createdAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'created_at', + ), + 'color': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'color', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'name': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'name', + iterable: false, + type: String, + ), + 'leaderId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'leader_id', + iterable: false, + type: String, + ), + 'officeIds': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'office_ids', + iterable: true, + type: String, + ), + 'createdAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'created_at', + iterable: false, + type: DateTime, + ), + 'color': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'color', + iterable: false, + type: String, + ), + }; + @override + Future primaryKeyByUniqueColumns( + Team instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'Team'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$TeamFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + Team input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => + await _$TeamToSupabase(input, provider: provider, repository: repository); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => + await _$TeamFromSqlite(input, provider: provider, repository: repository); + @override + Future> toSqlite( + Team input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => + await _$TeamToSqlite(input, provider: provider, repository: repository); +} diff --git a/lib/brick/adapters/ticket_adapter.g.dart b/lib/brick/adapters/ticket_adapter.g.dart new file mode 100644 index 00000000..2e11ff1a --- /dev/null +++ b/lib/brick/adapters/ticket_adapter.g.dart @@ -0,0 +1,277 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$TicketFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return Ticket( + id: data['id'] as String, + subject: data['subject'] as String, + description: data['description'] as String, + officeId: data['office_id'] as String, + status: data['status'] as String, + createdAt: DateTime.parse(data['created_at'] as String), + creatorId: data['creator_id'] == null + ? null + : data['creator_id'] as String?, + respondedAt: data['responded_at'] == null + ? null + : data['responded_at'] == null + ? null + : DateTime.tryParse(data['responded_at'] as String), + promotedAt: data['promoted_at'] == null + ? null + : data['promoted_at'] == null + ? null + : DateTime.tryParse(data['promoted_at'] as String), + closedAt: data['closed_at'] == null + ? null + : data['closed_at'] == null + ? null + : DateTime.tryParse(data['closed_at'] as String), + ); +} + +Future> _$TicketToSupabase( + Ticket instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'subject': instance.subject, + 'description': instance.description, + 'office_id': instance.officeId, + 'status': instance.status, + 'created_at': instance.createdAt.toIso8601String(), + 'creator_id': instance.creatorId, + 'responded_at': instance.respondedAt?.toIso8601String(), + 'promoted_at': instance.promotedAt?.toIso8601String(), + 'closed_at': instance.closedAt?.toIso8601String(), + }; +} + +Future _$TicketFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return Ticket( + id: data['id'] as String, + subject: data['subject'] as String, + description: data['description'] as String, + officeId: data['office_id'] as String, + status: data['status'] as String, + createdAt: DateTime.parse(data['created_at'] as String), + creatorId: data['creator_id'] == null + ? null + : data['creator_id'] as String?, + respondedAt: data['responded_at'] == null + ? null + : data['responded_at'] == null + ? null + : DateTime.tryParse(data['responded_at'] as String), + promotedAt: data['promoted_at'] == null + ? null + : data['promoted_at'] == null + ? null + : DateTime.tryParse(data['promoted_at'] as String), + closedAt: data['closed_at'] == null + ? null + : data['closed_at'] == null + ? null + : DateTime.tryParse(data['closed_at'] as String), + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$TicketToSqlite( + Ticket instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'subject': instance.subject, + 'description': instance.description, + 'office_id': instance.officeId, + 'status': instance.status, + 'created_at': instance.createdAt.toIso8601String(), + 'creator_id': instance.creatorId, + 'responded_at': instance.respondedAt?.toIso8601String(), + 'promoted_at': instance.promotedAt?.toIso8601String(), + 'closed_at': instance.closedAt?.toIso8601String(), + }; +} + +/// Construct a [Ticket] +class TicketAdapter extends OfflineFirstWithSupabaseAdapter { + TicketAdapter(); + + @override + final supabaseTableName = 'tickets'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'subject': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'subject', + ), + 'description': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'description', + ), + 'officeId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'office_id', + ), + 'status': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'status', + ), + 'createdAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'created_at', + ), + 'creatorId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'creator_id', + ), + 'respondedAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'responded_at', + ), + 'promotedAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'promoted_at', + ), + 'closedAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'closed_at', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'subject': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'subject', + iterable: false, + type: String, + ), + 'description': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'description', + iterable: false, + type: String, + ), + 'officeId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'office_id', + iterable: false, + type: String, + ), + 'status': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'status', + iterable: false, + type: String, + ), + 'createdAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'created_at', + iterable: false, + type: DateTime, + ), + 'creatorId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'creator_id', + iterable: false, + type: String, + ), + 'respondedAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'responded_at', + iterable: false, + type: DateTime, + ), + 'promotedAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'promoted_at', + iterable: false, + type: DateTime, + ), + 'closedAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'closed_at', + iterable: false, + type: DateTime, + ), + }; + @override + Future primaryKeyByUniqueColumns( + Ticket instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'Ticket'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$TicketFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + Ticket input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$TicketToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$TicketFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + Ticket input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => + await _$TicketToSqlite(input, provider: provider, repository: repository); +} diff --git a/lib/brick/brick.g.dart b/lib/brick/brick.g.dart new file mode 100644 index 00000000..6196c0d6 --- /dev/null +++ b/lib/brick/brick.g.dart @@ -0,0 +1,114 @@ +// ignore: unused_import, unused_shown_name, unnecessary_import +import 'package:brick_core/query.dart'; +// ignore: unused_import, unused_shown_name, unnecessary_import +import 'package:brick_sqlite/db.dart'; +// ignore: unused_import, unused_shown_name, unnecessary_import +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +// ignore: unused_import, unused_shown_name, unnecessary_import +import 'package:brick_sqlite/brick_sqlite.dart'; +// ignore: unused_import, unused_shown_name, unnecessary_import +import 'package:brick_supabase/brick_supabase.dart'; +// ignore: unused_import, unused_shown_name, unnecessary_import +import '../utils/app_time.dart'; +// ignore: unused_import, unused_shown_name, unnecessary_import +import 'package:tasq/models/team_member.dart';// GENERATED CODE DO NOT EDIT +// ignore: unused_import +import 'dart:convert'; +import 'package:brick_sqlite/brick_sqlite.dart' show SqliteModel, SqliteAdapter, SqliteModelDictionary, RuntimeSqliteColumnDefinition, SqliteProvider; +import 'package:brick_supabase/brick_supabase.dart' show SupabaseProvider, SupabaseModel, SupabaseAdapter, SupabaseModelDictionary; +// ignore: unused_import, unused_shown_name +import 'package:brick_offline_first/brick_offline_first.dart' show RuntimeOfflineFirstDefinition; +// ignore: unused_import, unused_shown_name +import 'package:sqflite_common/sqlite_api.dart' show DatabaseExecutor; + +import '../models/announcement.model.dart'; +import '../models/announcement_comment.model.dart'; +import '../models/attendance_log.model.dart'; +import '../models/chat_message.model.dart'; +import '../models/duty_schedule.model.dart'; +import '../models/it_service_request.model.dart'; +import '../models/it_service_request_action.model.dart'; +import '../models/it_service_request_activity_log.model.dart'; +import '../models/it_service_request_assignment.model.dart'; +import '../models/leave_of_absence.model.dart'; +import '../models/notification_item.model.dart'; +import '../models/office.model.dart'; +import '../models/pass_slip.model.dart'; +import '../models/profile.model.dart'; +import '../models/service.model.dart'; +import '../models/swap_request.model.dart'; +import '../models/task.model.dart'; +import '../models/task_activity_log.model.dart'; +import '../models/team.model.dart'; +import '../models/ticket.model.dart'; + +part 'adapters/announcement_adapter.g.dart'; +part 'adapters/announcement_comment_adapter.g.dart'; +part 'adapters/attendance_log_adapter.g.dart'; +part 'adapters/chat_message_adapter.g.dart'; +part 'adapters/duty_schedule_adapter.g.dart'; +part 'adapters/it_service_request_adapter.g.dart'; +part 'adapters/it_service_request_action_adapter.g.dart'; +part 'adapters/it_service_request_activity_log_adapter.g.dart'; +part 'adapters/it_service_request_assignment_adapter.g.dart'; +part 'adapters/leave_of_absence_adapter.g.dart'; +part 'adapters/notification_item_adapter.g.dart'; +part 'adapters/office_adapter.g.dart'; +part 'adapters/pass_slip_adapter.g.dart'; +part 'adapters/profile_adapter.g.dart'; +part 'adapters/service_adapter.g.dart'; +part 'adapters/swap_request_adapter.g.dart'; +part 'adapters/task_adapter.g.dart'; +part 'adapters/task_activity_log_adapter.g.dart'; +part 'adapters/team_adapter.g.dart'; +part 'adapters/ticket_adapter.g.dart'; + +/// Supabase mappings should only be used when initializing a [SupabaseProvider] +final Map> supabaseMappings = { + Announcement: AnnouncementAdapter(), + AnnouncementComment: AnnouncementCommentAdapter(), + AttendanceLog: AttendanceLogAdapter(), + ChatMessage: ChatMessageAdapter(), + DutySchedule: DutyScheduleAdapter(), + ItServiceRequest: ItServiceRequestAdapter(), + ItServiceRequestAction: ItServiceRequestActionAdapter(), + ItServiceRequestActivityLog: ItServiceRequestActivityLogAdapter(), + ItServiceRequestAssignment: ItServiceRequestAssignmentAdapter(), + LeaveOfAbsence: LeaveOfAbsenceAdapter(), + NotificationItem: NotificationItemAdapter(), + Office: OfficeAdapter(), + PassSlip: PassSlipAdapter(), + Profile: ProfileAdapter(), + Service: ServiceAdapter(), + SwapRequest: SwapRequestAdapter(), + Task: TaskAdapter(), + TaskActivityLog: TaskActivityLogAdapter(), + Team: TeamAdapter(), + Ticket: TicketAdapter() +}; +final supabaseModelDictionary = SupabaseModelDictionary(supabaseMappings); + +/// Sqlite mappings should only be used when initializing a [SqliteProvider] +final Map> sqliteMappings = { + Announcement: AnnouncementAdapter(), + AnnouncementComment: AnnouncementCommentAdapter(), + AttendanceLog: AttendanceLogAdapter(), + ChatMessage: ChatMessageAdapter(), + DutySchedule: DutyScheduleAdapter(), + ItServiceRequest: ItServiceRequestAdapter(), + ItServiceRequestAction: ItServiceRequestActionAdapter(), + ItServiceRequestActivityLog: ItServiceRequestActivityLogAdapter(), + ItServiceRequestAssignment: ItServiceRequestAssignmentAdapter(), + LeaveOfAbsence: LeaveOfAbsenceAdapter(), + NotificationItem: NotificationItemAdapter(), + Office: OfficeAdapter(), + PassSlip: PassSlipAdapter(), + Profile: ProfileAdapter(), + Service: ServiceAdapter(), + SwapRequest: SwapRequestAdapter(), + Task: TaskAdapter(), + TaskActivityLog: TaskActivityLogAdapter(), + Team: TeamAdapter(), + Ticket: TicketAdapter() +}; +final sqliteModelDictionary = SqliteModelDictionary(sqliteMappings); diff --git a/lib/brick/cache_helpers.dart b/lib/brick/cache_helpers.dart new file mode 100644 index 00000000..3b54e669 --- /dev/null +++ b/lib/brick/cache_helpers.dart @@ -0,0 +1,63 @@ +import 'package:flutter/foundation.dart' show debugPrint; + +import 'repository.dart'; + +/// Returns the locally cached list of [T] from Brick SQLite, or `[]` if Brick +/// isn't configured or the read fails. Never throws. +/// +/// Use this as `onOfflineData` for [StreamRecoveryWrapper] so that screens +/// render data from the local cache when the device is offline: +/// +/// ```dart +/// onOfflineData: () => cachedListFromBrick(), +/// ``` +Future> cachedListFromBrick() async { + if (!AppRepository.isConfigured) { + debugPrint('[cachedListFromBrick<$T>] BRICK NOT CONFIGURED — returning []'); + return const []; + } + try { + final rows = await AppRepository.instance.get( + policy: OfflineFirstGetPolicy.localOnly, + ); + if (rows.isEmpty) { + debugPrint('[cachedListFromBrick<$T>] empty cache (0 rows)'); + } + return rows; + } catch (e) { + debugPrint('[cachedListFromBrick<$T>] failed: $e'); + return const []; + } +} + +/// Mirrors a batch of model rows into Brick's local SQLite cache as a +/// fire-and-forget side effect. Errors are caught and logged. +/// +/// Use this as `onCacheMirror` for [StreamRecoveryWrapper] so that every +/// realtime emission keeps the local cache fresh — required for the cold- +/// launch-offline experience. +/// +/// ```dart +/// onCacheMirror: (rows) => mirrorBatchToBrick(rows, tag: 'tasks'), +/// ``` +void mirrorBatchToBrick( + List rows, { + String tag = 'mirror', +}) { + if (!AppRepository.isConfigured) return; + // Use sqliteProvider directly (local-only write). AppRepository.upsert is + // bidirectional — it re-POSTs rows to Supabase, which triggers RLS + // violations for other users' records and deadlocks from concurrent writes. + // Sequential processing prevents SQLite lock contention across providers. + // ignore: discarded_futures + Future(() async { + for (final row in rows) { + try { + await AppRepository.instance.sqliteProvider + .upsert(row, repository: AppRepository.instance); + } catch (e) { + debugPrint('[$tag] mirror upsert<$T> failed: $e'); + } + } + }); +} diff --git a/lib/brick/cache_warmer.dart b/lib/brick/cache_warmer.dart new file mode 100644 index 00000000..bf9ddb94 --- /dev/null +++ b/lib/brick/cache_warmer.dart @@ -0,0 +1,360 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart' show debugPrint, kIsWeb; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../models/announcement.model.dart'; +import '../models/announcement_comment.model.dart'; +import '../models/attendance_log.model.dart'; +import '../models/duty_schedule.model.dart'; +import '../models/it_service_request.model.dart'; +import '../models/leave_of_absence.model.dart'; +import '../models/notification_item.model.dart'; +import '../models/office.model.dart'; +import '../models/pass_slip.model.dart'; +import '../models/profile.model.dart'; +import '../models/service.model.dart'; +import '../models/swap_request.model.dart'; +import '../models/task.model.dart'; +import '../models/team.model.dart'; +import '../models/ticket.model.dart'; +import 'repository.dart'; + +/// Proactively seeds the local Brick SQLite cache with the data the app needs +/// to render correctly when launched offline. +/// +/// Triggered: +/// - After successful login. +/// - After the connectivity monitor flips offline → online. +/// - On a 1-hour timer in the foreground (defensive refresh). +/// +/// Never throws — a single table failure must not abort the rest. Each table +/// is wrapped in try/catch and only logs the error. +/// +/// Web is a no-op because Brick's SQLite is unavailable on web. +class CacheWarmer { + CacheWarmer._(); + + /// Window of mutable data to keep locally. 30 days is enough for the + /// "month of tasks/tickets" the user requested without bloating SQLite. + static const Duration window = Duration(days: 30); + + /// SharedPreferences keys for association tables (no PK → not Brick models). + static const _kTeamMembersKey = 'cache_team_members_json'; + static const _kUserOfficesKey = 'cache_user_offices_json'; + + /// Foreground timer handle so [start]/[stop] is idempotent. + static Timer? _refreshTimer; + + /// True when at least one warm has completed since app start. + static bool _hasWarmedOnce = false; + static bool get hasWarmedOnce => _hasWarmedOnce; + + /// Single-flight guard to prevent overlapping warms. + static Future? _inflight; + + /// Warm every cacheable table. Safe to call repeatedly. + static Future warmAll(SupabaseClient client) { + if (kIsWeb) return Future.value(); + if (!AppRepository.isConfigured) return Future.value(); + final existing = _inflight; + if (existing != null) return existing; + + final future = _runWarmAll(client); + _inflight = future; + return future.whenComplete(() => _inflight = null); + } + + static Future _runWarmAll(SupabaseClient client) async { + final stopwatch = Stopwatch()..start(); + final since = DateTime.now().toUtc().subtract(window); + final sinceIso = since.toIso8601String(); + + debugPrint('[CacheWarmer] starting (since=$sinceIso)'); + + // Run reference + per-module warms in parallel — a single failure in one + // table must not block the others. + await Future.wait([ + _safe('reference', () => _warmReference(client)), + _safe('tasks', () => _warmTasks(client, sinceIso)), + _safe('tickets', () => _warmTickets(client, sinceIso)), + _safe('itsr', () => _warmItServiceRequests(client, sinceIso)), + _safe('announcements', () => _warmAnnouncements(client, sinceIso)), + _safe('attendance', () => _warmAttendance(client, sinceIso)), + _safe('pass_slips', () => _warmPassSlips(client, sinceIso)), + _safe('leaves', () => _warmLeaves(client, sinceIso)), + _safe('duty_schedules', () => _warmDutySchedules(client, sinceIso)), + _safe('swap_requests', () => _warmSwapRequests(client, sinceIso)), + _safe('notifications', () => _warmNotifications(client, sinceIso)), + ]); + + _hasWarmedOnce = true; + stopwatch.stop(); + debugPrint('[CacheWarmer] done in ${stopwatch.elapsedMilliseconds}ms'); + } + + /// Schedule a periodic re-warm. Idempotent. + static void startPeriodicRefresh(SupabaseClient client) { + if (kIsWeb) return; + _refreshTimer?.cancel(); + _refreshTimer = Timer.periodic(const Duration(hours: 1), (_) { + warmAll(client); + }); + } + + /// Cancel periodic re-warm (e.g. on sign-out). + static void stopPeriodicRefresh() { + _refreshTimer?.cancel(); + _refreshTimer = null; + } + + // ------------------------------------------------------------------ + // Reference data — small, rarely-mutating. Pulled in full. + // ------------------------------------------------------------------ + + static Future _warmReference(SupabaseClient client) async { + await Future.wait([ + _safe('offices', () async { + final rows = await client.from('offices').select(); + for (final row in rows) { + await _localUpsert(Office.fromMap(row)); + } + }), + _safe('services', () async { + final rows = await client.from('services').select(); + for (final row in rows) { + await _localUpsert(Service.fromMap(row)); + } + }), + _safe('profiles', () async { + final rows = await client.from('profiles').select(); + for (final row in rows) { + await _localUpsert(Profile.fromMap(row)); + } + }), + _safe('teams', () async { + final rows = await client.from('teams').select(); + for (final row in rows) { + await _localUpsert(Team.fromMap(row)); + } + }), + // Association tables have composite PKs, so we cache them as JSON in + // SharedPreferences instead of Brick. The list is small and read-only. + _safe('team_members', () async { + final rows = await client.from('team_members').select(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kTeamMembersKey, jsonEncode(rows)); + }), + _safe('user_offices', () async { + final rows = await client.from('user_offices').select(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kUserOfficesKey, jsonEncode(rows)); + }), + ]); + } + + /// Read cached team_members rows from SharedPreferences. + /// Returns the same shape as the Supabase select (`[{team_id, user_id}, ...]`). + static Future>> readCachedTeamMembers() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_kTeamMembersKey); + if (raw == null) return const []; + try { + final list = jsonDecode(raw); + if (list is! List) return const []; + return list.cast>(); + } catch (_) { + return const []; + } + } + + /// Read cached user_offices rows from SharedPreferences. + static Future>> readCachedUserOffices() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_kUserOfficesKey); + if (raw == null) return const []; + try { + final list = jsonDecode(raw); + if (list is! List) return const []; + return list.cast>(); + } catch (_) { + return const []; + } + } + + // ------------------------------------------------------------------ + // Per-module warms — windowed by created_at >= since + // ------------------------------------------------------------------ + + static Future _warmTasks(SupabaseClient client, String sinceIso) async { + final rows = await client + .from('tasks') + .select() + .gte('created_at', sinceIso); + for (final row in rows) { + await _localUpsert(Task.fromMap(row)); + } + } + + static Future _warmTickets( + SupabaseClient client, + String sinceIso, + ) async { + final rows = await client + .from('tickets') + .select() + .gte('created_at', sinceIso); + for (final row in rows) { + await _localUpsert(Ticket.fromMap(row)); + } + } + + static Future _warmItServiceRequests( + SupabaseClient client, + String sinceIso, + ) async { + final rows = await client + .from('it_service_requests') + .select() + .gte('created_at', sinceIso); + for (final row in rows) { + await _localUpsert(ItServiceRequest.fromMap(row)); + } + } + + static Future _warmAnnouncements( + SupabaseClient client, + String sinceIso, + ) async { + final rows = await client + .from('announcements') + .select() + .gte('created_at', sinceIso); + for (final row in rows) { + await _localUpsert(Announcement.fromMap(row)); + } + // Also pull comments for the windowed announcements so detail screens have + // them offline. + try { + final commentRows = await client + .from('announcement_comments') + .select() + .gte('created_at', sinceIso); + for (final row in commentRows) { + await _localUpsert( + AnnouncementComment.fromMap(row), + ); + } + } catch (e) { + debugPrint('[CacheWarmer] announcement_comments failed: $e'); + } + } + + static Future _warmAttendance( + SupabaseClient client, + String sinceIso, + ) async { + final rows = await client + .from('attendance_logs') + .select() + .gte('check_in_at', sinceIso); + for (final row in rows) { + await _localUpsert(AttendanceLog.fromMap(row)); + } + } + + static Future _warmPassSlips( + SupabaseClient client, + String sinceIso, + ) async { + final rows = await client + .from('pass_slips') + .select() + .gte('created_at', sinceIso); + for (final row in rows) { + await _localUpsert(PassSlip.fromMap(row)); + } + } + + static Future _warmLeaves( + SupabaseClient client, + String sinceIso, + ) async { + final rows = await client + .from('leave_of_absence') + .select() + .gte('created_at', sinceIso); + for (final row in rows) { + await _localUpsert(LeaveOfAbsence.fromMap(row)); + } + } + + static Future _warmDutySchedules( + SupabaseClient client, + String sinceIso, + ) async { + // Duty schedules are upcoming-focused; pull a wider window so the user + // can see today + the next several weeks while offline. + final rows = await client + .from('duty_schedules') + .select() + .gte('start_time', sinceIso); + for (final row in rows) { + await _localUpsert(DutySchedule.fromMap(row)); + } + } + + static Future _warmSwapRequests( + SupabaseClient client, + String sinceIso, + ) async { + final rows = await client + .from('swap_requests') + .select() + .gte('created_at', sinceIso); + for (final row in rows) { + await _localUpsert(SwapRequest.fromMap(row)); + } + } + + static Future _warmNotifications( + SupabaseClient client, + String sinceIso, + ) async { + final rows = await client + .from('notifications') + .select() + .gte('created_at', sinceIso); + for (final row in rows) { + await _localUpsert(NotificationItem.fromMap(row)); + } + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + /// Writes [model] to local SQLite only, bypassing Brick's bidirectional + /// sync. AppRepository.upsert would re-POST rows to Supabase, causing RLS + /// violations for other users' records and deadlocks from concurrent writes. + static Future _localUpsert( + T model, + ) async { + await AppRepository.instance.sqliteProvider + .upsert(model, repository: AppRepository.instance); + } + + static Future _safe(String label, Future Function() task) async { + try { + await task(); + } catch (e, st) { + debugPrint('[CacheWarmer] $label failed: $e'); + assert(() { + debugPrint('$st'); + return true; + }()); + } + } +} diff --git a/lib/brick/db/20260411041808.migration.dart b/lib/brick/db/20260411041808.migration.dart new file mode 100644 index 00000000..58a507a6 --- /dev/null +++ b/lib/brick/db/20260411041808.migration.dart @@ -0,0 +1,502 @@ +// GENERATED CODE EDIT WITH CAUTION +// THIS FILE **WILL NOT** BE REGENERATED +// This file should be version controlled and can be manually edited. +part of 'schema.g.dart'; + +// While migrations are intelligently created, the difference between some commands, such as +// DropTable vs. RenameTable, cannot be determined. For this reason, please review migrations after +// they are created to ensure the correct inference was made. + +// The migration version must **always** mirror the file name + +const List _migration_20260411041808_up = [ + InsertTable('Announcement'), + InsertTable('AnnouncementComment'), + InsertTable('AttendanceLog'), + InsertTable('ChatMessage'), + InsertTable('DutySchedule'), + InsertTable('ItServiceRequest'), + InsertTable('ItServiceRequestAction'), + InsertTable('ItServiceRequestActivityLog'), + InsertTable('ItServiceRequestAssignment'), + InsertTable('LeaveOfAbsence'), + InsertTable('NotificationItem'), + InsertTable('Office'), + InsertTable('PassSlip'), + InsertTable('Profile'), + InsertTable('Service'), + InsertTable('SwapRequest'), + InsertTable('Task'), + InsertTable('TaskActivityLog'), + InsertTable('Team'), + InsertTable('Ticket'), + InsertColumn('id', Column.varchar, onTable: 'Announcement'), + InsertColumn('author_id', Column.varchar, onTable: 'Announcement'), + InsertColumn('title', Column.varchar, onTable: 'Announcement'), + InsertColumn('body', Column.varchar, onTable: 'Announcement'), + InsertColumn('visible_roles', Column.varchar, onTable: 'Announcement'), + InsertColumn('is_template', Column.boolean, onTable: 'Announcement'), + InsertColumn('template_id', Column.varchar, onTable: 'Announcement'), + InsertColumn('created_at', Column.datetime, onTable: 'Announcement'), + InsertColumn('updated_at', Column.datetime, onTable: 'Announcement'), + InsertColumn('banner_enabled', Column.boolean, onTable: 'Announcement'), + InsertColumn('banner_show_at', Column.datetime, onTable: 'Announcement'), + InsertColumn('banner_hide_at', Column.datetime, onTable: 'Announcement'), + InsertColumn('push_interval_minutes', Column.integer, onTable: 'Announcement'), + InsertColumn('is_banner_active', Column.boolean, onTable: 'Announcement'), + InsertColumn('hash_code', Column.integer, onTable: 'Announcement'), + InsertColumn('id', Column.varchar, onTable: 'AnnouncementComment'), + InsertColumn('announcement_id', Column.varchar, onTable: 'AnnouncementComment'), + InsertColumn('author_id', Column.varchar, onTable: 'AnnouncementComment'), + InsertColumn('body', Column.varchar, onTable: 'AnnouncementComment'), + InsertColumn('created_at', Column.datetime, onTable: 'AnnouncementComment'), + InsertColumn('hash_code', Column.integer, onTable: 'AnnouncementComment'), + InsertColumn('id', Column.varchar, onTable: 'AttendanceLog'), + InsertColumn('user_id', Column.varchar, onTable: 'AttendanceLog'), + InsertColumn('duty_schedule_id', Column.varchar, onTable: 'AttendanceLog'), + InsertColumn('shift_type', Column.varchar, onTable: 'AttendanceLog'), + InsertColumn('check_in_at', Column.datetime, onTable: 'AttendanceLog'), + InsertColumn('check_in_lat', Column.Double, onTable: 'AttendanceLog'), + InsertColumn('check_in_lng', Column.Double, onTable: 'AttendanceLog'), + InsertColumn('check_out_at', Column.datetime, onTable: 'AttendanceLog'), + InsertColumn('check_out_lat', Column.Double, onTable: 'AttendanceLog'), + InsertColumn('check_out_lng', Column.Double, onTable: 'AttendanceLog'), + InsertColumn('justification', Column.varchar, onTable: 'AttendanceLog'), + InsertColumn('check_out_justification', Column.varchar, onTable: 'AttendanceLog'), + InsertColumn('verification_status', Column.varchar, onTable: 'AttendanceLog'), + InsertColumn('check_in_verification_photo_url', Column.varchar, onTable: 'AttendanceLog'), + InsertColumn('check_out_verification_photo_url', Column.varchar, onTable: 'AttendanceLog'), + InsertColumn('is_checked_out', Column.boolean, onTable: 'AttendanceLog'), + InsertColumn('is_verified', Column.boolean, onTable: 'AttendanceLog'), + InsertColumn('is_unverified', Column.boolean, onTable: 'AttendanceLog'), + InsertColumn('id', Column.varchar, onTable: 'ChatMessage'), + InsertColumn('thread_id', Column.varchar, onTable: 'ChatMessage'), + InsertColumn('sender_id', Column.varchar, onTable: 'ChatMessage'), + InsertColumn('body', Column.varchar, onTable: 'ChatMessage'), + InsertColumn('created_at', Column.datetime, onTable: 'ChatMessage'), + InsertColumn('id', Column.varchar, onTable: 'DutySchedule'), + InsertColumn('user_id', Column.varchar, onTable: 'DutySchedule'), + InsertColumn('shift_type', Column.varchar, onTable: 'DutySchedule'), + InsertColumn('start_time', Column.datetime, onTable: 'DutySchedule'), + InsertColumn('end_time', Column.datetime, onTable: 'DutySchedule'), + InsertColumn('status', Column.varchar, onTable: 'DutySchedule'), + InsertColumn('created_at', Column.datetime, onTable: 'DutySchedule'), + InsertColumn('check_in_at', Column.datetime, onTable: 'DutySchedule'), + InsertColumn('reliever_ids', Column.varchar, onTable: 'DutySchedule'), + InsertColumn('swap_request_id', Column.varchar, onTable: 'DutySchedule'), + InsertColumn('id', Column.varchar, onTable: 'ItServiceRequest'), + InsertColumn('request_number', Column.varchar, onTable: 'ItServiceRequest'), + InsertColumn('services', Column.varchar, onTable: 'ItServiceRequest'), + InsertColumn('services_other', Column.varchar, onTable: 'ItServiceRequest'), + InsertColumn('event_name', Column.varchar, onTable: 'ItServiceRequest'), + InsertColumn('event_details', Column.varchar, onTable: 'ItServiceRequest'), + InsertColumn('event_date', Column.datetime, onTable: 'ItServiceRequest'), + InsertColumn('event_end_date', Column.datetime, onTable: 'ItServiceRequest'), + InsertColumn('dry_run_date', Column.datetime, onTable: 'ItServiceRequest'), + InsertColumn('dry_run_end_date', Column.datetime, onTable: 'ItServiceRequest'), + InsertColumn('contact_person', Column.varchar, onTable: 'ItServiceRequest'), + InsertColumn('contact_number', Column.varchar, onTable: 'ItServiceRequest'), + InsertColumn('remarks', Column.varchar, onTable: 'ItServiceRequest'), + InsertColumn('office_id', Column.varchar, onTable: 'ItServiceRequest'), + InsertColumn('requested_by', Column.varchar, onTable: 'ItServiceRequest'), + InsertColumn('requested_by_user_id', Column.varchar, onTable: 'ItServiceRequest'), + InsertColumn('approved_by', Column.varchar, onTable: 'ItServiceRequest'), + InsertColumn('approved_by_user_id', Column.varchar, onTable: 'ItServiceRequest'), + InsertColumn('approved_at', Column.datetime, onTable: 'ItServiceRequest'), + InsertColumn('status', Column.varchar, onTable: 'ItServiceRequest'), + InsertColumn('outside_premise_allowed', Column.boolean, onTable: 'ItServiceRequest'), + InsertColumn('cancellation_reason', Column.varchar, onTable: 'ItServiceRequest'), + InsertColumn('cancelled_at', Column.datetime, onTable: 'ItServiceRequest'), + InsertColumn('creator_id', Column.varchar, onTable: 'ItServiceRequest'), + InsertColumn('created_at', Column.datetime, onTable: 'ItServiceRequest'), + InsertColumn('updated_at', Column.datetime, onTable: 'ItServiceRequest'), + InsertColumn('completed_at', Column.datetime, onTable: 'ItServiceRequest'), + InsertColumn('date_time_received', Column.datetime, onTable: 'ItServiceRequest'), + InsertColumn('date_time_checked', Column.datetime, onTable: 'ItServiceRequest'), + InsertColumn('hash_code', Column.integer, onTable: 'ItServiceRequest'), + InsertColumn('id', Column.varchar, onTable: 'ItServiceRequestAction'), + InsertColumn('request_id', Column.varchar, onTable: 'ItServiceRequestAction'), + InsertColumn('user_id', Column.varchar, onTable: 'ItServiceRequestAction'), + InsertColumn('action_taken', Column.varchar, onTable: 'ItServiceRequestAction'), + InsertColumn('created_at', Column.datetime, onTable: 'ItServiceRequestAction'), + InsertColumn('updated_at', Column.datetime, onTable: 'ItServiceRequestAction'), + InsertColumn('id', Column.varchar, onTable: 'ItServiceRequestActivityLog'), + InsertColumn('request_id', Column.varchar, onTable: 'ItServiceRequestActivityLog'), + InsertColumn('actor_id', Column.varchar, onTable: 'ItServiceRequestActivityLog'), + InsertColumn('action_type', Column.varchar, onTable: 'ItServiceRequestActivityLog'), + InsertColumn('meta', Column.varchar, onTable: 'ItServiceRequestActivityLog'), + InsertColumn('created_at', Column.datetime, onTable: 'ItServiceRequestActivityLog'), + InsertColumn('id', Column.varchar, onTable: 'ItServiceRequestAssignment'), + InsertColumn('request_id', Column.varchar, onTable: 'ItServiceRequestAssignment'), + InsertColumn('user_id', Column.varchar, onTable: 'ItServiceRequestAssignment'), + InsertColumn('created_at', Column.datetime, onTable: 'ItServiceRequestAssignment'), + InsertColumn('id', Column.varchar, onTable: 'LeaveOfAbsence'), + InsertColumn('user_id', Column.varchar, onTable: 'LeaveOfAbsence'), + InsertColumn('leave_type', Column.varchar, onTable: 'LeaveOfAbsence'), + InsertColumn('justification', Column.varchar, onTable: 'LeaveOfAbsence'), + InsertColumn('start_time', Column.datetime, onTable: 'LeaveOfAbsence'), + InsertColumn('end_time', Column.datetime, onTable: 'LeaveOfAbsence'), + InsertColumn('status', Column.varchar, onTable: 'LeaveOfAbsence'), + InsertColumn('filed_by', Column.varchar, onTable: 'LeaveOfAbsence'), + InsertColumn('created_at', Column.datetime, onTable: 'LeaveOfAbsence'), + InsertColumn('leave_type_label', Column.varchar, onTable: 'LeaveOfAbsence'), + InsertColumn('id', Column.varchar, onTable: 'NotificationItem'), + InsertColumn('user_id', Column.varchar, onTable: 'NotificationItem'), + InsertColumn('actor_id', Column.varchar, onTable: 'NotificationItem'), + InsertColumn('ticket_id', Column.varchar, onTable: 'NotificationItem'), + InsertColumn('task_id', Column.varchar, onTable: 'NotificationItem'), + InsertColumn('leave_id', Column.varchar, onTable: 'NotificationItem'), + InsertColumn('pass_slip_id', Column.varchar, onTable: 'NotificationItem'), + InsertColumn('it_service_request_id', Column.varchar, onTable: 'NotificationItem'), + InsertColumn('announcement_id', Column.varchar, onTable: 'NotificationItem'), + InsertColumn('message_id', Column.integer, onTable: 'NotificationItem'), + InsertColumn('type', Column.varchar, onTable: 'NotificationItem'), + InsertColumn('created_at', Column.datetime, onTable: 'NotificationItem'), + InsertColumn('read_at', Column.datetime, onTable: 'NotificationItem'), + InsertColumn('is_unread', Column.boolean, onTable: 'NotificationItem'), + InsertColumn('id', Column.varchar, onTable: 'Office'), + InsertColumn('name', Column.varchar, onTable: 'Office'), + InsertColumn('service_id', Column.varchar, onTable: 'Office'), + InsertColumn('id', Column.varchar, onTable: 'PassSlip'), + InsertColumn('user_id', Column.varchar, onTable: 'PassSlip'), + InsertColumn('duty_schedule_id', Column.varchar, onTable: 'PassSlip'), + InsertColumn('reason', Column.varchar, onTable: 'PassSlip'), + InsertColumn('status', Column.varchar, onTable: 'PassSlip'), + InsertColumn('requested_at', Column.datetime, onTable: 'PassSlip'), + InsertColumn('approved_by', Column.varchar, onTable: 'PassSlip'), + InsertColumn('approved_at', Column.datetime, onTable: 'PassSlip'), + InsertColumn('slip_start', Column.datetime, onTable: 'PassSlip'), + InsertColumn('slip_end', Column.datetime, onTable: 'PassSlip'), + InsertColumn('requested_start', Column.datetime, onTable: 'PassSlip'), + InsertColumn('is_active', Column.boolean, onTable: 'PassSlip'), + InsertColumn('is_exceeded', Column.boolean, onTable: 'PassSlip'), + InsertColumn('id', Column.varchar, onTable: 'Profile'), + InsertColumn('role', Column.varchar, onTable: 'Profile'), + InsertColumn('full_name', Column.varchar, onTable: 'Profile'), + InsertColumn('religion', Column.varchar, onTable: 'Profile'), + InsertColumn('allow_tracking', Column.boolean, onTable: 'Profile'), + InsertColumn('avatar_url', Column.varchar, onTable: 'Profile'), + InsertColumn('face_photo_url', Column.varchar, onTable: 'Profile'), + InsertColumn('face_enrolled_at', Column.datetime, onTable: 'Profile'), + InsertColumn('has_face_enrolled', Column.boolean, onTable: 'Profile'), + InsertColumn('id', Column.varchar, onTable: 'Service'), + InsertColumn('name', Column.varchar, onTable: 'Service'), + InsertColumn('id', Column.varchar, onTable: 'SwapRequest'), + InsertColumn('requester_id', Column.varchar, onTable: 'SwapRequest'), + InsertColumn('recipient_id', Column.varchar, onTable: 'SwapRequest'), + InsertColumn('requester_schedule_id', Column.varchar, onTable: 'SwapRequest'), + InsertColumn('target_schedule_id', Column.varchar, onTable: 'SwapRequest'), + InsertColumn('status', Column.varchar, onTable: 'SwapRequest'), + InsertColumn('created_at', Column.datetime, onTable: 'SwapRequest'), + InsertColumn('updated_at', Column.datetime, onTable: 'SwapRequest'), + InsertColumn('chat_thread_id', Column.varchar, onTable: 'SwapRequest'), + InsertColumn('shift_type', Column.varchar, onTable: 'SwapRequest'), + InsertColumn('shift_start_time', Column.datetime, onTable: 'SwapRequest'), + InsertColumn('reliever_ids', Column.varchar, onTable: 'SwapRequest'), + InsertColumn('approved_by', Column.varchar, onTable: 'SwapRequest'), + InsertColumn('id', Column.varchar, onTable: 'Task'), + InsertColumn('ticket_id', Column.varchar, onTable: 'Task'), + InsertColumn('task_number', Column.varchar, onTable: 'Task'), + InsertColumn('title', Column.varchar, onTable: 'Task'), + InsertColumn('description', Column.varchar, onTable: 'Task'), + InsertColumn('office_id', Column.varchar, onTable: 'Task'), + InsertColumn('status', Column.varchar, onTable: 'Task'), + InsertColumn('priority', Column.integer, onTable: 'Task'), + InsertColumn('queue_order', Column.integer, onTable: 'Task'), + InsertColumn('created_at', Column.datetime, onTable: 'Task'), + InsertColumn('creator_id', Column.varchar, onTable: 'Task'), + InsertColumn('started_at', Column.datetime, onTable: 'Task'), + InsertColumn('completed_at', Column.datetime, onTable: 'Task'), + InsertColumn('requested_by', Column.varchar, onTable: 'Task'), + InsertColumn('noted_by', Column.varchar, onTable: 'Task'), + InsertColumn('received_by', Column.varchar, onTable: 'Task'), + InsertColumn('request_type', Column.varchar, onTable: 'Task'), + InsertColumn('request_type_other', Column.varchar, onTable: 'Task'), + InsertColumn('request_category', Column.varchar, onTable: 'Task'), + InsertColumn('action_taken', Column.varchar, onTable: 'Task'), + InsertColumn('cancellation_reason', Column.varchar, onTable: 'Task'), + InsertColumn('cancelled_at', Column.datetime, onTable: 'Task'), + InsertColumn('it_job_printed', Column.boolean, onTable: 'Task'), + InsertColumn('it_job_printed_at', Column.datetime, onTable: 'Task'), + InsertColumn('it_job_received_by_id', Column.varchar, onTable: 'Task'), + InsertColumn('hash_code', Column.integer, onTable: 'Task'), + InsertColumn('has_incomplete_details', Column.boolean, onTable: 'Task'), + InsertColumn('id', Column.varchar, onTable: 'TaskActivityLog'), + InsertColumn('task_id', Column.varchar, onTable: 'TaskActivityLog'), + InsertColumn('actor_id', Column.varchar, onTable: 'TaskActivityLog'), + InsertColumn('action_type', Column.varchar, onTable: 'TaskActivityLog'), + InsertColumn('meta', Column.varchar, onTable: 'TaskActivityLog'), + InsertColumn('created_at', Column.datetime, onTable: 'TaskActivityLog'), + InsertColumn('id', Column.varchar, onTable: 'Team'), + InsertColumn('name', Column.varchar, onTable: 'Team'), + InsertColumn('leader_id', Column.varchar, onTable: 'Team'), + InsertColumn('office_ids', Column.varchar, onTable: 'Team'), + InsertColumn('created_at', Column.datetime, onTable: 'Team'), + InsertColumn('color', Column.varchar, onTable: 'Team'), + InsertColumn('id', Column.varchar, onTable: 'Ticket'), + InsertColumn('subject', Column.varchar, onTable: 'Ticket'), + InsertColumn('description', Column.varchar, onTable: 'Ticket'), + InsertColumn('office_id', Column.varchar, onTable: 'Ticket'), + InsertColumn('status', Column.varchar, onTable: 'Ticket'), + InsertColumn('created_at', Column.datetime, onTable: 'Ticket'), + InsertColumn('creator_id', Column.varchar, onTable: 'Ticket'), + InsertColumn('responded_at', Column.datetime, onTable: 'Ticket'), + InsertColumn('promoted_at', Column.datetime, onTable: 'Ticket'), + InsertColumn('closed_at', Column.datetime, onTable: 'Ticket'), + InsertColumn('hash_code', Column.integer, onTable: 'Ticket') +]; + +const List _migration_20260411041808_down = [ + DropTable('Announcement'), + DropTable('AnnouncementComment'), + DropTable('AttendanceLog'), + DropTable('ChatMessage'), + DropTable('DutySchedule'), + DropTable('ItServiceRequest'), + DropTable('ItServiceRequestAction'), + DropTable('ItServiceRequestActivityLog'), + DropTable('ItServiceRequestAssignment'), + DropTable('LeaveOfAbsence'), + DropTable('NotificationItem'), + DropTable('Office'), + DropTable('PassSlip'), + DropTable('Profile'), + DropTable('Service'), + DropTable('SwapRequest'), + DropTable('Task'), + DropTable('TaskActivityLog'), + DropTable('Team'), + DropTable('Ticket'), + DropColumn('id', onTable: 'Announcement'), + DropColumn('author_id', onTable: 'Announcement'), + DropColumn('title', onTable: 'Announcement'), + DropColumn('body', onTable: 'Announcement'), + DropColumn('visible_roles', onTable: 'Announcement'), + DropColumn('is_template', onTable: 'Announcement'), + DropColumn('template_id', onTable: 'Announcement'), + DropColumn('created_at', onTable: 'Announcement'), + DropColumn('updated_at', onTable: 'Announcement'), + DropColumn('banner_enabled', onTable: 'Announcement'), + DropColumn('banner_show_at', onTable: 'Announcement'), + DropColumn('banner_hide_at', onTable: 'Announcement'), + DropColumn('push_interval_minutes', onTable: 'Announcement'), + DropColumn('is_banner_active', onTable: 'Announcement'), + DropColumn('hash_code', onTable: 'Announcement'), + DropColumn('id', onTable: 'AnnouncementComment'), + DropColumn('announcement_id', onTable: 'AnnouncementComment'), + DropColumn('author_id', onTable: 'AnnouncementComment'), + DropColumn('body', onTable: 'AnnouncementComment'), + DropColumn('created_at', onTable: 'AnnouncementComment'), + DropColumn('hash_code', onTable: 'AnnouncementComment'), + DropColumn('id', onTable: 'AttendanceLog'), + DropColumn('user_id', onTable: 'AttendanceLog'), + DropColumn('duty_schedule_id', onTable: 'AttendanceLog'), + DropColumn('shift_type', onTable: 'AttendanceLog'), + DropColumn('check_in_at', onTable: 'AttendanceLog'), + DropColumn('check_in_lat', onTable: 'AttendanceLog'), + DropColumn('check_in_lng', onTable: 'AttendanceLog'), + DropColumn('check_out_at', onTable: 'AttendanceLog'), + DropColumn('check_out_lat', onTable: 'AttendanceLog'), + DropColumn('check_out_lng', onTable: 'AttendanceLog'), + DropColumn('justification', onTable: 'AttendanceLog'), + DropColumn('check_out_justification', onTable: 'AttendanceLog'), + DropColumn('verification_status', onTable: 'AttendanceLog'), + DropColumn('check_in_verification_photo_url', onTable: 'AttendanceLog'), + DropColumn('check_out_verification_photo_url', onTable: 'AttendanceLog'), + DropColumn('is_checked_out', onTable: 'AttendanceLog'), + DropColumn('is_verified', onTable: 'AttendanceLog'), + DropColumn('is_unverified', onTable: 'AttendanceLog'), + DropColumn('id', onTable: 'ChatMessage'), + DropColumn('thread_id', onTable: 'ChatMessage'), + DropColumn('sender_id', onTable: 'ChatMessage'), + DropColumn('body', onTable: 'ChatMessage'), + DropColumn('created_at', onTable: 'ChatMessage'), + DropColumn('id', onTable: 'DutySchedule'), + DropColumn('user_id', onTable: 'DutySchedule'), + DropColumn('shift_type', onTable: 'DutySchedule'), + DropColumn('start_time', onTable: 'DutySchedule'), + DropColumn('end_time', onTable: 'DutySchedule'), + DropColumn('status', onTable: 'DutySchedule'), + DropColumn('created_at', onTable: 'DutySchedule'), + DropColumn('check_in_at', onTable: 'DutySchedule'), + DropColumn('reliever_ids', onTable: 'DutySchedule'), + DropColumn('swap_request_id', onTable: 'DutySchedule'), + DropColumn('id', onTable: 'ItServiceRequest'), + DropColumn('request_number', onTable: 'ItServiceRequest'), + DropColumn('services', onTable: 'ItServiceRequest'), + DropColumn('services_other', onTable: 'ItServiceRequest'), + DropColumn('event_name', onTable: 'ItServiceRequest'), + DropColumn('event_details', onTable: 'ItServiceRequest'), + DropColumn('event_date', onTable: 'ItServiceRequest'), + DropColumn('event_end_date', onTable: 'ItServiceRequest'), + DropColumn('dry_run_date', onTable: 'ItServiceRequest'), + DropColumn('dry_run_end_date', onTable: 'ItServiceRequest'), + DropColumn('contact_person', onTable: 'ItServiceRequest'), + DropColumn('contact_number', onTable: 'ItServiceRequest'), + DropColumn('remarks', onTable: 'ItServiceRequest'), + DropColumn('office_id', onTable: 'ItServiceRequest'), + DropColumn('requested_by', onTable: 'ItServiceRequest'), + DropColumn('requested_by_user_id', onTable: 'ItServiceRequest'), + DropColumn('approved_by', onTable: 'ItServiceRequest'), + DropColumn('approved_by_user_id', onTable: 'ItServiceRequest'), + DropColumn('approved_at', onTable: 'ItServiceRequest'), + DropColumn('status', onTable: 'ItServiceRequest'), + DropColumn('outside_premise_allowed', onTable: 'ItServiceRequest'), + DropColumn('cancellation_reason', onTable: 'ItServiceRequest'), + DropColumn('cancelled_at', onTable: 'ItServiceRequest'), + DropColumn('creator_id', onTable: 'ItServiceRequest'), + DropColumn('created_at', onTable: 'ItServiceRequest'), + DropColumn('updated_at', onTable: 'ItServiceRequest'), + DropColumn('completed_at', onTable: 'ItServiceRequest'), + DropColumn('date_time_received', onTable: 'ItServiceRequest'), + DropColumn('date_time_checked', onTable: 'ItServiceRequest'), + DropColumn('hash_code', onTable: 'ItServiceRequest'), + DropColumn('id', onTable: 'ItServiceRequestAction'), + DropColumn('request_id', onTable: 'ItServiceRequestAction'), + DropColumn('user_id', onTable: 'ItServiceRequestAction'), + DropColumn('action_taken', onTable: 'ItServiceRequestAction'), + DropColumn('created_at', onTable: 'ItServiceRequestAction'), + DropColumn('updated_at', onTable: 'ItServiceRequestAction'), + DropColumn('id', onTable: 'ItServiceRequestActivityLog'), + DropColumn('request_id', onTable: 'ItServiceRequestActivityLog'), + DropColumn('actor_id', onTable: 'ItServiceRequestActivityLog'), + DropColumn('action_type', onTable: 'ItServiceRequestActivityLog'), + DropColumn('meta', onTable: 'ItServiceRequestActivityLog'), + DropColumn('created_at', onTable: 'ItServiceRequestActivityLog'), + DropColumn('id', onTable: 'ItServiceRequestAssignment'), + DropColumn('request_id', onTable: 'ItServiceRequestAssignment'), + DropColumn('user_id', onTable: 'ItServiceRequestAssignment'), + DropColumn('created_at', onTable: 'ItServiceRequestAssignment'), + DropColumn('id', onTable: 'LeaveOfAbsence'), + DropColumn('user_id', onTable: 'LeaveOfAbsence'), + DropColumn('leave_type', onTable: 'LeaveOfAbsence'), + DropColumn('justification', onTable: 'LeaveOfAbsence'), + DropColumn('start_time', onTable: 'LeaveOfAbsence'), + DropColumn('end_time', onTable: 'LeaveOfAbsence'), + DropColumn('status', onTable: 'LeaveOfAbsence'), + DropColumn('filed_by', onTable: 'LeaveOfAbsence'), + DropColumn('created_at', onTable: 'LeaveOfAbsence'), + DropColumn('leave_type_label', onTable: 'LeaveOfAbsence'), + DropColumn('id', onTable: 'NotificationItem'), + DropColumn('user_id', onTable: 'NotificationItem'), + DropColumn('actor_id', onTable: 'NotificationItem'), + DropColumn('ticket_id', onTable: 'NotificationItem'), + DropColumn('task_id', onTable: 'NotificationItem'), + DropColumn('leave_id', onTable: 'NotificationItem'), + DropColumn('pass_slip_id', onTable: 'NotificationItem'), + DropColumn('it_service_request_id', onTable: 'NotificationItem'), + DropColumn('announcement_id', onTable: 'NotificationItem'), + DropColumn('message_id', onTable: 'NotificationItem'), + DropColumn('type', onTable: 'NotificationItem'), + DropColumn('created_at', onTable: 'NotificationItem'), + DropColumn('read_at', onTable: 'NotificationItem'), + DropColumn('is_unread', onTable: 'NotificationItem'), + DropColumn('id', onTable: 'Office'), + DropColumn('name', onTable: 'Office'), + DropColumn('service_id', onTable: 'Office'), + DropColumn('id', onTable: 'PassSlip'), + DropColumn('user_id', onTable: 'PassSlip'), + DropColumn('duty_schedule_id', onTable: 'PassSlip'), + DropColumn('reason', onTable: 'PassSlip'), + DropColumn('status', onTable: 'PassSlip'), + DropColumn('requested_at', onTable: 'PassSlip'), + DropColumn('approved_by', onTable: 'PassSlip'), + DropColumn('approved_at', onTable: 'PassSlip'), + DropColumn('slip_start', onTable: 'PassSlip'), + DropColumn('slip_end', onTable: 'PassSlip'), + DropColumn('requested_start', onTable: 'PassSlip'), + DropColumn('is_active', onTable: 'PassSlip'), + DropColumn('is_exceeded', onTable: 'PassSlip'), + DropColumn('id', onTable: 'Profile'), + DropColumn('role', onTable: 'Profile'), + DropColumn('full_name', onTable: 'Profile'), + DropColumn('religion', onTable: 'Profile'), + DropColumn('allow_tracking', onTable: 'Profile'), + DropColumn('avatar_url', onTable: 'Profile'), + DropColumn('face_photo_url', onTable: 'Profile'), + DropColumn('face_enrolled_at', onTable: 'Profile'), + DropColumn('has_face_enrolled', onTable: 'Profile'), + DropColumn('id', onTable: 'Service'), + DropColumn('name', onTable: 'Service'), + DropColumn('id', onTable: 'SwapRequest'), + DropColumn('requester_id', onTable: 'SwapRequest'), + DropColumn('recipient_id', onTable: 'SwapRequest'), + DropColumn('requester_schedule_id', onTable: 'SwapRequest'), + DropColumn('target_schedule_id', onTable: 'SwapRequest'), + DropColumn('status', onTable: 'SwapRequest'), + DropColumn('created_at', onTable: 'SwapRequest'), + DropColumn('updated_at', onTable: 'SwapRequest'), + DropColumn('chat_thread_id', onTable: 'SwapRequest'), + DropColumn('shift_type', onTable: 'SwapRequest'), + DropColumn('shift_start_time', onTable: 'SwapRequest'), + DropColumn('reliever_ids', onTable: 'SwapRequest'), + DropColumn('approved_by', onTable: 'SwapRequest'), + DropColumn('id', onTable: 'Task'), + DropColumn('ticket_id', onTable: 'Task'), + DropColumn('task_number', onTable: 'Task'), + DropColumn('title', onTable: 'Task'), + DropColumn('description', onTable: 'Task'), + DropColumn('office_id', onTable: 'Task'), + DropColumn('status', onTable: 'Task'), + DropColumn('priority', onTable: 'Task'), + DropColumn('queue_order', onTable: 'Task'), + DropColumn('created_at', onTable: 'Task'), + DropColumn('creator_id', onTable: 'Task'), + DropColumn('started_at', onTable: 'Task'), + DropColumn('completed_at', onTable: 'Task'), + DropColumn('requested_by', onTable: 'Task'), + DropColumn('noted_by', onTable: 'Task'), + DropColumn('received_by', onTable: 'Task'), + DropColumn('request_type', onTable: 'Task'), + DropColumn('request_type_other', onTable: 'Task'), + DropColumn('request_category', onTable: 'Task'), + DropColumn('action_taken', onTable: 'Task'), + DropColumn('cancellation_reason', onTable: 'Task'), + DropColumn('cancelled_at', onTable: 'Task'), + DropColumn('it_job_printed', onTable: 'Task'), + DropColumn('it_job_printed_at', onTable: 'Task'), + DropColumn('it_job_received_by_id', onTable: 'Task'), + DropColumn('hash_code', onTable: 'Task'), + DropColumn('has_incomplete_details', onTable: 'Task'), + DropColumn('id', onTable: 'TaskActivityLog'), + DropColumn('task_id', onTable: 'TaskActivityLog'), + DropColumn('actor_id', onTable: 'TaskActivityLog'), + DropColumn('action_type', onTable: 'TaskActivityLog'), + DropColumn('meta', onTable: 'TaskActivityLog'), + DropColumn('created_at', onTable: 'TaskActivityLog'), + DropColumn('id', onTable: 'Team'), + DropColumn('name', onTable: 'Team'), + DropColumn('leader_id', onTable: 'Team'), + DropColumn('office_ids', onTable: 'Team'), + DropColumn('created_at', onTable: 'Team'), + DropColumn('color', onTable: 'Team'), + DropColumn('id', onTable: 'Ticket'), + DropColumn('subject', onTable: 'Ticket'), + DropColumn('description', onTable: 'Ticket'), + DropColumn('office_id', onTable: 'Ticket'), + DropColumn('status', onTable: 'Ticket'), + DropColumn('created_at', onTable: 'Ticket'), + DropColumn('creator_id', onTable: 'Ticket'), + DropColumn('responded_at', onTable: 'Ticket'), + DropColumn('promoted_at', onTable: 'Ticket'), + DropColumn('closed_at', onTable: 'Ticket'), + DropColumn('hash_code', onTable: 'Ticket') +]; + +// +// DO NOT EDIT BELOW THIS LINE +// + +@Migratable( + version: '20260411041808', + up: _migration_20260411041808_up, + down: _migration_20260411041808_down, +) +class Migration20260411041808 extends Migration { + const Migration20260411041808() + : super( + version: 20260411041808, + up: _migration_20260411041808_up, + down: _migration_20260411041808_down, + ); +} diff --git a/lib/brick/db/20260411042152.migration.dart b/lib/brick/db/20260411042152.migration.dart new file mode 100644 index 00000000..11d73c2b --- /dev/null +++ b/lib/brick/db/20260411042152.migration.dart @@ -0,0 +1,50 @@ +// GENERATED CODE EDIT WITH CAUTION +// THIS FILE **WILL NOT** BE REGENERATED +// This file should be version controlled and can be manually edited. +part of 'schema.g.dart'; + +// While migrations are intelligently created, the difference between some commands, such as +// DropTable vs. RenameTable, cannot be determined. For this reason, please review migrations after +// they are created to ensure the correct inference was made. + +// The migration version must **always** mirror the file name + +const List _migration_20260411042152_up = [ + DropColumn('is_banner_active', onTable: 'Announcement'), + DropColumn('hash_code', onTable: 'Announcement'), + DropColumn('hash_code', onTable: 'AnnouncementComment'), + DropColumn('is_checked_out', onTable: 'AttendanceLog'), + DropColumn('is_verified', onTable: 'AttendanceLog'), + DropColumn('is_unverified', onTable: 'AttendanceLog'), + DropColumn('hash_code', onTable: 'ItServiceRequest'), + DropColumn('leave_type_label', onTable: 'LeaveOfAbsence'), + DropColumn('is_unread', onTable: 'NotificationItem'), + DropColumn('is_active', onTable: 'PassSlip'), + DropColumn('is_exceeded', onTable: 'PassSlip'), + DropColumn('has_face_enrolled', onTable: 'Profile'), + DropColumn('hash_code', onTable: 'Task'), + DropColumn('has_incomplete_details', onTable: 'Task'), + DropColumn('hash_code', onTable: 'Ticket') +]; + +const List _migration_20260411042152_down = [ + +]; + +// +// DO NOT EDIT BELOW THIS LINE +// + +@Migratable( + version: '20260411042152', + up: _migration_20260411042152_up, + down: _migration_20260411042152_down, +) +class Migration20260411042152 extends Migration { + const Migration20260411042152() + : super( + version: 20260411042152, + up: _migration_20260411042152_up, + down: _migration_20260411042152_down, + ); +} diff --git a/lib/brick/db/schema.g.dart b/lib/brick/db/schema.g.dart new file mode 100644 index 00000000..b1b6c52e --- /dev/null +++ b/lib/brick/db/schema.g.dart @@ -0,0 +1,478 @@ +// GENERATED CODE DO NOT EDIT +// This file should be version controlled +import 'package:brick_sqlite/db.dart'; +part '20260411041808.migration.dart'; +part '20260411042152.migration.dart'; + +/// All intelligently-generated migrations from all `@Migratable` classes on disk +final migrations = { + const Migration20260411041808(), + const Migration20260411042152(), +}; + +/// A consumable database structure including the latest generated migration. +final schema = Schema( + 20260411042152, + generatorVersion: 1, + tables: { + SchemaTable( + 'Announcement', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('author_id', Column.varchar), + SchemaColumn('title', Column.varchar), + SchemaColumn('body', Column.varchar), + SchemaColumn('visible_roles', Column.varchar), + SchemaColumn('is_template', Column.boolean), + SchemaColumn('template_id', Column.varchar), + SchemaColumn('created_at', Column.datetime), + SchemaColumn('updated_at', Column.datetime), + SchemaColumn('banner_enabled', Column.boolean), + SchemaColumn('banner_show_at', Column.datetime), + SchemaColumn('banner_hide_at', Column.datetime), + SchemaColumn('push_interval_minutes', Column.integer), + }, + indices: {}, + ), + SchemaTable( + 'AnnouncementComment', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('announcement_id', Column.varchar), + SchemaColumn('author_id', Column.varchar), + SchemaColumn('body', Column.varchar), + SchemaColumn('created_at', Column.datetime), + }, + indices: {}, + ), + SchemaTable( + 'AttendanceLog', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('user_id', Column.varchar), + SchemaColumn('duty_schedule_id', Column.varchar), + SchemaColumn('shift_type', Column.varchar), + SchemaColumn('check_in_at', Column.datetime), + SchemaColumn('check_in_lat', Column.Double), + SchemaColumn('check_in_lng', Column.Double), + SchemaColumn('check_out_at', Column.datetime), + SchemaColumn('check_out_lat', Column.Double), + SchemaColumn('check_out_lng', Column.Double), + SchemaColumn('justification', Column.varchar), + SchemaColumn('check_out_justification', Column.varchar), + SchemaColumn('verification_status', Column.varchar), + SchemaColumn('check_in_verification_photo_url', Column.varchar), + SchemaColumn('check_out_verification_photo_url', Column.varchar), + }, + indices: {}, + ), + SchemaTable( + 'ChatMessage', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('thread_id', Column.varchar), + SchemaColumn('sender_id', Column.varchar), + SchemaColumn('body', Column.varchar), + SchemaColumn('created_at', Column.datetime), + }, + indices: {}, + ), + SchemaTable( + 'DutySchedule', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('user_id', Column.varchar), + SchemaColumn('shift_type', Column.varchar), + SchemaColumn('start_time', Column.datetime), + SchemaColumn('end_time', Column.datetime), + SchemaColumn('status', Column.varchar), + SchemaColumn('created_at', Column.datetime), + SchemaColumn('check_in_at', Column.datetime), + SchemaColumn('reliever_ids', Column.varchar), + SchemaColumn('swap_request_id', Column.varchar), + }, + indices: {}, + ), + SchemaTable( + 'ItServiceRequest', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('request_number', Column.varchar), + SchemaColumn('services', Column.varchar), + SchemaColumn('services_other', Column.varchar), + SchemaColumn('event_name', Column.varchar), + SchemaColumn('event_details', Column.varchar), + SchemaColumn('event_date', Column.datetime), + SchemaColumn('event_end_date', Column.datetime), + SchemaColumn('dry_run_date', Column.datetime), + SchemaColumn('dry_run_end_date', Column.datetime), + SchemaColumn('contact_person', Column.varchar), + SchemaColumn('contact_number', Column.varchar), + SchemaColumn('remarks', Column.varchar), + SchemaColumn('office_id', Column.varchar), + SchemaColumn('requested_by', Column.varchar), + SchemaColumn('requested_by_user_id', Column.varchar), + SchemaColumn('approved_by', Column.varchar), + SchemaColumn('approved_by_user_id', Column.varchar), + SchemaColumn('approved_at', Column.datetime), + SchemaColumn('status', Column.varchar), + SchemaColumn('outside_premise_allowed', Column.boolean), + SchemaColumn('cancellation_reason', Column.varchar), + SchemaColumn('cancelled_at', Column.datetime), + SchemaColumn('creator_id', Column.varchar), + SchemaColumn('created_at', Column.datetime), + SchemaColumn('updated_at', Column.datetime), + SchemaColumn('completed_at', Column.datetime), + SchemaColumn('date_time_received', Column.datetime), + SchemaColumn('date_time_checked', Column.datetime), + }, + indices: {}, + ), + SchemaTable( + 'ItServiceRequestAction', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('request_id', Column.varchar), + SchemaColumn('user_id', Column.varchar), + SchemaColumn('action_taken', Column.varchar), + SchemaColumn('created_at', Column.datetime), + SchemaColumn('updated_at', Column.datetime), + }, + indices: {}, + ), + SchemaTable( + 'ItServiceRequestActivityLog', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('request_id', Column.varchar), + SchemaColumn('actor_id', Column.varchar), + SchemaColumn('action_type', Column.varchar), + SchemaColumn('meta', Column.varchar), + SchemaColumn('created_at', Column.datetime), + }, + indices: {}, + ), + SchemaTable( + 'ItServiceRequestAssignment', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('request_id', Column.varchar), + SchemaColumn('user_id', Column.varchar), + SchemaColumn('created_at', Column.datetime), + }, + indices: {}, + ), + SchemaTable( + 'LeaveOfAbsence', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('user_id', Column.varchar), + SchemaColumn('leave_type', Column.varchar), + SchemaColumn('justification', Column.varchar), + SchemaColumn('start_time', Column.datetime), + SchemaColumn('end_time', Column.datetime), + SchemaColumn('status', Column.varchar), + SchemaColumn('filed_by', Column.varchar), + SchemaColumn('created_at', Column.datetime), + }, + indices: {}, + ), + SchemaTable( + 'NotificationItem', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('user_id', Column.varchar), + SchemaColumn('actor_id', Column.varchar), + SchemaColumn('ticket_id', Column.varchar), + SchemaColumn('task_id', Column.varchar), + SchemaColumn('leave_id', Column.varchar), + SchemaColumn('pass_slip_id', Column.varchar), + SchemaColumn('it_service_request_id', Column.varchar), + SchemaColumn('announcement_id', Column.varchar), + SchemaColumn('message_id', Column.integer), + SchemaColumn('type', Column.varchar), + SchemaColumn('created_at', Column.datetime), + SchemaColumn('read_at', Column.datetime), + }, + indices: {}, + ), + SchemaTable( + 'Office', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('name', Column.varchar), + SchemaColumn('service_id', Column.varchar), + }, + indices: {}, + ), + SchemaTable( + 'PassSlip', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('user_id', Column.varchar), + SchemaColumn('duty_schedule_id', Column.varchar), + SchemaColumn('reason', Column.varchar), + SchemaColumn('status', Column.varchar), + SchemaColumn('requested_at', Column.datetime), + SchemaColumn('approved_by', Column.varchar), + SchemaColumn('approved_at', Column.datetime), + SchemaColumn('slip_start', Column.datetime), + SchemaColumn('slip_end', Column.datetime), + SchemaColumn('requested_start', Column.datetime), + }, + indices: {}, + ), + SchemaTable( + 'Profile', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('role', Column.varchar), + SchemaColumn('full_name', Column.varchar), + SchemaColumn('religion', Column.varchar), + SchemaColumn('allow_tracking', Column.boolean), + SchemaColumn('avatar_url', Column.varchar), + SchemaColumn('face_photo_url', Column.varchar), + SchemaColumn('face_enrolled_at', Column.datetime), + }, + indices: {}, + ), + SchemaTable( + 'Service', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('name', Column.varchar), + }, + indices: {}, + ), + SchemaTable( + 'SwapRequest', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('requester_id', Column.varchar), + SchemaColumn('recipient_id', Column.varchar), + SchemaColumn('requester_schedule_id', Column.varchar), + SchemaColumn('target_schedule_id', Column.varchar), + SchemaColumn('status', Column.varchar), + SchemaColumn('created_at', Column.datetime), + SchemaColumn('updated_at', Column.datetime), + SchemaColumn('chat_thread_id', Column.varchar), + SchemaColumn('shift_type', Column.varchar), + SchemaColumn('shift_start_time', Column.datetime), + SchemaColumn('reliever_ids', Column.varchar), + SchemaColumn('approved_by', Column.varchar), + }, + indices: {}, + ), + SchemaTable( + 'Task', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('ticket_id', Column.varchar), + SchemaColumn('task_number', Column.varchar), + SchemaColumn('title', Column.varchar), + SchemaColumn('description', Column.varchar), + SchemaColumn('office_id', Column.varchar), + SchemaColumn('status', Column.varchar), + SchemaColumn('priority', Column.integer), + SchemaColumn('queue_order', Column.integer), + SchemaColumn('created_at', Column.datetime), + SchemaColumn('creator_id', Column.varchar), + SchemaColumn('started_at', Column.datetime), + SchemaColumn('completed_at', Column.datetime), + SchemaColumn('requested_by', Column.varchar), + SchemaColumn('noted_by', Column.varchar), + SchemaColumn('received_by', Column.varchar), + SchemaColumn('request_type', Column.varchar), + SchemaColumn('request_type_other', Column.varchar), + SchemaColumn('request_category', Column.varchar), + SchemaColumn('action_taken', Column.varchar), + SchemaColumn('cancellation_reason', Column.varchar), + SchemaColumn('cancelled_at', Column.datetime), + SchemaColumn('it_job_printed', Column.boolean), + SchemaColumn('it_job_printed_at', Column.datetime), + SchemaColumn('it_job_received_by_id', Column.varchar), + }, + indices: {}, + ), + SchemaTable( + 'TaskActivityLog', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('task_id', Column.varchar), + SchemaColumn('actor_id', Column.varchar), + SchemaColumn('action_type', Column.varchar), + SchemaColumn('meta', Column.varchar), + SchemaColumn('created_at', Column.datetime), + }, + indices: {}, + ), + SchemaTable( + 'Team', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('name', Column.varchar), + SchemaColumn('leader_id', Column.varchar), + SchemaColumn('office_ids', Column.varchar), + SchemaColumn('created_at', Column.datetime), + SchemaColumn('color', Column.varchar), + }, + indices: {}, + ), + SchemaTable( + 'Ticket', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('subject', Column.varchar), + SchemaColumn('description', Column.varchar), + SchemaColumn('office_id', Column.varchar), + SchemaColumn('status', Column.varchar), + SchemaColumn('created_at', Column.datetime), + SchemaColumn('creator_id', Column.varchar), + SchemaColumn('responded_at', Column.datetime), + SchemaColumn('promoted_at', Column.datetime), + SchemaColumn('closed_at', Column.datetime), + }, + indices: {}, + ), + }, +); diff --git a/lib/brick/repository.dart b/lib/brick/repository.dart new file mode 100644 index 00000000..e2208a72 --- /dev/null +++ b/lib/brick/repository.dart @@ -0,0 +1,160 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_offline_first_with_rest/offline_queue.dart'; +import 'package:brick_sqlite/brick_sqlite.dart'; +import 'package:brick_sqlite/memory_cache_provider.dart'; +// Hide the brick_supabase @Supabase annotation to avoid conflict with +// supabase_flutter's Supabase singleton class. +import 'package:brick_supabase/brick_supabase.dart' hide Supabase; +import 'package:flutter/foundation.dart' show kIsWeb, debugPrint; +import 'package:http/http.dart' as http; +import 'package:sqflite/sqflite.dart' show databaseFactory; +import 'package:supabase_flutter/supabase_flutter.dart'; + +// Generated by `flutter pub run build_runner build`. +// Run: flutter pub run build_runner build --delete-conflicting-outputs +// ignore: uri_does_not_exist +import 'brick.g.dart'; +// migrations + schema are generated into db/schema.g.dart +// ignore: uri_does_not_exist +import 'db/schema.g.dart'; + +export 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart' + show OfflineFirstWithSupabaseModel; +export 'package:brick_supabase/brick_supabase.dart' + show SupabaseSerializable; +export 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart' + show ConnectOfflineFirstWithSupabase; +export 'package:brick_sqlite/brick_sqlite.dart' show SqliteSerializable; +export 'package:brick_offline_first/brick_offline_first.dart' + show OfflineFirstGetPolicy; + +/// Singleton offline-first repository backed by Supabase + local SQLite. +/// +/// Initialization sequence in [main]: +/// ```dart +/// // 1. Get the Brick HTTP client (mobile only; null on web) +/// final httpClient = AppRepository.createHttpClient(); +/// +/// // 2. Initialize Supabase WITH the Brick HTTP client so all REST +/// // writes are intercepted and queued when offline. +/// await Supabase.initialize(url: ..., anonKey: ..., httpClient: httpClient); +/// +/// // 3. Configure the repository (reads model dictionary from brick.g.dart) +/// await AppRepository.configure(); +/// ``` +class AppRepository extends OfflineFirstWithSupabaseRepository { + static AppRepository? _instance; + static RestOfflineRequestQueue? _pendingQueue; + + /// The singleton. Throws if [configure] was not called first. + static AppRepository get instance { + assert(_instance != null, 'Call AppRepository.configure() first'); + return _instance!; + } + + /// True when this platform supports offline SQLite storage (i.e. not web). + static bool get isOfflineSupported => !kIsWeb; + + /// True after [configure] has completed successfully. + static bool get isConfigured => _instance != null; + + /// The Brick HTTP offline queue. `null` before [configure] or on web. + /// + /// Call [RestOfflineRequestQueue.stop] when offline to silence retry logs + /// and avoid hammering a network that is known to be unavailable. + /// Call [RestOfflineRequestQueue.start] when back online to replay queued + /// writes. The stored requests are preserved in both states. + static RestOfflineRequestQueue? get offlineQueue => + _instance?.offlineRequestQueue; + + AppRepository._({ + required super.supabaseProvider, + required super.sqliteProvider, + required super.migrations, + required super.offlineRequestQueue, + super.memoryCacheProvider, + }); + + /// Creates and returns the Brick-managed HTTP client to pass to + /// [Supabase.initialize]. Returns `null` on web (SQLite unavailable). + /// + /// Must be called *before* [Supabase.initialize] and *before* [configure]. + static http.Client? createHttpClient() { + if (kIsWeb) return null; + final (client, queue) = OfflineFirstWithSupabaseRepository.clientQueue( + databaseFactory: databaseFactory, + // Only retry on truly transient server errors. + // 400/409 indicate a permanent failure (bad payload or conflict) and + // should NOT be retried — they will never self-heal and cause infinite + // retry loops that spam the logs. + reattemptForStatusCodes: const [408, 429, 500, 502, 503, 504], + ); + _pendingQueue = queue; + return client; + } + + /// Configures the repository singleton. + /// + /// Must be called *after* [Supabase.initialize]. Reads the Brick-generated + /// model dictionaries from `brick.g.dart` (created by build_runner). + static Future configure() async { + if (_instance != null) return; + if (kIsWeb) { + debugPrint('[AppRepository] web — offline queue disabled'); + return; + } + + final queue = _pendingQueue; + if (queue == null) { + throw StateError( + 'Call AppRepository.createHttpClient() before configure()', + ); + } + + _instance = AppRepository._( + supabaseProvider: SupabaseProvider( + Supabase.instance.client, + modelDictionary: supabaseModelDictionary, + ), + sqliteProvider: SqliteProvider( + 'tasq_offline.sqlite', + databaseFactory: databaseFactory, + modelDictionary: sqliteModelDictionary, + ), + migrations: migrations, + offlineRequestQueue: queue, + memoryCacheProvider: MemoryCacheProvider(), + ); + + await _instance!.initialize(); + _pendingQueue = null; + debugPrint('[AppRepository] initialized — offline queue active'); + } +} + +/// Mirrors a Supabase realtime stream into the Brick local SQLite cache. +/// +/// Wrap any `client.from('t').stream(...)` source so every emission is +/// upserted into Brick. The cache stays fresh during normal online use, which +/// means a cold launch with no network can read a recent snapshot via +/// `OfflineFirstGetPolicy.localOnly` instead of rendering an empty list. +/// +/// Cache writes are fire-and-forget — they never block UI updates and any +/// upsert error is silently logged (mirroring is best-effort). +extension AppRepositoryStreamMirror on AppRepository { + Stream> mirroredStream( + Stream> source, + ) async* { + await for (final batch in source) { + for (final item in batch) { + // Fire-and-forget; never block the UI on cache writes. + // ignore: discarded_futures + upsert(item).catchError((Object e) { + debugPrint('[AppRepository] mirror upsert<$T> failed: $e'); + return item; + }); + } + yield batch; + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 08c7bb54..677b4be7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,8 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'firebase_options.dart'; // removed unused imports import 'app.dart'; +import 'brick/cache_warmer.dart'; +import 'brick/repository.dart'; import 'theme/app_theme.dart'; import 'providers/notifications_provider.dart'; import 'providers/notification_navigation_provider.dart'; @@ -276,10 +278,16 @@ Future main() async { return; } + // Create Brick's HTTP client BEFORE Supabase.initialize so that all + // Supabase REST writes are intercepted and queued when offline. + // Returns null on web (SQLite unavailable there). + final brickHttpClient = AppRepository.createHttpClient(); + try { await Supabase.initialize( url: supabaseUrl, anonKey: supabaseAnonKey, + httpClient: brickHttpClient, ).timeout(const Duration(seconds: 20)); } catch (e) { debugPrint('Supabase init failed or timed out: $e'); @@ -287,6 +295,16 @@ Future main() async { return; } + // Configure the Brick repository after Supabase is ready. + try { + await AppRepository.configure(); + } catch (e, st) { + debugPrint('AppRepository configure FAILED: $e\n$st'); + } + debugPrint( + '[Boot] AppRepository.isConfigured=${AppRepository.isConfigured}', + ); + // Initialize background location service (flutter_background_service) if (!kIsWeb) { try { @@ -302,6 +320,17 @@ Future main() async { // Run this after runApp so startup is not blocked by network/token ops. final supaClient = Supabase.instance.client; + // Cold-launch with a persisted session: AuthChangeEvent.signedIn does NOT + // fire on session rehydration (only initialSession/tokenRefreshed do), so + // the auth-change listener below misses cold-starts. Warm the Brick mirror + // here in the background so cached lists stay fresh between sessions. + // CacheWarmer.warmAll is no-op-safe offline (REST calls just fail). + if (supaClient.auth.currentUser != null && AppRepository.isConfigured) { + debugPrint('[Boot] Persisted session detected — warming Brick cache'); + CacheWarmer.warmAll(supaClient); + CacheWarmer.startPeriodicRefresh(supaClient); + } + // listen for auth changes to register/unregister token accordingly supaClient.auth.onAuthStateChange.listen((data) async { final event = data.event; @@ -347,6 +376,16 @@ Future main() async { return; } + // Trigger a cache warm immediately when the user signs in so the local + // Brick SQLite mirror is populated for the next cold-launch. Fire-and- + // forget — never block the auth flow on the warmer. + if (event == AuthChangeEvent.signedIn) { + CacheWarmer.warmAll(supaClient); + CacheWarmer.startPeriodicRefresh(supaClient); + } else if (event == AuthChangeEvent.signedOut) { + CacheWarmer.stopPeriodicRefresh(); + } + String? token; try { token = await FirebaseMessaging.instance.getToken(); diff --git a/lib/models/announcement.dart b/lib/models/announcement.dart index 5c4761ad..537dc6b9 100644 --- a/lib/models/announcement.dart +++ b/lib/models/announcement.dart @@ -1,111 +1 @@ -import '../utils/app_time.dart'; - -class Announcement { - Announcement({ - required this.id, - required this.authorId, - required this.title, - required this.body, - required this.visibleRoles, - required this.isTemplate, - this.templateId, - required this.createdAt, - required this.updatedAt, - required this.bannerEnabled, - this.bannerShowAt, - this.bannerHideAt, - this.pushIntervalMinutes, - }); - - final String id; - final String authorId; - final String title; - final String body; - final List visibleRoles; - final bool isTemplate; - final String? templateId; - final DateTime createdAt; - final DateTime updatedAt; - - /// Whether a persistent banner is shown at the top of the announcements screen. - final bool bannerEnabled; - - /// When the banner should start showing. [null] means immediately. - final DateTime? bannerShowAt; - - /// When the banner should stop showing. [null] means it requires a manual - /// turn-off by the poster or an admin. - final DateTime? bannerHideAt; - - /// How often (in minutes) a scheduled push notification is sent while the - /// banner is active. [null] means no scheduled push. Max is 1440 (daily). - final int? pushIntervalMinutes; - - /// Whether the banner is currently active (visible) based on the current time. - bool get isBannerActive { - if (!bannerEnabled) return false; - final now = AppTime.now(); - if (bannerShowAt != null && now.isBefore(bannerShowAt!)) return false; - if (bannerHideAt != null && now.isAfter(bannerHideAt!)) return false; - return true; - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is Announcement && - runtimeType == other.runtimeType && - id == other.id && - authorId == other.authorId && - title == other.title && - body == other.body && - isTemplate == other.isTemplate && - templateId == other.templateId && - bannerEnabled == other.bannerEnabled && - bannerShowAt == other.bannerShowAt && - bannerHideAt == other.bannerHideAt && - pushIntervalMinutes == other.pushIntervalMinutes && - createdAt == other.createdAt && - updatedAt == other.updatedAt; - - @override - int get hashCode => Object.hash( - id, - authorId, - title, - body, - isTemplate, - templateId, - bannerEnabled, - bannerShowAt, - bannerHideAt, - pushIntervalMinutes, - createdAt, - updatedAt, - ); - - factory Announcement.fromMap(Map map) { - final rolesRaw = map['visible_roles']; - final roles = rolesRaw is List ? rolesRaw.cast() : []; - - return Announcement( - id: map['id'] as String, - authorId: map['author_id'] as String, - title: map['title'] as String? ?? '', - body: map['body'] as String? ?? '', - visibleRoles: roles, - isTemplate: map['is_template'] as bool? ?? false, - templateId: map['template_id'] as String?, - createdAt: AppTime.parse(map['created_at'] as String), - updatedAt: AppTime.parse(map['updated_at'] as String), - bannerEnabled: map['banner_enabled'] as bool? ?? false, - bannerShowAt: map['banner_show_at'] != null - ? AppTime.parse(map['banner_show_at'] as String) - : null, - bannerHideAt: map['banner_hide_at'] != null - ? AppTime.parse(map['banner_hide_at'] as String) - : null, - pushIntervalMinutes: map['push_interval_minutes'] as int?, - ); - } -} +export 'announcement.model.dart'; diff --git a/lib/models/announcement.model.dart b/lib/models/announcement.model.dart new file mode 100644 index 00000000..678e3684 --- /dev/null +++ b/lib/models/announcement.model.dart @@ -0,0 +1,95 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_sqlite/brick_sqlite.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +import '../utils/app_time.dart'; + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'announcements'), +) +class Announcement extends OfflineFirstWithSupabaseModel { + final String id; + final String authorId; + final String title; + final String body; + final List visibleRoles; + final bool isTemplate; + final String? templateId; + final DateTime createdAt; + final DateTime updatedAt; + final bool bannerEnabled; + final DateTime? bannerShowAt; + final DateTime? bannerHideAt; + final int? pushIntervalMinutes; + + @Supabase(ignore: true) + @Sqlite(ignore: true) + bool get isBannerActive { + if (!bannerEnabled) return false; + final now = AppTime.now(); + if (bannerShowAt != null && now.isBefore(bannerShowAt!)) return false; + if (bannerHideAt != null && now.isAfter(bannerHideAt!)) return false; + return true; + } + + Announcement({ + required this.id, + required this.authorId, + required this.title, + required this.body, + required this.visibleRoles, + required this.isTemplate, + this.templateId, + required this.createdAt, + required this.updatedAt, + required this.bannerEnabled, + this.bannerShowAt, + this.bannerHideAt, + this.pushIntervalMinutes, + }); + + @Supabase(ignore: true) + @Sqlite(ignore: true) + @override + bool operator ==(Object other) => + identical(this, other) || + other is Announcement && + runtimeType == other.runtimeType && + id == other.id && authorId == other.authorId && + title == other.title && body == other.body && + isTemplate == other.isTemplate && templateId == other.templateId && + bannerEnabled == other.bannerEnabled && + bannerShowAt == other.bannerShowAt && + bannerHideAt == other.bannerHideAt && + pushIntervalMinutes == other.pushIntervalMinutes && + createdAt == other.createdAt && updatedAt == other.updatedAt; + + @Supabase(ignore: true) + @Sqlite(ignore: true) + @override + int get hashCode => Object.hash(id, authorId, title, body, isTemplate, + templateId, bannerEnabled, bannerShowAt, bannerHideAt, + pushIntervalMinutes, createdAt, updatedAt); + + factory Announcement.fromMap(Map map) { + final rolesRaw = map['visible_roles']; + final roles = rolesRaw is List ? rolesRaw.cast() : []; + return Announcement( + id: map['id'] as String, + authorId: map['author_id'] as String, + title: map['title'] as String? ?? '', + body: map['body'] as String? ?? '', + visibleRoles: roles, + isTemplate: map['is_template'] as bool? ?? false, + templateId: map['template_id'] as String?, + createdAt: AppTime.parse(map['created_at'] as String), + updatedAt: AppTime.parse(map['updated_at'] as String), + bannerEnabled: map['banner_enabled'] as bool? ?? false, + bannerShowAt: map['banner_show_at'] != null + ? AppTime.parse(map['banner_show_at'] as String) : null, + bannerHideAt: map['banner_hide_at'] != null + ? AppTime.parse(map['banner_hide_at'] as String) : null, + pushIntervalMinutes: map['push_interval_minutes'] as int?, + ); + } +} diff --git a/lib/models/announcement_comment.dart b/lib/models/announcement_comment.dart index 87342c80..52118e32 100644 --- a/lib/models/announcement_comment.dart +++ b/lib/models/announcement_comment.dart @@ -1,47 +1 @@ -import '../utils/app_time.dart'; - -class AnnouncementComment { - AnnouncementComment({ - required this.id, - required this.announcementId, - required this.authorId, - required this.body, - required this.createdAt, - }); - - final String id; - final String announcementId; - final String authorId; - final String body; - final DateTime createdAt; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is AnnouncementComment && - runtimeType == other.runtimeType && - id == other.id && - announcementId == other.announcementId && - authorId == other.authorId && - body == other.body && - createdAt == other.createdAt; - - @override - int get hashCode => Object.hash( - id, - announcementId, - authorId, - body, - createdAt, - ); - - factory AnnouncementComment.fromMap(Map map) { - return AnnouncementComment( - id: map['id'] as String, - announcementId: map['announcement_id'] as String, - authorId: map['author_id'] as String, - body: map['body'] as String? ?? '', - createdAt: AppTime.parse(map['created_at'] as String), - ); - } -} +export 'announcement_comment.model.dart'; diff --git a/lib/models/announcement_comment.model.dart b/lib/models/announcement_comment.model.dart new file mode 100644 index 00000000..102c8455 --- /dev/null +++ b/lib/models/announcement_comment.model.dart @@ -0,0 +1,51 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_sqlite/brick_sqlite.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +import '../utils/app_time.dart'; + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'announcement_comments'), +) +class AnnouncementComment extends OfflineFirstWithSupabaseModel { + final String id; + final String announcementId; + final String authorId; + final String body; + final DateTime createdAt; + + AnnouncementComment({ + required this.id, + required this.announcementId, + required this.authorId, + required this.body, + required this.createdAt, + }); + + @Supabase(ignore: true) + @Sqlite(ignore: true) + @override + bool operator ==(Object other) => + identical(this, other) || + other is AnnouncementComment && + runtimeType == other.runtimeType && + id == other.id && announcementId == other.announcementId && + authorId == other.authorId && body == other.body && + createdAt == other.createdAt; + + @Supabase(ignore: true) + @Sqlite(ignore: true) + @override + int get hashCode => + Object.hash(id, announcementId, authorId, body, createdAt); + + factory AnnouncementComment.fromMap(Map map) { + return AnnouncementComment( + id: map['id'] as String, + announcementId: map['announcement_id'] as String, + authorId: map['author_id'] as String, + body: map['body'] as String? ?? '', + createdAt: AppTime.parse(map['created_at'] as String), + ); + } +} diff --git a/lib/models/attendance_log.dart b/lib/models/attendance_log.dart index f610a8fc..d558ba26 100644 --- a/lib/models/attendance_log.dart +++ b/lib/models/attendance_log.dart @@ -1,68 +1 @@ -import '../utils/app_time.dart'; - -class AttendanceLog { - AttendanceLog({ - required this.id, - required this.userId, - required this.dutyScheduleId, - required this.shiftType, - required this.checkInAt, - required this.checkInLat, - required this.checkInLng, - this.checkOutAt, - this.checkOutLat, - this.checkOutLng, - this.justification, - this.checkOutJustification, - this.verificationStatus = 'pending', - this.checkInVerificationPhotoUrl, - this.checkOutVerificationPhotoUrl, - }); - - final String id; - final String userId; - final String dutyScheduleId; - final String shiftType; - final DateTime checkInAt; - final double checkInLat; - final double checkInLng; - final DateTime? checkOutAt; - final double? checkOutLat; - final double? checkOutLng; - final String? justification; - final String? checkOutJustification; - final String verificationStatus; // pending, verified, unverified, skipped - final String? checkInVerificationPhotoUrl; - final String? checkOutVerificationPhotoUrl; - - bool get isCheckedOut => checkOutAt != null; - bool get isVerified => verificationStatus == 'verified'; - bool get isUnverified => - verificationStatus == 'unverified' || verificationStatus == 'skipped'; - - factory AttendanceLog.fromMap(Map map) { - // Support both old single-column and new dual-column schemas. - final legacyUrl = map['verification_photo_url'] as String?; - return AttendanceLog( - id: map['id'] as String, - userId: map['user_id'] as String, - dutyScheduleId: map['duty_schedule_id'] as String, - shiftType: map['shift_type'] as String? ?? 'normal', - checkInAt: AppTime.parse(map['check_in_at'] as String), - checkInLat: (map['check_in_lat'] as num).toDouble(), - checkInLng: (map['check_in_lng'] as num).toDouble(), - checkOutAt: map['check_out_at'] == null - ? null - : AppTime.parse(map['check_out_at'] as String), - checkOutLat: (map['check_out_lat'] as num?)?.toDouble(), - checkOutLng: (map['check_out_lng'] as num?)?.toDouble(), - justification: map['justification'] as String?, - checkOutJustification: map['check_out_justification'] as String?, - verificationStatus: map['verification_status'] as String? ?? 'pending', - checkInVerificationPhotoUrl: - map['check_in_verification_photo_url'] as String? ?? legacyUrl, - checkOutVerificationPhotoUrl: - map['check_out_verification_photo_url'] as String?, - ); - } -} +export 'attendance_log.model.dart'; diff --git a/lib/models/attendance_log.model.dart b/lib/models/attendance_log.model.dart new file mode 100644 index 00000000..a6e7f150 --- /dev/null +++ b/lib/models/attendance_log.model.dart @@ -0,0 +1,79 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_sqlite/brick_sqlite.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +import '../utils/app_time.dart'; + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'attendance_logs'), +) +class AttendanceLog extends OfflineFirstWithSupabaseModel { + final String id; + final String userId; + final String dutyScheduleId; + final String shiftType; + final DateTime checkInAt; + final double checkInLat; + final double checkInLng; + final DateTime? checkOutAt; + final double? checkOutLat; + final double? checkOutLng; + final String? justification; + final String? checkOutJustification; + final String verificationStatus; + final String? checkInVerificationPhotoUrl; + final String? checkOutVerificationPhotoUrl; + + @Supabase(ignore: true) + @Sqlite(ignore: true) + bool get isCheckedOut => checkOutAt != null; + @Supabase(ignore: true) + @Sqlite(ignore: true) + bool get isVerified => verificationStatus == 'verified'; + @Supabase(ignore: true) + @Sqlite(ignore: true) + bool get isUnverified => + verificationStatus == 'unverified' || verificationStatus == 'skipped'; + + AttendanceLog({ + required this.id, + required this.userId, + required this.dutyScheduleId, + required this.shiftType, + required this.checkInAt, + required this.checkInLat, + required this.checkInLng, + this.checkOutAt, + this.checkOutLat, + this.checkOutLng, + this.justification, + this.checkOutJustification, + this.verificationStatus = 'pending', + this.checkInVerificationPhotoUrl, + this.checkOutVerificationPhotoUrl, + }); + + factory AttendanceLog.fromMap(Map map) { + final legacyUrl = map['verification_photo_url'] as String?; + return AttendanceLog( + id: map['id'] as String, + userId: map['user_id'] as String, + dutyScheduleId: map['duty_schedule_id'] as String, + shiftType: map['shift_type'] as String? ?? 'normal', + checkInAt: AppTime.parse(map['check_in_at'] as String), + checkInLat: (map['check_in_lat'] as num).toDouble(), + checkInLng: (map['check_in_lng'] as num).toDouble(), + checkOutAt: map['check_out_at'] == null + ? null : AppTime.parse(map['check_out_at'] as String), + checkOutLat: (map['check_out_lat'] as num?)?.toDouble(), + checkOutLng: (map['check_out_lng'] as num?)?.toDouble(), + justification: map['justification'] as String?, + checkOutJustification: map['check_out_justification'] as String?, + verificationStatus: map['verification_status'] as String? ?? 'pending', + checkInVerificationPhotoUrl: + map['check_in_verification_photo_url'] as String? ?? legacyUrl, + checkOutVerificationPhotoUrl: + map['check_out_verification_photo_url'] as String?, + ); + } +} diff --git a/lib/models/chat_message.dart b/lib/models/chat_message.dart index fe932e41..b5af515b 100644 --- a/lib/models/chat_message.dart +++ b/lib/models/chat_message.dart @@ -1,33 +1 @@ -import '../utils/app_time.dart'; - -class ChatMessage { - ChatMessage({ - required this.id, - required this.threadId, - required this.senderId, - required this.body, - required this.createdAt, - }); - - final String id; - final String threadId; - final String senderId; - final String body; - final DateTime createdAt; - - factory ChatMessage.fromMap(Map map) { - return ChatMessage( - id: map['id'] as String, - threadId: map['thread_id'] as String, - senderId: map['sender_id'] as String, - body: map['body'] as String, - createdAt: AppTime.parse(map['created_at'] as String), - ); - } - - Map toJson() => { - 'thread_id': threadId, - 'sender_id': senderId, - 'body': body, - }; -} +export 'chat_message.model.dart'; diff --git a/lib/models/chat_message.model.dart b/lib/models/chat_message.model.dart new file mode 100644 index 00000000..aaf55502 --- /dev/null +++ b/lib/models/chat_message.model.dart @@ -0,0 +1,39 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +import '../utils/app_time.dart'; + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'chat_messages'), +) +class ChatMessage extends OfflineFirstWithSupabaseModel { + final String id; + final String threadId; + final String senderId; + final String body; + final DateTime createdAt; + + ChatMessage({ + required this.id, + required this.threadId, + required this.senderId, + required this.body, + required this.createdAt, + }); + + factory ChatMessage.fromMap(Map map) { + return ChatMessage( + id: map['id'] as String, + threadId: map['thread_id'] as String, + senderId: map['sender_id'] as String, + body: map['body'] as String, + createdAt: AppTime.parse(map['created_at'] as String), + ); + } + + Map toJson() => { + 'thread_id': threadId, + 'sender_id': senderId, + 'body': body, + }; +} diff --git a/lib/models/duty_schedule.dart b/lib/models/duty_schedule.dart index 5657e460..b8e42f82 100644 --- a/lib/models/duty_schedule.dart +++ b/lib/models/duty_schedule.dart @@ -1,55 +1 @@ -import '../utils/app_time.dart'; - -class DutySchedule { - DutySchedule({ - required this.id, - required this.userId, - required this.shiftType, - required this.startTime, - required this.endTime, - required this.status, - required this.createdAt, - required this.checkInAt, - required this.checkInLocation, - required this.relieverIds, - this.swapRequestId, - }); - - final String id; - final String userId; - final String shiftType; - final DateTime startTime; - final DateTime endTime; - final String status; - final DateTime createdAt; - final DateTime? checkInAt; - final Object? checkInLocation; - final List relieverIds; - final String? swapRequestId; - - factory DutySchedule.fromMap(Map map) { - final relieversRaw = map['reliever_ids']; - final relievers = relieversRaw is List - ? relieversRaw - .where((e) => e != null) - .map((entry) => entry.toString()) - .where((s) => s.isNotEmpty) - .toList() - : []; - return DutySchedule( - id: map['id'] as String, - userId: map['user_id'] as String, - shiftType: map['shift_type'] as String? ?? 'normal', - startTime: AppTime.parse(map['start_time'] as String), - endTime: AppTime.parse(map['end_time'] as String), - status: map['status'] as String? ?? 'scheduled', - createdAt: AppTime.parse(map['created_at'] as String), - checkInAt: map['check_in_at'] == null - ? null - : AppTime.parse(map['check_in_at'] as String), - checkInLocation: map['check_in_location'], - relieverIds: relievers, - swapRequestId: map['swap_request_id'] as String?, - ); - } -} +export 'duty_schedule.model.dart'; diff --git a/lib/models/duty_schedule.model.dart b/lib/models/duty_schedule.model.dart new file mode 100644 index 00000000..d4407e7f --- /dev/null +++ b/lib/models/duty_schedule.model.dart @@ -0,0 +1,67 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_sqlite/brick_sqlite.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +import '../utils/app_time.dart'; + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'duty_schedules'), +) +class DutySchedule extends OfflineFirstWithSupabaseModel { + final String id; + final String userId; + final String shiftType; + final DateTime startTime; + final DateTime endTime; + final String status; + final DateTime createdAt; + final DateTime? checkInAt; + + // PostGIS geometry — Brick cannot serialize Object; ignored in local cache. + @Supabase(ignore: true) + @Sqlite(ignore: true) + final Object? checkInLocation; + + final List relieverIds; + final String? swapRequestId; + + DutySchedule({ + required this.id, + required this.userId, + required this.shiftType, + required this.startTime, + required this.endTime, + required this.status, + required this.createdAt, + this.checkInAt, + this.checkInLocation, + required this.relieverIds, + this.swapRequestId, + }); + + factory DutySchedule.fromMap(Map map) { + final relieversRaw = map['reliever_ids']; + final relievers = relieversRaw is List + ? relieversRaw + .where((e) => e != null) + .map((entry) => entry.toString()) + .where((s) => s.isNotEmpty) + .toList() + : []; + return DutySchedule( + id: map['id'] as String, + userId: map['user_id'] as String, + shiftType: map['shift_type'] as String? ?? 'normal', + startTime: AppTime.parse(map['start_time'] as String), + endTime: AppTime.parse(map['end_time'] as String), + status: map['status'] as String? ?? 'scheduled', + createdAt: AppTime.parse(map['created_at'] as String), + checkInAt: map['check_in_at'] == null + ? null + : AppTime.parse(map['check_in_at'] as String), + checkInLocation: map['check_in_location'], + relieverIds: relievers, + swapRequestId: map['swap_request_id'] as String?, + ); + } +} diff --git a/lib/models/it_service_request.dart b/lib/models/it_service_request.dart index bdd82cf3..d68ec1bb 100644 --- a/lib/models/it_service_request.dart +++ b/lib/models/it_service_request.dart @@ -1,259 +1 @@ -import 'dart:convert'; - -import '../utils/app_time.dart'; - -/// Available IT services from the form. -class ItServiceType { - static const fbLiveStream = 'fb_live_stream'; - static const videoRecording = 'video_recording'; - static const technicalAssistance = 'technical_assistance'; - static const wifi = 'wifi'; - static const others = 'others'; - - static const all = [ - fbLiveStream, - videoRecording, - technicalAssistance, - wifi, - others, - ]; - - static String label(String type) { - switch (type) { - case fbLiveStream: - return 'FB Live Stream'; - case videoRecording: - return 'Video Recording'; - case technicalAssistance: - return 'Technical Assistance'; - case wifi: - return 'WiFi'; - case others: - return 'Others'; - default: - return type; - } - } -} - -/// Status lifecycle for an IT Service Request. -class ItServiceRequestStatus { - static const draft = 'draft'; - static const pendingApproval = 'pending_approval'; - static const scheduled = 'scheduled'; - static const inProgressDryRun = 'in_progress_dry_run'; - static const inProgress = 'in_progress'; - static const completed = 'completed'; - static const cancelled = 'cancelled'; - - static const all = [ - draft, - pendingApproval, - scheduled, - inProgressDryRun, - inProgress, - completed, - cancelled, - ]; - - static String label(String status) { - switch (status) { - case draft: - return 'Draft'; - case pendingApproval: - return 'Pending Approval'; - case scheduled: - return 'Scheduled'; - case inProgressDryRun: - return 'In Progress (Dry Run)'; - case inProgress: - return 'In Progress'; - case completed: - return 'Completed'; - case cancelled: - return 'Cancelled'; - default: - return status; - } - } -} - -class ItServiceRequest { - ItServiceRequest({ - required this.id, - this.requestNumber, - required this.services, - this.servicesOther, - required this.eventName, - this.eventDetails, - this.eventDate, - this.eventEndDate, - this.dryRunDate, - this.dryRunEndDate, - this.contactPerson, - this.contactNumber, - this.remarks, - this.officeId, - this.requestedBy, - this.requestedByUserId, - this.approvedBy, - this.approvedByUserId, - this.approvedAt, - required this.status, - required this.outsidePremiseAllowed, - this.cancellationReason, - this.cancelledAt, - this.creatorId, - required this.createdAt, - required this.updatedAt, - this.completedAt, - this.dateTimeReceived, - this.dateTimeChecked, - }); - - final String id; - final String? requestNumber; - final List services; - final String? servicesOther; - final String eventName; - final String? eventDetails; // Quill Delta JSON - final DateTime? eventDate; - final DateTime? eventEndDate; - final DateTime? dryRunDate; - final DateTime? dryRunEndDate; - final String? contactPerson; - final String? contactNumber; - final String? remarks; // Quill Delta JSON - final String? officeId; - final String? requestedBy; - final String? requestedByUserId; - final String? approvedBy; - final String? approvedByUserId; - final DateTime? approvedAt; - final String status; - final bool outsidePremiseAllowed; - final String? cancellationReason; - final DateTime? cancelledAt; - final String? creatorId; - final DateTime createdAt; - final DateTime updatedAt; - final DateTime? completedAt; - final DateTime? dateTimeReceived; - final DateTime? dateTimeChecked; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is ItServiceRequest && - runtimeType == other.runtimeType && - id == other.id && - requestNumber == other.requestNumber && - status == other.status && - updatedAt == other.updatedAt; - - @override - int get hashCode => Object.hash(id, requestNumber, status, updatedAt); - - /// Whether the request is on a day that would allow geofence override. - bool isGeofenceOverrideActive(DateTime now) { - if (!outsidePremiseAllowed) return false; - final today = DateTime(now.year, now.month, now.day); - // Active on dry run date or event date - if (dryRunDate != null) { - final dryDay = DateTime( - dryRunDate!.year, - dryRunDate!.month, - dryRunDate!.day, - ); - if (!today.isBefore(dryDay) && - today.isBefore(dryDay.add(const Duration(days: 1)))) { - return true; - } - } - if (eventDate != null) { - final eventDay = DateTime( - eventDate!.year, - eventDate!.month, - eventDate!.day, - ); - if (!today.isBefore(eventDay) && - today.isBefore(eventDay.add(const Duration(days: 1)))) { - return true; - } - } - return false; - } - - factory ItServiceRequest.fromMap(Map map) { - List parseServices(dynamic raw) { - if (raw is List) return raw.map((e) => e.toString()).toList(); - if (raw is String) { - // Handle PostgreSQL array format: {a,b,c} - final trimmed = raw.replaceAll('{', '').replaceAll('}', ''); - if (trimmed.isEmpty) return []; - return trimmed.split(',').map((e) => e.trim()).toList(); - } - return []; - } - - String? quillField(dynamic raw) { - if (raw == null) return null; - if (raw is String) return raw; - try { - return jsonEncode(raw); - } catch (_) { - return raw.toString(); - } - } - - return ItServiceRequest( - id: map['id'] as String, - requestNumber: map['request_number'] as String?, - services: parseServices(map['services']), - servicesOther: map['services_other'] as String?, - eventName: map['event_name'] as String? ?? '', - eventDetails: quillField(map['event_details']), - eventDate: map['event_date'] == null - ? null - : AppTime.parse(map['event_date'] as String), - eventEndDate: map['event_end_date'] == null - ? null - : AppTime.parse(map['event_end_date'] as String), - dryRunDate: map['dry_run_date'] == null - ? null - : AppTime.parse(map['dry_run_date'] as String), - dryRunEndDate: map['dry_run_end_date'] == null - ? null - : AppTime.parse(map['dry_run_end_date'] as String), - contactPerson: map['contact_person'] as String?, - contactNumber: map['contact_number'] as String?, - remarks: quillField(map['remarks']), - officeId: map['office_id'] as String?, - requestedBy: map['requested_by'] as String?, - requestedByUserId: map['requested_by_user_id'] as String?, - approvedBy: map['approved_by'] as String?, - approvedByUserId: map['approved_by_user_id'] as String?, - approvedAt: map['approved_at'] == null - ? null - : AppTime.parse(map['approved_at'] as String), - status: map['status'] as String? ?? 'draft', - outsidePremiseAllowed: map['outside_premise_allowed'] as bool? ?? false, - cancellationReason: map['cancellation_reason'] as String?, - cancelledAt: map['cancelled_at'] == null - ? null - : AppTime.parse(map['cancelled_at'] as String), - creatorId: map['creator_id'] as String?, - createdAt: AppTime.parse(map['created_at'] as String), - updatedAt: AppTime.parse(map['updated_at'] as String), - completedAt: map['completed_at'] == null - ? null - : AppTime.parse(map['completed_at'] as String), - dateTimeReceived: map['date_time_received'] == null - ? null - : AppTime.parse(map['date_time_received'] as String), - dateTimeChecked: map['date_time_checked'] == null - ? null - : AppTime.parse(map['date_time_checked'] as String), - ); - } -} +export 'it_service_request.model.dart'; diff --git a/lib/models/it_service_request.model.dart b/lib/models/it_service_request.model.dart new file mode 100644 index 00000000..b0d2aa00 --- /dev/null +++ b/lib/models/it_service_request.model.dart @@ -0,0 +1,268 @@ +import 'dart:convert'; + +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_sqlite/brick_sqlite.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +import '../utils/app_time.dart'; + +/// Available IT services from the form. +class ItServiceType { + static const fbLiveStream = 'fb_live_stream'; + static const videoRecording = 'video_recording'; + static const technicalAssistance = 'technical_assistance'; + static const wifi = 'wifi'; + static const others = 'others'; + + static const all = [ + fbLiveStream, + videoRecording, + technicalAssistance, + wifi, + others, + ]; + + static String label(String type) { + switch (type) { + case fbLiveStream: + return 'FB Live Stream'; + case videoRecording: + return 'Video Recording'; + case technicalAssistance: + return 'Technical Assistance'; + case wifi: + return 'WiFi'; + case others: + return 'Others'; + default: + return type; + } + } +} + +/// Status lifecycle for an IT Service Request. +class ItServiceRequestStatus { + static const draft = 'draft'; + static const pendingApproval = 'pending_approval'; + static const scheduled = 'scheduled'; + static const inProgressDryRun = 'in_progress_dry_run'; + static const inProgress = 'in_progress'; + static const completed = 'completed'; + static const cancelled = 'cancelled'; + + static const all = [ + draft, + pendingApproval, + scheduled, + inProgressDryRun, + inProgress, + completed, + cancelled, + ]; + + static String label(String status) { + switch (status) { + case draft: + return 'Draft'; + case pendingApproval: + return 'Pending Approval'; + case scheduled: + return 'Scheduled'; + case inProgressDryRun: + return 'In Progress (Dry Run)'; + case inProgress: + return 'In Progress'; + case completed: + return 'Completed'; + case cancelled: + return 'Cancelled'; + default: + return status; + } + } +} + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'it_service_requests'), +) +class ItServiceRequest extends OfflineFirstWithSupabaseModel { + final String id; + final String? requestNumber; + final List services; + final String? servicesOther; + final String eventName; + // Quill Delta JSON stored as plain strings. + final String? eventDetails; + final DateTime? eventDate; + final DateTime? eventEndDate; + final DateTime? dryRunDate; + final DateTime? dryRunEndDate; + final String? contactPerson; + final String? contactNumber; + final String? remarks; + final String? officeId; + final String? requestedBy; + final String? requestedByUserId; + final String? approvedBy; + final String? approvedByUserId; + final DateTime? approvedAt; + final String status; + final bool outsidePremiseAllowed; + final String? cancellationReason; + final DateTime? cancelledAt; + final String? creatorId; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? completedAt; + final DateTime? dateTimeReceived; + final DateTime? dateTimeChecked; + + ItServiceRequest({ + required this.id, + this.requestNumber, + required this.services, + this.servicesOther, + required this.eventName, + this.eventDetails, + this.eventDate, + this.eventEndDate, + this.dryRunDate, + this.dryRunEndDate, + this.contactPerson, + this.contactNumber, + this.remarks, + this.officeId, + this.requestedBy, + this.requestedByUserId, + this.approvedBy, + this.approvedByUserId, + this.approvedAt, + required this.status, + required this.outsidePremiseAllowed, + this.cancellationReason, + this.cancelledAt, + this.creatorId, + required this.createdAt, + required this.updatedAt, + this.completedAt, + this.dateTimeReceived, + this.dateTimeChecked, + }); + + @Supabase(ignore: true) + @Sqlite(ignore: true) + @override + bool operator ==(Object other) => + identical(this, other) || + other is ItServiceRequest && + runtimeType == other.runtimeType && + id == other.id && + requestNumber == other.requestNumber && + status == other.status && + updatedAt == other.updatedAt; + + @Supabase(ignore: true) + @Sqlite(ignore: true) + @override + int get hashCode => Object.hash(id, requestNumber, status, updatedAt); + + bool isGeofenceOverrideActive(DateTime now) { + if (!outsidePremiseAllowed) return false; + final today = DateTime(now.year, now.month, now.day); + if (dryRunDate != null) { + final dryDay = DateTime( + dryRunDate!.year, + dryRunDate!.month, + dryRunDate!.day, + ); + if (!today.isBefore(dryDay) && + today.isBefore(dryDay.add(const Duration(days: 1)))) { + return true; + } + } + if (eventDate != null) { + final eventDay = DateTime( + eventDate!.year, + eventDate!.month, + eventDate!.day, + ); + if (!today.isBefore(eventDay) && + today.isBefore(eventDay.add(const Duration(days: 1)))) { + return true; + } + } + return false; + } + + factory ItServiceRequest.fromMap(Map map) { + List parseServices(dynamic raw) { + if (raw is List) return raw.map((e) => e.toString()).toList(); + if (raw is String) { + final trimmed = raw.replaceAll('{', '').replaceAll('}', ''); + if (trimmed.isEmpty) return []; + return trimmed.split(',').map((e) => e.trim()).toList(); + } + return []; + } + + String? quillField(dynamic raw) { + if (raw == null) return null; + if (raw is String) return raw; + try { + return jsonEncode(raw); + } catch (_) { + return raw.toString(); + } + } + + return ItServiceRequest( + id: map['id'] as String, + requestNumber: map['request_number'] as String?, + services: parseServices(map['services']), + servicesOther: map['services_other'] as String?, + eventName: map['event_name'] as String? ?? '', + eventDetails: quillField(map['event_details']), + eventDate: map['event_date'] == null + ? null + : AppTime.parse(map['event_date'] as String), + eventEndDate: map['event_end_date'] == null + ? null + : AppTime.parse(map['event_end_date'] as String), + dryRunDate: map['dry_run_date'] == null + ? null + : AppTime.parse(map['dry_run_date'] as String), + dryRunEndDate: map['dry_run_end_date'] == null + ? null + : AppTime.parse(map['dry_run_end_date'] as String), + contactPerson: map['contact_person'] as String?, + contactNumber: map['contact_number'] as String?, + remarks: quillField(map['remarks']), + officeId: map['office_id'] as String?, + requestedBy: map['requested_by'] as String?, + requestedByUserId: map['requested_by_user_id'] as String?, + approvedBy: map['approved_by'] as String?, + approvedByUserId: map['approved_by_user_id'] as String?, + approvedAt: map['approved_at'] == null + ? null + : AppTime.parse(map['approved_at'] as String), + status: map['status'] as String? ?? 'draft', + outsidePremiseAllowed: map['outside_premise_allowed'] as bool? ?? false, + cancellationReason: map['cancellation_reason'] as String?, + cancelledAt: map['cancelled_at'] == null + ? null + : AppTime.parse(map['cancelled_at'] as String), + creatorId: map['creator_id'] as String?, + createdAt: AppTime.parse(map['created_at'] as String), + updatedAt: AppTime.parse(map['updated_at'] as String), + completedAt: map['completed_at'] == null + ? null + : AppTime.parse(map['completed_at'] as String), + dateTimeReceived: map['date_time_received'] == null + ? null + : AppTime.parse(map['date_time_received'] as String), + dateTimeChecked: map['date_time_checked'] == null + ? null + : AppTime.parse(map['date_time_checked'] as String), + ); + } +} diff --git a/lib/models/it_service_request_action.dart b/lib/models/it_service_request_action.dart index 1e2a1652..53ba8b4d 100644 --- a/lib/models/it_service_request_action.dart +++ b/lib/models/it_service_request_action.dart @@ -1,42 +1 @@ -import 'dart:convert'; - -import '../utils/app_time.dart'; - -class ItServiceRequestAction { - ItServiceRequestAction({ - required this.id, - required this.requestId, - required this.userId, - this.actionTaken, - required this.createdAt, - required this.updatedAt, - }); - - final String id; - final String requestId; - final String userId; - final String? actionTaken; // Quill Delta JSON - final DateTime createdAt; - final DateTime updatedAt; - - factory ItServiceRequestAction.fromMap(Map map) { - String? quillField(dynamic raw) { - if (raw == null) return null; - if (raw is String) return raw; - try { - return jsonEncode(raw); - } catch (_) { - return raw.toString(); - } - } - - return ItServiceRequestAction( - id: map['id'] as String, - requestId: map['request_id'] as String, - userId: map['user_id'] as String, - actionTaken: quillField(map['action_taken']), - createdAt: AppTime.parse(map['created_at'] as String), - updatedAt: AppTime.parse(map['updated_at'] as String), - ); - } -} +export 'it_service_request_action.model.dart'; diff --git a/lib/models/it_service_request_action.model.dart b/lib/models/it_service_request_action.model.dart new file mode 100644 index 00000000..b95695ff --- /dev/null +++ b/lib/models/it_service_request_action.model.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; + +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +import '../utils/app_time.dart'; + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'it_service_request_actions'), +) +class ItServiceRequestAction extends OfflineFirstWithSupabaseModel { + final String id; + final String requestId; + final String userId; + // Quill Delta JSON stored as plain string. + final String? actionTaken; + final DateTime createdAt; + final DateTime updatedAt; + + ItServiceRequestAction({ + required this.id, + required this.requestId, + required this.userId, + this.actionTaken, + required this.createdAt, + required this.updatedAt, + }); + + factory ItServiceRequestAction.fromMap(Map map) { + String? quillField(dynamic raw) { + if (raw == null) return null; + if (raw is String) return raw; + try { + return jsonEncode(raw); + } catch (_) { + return raw.toString(); + } + } + + return ItServiceRequestAction( + id: map['id'] as String, + requestId: map['request_id'] as String, + userId: map['user_id'] as String, + actionTaken: quillField(map['action_taken']), + createdAt: AppTime.parse(map['created_at'] as String), + updatedAt: AppTime.parse(map['updated_at'] as String), + ); + } +} diff --git a/lib/models/it_service_request_activity_log.dart b/lib/models/it_service_request_activity_log.dart index a1e75ae9..f424d71b 100644 --- a/lib/models/it_service_request_activity_log.dart +++ b/lib/models/it_service_request_activity_log.dart @@ -1,74 +1 @@ -import 'dart:convert'; - -import '../utils/app_time.dart'; - -class ItServiceRequestActivityLog { - ItServiceRequestActivityLog({ - required this.id, - required this.requestId, - this.actorId, - required this.actionType, - this.meta, - required this.createdAt, - }); - - final String id; - final String requestId; - final String? actorId; - final String actionType; - final Map? meta; - final DateTime createdAt; - - factory ItServiceRequestActivityLog.fromMap(Map map) { - final rawId = map['id']; - final rawRequestId = map['request_id']; - String id = rawId == null ? '' : rawId.toString(); - String requestId = rawRequestId == null ? '' : rawRequestId.toString(); - final actorId = map['actor_id']?.toString(); - final actionType = (map['action_type'] as String?) ?? 'unknown'; - - Map? meta; - final rawMeta = map['meta']; - if (rawMeta is Map) { - meta = rawMeta; - } else if (rawMeta is Map) { - try { - meta = rawMeta.map((k, v) => MapEntry(k.toString(), v)); - } catch (_) { - meta = null; - } - } else if (rawMeta is String && rawMeta.isNotEmpty) { - try { - final decoded = jsonDecode(rawMeta); - if (decoded is Map) { - meta = decoded; - } else if (decoded is Map) { - meta = decoded.map((k, v) => MapEntry(k.toString(), v)); - } - } catch (_) { - meta = null; - } - } - - DateTime createdAt; - final rawCreated = map['created_at']; - if (rawCreated is String) { - try { - createdAt = AppTime.parse(rawCreated); - } catch (_) { - createdAt = AppTime.now(); - } - } else { - createdAt = AppTime.now(); - } - - return ItServiceRequestActivityLog( - id: id, - requestId: requestId, - actorId: actorId, - actionType: actionType, - meta: meta, - createdAt: createdAt, - ); - } -} +export 'it_service_request_activity_log.model.dart'; diff --git a/lib/models/it_service_request_activity_log.model.dart b/lib/models/it_service_request_activity_log.model.dart new file mode 100644 index 00000000..c667c479 --- /dev/null +++ b/lib/models/it_service_request_activity_log.model.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; + +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +import '../utils/app_time.dart'; + +// meta: Map? is stored as JSON text in SQLite by Brick. +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'it_service_request_activity_logs'), +) +class ItServiceRequestActivityLog extends OfflineFirstWithSupabaseModel { + final String id; + final String requestId; + final String? actorId; + final String actionType; + final Map? meta; + final DateTime createdAt; + + ItServiceRequestActivityLog({ + required this.id, + required this.requestId, + this.actorId, + required this.actionType, + this.meta, + required this.createdAt, + }); + + factory ItServiceRequestActivityLog.fromMap(Map map) { + final rawId = map['id']; + final rawRequestId = map['request_id']; + String id = rawId == null ? '' : rawId.toString(); + String requestId = rawRequestId == null ? '' : rawRequestId.toString(); + final actorId = map['actor_id']?.toString(); + final actionType = (map['action_type'] as String?) ?? 'unknown'; + + Map? meta; + final rawMeta = map['meta']; + if (rawMeta is Map) { + meta = rawMeta; + } else if (rawMeta is Map) { + try { + meta = rawMeta.map((k, v) => MapEntry(k.toString(), v)); + } catch (_) { + meta = null; + } + } else if (rawMeta is String && rawMeta.isNotEmpty) { + try { + final decoded = jsonDecode(rawMeta); + if (decoded is Map) { + meta = decoded; + } else if (decoded is Map) { + meta = decoded.map((k, v) => MapEntry(k.toString(), v)); + } + } catch (_) { + meta = null; + } + } + + DateTime createdAt; + final rawCreated = map['created_at']; + if (rawCreated is String) { + try { + createdAt = AppTime.parse(rawCreated); + } catch (_) { + createdAt = AppTime.now(); + } + } else { + createdAt = AppTime.now(); + } + + return ItServiceRequestActivityLog( + id: id, + requestId: requestId, + actorId: actorId, + actionType: actionType, + meta: meta, + createdAt: createdAt, + ); + } +} diff --git a/lib/models/it_service_request_assignment.dart b/lib/models/it_service_request_assignment.dart index f67c87cd..00effcdd 100644 --- a/lib/models/it_service_request_assignment.dart +++ b/lib/models/it_service_request_assignment.dart @@ -1,24 +1 @@ -import '../utils/app_time.dart'; - -class ItServiceRequestAssignment { - ItServiceRequestAssignment({ - required this.id, - required this.requestId, - required this.userId, - required this.createdAt, - }); - - final String id; - final String requestId; - final String userId; - final DateTime createdAt; - - factory ItServiceRequestAssignment.fromMap(Map map) { - return ItServiceRequestAssignment( - id: map['id'] as String, - requestId: map['request_id'] as String, - userId: map['user_id'] as String, - createdAt: AppTime.parse(map['created_at'] as String), - ); - } -} +export 'it_service_request_assignment.model.dart'; diff --git a/lib/models/it_service_request_assignment.model.dart b/lib/models/it_service_request_assignment.model.dart new file mode 100644 index 00000000..eb9d547d --- /dev/null +++ b/lib/models/it_service_request_assignment.model.dart @@ -0,0 +1,30 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +import '../utils/app_time.dart'; + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'it_service_request_assignments'), +) +class ItServiceRequestAssignment extends OfflineFirstWithSupabaseModel { + final String id; + final String requestId; + final String userId; + final DateTime createdAt; + + ItServiceRequestAssignment({ + required this.id, + required this.requestId, + required this.userId, + required this.createdAt, + }); + + factory ItServiceRequestAssignment.fromMap(Map map) { + return ItServiceRequestAssignment( + id: map['id'] as String, + requestId: map['request_id'] as String, + userId: map['user_id'] as String, + createdAt: AppTime.parse(map['created_at'] as String), + ); + } +} diff --git a/lib/models/leave_of_absence.dart b/lib/models/leave_of_absence.dart index 6de59f9e..94614ffe 100644 --- a/lib/models/leave_of_absence.dart +++ b/lib/models/leave_of_absence.dart @@ -1,54 +1 @@ -import '../utils/app_time.dart'; - -class LeaveOfAbsence { - LeaveOfAbsence({ - required this.id, - required this.userId, - required this.leaveType, - required this.justification, - required this.startTime, - required this.endTime, - required this.status, - required this.filedBy, - required this.createdAt, - }); - - final String id; - final String userId; - final String leaveType; - final String justification; - final DateTime startTime; - final DateTime endTime; - final String status; - final String filedBy; - final DateTime createdAt; - - factory LeaveOfAbsence.fromMap(Map map) { - return LeaveOfAbsence( - id: map['id'] as String, - userId: map['user_id'] as String, - leaveType: map['leave_type'] as String, - justification: map['justification'] as String, - startTime: AppTime.parse(map['start_time'] as String), - endTime: AppTime.parse(map['end_time'] as String), - status: map['status'] as String? ?? 'pending', - filedBy: map['filed_by'] as String, - createdAt: AppTime.parse(map['created_at'] as String), - ); - } - - String get leaveTypeLabel { - switch (leaveType) { - case 'emergency_leave': - return 'Emergency Leave'; - case 'parental_leave': - return 'Parental Leave'; - case 'sick_leave': - return 'Sick Leave'; - case 'vacation_leave': - return 'Vacation Leave'; - default: - return leaveType; - } - } -} +export 'leave_of_absence.model.dart'; diff --git a/lib/models/leave_of_absence.model.dart b/lib/models/leave_of_absence.model.dart new file mode 100644 index 00000000..baf6ab75 --- /dev/null +++ b/lib/models/leave_of_absence.model.dart @@ -0,0 +1,59 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_sqlite/brick_sqlite.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +import '../utils/app_time.dart'; + +// Supabase table is singular: 'leave_of_absence' (not 'leave_of_absences'). +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'leave_of_absence'), +) +class LeaveOfAbsence extends OfflineFirstWithSupabaseModel { + final String id; + final String userId; + final String leaveType; + final String justification; + final DateTime startTime; + final DateTime endTime; + final String status; + final String filedBy; + final DateTime createdAt; + + LeaveOfAbsence({ + required this.id, + required this.userId, + required this.leaveType, + required this.justification, + required this.startTime, + required this.endTime, + required this.status, + required this.filedBy, + required this.createdAt, + }); + + factory LeaveOfAbsence.fromMap(Map map) { + return LeaveOfAbsence( + id: map['id'] as String, + userId: map['user_id'] as String, + leaveType: map['leave_type'] as String, + justification: map['justification'] as String, + startTime: AppTime.parse(map['start_time'] as String), + endTime: AppTime.parse(map['end_time'] as String), + status: map['status'] as String? ?? 'pending', + filedBy: map['filed_by'] as String, + createdAt: AppTime.parse(map['created_at'] as String), + ); + } + + @Supabase(ignore: true) + @Sqlite(ignore: true) + String get leaveTypeLabel { + switch (leaveType) { + case 'emergency_leave': return 'Emergency Leave'; + case 'parental_leave': return 'Parental Leave'; + case 'sick_leave': return 'Sick Leave'; + case 'vacation_leave': return 'Vacation Leave'; + default: return leaveType; + } + } +} diff --git a/lib/models/notification_item.dart b/lib/models/notification_item.dart index 76c973c0..98b96b7a 100644 --- a/lib/models/notification_item.dart +++ b/lib/models/notification_item.dart @@ -1,55 +1 @@ -import '../utils/app_time.dart'; - -class NotificationItem { - NotificationItem({ - required this.id, - required this.userId, - required this.actorId, - required this.ticketId, - required this.taskId, - this.leaveId, - this.passSlipId, - required this.itServiceRequestId, - this.announcementId, - required this.messageId, - required this.type, - required this.createdAt, - required this.readAt, - }); - - final String id; - final String userId; - final String? actorId; - final String? ticketId; - final String? taskId; - final String? leaveId; - final String? passSlipId; - final String? itServiceRequestId; - final String? announcementId; - final int? messageId; - final String type; - final DateTime createdAt; - final DateTime? readAt; - - bool get isUnread => readAt == null; - - factory NotificationItem.fromMap(Map map) { - return NotificationItem( - id: map['id'] as String, - userId: map['user_id'] as String, - actorId: map['actor_id'] as String?, - ticketId: map['ticket_id'] as String?, - taskId: map['task_id'] as String?, - leaveId: map['leave_id'] as String?, - passSlipId: map['pass_slip_id'] as String?, - itServiceRequestId: map['it_service_request_id'] as String?, - announcementId: map['announcement_id'] as String?, - messageId: map['message_id'] as int?, - type: map['type'] as String? ?? 'mention', - createdAt: AppTime.parse(map['created_at'] as String), - readAt: map['read_at'] == null - ? null - : AppTime.parse(map['read_at'] as String), - ); - } -} +export 'notification_item.model.dart'; diff --git a/lib/models/notification_item.model.dart b/lib/models/notification_item.model.dart new file mode 100644 index 00000000..9d4c2ca9 --- /dev/null +++ b/lib/models/notification_item.model.dart @@ -0,0 +1,65 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_sqlite/brick_sqlite.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +import '../utils/app_time.dart'; + +// Supabase table is 'notifications' (not 'notification_items'). +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'notifications'), +) +class NotificationItem extends OfflineFirstWithSupabaseModel { + final String id; + final String userId; + final String? actorId; + final String? ticketId; + final String? taskId; + final String? leaveId; + final String? passSlipId; + final String? itServiceRequestId; + final String? announcementId; + final int? messageId; + final String type; + final DateTime createdAt; + final DateTime? readAt; + + @Supabase(ignore: true) + @Sqlite(ignore: true) + bool get isUnread => readAt == null; + + NotificationItem({ + required this.id, + required this.userId, + required this.actorId, + required this.ticketId, + required this.taskId, + this.leaveId, + this.passSlipId, + required this.itServiceRequestId, + this.announcementId, + required this.messageId, + required this.type, + required this.createdAt, + required this.readAt, + }); + + factory NotificationItem.fromMap(Map map) { + return NotificationItem( + id: map['id'] as String, + userId: map['user_id'] as String, + actorId: map['actor_id'] as String?, + ticketId: map['ticket_id'] as String?, + taskId: map['task_id'] as String?, + leaveId: map['leave_id'] as String?, + passSlipId: map['pass_slip_id'] as String?, + itServiceRequestId: map['it_service_request_id'] as String?, + announcementId: map['announcement_id'] as String?, + messageId: map['message_id'] as int?, + type: map['type'] as String? ?? 'mention', + createdAt: AppTime.parse(map['created_at'] as String), + readAt: map['read_at'] == null + ? null + : AppTime.parse(map['read_at'] as String), + ); + } +} diff --git a/lib/models/office.dart b/lib/models/office.dart index d84a99c2..f20d7117 100644 --- a/lib/models/office.dart +++ b/lib/models/office.dart @@ -1,15 +1 @@ -class Office { - Office({required this.id, required this.name, this.serviceId}); - - final String id; - final String name; - final String? serviceId; - - factory Office.fromMap(Map map) { - return Office( - id: map['id'] as String, - name: map['name'] as String? ?? '', - serviceId: map['service_id'] as String?, - ); - } -} +export 'office.model.dart'; diff --git a/lib/models/office.model.dart b/lib/models/office.model.dart new file mode 100644 index 00000000..cc43b4e9 --- /dev/null +++ b/lib/models/office.model.dart @@ -0,0 +1,21 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'offices'), +) +class Office extends OfflineFirstWithSupabaseModel { + final String id; + final String name; + final String? serviceId; + + Office({required this.id, required this.name, this.serviceId}); + + factory Office.fromMap(Map map) { + return Office( + id: map['id'] as String, + name: map['name'] as String? ?? '', + serviceId: map['service_id'] as String?, + ); + } +} diff --git a/lib/models/pass_slip.dart b/lib/models/pass_slip.dart index eaa13893..a60eca34 100644 --- a/lib/models/pass_slip.dart +++ b/lib/models/pass_slip.dart @@ -1,62 +1 @@ -import '../utils/app_time.dart'; - -class PassSlip { - PassSlip({ - required this.id, - required this.userId, - required this.dutyScheduleId, - required this.reason, - required this.status, - required this.requestedAt, - this.approvedBy, - this.approvedAt, - this.slipStart, - this.slipEnd, - this.requestedStart, - }); - - final String id; - final String userId; - final String dutyScheduleId; - final String reason; - final String status; // 'pending', 'approved', 'rejected', 'completed' - final DateTime requestedAt; - final String? approvedBy; - final DateTime? approvedAt; - final DateTime? slipStart; - final DateTime? slipEnd; - final DateTime? requestedStart; - - /// Whether the slip is active (approved but not yet completed). - bool get isActive => status == 'approved' && slipEnd == null; - - /// Whether the active slip has exceeded 1 hour. - bool get isExceeded => - isActive && - slipStart != null && - AppTime.now().difference(slipStart!) > const Duration(hours: 1); - - factory PassSlip.fromMap(Map map) { - return PassSlip( - id: map['id'] as String, - userId: map['user_id'] as String, - dutyScheduleId: map['duty_schedule_id'] as String, - reason: map['reason'] as String, - status: map['status'] as String? ?? 'pending', - requestedAt: AppTime.parse(map['requested_at'] as String), - approvedBy: map['approved_by'] as String?, - approvedAt: map['approved_at'] == null - ? null - : AppTime.parse(map['approved_at'] as String), - slipStart: map['slip_start'] == null - ? null - : AppTime.parse(map['slip_start'] as String), - slipEnd: map['slip_end'] == null - ? null - : AppTime.parse(map['slip_end'] as String), - requestedStart: map['requested_start'] == null - ? null - : AppTime.parse(map['requested_start'] as String), - ); - } -} +export 'pass_slip.model.dart'; diff --git a/lib/models/pass_slip.model.dart b/lib/models/pass_slip.model.dart new file mode 100644 index 00000000..772a6352 --- /dev/null +++ b/lib/models/pass_slip.model.dart @@ -0,0 +1,71 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_sqlite/brick_sqlite.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +import '../utils/app_time.dart'; + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'pass_slips'), +) +class PassSlip extends OfflineFirstWithSupabaseModel { + final String id; + final String userId; + final String dutyScheduleId; + final String reason; + final String status; + final DateTime requestedAt; + final String? approvedBy; + final DateTime? approvedAt; + final DateTime? slipStart; + final DateTime? slipEnd; + final DateTime? requestedStart; + + @Supabase(ignore: true) + @Sqlite(ignore: true) + bool get isActive => status == 'approved' && slipEnd == null; + + @Supabase(ignore: true) + @Sqlite(ignore: true) + bool get isExceeded => + isActive && + slipStart != null && + AppTime.now().difference(slipStart!) > const Duration(hours: 1); + + PassSlip({ + required this.id, + required this.userId, + required this.dutyScheduleId, + required this.reason, + required this.status, + required this.requestedAt, + this.approvedBy, + this.approvedAt, + this.slipStart, + this.slipEnd, + this.requestedStart, + }); + + factory PassSlip.fromMap(Map map) { + return PassSlip( + id: map['id'] as String, + userId: map['user_id'] as String, + dutyScheduleId: map['duty_schedule_id'] as String, + reason: map['reason'] as String, + status: map['status'] as String? ?? 'pending', + requestedAt: AppTime.parse(map['requested_at'] as String), + approvedBy: map['approved_by'] as String?, + approvedAt: map['approved_at'] == null + ? null + : AppTime.parse(map['approved_at'] as String), + slipStart: map['slip_start'] == null + ? null + : AppTime.parse(map['slip_start'] as String), + slipEnd: map['slip_end'] == null + ? null + : AppTime.parse(map['slip_end'] as String), + requestedStart: map['requested_start'] == null + ? null + : AppTime.parse(map['requested_start'] as String), + ); + } +} diff --git a/lib/models/profile.dart b/lib/models/profile.dart index a7a7aae5..0098a0ab 100644 --- a/lib/models/profile.dart +++ b/lib/models/profile.dart @@ -1,38 +1 @@ -class Profile { - Profile({ - required this.id, - required this.role, - required this.fullName, - this.religion = 'catholic', - this.allowTracking = false, - this.avatarUrl, - this.facePhotoUrl, - this.faceEnrolledAt, - }); - - final String id; - final String role; - final String fullName; - final String religion; - final bool allowTracking; - final String? avatarUrl; - final String? facePhotoUrl; - final DateTime? faceEnrolledAt; - - bool get hasFaceEnrolled => facePhotoUrl != null && faceEnrolledAt != null; - - factory Profile.fromMap(Map map) { - return Profile( - id: map['id'] as String, - role: map['role'] as String? ?? 'standard', - fullName: map['full_name'] as String? ?? '', - religion: map['religion'] as String? ?? 'catholic', - allowTracking: map['allow_tracking'] as bool? ?? false, - avatarUrl: map['avatar_url'] as String?, - facePhotoUrl: map['face_photo_url'] as String?, - faceEnrolledAt: map['face_enrolled_at'] == null - ? null - : DateTime.tryParse(map['face_enrolled_at'] as String), - ); - } -} +export 'profile.model.dart'; diff --git a/lib/models/profile.model.dart b/lib/models/profile.model.dart new file mode 100644 index 00000000..da9b679a --- /dev/null +++ b/lib/models/profile.model.dart @@ -0,0 +1,47 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_sqlite/brick_sqlite.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'profiles'), +) +class Profile extends OfflineFirstWithSupabaseModel { + final String id; + final String role; + final String fullName; + final String religion; + final bool allowTracking; + final String? avatarUrl; + final String? facePhotoUrl; + final DateTime? faceEnrolledAt; + + @Supabase(ignore: true) + @Sqlite(ignore: true) + bool get hasFaceEnrolled => facePhotoUrl != null && faceEnrolledAt != null; + + Profile({ + required this.id, + required this.role, + required this.fullName, + this.religion = 'catholic', + this.allowTracking = false, + this.avatarUrl, + this.facePhotoUrl, + this.faceEnrolledAt, + }); + + factory Profile.fromMap(Map map) { + return Profile( + id: map['id'] as String, + role: map['role'] as String? ?? 'standard', + fullName: map['full_name'] as String? ?? '', + religion: map['religion'] as String? ?? 'catholic', + allowTracking: map['allow_tracking'] as bool? ?? false, + avatarUrl: map['avatar_url'] as String?, + facePhotoUrl: map['face_photo_url'] as String?, + faceEnrolledAt: map['face_enrolled_at'] == null + ? null + : DateTime.tryParse(map['face_enrolled_at'] as String), + ); + } +} diff --git a/lib/models/service.dart b/lib/models/service.dart index b5049770..486a7371 100644 --- a/lib/models/service.dart +++ b/lib/models/service.dart @@ -1,10 +1 @@ -class Service { - Service({required this.id, required this.name}); - - final String id; - final String name; - - factory Service.fromMap(Map map) { - return Service(id: map['id'] as String, name: map['name'] as String? ?? ''); - } -} +export 'service.model.dart'; diff --git a/lib/models/service.model.dart b/lib/models/service.model.dart new file mode 100644 index 00000000..45ee3000 --- /dev/null +++ b/lib/models/service.model.dart @@ -0,0 +1,19 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'services'), +) +class Service extends OfflineFirstWithSupabaseModel { + final String id; + final String name; + + Service({required this.id, required this.name}); + + factory Service.fromMap(Map map) { + return Service( + id: map['id'] as String, + name: map['name'] as String? ?? '', + ); + } +} diff --git a/lib/models/swap_request.dart b/lib/models/swap_request.dart index 905f9a1b..74d3816d 100644 --- a/lib/models/swap_request.dart +++ b/lib/models/swap_request.dart @@ -1,62 +1 @@ -import '../utils/app_time.dart'; - -class SwapRequest { - SwapRequest({ - required this.id, - required this.requesterId, - required this.recipientId, - required this.requesterScheduleId, - required this.targetScheduleId, - required this.status, - required this.createdAt, - required this.updatedAt, - this.chatThreadId, - this.shiftType, - this.shiftStartTime, - this.relieverIds, - this.approvedBy, - }); - - final String id; - final String requesterId; - final String recipientId; - final String requesterScheduleId; // previously `shiftId` - final String? targetScheduleId; - final String status; - final DateTime createdAt; - final DateTime? updatedAt; - final String? chatThreadId; - final String? shiftType; - final DateTime? shiftStartTime; - final List? relieverIds; - final String? approvedBy; - - factory SwapRequest.fromMap(Map map) { - return SwapRequest( - id: map['id'] as String, - requesterId: map['requester_id'] as String, - recipientId: map['recipient_id'] as String, - requesterScheduleId: - (map['requester_schedule_id'] as String?) ?? - (map['shift_id'] as String), - targetScheduleId: map['target_shift_id'] as String?, - status: map['status'] as String? ?? 'pending', - createdAt: AppTime.parse(map['created_at'] as String), - updatedAt: map['updated_at'] == null - ? null - : AppTime.parse(map['updated_at'] as String), - chatThreadId: map['chat_thread_id'] as String?, - shiftType: map['shift_type'] as String?, - shiftStartTime: map['shift_start_time'] == null - ? null - : AppTime.parse(map['shift_start_time'] as String), - relieverIds: map['reliever_ids'] is List - ? (map['reliever_ids'] as List) - .where((e) => e != null) - .map((e) => e.toString()) - .toList() - : const [], - approvedBy: map['approved_by'] as String?, - ); - } -} +export 'swap_request.model.dart'; diff --git a/lib/models/swap_request.model.dart b/lib/models/swap_request.model.dart new file mode 100644 index 00000000..a693f82a --- /dev/null +++ b/lib/models/swap_request.model.dart @@ -0,0 +1,72 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +import '../utils/app_time.dart'; + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'swap_requests'), +) +class SwapRequest extends OfflineFirstWithSupabaseModel { + final String id; + final String requesterId; + final String recipientId; + final String requesterScheduleId; + + // DB column is 'target_shift_id' (legacy name differs from Dart field). + @Supabase(name: 'target_shift_id') + final String? targetScheduleId; + + final String status; + final DateTime createdAt; + final DateTime? updatedAt; + final String? chatThreadId; + final String? shiftType; + final DateTime? shiftStartTime; + final List? relieverIds; + final String? approvedBy; + + SwapRequest({ + required this.id, + required this.requesterId, + required this.recipientId, + required this.requesterScheduleId, + required this.targetScheduleId, + required this.status, + required this.createdAt, + required this.updatedAt, + this.chatThreadId, + this.shiftType, + this.shiftStartTime, + this.relieverIds, + this.approvedBy, + }); + + factory SwapRequest.fromMap(Map map) { + return SwapRequest( + id: map['id'] as String, + requesterId: map['requester_id'] as String, + recipientId: map['recipient_id'] as String, + requesterScheduleId: + (map['requester_schedule_id'] as String?) ?? + (map['shift_id'] as String), + targetScheduleId: map['target_shift_id'] as String?, + status: map['status'] as String? ?? 'pending', + createdAt: AppTime.parse(map['created_at'] as String), + updatedAt: map['updated_at'] == null + ? null + : AppTime.parse(map['updated_at'] as String), + chatThreadId: map['chat_thread_id'] as String?, + shiftType: map['shift_type'] as String?, + shiftStartTime: map['shift_start_time'] == null + ? null + : AppTime.parse(map['shift_start_time'] as String), + relieverIds: map['reliever_ids'] is List + ? (map['reliever_ids'] as List) + .where((e) => e != null) + .map((e) => e.toString()) + .toList() + : const [], + approvedBy: map['approved_by'] as String?, + ); + } +} diff --git a/lib/models/task.dart b/lib/models/task.dart index 39c368f0..1239b1e1 100644 --- a/lib/models/task.dart +++ b/lib/models/task.dart @@ -1,186 +1 @@ -import 'dart:convert'; - -import '../utils/app_time.dart'; - -class Task { - Task({ - required this.id, - required this.ticketId, - required this.taskNumber, - required this.title, - required this.description, - required this.officeId, - required this.status, - required this.priority, - required this.queueOrder, - required this.createdAt, - required this.creatorId, - required this.startedAt, - required this.completedAt, - // new optional metadata fields - this.requestedBy, - this.notedBy, - this.receivedBy, - this.requestType, - this.requestTypeOther, - this.requestCategory, - this.actionTaken, - this.cancellationReason, - this.cancelledAt, - this.itJobPrinted = false, - this.itJobPrintedAt, - this.itJobReceivedById, - }); - - final String id; - final String? ticketId; - final String? taskNumber; - final String title; - final String description; - final String? officeId; - final String status; - final int priority; - final int? queueOrder; - final DateTime createdAt; - final String? creatorId; - final DateTime? startedAt; - final DateTime? completedAt; - - // Optional client/user metadata - final String? requestedBy; - final String? notedBy; - final String? receivedBy; - - /// Optional request metadata added later in lifecycle. - final String? requestType; - final String? requestTypeOther; - final String? requestCategory; - // JSON serialized rich text for action taken (Quill Delta JSON encoded) - final String? actionTaken; - - /// Cancellation details when a task was cancelled. - final String? cancellationReason; - final DateTime? cancelledAt; - - /// Whether the printed IT Job has been submitted. - final bool itJobPrinted; - final DateTime? itJobPrintedAt; - final String? itJobReceivedById; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is Task && - runtimeType == other.runtimeType && - id == other.id && - ticketId == other.ticketId && - taskNumber == other.taskNumber && - title == other.title && - description == other.description && - officeId == other.officeId && - status == other.status && - priority == other.priority && - queueOrder == other.queueOrder && - createdAt == other.createdAt && - creatorId == other.creatorId && - startedAt == other.startedAt && - completedAt == other.completedAt && - requestedBy == other.requestedBy && - notedBy == other.notedBy && - receivedBy == other.receivedBy && - requestType == other.requestType && - requestTypeOther == other.requestTypeOther && - requestCategory == other.requestCategory && - actionTaken == other.actionTaken && - cancellationReason == other.cancellationReason && - cancelledAt == other.cancelledAt && - itJobPrinted == other.itJobPrinted && - itJobPrintedAt == other.itJobPrintedAt && - itJobReceivedById == other.itJobReceivedById; - - @override - int get hashCode => Object.hash( - id, - ticketId, - taskNumber, - title, - description, - officeId, - status, - priority, - queueOrder, - createdAt, - creatorId, - startedAt, - completedAt, - requestedBy, - notedBy, - receivedBy, - requestType, - requestTypeOther, - requestCategory, - // Object.hash supports max 20 positional args; combine remainder. - Object.hash(actionTaken, cancellationReason, cancelledAt, - itJobPrinted, itJobPrintedAt, itJobReceivedById), - ); - - /// Helper that indicates whether a completed task still has missing - /// metadata such as signatories or action details. The parameter is used - /// by UI to surface a warning icon/banner when a task has been closed but - /// the user skipped filling out all fields. - bool get hasIncompleteDetails { - if (status != 'completed') return false; - bool empty(String? v) => v == null || v.trim().isEmpty; - return empty(requestedBy) || - empty(notedBy) || - empty(receivedBy) || - empty(actionTaken); - } - - factory Task.fromMap(Map map) { - return Task( - id: map['id'] as String, - ticketId: map['ticket_id'] as String?, - taskNumber: map['task_number'] as String?, - title: map['title'] as String? ?? 'Task', - description: map['description'] as String? ?? '', - officeId: map['office_id'] as String?, - status: map['status'] as String? ?? 'queued', - priority: map['priority'] as int? ?? 1, - queueOrder: map['queue_order'] as int?, - createdAt: AppTime.parse(map['created_at'] as String), - creatorId: map['creator_id'] as String?, - startedAt: map['started_at'] == null - ? null - : AppTime.parse(map['started_at'] as String), - completedAt: map['completed_at'] == null - ? null - : AppTime.parse(map['completed_at'] as String), - requestType: map['request_type'] as String?, - requestTypeOther: map['request_type_other'] as String?, - requestCategory: map['request_category'] as String?, - requestedBy: map['requested_by'] as String?, - notedBy: map['noted_by'] as String?, - receivedBy: map['received_by'] as String?, - cancellationReason: map['cancellation_reason'] as String?, - cancelledAt: map['cancelled_at'] == null - ? null - : AppTime.parse(map['cancelled_at'] as String), - itJobPrinted: map['it_job_printed'] as bool? ?? false, - itJobPrintedAt: map['it_job_printed_at'] == null - ? null - : AppTime.parse(map['it_job_printed_at'] as String), - itJobReceivedById: map['it_job_received_by_id'] as String?, - actionTaken: (() { - final at = map['action_taken']; - if (at == null) return null; - if (at is String) return at; - try { - return jsonEncode(at); - } catch (_) { - return at.toString(); - } - })(), - ); - } -} +export 'task.model.dart'; diff --git a/lib/models/task.model.dart b/lib/models/task.model.dart new file mode 100644 index 00000000..2af4ce2f --- /dev/null +++ b/lib/models/task.model.dart @@ -0,0 +1,159 @@ +import 'dart:convert'; + +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_sqlite/brick_sqlite.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +import '../utils/app_time.dart'; + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'tasks'), +) +class Task extends OfflineFirstWithSupabaseModel { + final String id; + final String? ticketId; + final String? taskNumber; + final String title; + final String description; + final String? officeId; + final String status; + final int priority; + final int? queueOrder; + final DateTime createdAt; + final String? creatorId; + final DateTime? startedAt; + final DateTime? completedAt; + final String? requestedBy; + final String? notedBy; + final String? receivedBy; + final String? requestType; + final String? requestTypeOther; + final String? requestCategory; + // JSON-encoded Quill Delta stored as plain string. + final String? actionTaken; + final String? cancellationReason; + final DateTime? cancelledAt; + final bool itJobPrinted; + final DateTime? itJobPrintedAt; + final String? itJobReceivedById; + + Task({ + required this.id, + required this.ticketId, + required this.taskNumber, + required this.title, + required this.description, + required this.officeId, + required this.status, + required this.priority, + required this.queueOrder, + required this.createdAt, + required this.creatorId, + required this.startedAt, + required this.completedAt, + this.requestedBy, + this.notedBy, + this.receivedBy, + this.requestType, + this.requestTypeOther, + this.requestCategory, + this.actionTaken, + this.cancellationReason, + this.cancelledAt, + this.itJobPrinted = false, + this.itJobPrintedAt, + this.itJobReceivedById, + }); + + @Supabase(ignore: true) + @Sqlite(ignore: true) + @override + bool operator ==(Object other) => + identical(this, other) || + other is Task && + runtimeType == other.runtimeType && + id == other.id && + ticketId == other.ticketId && + taskNumber == other.taskNumber && + title == other.title && + description == other.description && + officeId == other.officeId && + status == other.status && + priority == other.priority && + queueOrder == other.queueOrder && + createdAt == other.createdAt && + creatorId == other.creatorId && + startedAt == other.startedAt && + completedAt == other.completedAt && + requestedBy == other.requestedBy && + notedBy == other.notedBy && + receivedBy == other.receivedBy && + requestType == other.requestType && + requestTypeOther == other.requestTypeOther && + requestCategory == other.requestCategory && + actionTaken == other.actionTaken && + cancellationReason == other.cancellationReason && + cancelledAt == other.cancelledAt && + itJobPrinted == other.itJobPrinted && + itJobPrintedAt == other.itJobPrintedAt && + itJobReceivedById == other.itJobReceivedById; + + @Supabase(ignore: true) + @Sqlite(ignore: true) + @override + int get hashCode => Object.hash( + id, ticketId, taskNumber, title, description, officeId, status, priority, + queueOrder, createdAt, creatorId, startedAt, completedAt, requestedBy, + notedBy, receivedBy, requestType, requestTypeOther, requestCategory, + Object.hash(actionTaken, cancellationReason, cancelledAt, + itJobPrinted, itJobPrintedAt, itJobReceivedById), + ); + + @Supabase(ignore: true) + @Sqlite(ignore: true) + bool get hasIncompleteDetails { + if (status != 'completed') return false; + bool empty(String? v) => v == null || v.trim().isEmpty; + return empty(requestedBy) || empty(notedBy) || + empty(receivedBy) || empty(actionTaken); + } + + factory Task.fromMap(Map map) { + return Task( + id: map['id'] as String, + ticketId: map['ticket_id'] as String?, + taskNumber: map['task_number'] as String?, + title: map['title'] as String? ?? 'Task', + description: map['description'] as String? ?? '', + officeId: map['office_id'] as String?, + status: map['status'] as String? ?? 'queued', + priority: map['priority'] as int? ?? 1, + queueOrder: map['queue_order'] as int?, + createdAt: AppTime.parse(map['created_at'] as String), + creatorId: map['creator_id'] as String?, + startedAt: map['started_at'] == null + ? null : AppTime.parse(map['started_at'] as String), + completedAt: map['completed_at'] == null + ? null : AppTime.parse(map['completed_at'] as String), + requestType: map['request_type'] as String?, + requestTypeOther: map['request_type_other'] as String?, + requestCategory: map['request_category'] as String?, + requestedBy: map['requested_by'] as String?, + notedBy: map['noted_by'] as String?, + receivedBy: map['received_by'] as String?, + cancellationReason: map['cancellation_reason'] as String?, + cancelledAt: map['cancelled_at'] == null + ? null : AppTime.parse(map['cancelled_at'] as String), + itJobPrinted: map['it_job_printed'] as bool? ?? false, + itJobPrintedAt: map['it_job_printed_at'] == null + ? null : AppTime.parse(map['it_job_printed_at'] as String), + itJobReceivedById: map['it_job_received_by_id'] as String?, + actionTaken: (() { + final at = map['action_taken']; + if (at == null) return null; + if (at is String) return at; + try { return jsonEncode(at); } catch (_) { return at.toString(); } + })(), + ); + } +} diff --git a/lib/models/task_activity_log.dart b/lib/models/task_activity_log.dart index 8d71b0e8..de0b5529 100644 --- a/lib/models/task_activity_log.dart +++ b/lib/models/task_activity_log.dart @@ -1,106 +1 @@ -import 'dart:convert'; - -import '../utils/app_time.dart'; - -class TaskActivityLog { - TaskActivityLog({ - required this.id, - required this.taskId, - this.actorId, - required this.actionType, - this.meta, - required this.createdAt, - }); - - final String id; - final String taskId; - final String? actorId; - final String actionType; // created, assigned, reassigned, started, completed - final Map? meta; - final DateTime createdAt; - - factory TaskActivityLog.fromMap(Map map) { - // id and task_id may be returned as int or String depending on DB - final rawId = map['id']; - final rawTaskId = map['task_id']; - - String id = rawId == null ? '' : rawId.toString(); - String taskId = rawTaskId == null ? '' : rawTaskId.toString(); - - // actor_id is nullable - final actorId = map['actor_id']?.toString(); - - // action_type fallback - final actionType = (map['action_type'] as String?) ?? 'unknown'; - - // meta may be a Map, Map, JSON-encoded string, List, or null - Map? meta; - final rawMeta = map['meta']; - if (rawMeta is Map) { - meta = rawMeta; - } else if (rawMeta is Map) { - // convert dynamic-key map to Map - try { - meta = rawMeta.map((k, v) => MapEntry(k.toString(), v)); - } catch (_) { - meta = null; - } - } else if (rawMeta is String && rawMeta.isNotEmpty) { - try { - final decoded = jsonDecode(rawMeta); - if (decoded is Map) { - meta = decoded; - } else if (decoded is Map) { - meta = decoded.map((k, v) => MapEntry(k.toString(), v)); - } - } catch (_) { - meta = null; - } - } else { - meta = null; - } - - // created_at may be ISO string, DateTime, or numeric (seconds/millis since epoch) - final rawCreated = map['created_at']; - DateTime createdAt; - if (rawCreated is DateTime) { - createdAt = AppTime.toAppTime(rawCreated); - } else if (rawCreated is String) { - try { - createdAt = AppTime.parse(rawCreated); - } catch (_) { - createdAt = AppTime.now(); - } - } else if (rawCreated is int) { - // assume seconds or milliseconds - if (rawCreated > 1e12) { - // likely microseconds or nanoseconds - treat as milliseconds - createdAt = AppTime.toAppTime( - DateTime.fromMillisecondsSinceEpoch(rawCreated), - ); - } else if (rawCreated > 1e10) { - createdAt = AppTime.toAppTime( - DateTime.fromMillisecondsSinceEpoch(rawCreated), - ); - } else { - createdAt = AppTime.toAppTime( - DateTime.fromMillisecondsSinceEpoch(rawCreated * 1000), - ); - } - } else if (rawCreated is double) { - final asInt = rawCreated.toInt(); - createdAt = AppTime.toAppTime(DateTime.fromMillisecondsSinceEpoch(asInt)); - } else { - createdAt = AppTime.now(); - } - - return TaskActivityLog( - id: id, - taskId: taskId, - actorId: actorId, - actionType: actionType, - meta: meta, - createdAt: createdAt, - ); - } -} +export 'task_activity_log.model.dart'; diff --git a/lib/models/task_activity_log.model.dart b/lib/models/task_activity_log.model.dart new file mode 100644 index 00000000..49391ecd --- /dev/null +++ b/lib/models/task_activity_log.model.dart @@ -0,0 +1,102 @@ +import 'dart:convert'; + +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +import '../utils/app_time.dart'; + +// meta: Map? is stored as JSON text in SQLite by Brick. +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'task_activity_logs'), +) +class TaskActivityLog extends OfflineFirstWithSupabaseModel { + final String id; + final String taskId; + final String? actorId; + final String actionType; + final Map? meta; + final DateTime createdAt; + + TaskActivityLog({ + required this.id, + required this.taskId, + this.actorId, + required this.actionType, + this.meta, + required this.createdAt, + }); + + factory TaskActivityLog.fromMap(Map map) { + final rawId = map['id']; + final rawTaskId = map['task_id']; + + String id = rawId == null ? '' : rawId.toString(); + String taskId = rawTaskId == null ? '' : rawTaskId.toString(); + final actorId = map['actor_id']?.toString(); + final actionType = (map['action_type'] as String?) ?? 'unknown'; + + Map? meta; + final rawMeta = map['meta']; + if (rawMeta is Map) { + meta = rawMeta; + } else if (rawMeta is Map) { + try { + meta = rawMeta.map((k, v) => MapEntry(k.toString(), v)); + } catch (_) { + meta = null; + } + } else if (rawMeta is String && rawMeta.isNotEmpty) { + try { + final decoded = jsonDecode(rawMeta); + if (decoded is Map) { + meta = decoded; + } else if (decoded is Map) { + meta = decoded.map((k, v) => MapEntry(k.toString(), v)); + } + } catch (_) { + meta = null; + } + } + + final rawCreated = map['created_at']; + DateTime createdAt; + if (rawCreated is DateTime) { + createdAt = AppTime.toAppTime(rawCreated); + } else if (rawCreated is String) { + try { + createdAt = AppTime.parse(rawCreated); + } catch (_) { + createdAt = AppTime.now(); + } + } else if (rawCreated is int) { + if (rawCreated > 1e12) { + createdAt = AppTime.toAppTime( + DateTime.fromMillisecondsSinceEpoch(rawCreated), + ); + } else if (rawCreated > 1e10) { + createdAt = AppTime.toAppTime( + DateTime.fromMillisecondsSinceEpoch(rawCreated), + ); + } else { + createdAt = AppTime.toAppTime( + DateTime.fromMillisecondsSinceEpoch(rawCreated * 1000), + ); + } + } else if (rawCreated is double) { + createdAt = AppTime.toAppTime( + DateTime.fromMillisecondsSinceEpoch(rawCreated.toInt()), + ); + } else { + createdAt = AppTime.now(); + } + + return TaskActivityLog( + id: id, + taskId: taskId, + actorId: actorId, + actionType: actionType, + meta: meta, + createdAt: createdAt, + ); + } +} diff --git a/lib/models/team.dart b/lib/models/team.dart index 826a6b01..08c303de 100644 --- a/lib/models/team.dart +++ b/lib/models/team.dart @@ -1,42 +1 @@ -// Extension to add members property to Team -import '../models/team_member.dart'; - -extension TeamMembersExtension on Team { - List members(List allMembers) { - return allMembers - .where((m) => m.teamId == id) - .map((m) => m.userId) - .toList(); - } -} - -class Team { - Team({ - required this.id, - required this.name, - required this.leaderId, - required this.officeIds, - required this.createdAt, - this.color, - }); - - final String id; - final String name; - final String leaderId; - final List officeIds; - final DateTime createdAt; - final String? color; - - factory Team.fromMap(Map map) { - return Team( - id: map['id'] as String, - name: map['name'] as String? ?? '', - leaderId: map['leader_id'] as String? ?? '', - officeIds: - (map['office_ids'] as List?)?.map((e) => e.toString()).toList() ?? - [], - createdAt: DateTime.parse(map['created_at'] as String), - color: map['color'] as String?, - ); - } -} +export 'team.model.dart'; diff --git a/lib/models/team.model.dart b/lib/models/team.model.dart new file mode 100644 index 00000000..a5328e87 --- /dev/null +++ b/lib/models/team.model.dart @@ -0,0 +1,47 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +import 'package:tasq/models/team_member.dart'; + +extension TeamMembersExtension on Team { + List members(List allMembers) { + return allMembers + .where((m) => m.teamId == id) + .map((m) => m.userId) + .toList(); + } +} + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'teams'), +) +class Team extends OfflineFirstWithSupabaseModel { + final String id; + final String name; + final String leaderId; + final List officeIds; + final DateTime createdAt; + final String? color; + + Team({ + required this.id, + required this.name, + required this.leaderId, + required this.officeIds, + required this.createdAt, + this.color, + }); + + factory Team.fromMap(Map map) { + return Team( + id: map['id'] as String, + name: map['name'] as String? ?? '', + leaderId: map['leader_id'] as String? ?? '', + officeIds: + (map['office_ids'] as List?)?.map((e) => e.toString()).toList() ?? + [], + createdAt: DateTime.parse(map['created_at'] as String), + color: map['color'] as String?, + ); + } +} diff --git a/lib/models/ticket.dart b/lib/models/ticket.dart index 1172d561..c7f9e39d 100644 --- a/lib/models/ticket.dart +++ b/lib/models/ticket.dart @@ -1,78 +1 @@ -import '../utils/app_time.dart'; - -class Ticket { - Ticket({ - required this.id, - required this.subject, - required this.description, - required this.officeId, - required this.status, - required this.createdAt, - required this.creatorId, - required this.respondedAt, - required this.promotedAt, - required this.closedAt, - }); - - final String id; - final String subject; - final String description; - final String officeId; - final String status; - final DateTime createdAt; - final String? creatorId; - final DateTime? respondedAt; - final DateTime? promotedAt; - final DateTime? closedAt; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is Ticket && - runtimeType == other.runtimeType && - id == other.id && - subject == other.subject && - description == other.description && - officeId == other.officeId && - status == other.status && - createdAt == other.createdAt && - creatorId == other.creatorId && - respondedAt == other.respondedAt && - promotedAt == other.promotedAt && - closedAt == other.closedAt; - - @override - int get hashCode => Object.hash( - id, - subject, - description, - officeId, - status, - createdAt, - creatorId, - respondedAt, - promotedAt, - closedAt, - ); - - factory Ticket.fromMap(Map map) { - return Ticket( - id: map['id'] as String, - subject: map['subject'] as String? ?? '', - description: map['description'] as String? ?? '', - officeId: map['office_id'] as String? ?? '', - status: map['status'] as String? ?? 'pending', - createdAt: AppTime.parse(map['created_at'] as String), - creatorId: map['creator_id'] as String?, - respondedAt: map['responded_at'] == null - ? null - : AppTime.parse(map['responded_at'] as String), - promotedAt: map['promoted_at'] == null - ? null - : AppTime.parse(map['promoted_at'] as String), - closedAt: map['closed_at'] == null - ? null - : AppTime.parse(map['closed_at'] as String), - ); - } -} +export 'ticket.model.dart'; diff --git a/lib/models/ticket.model.dart b/lib/models/ticket.model.dart new file mode 100644 index 00000000..93ac69b5 --- /dev/null +++ b/lib/models/ticket.model.dart @@ -0,0 +1,71 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_sqlite/brick_sqlite.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +import '../utils/app_time.dart'; + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'tickets'), +) +class Ticket extends OfflineFirstWithSupabaseModel { + final String id; + final String subject; + final String description; + final String officeId; + final String status; + final DateTime createdAt; + final String? creatorId; + final DateTime? respondedAt; + final DateTime? promotedAt; + final DateTime? closedAt; + + Ticket({ + required this.id, + required this.subject, + required this.description, + required this.officeId, + required this.status, + required this.createdAt, + required this.creatorId, + required this.respondedAt, + required this.promotedAt, + required this.closedAt, + }); + + @Supabase(ignore: true) + @Sqlite(ignore: true) + @override + bool operator ==(Object other) => + identical(this, other) || + other is Ticket && + runtimeType == other.runtimeType && + id == other.id && subject == other.subject && + description == other.description && officeId == other.officeId && + status == other.status && createdAt == other.createdAt && + creatorId == other.creatorId && respondedAt == other.respondedAt && + promotedAt == other.promotedAt && closedAt == other.closedAt; + + @Supabase(ignore: true) + @Sqlite(ignore: true) + @override + int get hashCode => Object.hash(id, subject, description, officeId, status, + createdAt, creatorId, respondedAt, promotedAt, closedAt); + + factory Ticket.fromMap(Map map) { + return Ticket( + id: map['id'] as String, + subject: map['subject'] as String? ?? '', + description: map['description'] as String? ?? '', + officeId: map['office_id'] as String? ?? '', + status: map['status'] as String? ?? 'pending', + createdAt: AppTime.parse(map['created_at'] as String), + creatorId: map['creator_id'] as String?, + respondedAt: map['responded_at'] == null + ? null : AppTime.parse(map['responded_at'] as String), + promotedAt: map['promoted_at'] == null + ? null : AppTime.parse(map['promoted_at'] as String), + closedAt: map['closed_at'] == null + ? null : AppTime.parse(map['closed_at'] as String), + ); + } +} diff --git a/lib/providers/announcements_provider.dart b/lib/providers/announcements_provider.dart index fd006511..fd62f9b8 100644 --- a/lib/providers/announcements_provider.dart +++ b/lib/providers/announcements_provider.dart @@ -1,17 +1,20 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uuid/uuid.dart'; +import '../brick/cache_helpers.dart'; import '../models/announcement.dart'; import '../models/announcement_comment.dart'; +import '../utils/app_time.dart'; +import '../utils/snackbar.dart' show isOfflineSaveError; +import 'connectivity_provider.dart'; import 'notifications_provider.dart'; import 'profile_provider.dart'; import 'supabase_provider.dart'; import 'stream_recovery.dart'; import 'realtime_controller.dart'; -import '../utils/app_time.dart'; -/// Maximum rows fetched per poll. Announcements are lower-volume than tasks, -/// so 100 is sufficient; adjust if orgs routinely exceed this. +/// Maximum rows fetched per poll. const int kAnnouncementsPageLimit = 100; /// Thrown when an announcement or comment is saved successfully but @@ -24,6 +27,26 @@ class AnnouncementNotificationException implements Exception { String toString() => message; } +// --------------------------------------------------------------------------- +// Offline-pending state +// --------------------------------------------------------------------------- + +/// Announcements created offline that haven't been confirmed by Supabase yet. +final offlinePendingAnnouncementsProvider = + StateProvider>((ref) => const []); + +/// Field-level updates to existing announcements made offline, keyed by id. +final offlinePendingAnnouncementUpdatesProvider = + StateProvider>>((ref) => const {}); + +/// Comments added offline, not yet confirmed by Supabase. +final offlinePendingAnnouncementCommentsProvider = + StateProvider>((ref) => const []); + +// --------------------------------------------------------------------------- +// Read providers +// --------------------------------------------------------------------------- + /// Streams all announcements visible to the current user (RLS filtered). final announcementsProvider = StreamProvider>((ref) { final userId = ref.watch(currentUserIdProvider); @@ -31,6 +54,53 @@ final announcementsProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); + final pendingNew = ref.watch(offlinePendingAnnouncementsProvider); + final pendingUpdates = ref.watch(offlinePendingAnnouncementUpdatesProvider); + + List applyPending(List rows) { + final byId = {for (final r in rows) r.id: r}; + pendingUpdates.forEach((id, fields) { + final existing = byId[id]; + if (existing == null) return; + byId[id] = Announcement( + id: existing.id, + authorId: existing.authorId, + title: (fields['title'] as String?) ?? existing.title, + body: (fields['body'] as String?) ?? existing.body, + visibleRoles: (fields['visible_roles'] as List?) ?? + existing.visibleRoles, + isTemplate: (fields['is_template'] as bool?) ?? existing.isTemplate, + templateId: fields.containsKey('template_id') + ? fields['template_id'] as String? + : existing.templateId, + createdAt: existing.createdAt, + updatedAt: fields.containsKey('updated_at') + ? AppTime.parse(fields['updated_at'] as String) + : existing.updatedAt, + bannerEnabled: + (fields['banner_enabled'] as bool?) ?? existing.bannerEnabled, + bannerShowAt: fields.containsKey('banner_show_at') + ? (fields['banner_show_at'] != null + ? AppTime.parse(fields['banner_show_at'] as String) + : null) + : existing.bannerShowAt, + bannerHideAt: fields.containsKey('banner_hide_at') + ? (fields['banner_hide_at'] != null + ? AppTime.parse(fields['banner_hide_at'] as String) + : null) + : existing.bannerHideAt, + pushIntervalMinutes: fields.containsKey('push_interval_minutes') + ? fields['push_interval_minutes'] as int? + : existing.pushIntervalMinutes, + ); + }); + for (final p in pendingNew) { + byId.putIfAbsent(p.id, () => p); + } + return byId.values.toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + } + final wrapper = StreamRecoveryWrapper( stream: client .from('announcements') @@ -47,10 +117,138 @@ final announcementsProvider = StreamProvider>((ref) { fromMap: Announcement.fromMap, channelName: 'announcements', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + onOfflineData: () async { + final all = await cachedListFromBrick(); + return applyPending(all); + }, + onCacheMirror: (rows) => + mirrorBatchToBrick(rows, tag: 'announcements'), ); ref.onDispose(wrapper.dispose); - return wrapper.stream.map((result) => result.data); + + // Reconnect-replay: POST queued items directly, bypassing Brick's queue. + ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { + if (wasOnline == true || !isNowOnline) return; + + // 1. Replay offline-created announcements + final pendingList = + List.from(ref.read(offlinePendingAnnouncementsProvider)); + if (pendingList.isNotEmpty) { + debugPrint( + '[announcementsProvider] reconnected — syncing ${pendingList.length} announcement(s)', + ); + final synced = []; + for (final a in pendingList) { + try { + await client.from('announcements').insert({ + 'id': a.id, + 'author_id': a.authorId, + 'title': a.title, + 'body': a.body, + 'visible_roles': a.visibleRoles, + 'is_template': a.isTemplate, + 'template_id': a.templateId, + 'banner_enabled': a.bannerEnabled, + 'banner_show_at': a.bannerShowAt?.toIso8601String(), + 'banner_hide_at': a.bannerHideAt?.toIso8601String(), + 'push_interval_minutes': a.pushIntervalMinutes, + }); + synced.add(a.id); + } catch (e) { + final msg = e.toString(); + if (msg.contains('23505') || msg.contains('duplicate key')) { + synced.add(a.id); + } else { + debugPrint( + '[announcementsProvider] failed to sync id=${a.id}: $e', + ); + } + } + } + if (synced.isNotEmpty) { + final remaining = ref + .read(offlinePendingAnnouncementsProvider) + .where((a) => !synced.contains(a.id)) + .toList(); + ref.read(offlinePendingAnnouncementsProvider.notifier).state = + List.unmodifiable(remaining); + } + } + + // 2. Replay offline announcement updates + final pendingUpdatesMap = Map>.from( + ref.read(offlinePendingAnnouncementUpdatesProvider), + ); + if (pendingUpdatesMap.isNotEmpty) { + debugPrint( + '[announcementsProvider] reconnected — syncing ${pendingUpdatesMap.length} update(s)', + ); + final syncedIds = []; + for (final entry in pendingUpdatesMap.entries) { + try { + await client + .from('announcements') + .update(entry.value) + .eq('id', entry.key); + syncedIds.add(entry.key); + } catch (e) { + debugPrint( + '[announcementsProvider] failed to sync update id=${entry.key}: $e', + ); + } + } + if (syncedIds.isNotEmpty) { + final remaining = Map>.from( + ref.read(offlinePendingAnnouncementUpdatesProvider), + ); + for (final id in syncedIds) { remaining.remove(id); } + ref.read(offlinePendingAnnouncementUpdatesProvider.notifier).state = + remaining; + } + } + + // 3. Replay offline comments + final pendingComments = List.from( + ref.read(offlinePendingAnnouncementCommentsProvider), + ); + if (pendingComments.isNotEmpty) { + debugPrint( + '[announcementsProvider] reconnected — syncing ${pendingComments.length} comment(s)', + ); + final synced = []; + for (final c in pendingComments) { + try { + await client.from('announcement_comments').insert({ + 'id': c.id, + 'announcement_id': c.announcementId, + 'author_id': c.authorId, + 'body': c.body, + }); + synced.add(c.id); + } catch (e) { + final msg = e.toString(); + if (msg.contains('23505') || msg.contains('duplicate key')) { + synced.add(c.id); + } else { + debugPrint( + '[announcementsProvider] failed to sync comment id=${c.id}: $e', + ); + } + } + } + if (synced.isNotEmpty) { + final remaining = ref + .read(offlinePendingAnnouncementCommentsProvider) + .where((c) => !synced.contains(c.id)) + .toList(); + ref.read(offlinePendingAnnouncementCommentsProvider.notifier).state = + List.unmodifiable(remaining); + } + } + }); + + return wrapper.stream.map((result) => applyPending(result.data)); }); /// Streams comments for a specific announcement. @@ -61,6 +259,20 @@ final announcementCommentsProvider = ) { final client = ref.watch(supabaseClientProvider); + final pendingComments = + ref.watch(offlinePendingAnnouncementCommentsProvider); + + List applyPending(List rows) { + final byId = {for (final r in rows) r.id: r}; + for (final c in pendingComments) { + if (c.announcementId == announcementId) { + byId.putIfAbsent(c.id, () => c); + } + } + return byId.values.toList() + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + } + final wrapper = StreamRecoveryWrapper( stream: client .from('announcement_comments') @@ -78,37 +290,51 @@ final announcementCommentsProvider = fromMap: AnnouncementComment.fromMap, channelName: 'announcement_comments_$announcementId', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + onOfflineData: () async { + final all = await cachedListFromBrick(); + final filtered = all + .where((c) => c.announcementId == announcementId) + .toList() + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + return applyPending(filtered); + }, + onCacheMirror: (rows) => mirrorBatchToBrick( + rows, + tag: 'announcement_comments'), ); ref.onDispose(wrapper.dispose); - return wrapper.stream.map((result) => result.data); + return wrapper.stream.map((result) => applyPending(result.data)); }); /// Active banner announcements for the current user. -/// Returns only non-template announcements whose banner is currently in its -/// active time window ([Announcement.isBannerActive]). final activeBannerAnnouncementsProvider = Provider>((ref) { final all = ref.watch(announcementsProvider).valueOrNull ?? []; return all.where((a) => !a.isTemplate && a.isBannerActive).toList(); }); +// --------------------------------------------------------------------------- +// Controller +// --------------------------------------------------------------------------- + final announcementsControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); final notifCtrl = ref.watch(notificationsControllerProvider); - return AnnouncementsController(client, notifCtrl); + return AnnouncementsController(client, notifCtrl, ref); }); class AnnouncementsController { - AnnouncementsController(this._client, this._notifCtrl); + AnnouncementsController(this._client, this._notifCtrl, [this._ref]); - // _client is declared dynamic to allow test doubles that mimic only the - // subset of methods used by this class. In production it is a SupabaseClient. + // _client is declared dynamic to allow test doubles. // ignore: avoid_dynamic_calls final dynamic _client; final dynamic _notifCtrl; + final Ref? _ref; /// Create a new announcement and send push notifications to target users. + /// Offline: queues a local row and returns silently. Future createAnnouncement({ required String title, required String body, @@ -120,10 +346,14 @@ class AnnouncementsController { DateTime? bannerHideAt, int? pushIntervalMinutes, }) async { - final authorId = _client.auth.currentUser?.id; + final authorId = _client.auth.currentUser?.id as String?; if (authorId == null) return; + final id = const Uuid().v4(); + final now = AppTime.now(); + final row = { + 'id': id, 'author_id': authorId, 'title': title, 'body': body, @@ -136,23 +366,39 @@ class AnnouncementsController { 'push_interval_minutes': pushIntervalMinutes, }; - final result = await _client - .from('announcements') - .insert(row) - .select('id') - .single(); - final announcementId = result['id'] as String; + String? announcementId; + try { + final result = await _client + .from('announcements') + .insert(row) + .select('id') + .single(); + announcementId = result['id'] as String; + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queuePendingAnnouncement( + Announcement( + id: id, + authorId: authorId, + title: title, + body: body, + visibleRoles: visibleRoles, + isTemplate: isTemplate, + templateId: templateId, + createdAt: now, + updatedAt: now, + bannerEnabled: bannerEnabled, + bannerShowAt: bannerShowAt, + bannerHideAt: bannerHideAt, + pushIntervalMinutes: pushIntervalMinutes, + ), + ); + return; + } - // Don't send notifications for templates (they are drafts for reuse) if (isTemplate) return; - - // Skip the one-time creation push when a scheduled banner push is - // configured. The banner scheduler will send the first push on its own - // interval, so firing an extra push here would result in two back-to-back - // notifications for the same announcement. if (bannerEnabled && pushIntervalMinutes != null) return; - // Query users whose role matches visible_roles, excluding the author try { final profiles = await _client .from('profiles') @@ -160,9 +406,8 @@ class AnnouncementsController { .inFilter('role', visibleRoles); final userIds = (profiles as List) .map((p) => p['id'] as String) - .where((id) => id != authorId) + .where((uid) => uid != authorId) .toList(); - if (userIds.isEmpty) return; await _notifCtrl.createNotification( @@ -183,7 +428,7 @@ class AnnouncementsController { } } - /// Update an existing announcement. + /// Update an existing announcement. Offline: queues the field patch. Future updateAnnouncement({ required String id, required String title, @@ -221,11 +466,16 @@ class AnnouncementsController { } else if (clearPushInterval) { payload['push_interval_minutes'] = null; } - await _client.from('announcements').update(payload).eq('id', id); + + try { + await _client.from('announcements').update(payload).eq('id', id); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queueAnnouncementUpdate(id, payload); + } } - /// Update only the banner settings on an existing announcement. - /// Intended for the "Manage Banner" popup available to the poster and admins. + /// Update only the banner settings. Offline: queues the field patch. Future updateBannerSettings({ required String id, required bool bannerEnabled, @@ -233,25 +483,37 @@ class AnnouncementsController { DateTime? bannerHideAt, int? pushIntervalMinutes, }) async { - await _client.from('announcements').update({ + final payload = { 'banner_enabled': bannerEnabled, 'banner_show_at': bannerShowAt?.toUtc().toIso8601String(), 'banner_hide_at': bannerHideAt?.toUtc().toIso8601String(), 'push_interval_minutes': pushIntervalMinutes, 'updated_at': AppTime.nowUtc().toIso8601String(), - }).eq('id', id); + }; + try { + await _client.from('announcements').update(payload).eq('id', id); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queueAnnouncementUpdate(id, payload); + } } - /// Immediately stops a banner by setting [banner_hide_at] to now. - /// Usable by the poster or an admin. + /// Immediately stops a banner by setting banner_hide_at to now. + /// Offline: queues the field patch. Future dismissBanner(String id) async { - await _client.from('announcements').update({ + final payload = { 'banner_hide_at': AppTime.nowUtc().toIso8601String(), 'updated_at': AppTime.nowUtc().toIso8601String(), - }).eq('id', id); + }; + try { + await _client.from('announcements').update(payload).eq('id', id); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queueAnnouncementUpdate(id, payload); + } } - /// Delete an announcement. + /// Delete an announcement. Online-only — destructive actions are not queued. Future deleteAnnouncement(String id) async { await _client.from('announcements').delete().eq('id', id); } @@ -264,27 +526,41 @@ class AnnouncementsController { .eq('id', templateId) .maybeSingle(); if (data == null) return null; - return Announcement.fromMap(data); + return Announcement.fromMap(data as Map); } - /// Add a comment to an announcement. - /// Notifies the announcement author and all previous commenters. + /// Add a comment. Offline: queues the comment locally. Future addComment({ required String announcementId, required String body, }) async { - final authorId = _client.auth.currentUser?.id; + final authorId = _client.auth.currentUser?.id as String?; if (authorId == null) return; - await _client.from('announcement_comments').insert({ - 'announcement_id': announcementId, - 'author_id': authorId, - 'body': body, - }); + final id = const Uuid().v4(); + + try { + await _client.from('announcement_comments').insert({ + 'id': id, + 'announcement_id': announcementId, + 'author_id': authorId, + 'body': body, + }); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queuePendingComment( + AnnouncementComment( + id: id, + announcementId: announcementId, + authorId: authorId, + body: body, + createdAt: AppTime.now(), + ), + ); + return; + } - // Notify announcement author + previous commenters try { - // Get the announcement author and title final announcement = await _client .from('announcements') .select('author_id, title') @@ -296,23 +572,18 @@ class AnnouncementsController { final announcementTitle = announcement['title'] as String? ?? 'an announcement'; - // Get all unique commenters on this announcement final comments = await _client .from('announcement_comments') .select('author_id') .eq('announcement_id', announcementId); - final commenterIds = (comments as List) - .map((c) => c['author_id'] as String) - .toSet(); + final commenterIds = + (comments as List).map((c) => c['author_id'] as String).toSet(); - // Combine author + commenters, exclude self final notifyIds = {announcementAuthorId, ...commenterIds} - .where((id) => id != authorId) + .where((uid) => uid != authorId) .toList(); - if (notifyIds.isEmpty) return; - // Fetch commenter's display name for a human-readable push body final commenterData = await _client .from('profiles') .select('full_name') @@ -340,10 +611,9 @@ class AnnouncementsController { } /// Re-sends push notifications for an existing announcement. - /// Intended for use by admins or the announcement author. Future resendAnnouncementNotification( Announcement announcement) async { - final actorId = _client.auth.currentUser?.id; + final actorId = _client.auth.currentUser?.id as String?; if (actorId == null) return; if (announcement.visibleRoles.isEmpty) return; @@ -354,9 +624,8 @@ class AnnouncementsController { .inFilter('role', announcement.visibleRoles); final userIds = (profiles as List) .map((p) => p['id'] as String) - .where((id) => id != actorId) + .where((uid) => uid != actorId) .toList(); - if (userIds.isEmpty) return; await _notifCtrl.createNotification( @@ -379,8 +648,47 @@ class AnnouncementsController { } } - /// Delete a comment. + /// Delete a comment. Online-only — destructive actions are not queued. Future deleteComment(String id) async { await _client.from('announcement_comments').delete().eq('id', id); } + + // --------------------------------------------------------------------------- + // Queue helpers + // --------------------------------------------------------------------------- + + void _queuePendingAnnouncement(Announcement announcement) { + final ref = _ref; + if (ref == null) return; + final current = List.from( + ref.read(offlinePendingAnnouncementsProvider), + ); + current.add(announcement); + ref.read(offlinePendingAnnouncementsProvider.notifier).state = + List.unmodifiable(current); + mirrorBatchToBrick([announcement], tag: 'offline_announcement'); + } + + void _queueAnnouncementUpdate(String id, Map fields) { + final ref = _ref; + if (ref == null) return; + final current = Map>.from( + ref.read(offlinePendingAnnouncementUpdatesProvider), + ); + current[id] = {...?current[id], ...fields}; + ref.read(offlinePendingAnnouncementUpdatesProvider.notifier).state = current; + } + + void _queuePendingComment(AnnouncementComment comment) { + final ref = _ref; + if (ref == null) return; + final current = List.from( + ref.read(offlinePendingAnnouncementCommentsProvider), + ); + current.add(comment); + ref.read(offlinePendingAnnouncementCommentsProvider.notifier).state = + List.unmodifiable(current); + mirrorBatchToBrick([comment], + tag: 'offline_announcement_comment'); + } } diff --git a/lib/providers/attendance_provider.dart b/lib/providers/attendance_provider.dart index 3e13cc88..96287a81 100644 --- a/lib/providers/attendance_provider.dart +++ b/lib/providers/attendance_provider.dart @@ -1,17 +1,22 @@ import 'dart:typed_data'; +import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:uuid/uuid.dart'; +import '../brick/cache_helpers.dart'; import '../models/attendance_log.dart'; import '../utils/app_time.dart'; +import '../utils/snackbar.dart' show isOfflineSaveError; +import 'connectivity_provider.dart'; import 'profile_provider.dart'; import 'reports_provider.dart'; import 'supabase_provider.dart'; import 'stream_recovery.dart'; import 'realtime_controller.dart'; -/// Date range for attendance logbook, defaults to "Last 7 Days". +/// Date range for attendance logbook, defaults to "Today". final attendanceDateRangeProvider = StateProvider((ref) { final now = AppTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -25,12 +30,32 @@ final attendanceDateRangeProvider = StateProvider((ref) { /// Filter for logbook users (multi-select). If empty, all users are shown. final attendanceUserFilterProvider = StateProvider>((ref) => []); +// --------------------------------------------------------------------------- +// Offline-pending state +// --------------------------------------------------------------------------- + +/// Check-ins created offline (typed model — merged into live stream). +final offlinePendingCheckInsProvider = + StateProvider>((ref) => const []); + +/// Raw RPC params for offline check-ins — used for reconnect replay. +/// Each entry: {p_duty_id, p_lat, p_lng, localId} +final offlinePendingCheckInRawProvider = + StateProvider>>((ref) => const []); + +/// Attendance field updates made offline, keyed by attendance log id. +/// Covers: checkouts, verification status updates, skipVerification. +final offlinePendingAttendanceUpdatesProvider = + StateProvider>>((ref) => const {}); + +// --------------------------------------------------------------------------- +// Read provider +// --------------------------------------------------------------------------- + /// All visible attendance logs (own for standard, all for admin/dispatcher/it_staff). final attendanceLogsProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); - // Use .select() so the stream is only recreated when the user id or role - // actually changes (not on avatar/name edits, etc.). final profileId = ref.watch( currentProfileProvider.select((p) => p.valueOrNull?.id), ); @@ -45,6 +70,56 @@ final attendanceLogsProvider = StreamProvider>((ref) { profileRole == 'dispatcher' || profileRole == 'it_staff'; + final pendingCheckIns = ref.watch(offlinePendingCheckInsProvider); + final pendingUpdates = ref.watch(offlinePendingAttendanceUpdatesProvider); + + List applyPending(List rows) { + final byId = {for (final r in rows) r.id: r}; + + // Apply pending checkouts and other field updates + pendingUpdates.forEach((id, fields) { + final existing = byId[id]; + if (existing == null) return; + byId[id] = AttendanceLog( + id: existing.id, + userId: existing.userId, + dutyScheduleId: existing.dutyScheduleId, + shiftType: existing.shiftType, + checkInAt: existing.checkInAt, + checkInLat: existing.checkInLat, + checkInLng: existing.checkInLng, + checkOutAt: fields.containsKey('check_out_at') + ? (fields['check_out_at'] != null + ? AppTime.parse(fields['check_out_at'] as String) + : null) + : existing.checkOutAt, + checkOutLat: (fields['check_out_lat'] as num?)?.toDouble() ?? + existing.checkOutLat, + checkOutLng: (fields['check_out_lng'] as num?)?.toDouble() ?? + existing.checkOutLng, + justification: existing.justification, + checkOutJustification: + (fields['check_out_justification'] as String?) ?? + existing.checkOutJustification, + verificationStatus: + (fields['verification_status'] as String?) ?? + existing.verificationStatus, + checkInVerificationPhotoUrl: existing.checkInVerificationPhotoUrl, + checkOutVerificationPhotoUrl: existing.checkOutVerificationPhotoUrl, + ); + }); + + // Append offline check-ins not yet on server + for (final p in pendingCheckIns) { + if (hasFullAccess || p.userId == profileId) { + byId.putIfAbsent(p.id, () => p); + } + } + + return byId.values.toList() + ..sort((a, b) => b.checkInAt.compareTo(a.checkInAt)); + } + final wrapper = StreamRecoveryWrapper( stream: hasFullAccess ? client @@ -68,55 +143,225 @@ final attendanceLogsProvider = StreamProvider>((ref) { fromMap: AttendanceLog.fromMap, channelName: 'attendance_logs', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + onOfflineData: () async { + final all = await cachedListFromBrick(); + final filtered = hasFullAccess + ? all + : all.where((log) => log.userId == profileId).toList(); + filtered.sort((a, b) => b.checkInAt.compareTo(a.checkInAt)); + return applyPending(filtered); + }, + onCacheMirror: (rows) => + mirrorBatchToBrick(rows, tag: 'attendance_logs'), ); ref.onDispose(wrapper.dispose); - return wrapper.stream.map((result) => result.data); + + // Reconnect-replay: POST/PATCH queued items directly, bypassing Brick's queue. + ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { + if (wasOnline == true || !isNowOnline) return; + + // 1. Replay offline check-ins + final pendingRaw = List>.from( + ref.read(offlinePendingCheckInRawProvider), + ); + if (pendingRaw.isNotEmpty) { + debugPrint( + '[attendanceLogsProvider] reconnected — syncing ${pendingRaw.length} check-in(s)', + ); + final syncedLocalIds = []; + for (final params in pendingRaw) { + final localId = params['localId'] as String; + try { + await client.rpc( + 'attendance_check_in', + params: { + 'p_duty_id': params['p_duty_id'], + 'p_lat': params['p_lat'], + 'p_lng': params['p_lng'], + }, + ); + syncedLocalIds.add(localId); + } catch (e) { + final msg = e.toString(); + if (msg.contains('23505') || msg.contains('duplicate key')) { + syncedLocalIds.add(localId); + } else { + debugPrint( + '[attendanceLogsProvider] failed to sync check-in localId=$localId: $e', + ); + } + } + } + if (syncedLocalIds.isNotEmpty) { + final remainingRaw = ref + .read(offlinePendingCheckInRawProvider) + .where((p) => !syncedLocalIds.contains(p['localId'] as String)) + .toList(); + ref.read(offlinePendingCheckInRawProvider.notifier).state = + List.unmodifiable(remainingRaw); + + final remainingModels = ref + .read(offlinePendingCheckInsProvider) + .where((l) => !syncedLocalIds.contains(l.id)) + .toList(); + ref.read(offlinePendingCheckInsProvider.notifier).state = + List.unmodifiable(remainingModels); + } + } + + // 2. Replay pending checkouts and field updates (direct PATCH) + final pendingUpdatesMap = Map>.from( + ref.read(offlinePendingAttendanceUpdatesProvider), + ); + if (pendingUpdatesMap.isNotEmpty) { + debugPrint( + '[attendanceLogsProvider] reconnected — syncing ${pendingUpdatesMap.length} attendance update(s)', + ); + final syncedIds = []; + for (final entry in pendingUpdatesMap.entries) { + final attendanceId = entry.key; + final fields = Map.from(entry.value); + + // If this was a checkout via RPC, call the RPC instead of a raw PATCH + if (fields.containsKey('_rpc_checkout')) { + fields.remove('_rpc_checkout'); + try { + await client.rpc( + 'attendance_check_out', + params: { + 'p_attendance_id': attendanceId, + 'p_lat': fields['check_out_lat'], + 'p_lng': fields['check_out_lng'], + 'p_justification': fields['check_out_justification'], + }, + ); + syncedIds.add(attendanceId); + } catch (e) { + debugPrint( + '[attendanceLogsProvider] failed to sync checkout id=$attendanceId: $e', + ); + } + } else { + try { + await client + .from('attendance_logs') + .update(fields) + .eq('id', attendanceId); + syncedIds.add(attendanceId); + } catch (e) { + debugPrint( + '[attendanceLogsProvider] failed to sync update id=$attendanceId: $e', + ); + } + } + } + if (syncedIds.isNotEmpty) { + final remaining = Map>.from( + ref.read(offlinePendingAttendanceUpdatesProvider), + ); + for (final id in syncedIds) { + remaining.remove(id); + } + ref.read(offlinePendingAttendanceUpdatesProvider.notifier).state = + remaining; + } + } + }); + + return wrapper.stream.map((result) => applyPending(result.data)); }); +// --------------------------------------------------------------------------- +// Controller +// --------------------------------------------------------------------------- + final attendanceControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); - return AttendanceController(client); + return AttendanceController(client, ref); }); class AttendanceController { - AttendanceController(this._client); + AttendanceController(this._client, [this._ref]); final SupabaseClient _client; + final Ref? _ref; /// Check in to a duty schedule. Returns the attendance log ID. + /// Offline: queues locally via StateProvider, returns local UUID. Future checkIn({ required String dutyScheduleId, required double lat, required double lng, + String shiftType = 'normal', }) async { - final data = await _client.rpc( - 'attendance_check_in', - params: {'p_duty_id': dutyScheduleId, 'p_lat': lat, 'p_lng': lng}, - ); - return data as String?; + try { + final data = await _client.rpc( + 'attendance_check_in', + params: {'p_duty_id': dutyScheduleId, 'p_lat': lat, 'p_lng': lng}, + ); + return data as String?; + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + + final userId = _client.auth.currentUser?.id; + if (userId == null) rethrow; + + final id = const Uuid().v4(); + final log = AttendanceLog( + id: id, + userId: userId, + dutyScheduleId: dutyScheduleId, + shiftType: shiftType, + checkInAt: AppTime.now(), + checkInLat: lat, + checkInLng: lng, + verificationStatus: 'pending', + ); + + _queuePendingCheckIn( + log, + {'p_duty_id': dutyScheduleId, 'p_lat': lat, 'p_lng': lng, 'localId': id}, + ); + debugPrint('[AttendanceController] checkIn queued offline: $id'); + return id; + } } - /// Check out from an attendance log. + /// Check out from an attendance log. Offline: queues the checkout params. Future checkOut({ required String attendanceId, required double lat, required double lng, String? justification, }) async { - await _client.rpc( - 'attendance_check_out', - params: { - 'p_attendance_id': attendanceId, - 'p_lat': lat, - 'p_lng': lng, - 'p_justification': justification, - }, - ); + try { + await _client.rpc( + 'attendance_check_out', + params: { + 'p_attendance_id': attendanceId, + 'p_lat': lat, + 'p_lng': lng, + 'p_justification': justification, + }, + ); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + + _queueAttendanceUpdate(attendanceId, { + '_rpc_checkout': true, + 'check_out_at': AppTime.now().toIso8601String(), + 'check_out_lat': lat, + 'check_out_lng': lng, + 'check_out_justification': justification, + }); + debugPrint( + '[AttendanceController] checkOut queued offline: $attendanceId', + ); + } } - /// Overtime check-in (no pre-existing schedule required). - /// Creates an overtime duty schedule + attendance log in one RPC call. + /// Overtime check-in (online-only — requires server-side schedule creation). Future overtimeCheckIn({ required double lat, required double lng, @@ -130,41 +375,99 @@ class AttendanceController { } /// Upload a verification selfie and update the attendance log. + /// Offline: queues the status update only (photo bytes are not persisted). Future uploadVerification({ required String attendanceId, required Uint8List bytes, required String fileName, - required String status, // 'verified', 'unverified' + required String status, bool isCheckOut = false, }) async { final userId = _client.auth.currentUser!.id; final ext = fileName.split('.').last.toLowerCase(); final prefix = isCheckOut ? 'checkout' : 'checkin'; final path = '$userId/${prefix}_$attendanceId.$ext'; - await _client.storage - .from('attendance-verification') - .uploadBinary( - path, - bytes, - fileOptions: const FileOptions(upsert: true), - ); - final url = _client.storage - .from('attendance-verification') - .getPublicUrl(path); - final column = isCheckOut - ? 'check_out_verification_photo_url' - : 'check_in_verification_photo_url'; - await _client - .from('attendance_logs') - .update({'verification_status': status, column: url}) - .eq('id', attendanceId); + + try { + await _client.storage + .from('attendance-verification') + .uploadBinary( + path, + bytes, + fileOptions: const FileOptions(upsert: true), + ); + final url = _client.storage + .from('attendance-verification') + .getPublicUrl(path); + final column = isCheckOut + ? 'check_out_verification_photo_url' + : 'check_in_verification_photo_url'; + await _client + .from('attendance_logs') + .update({'verification_status': status, column: url}) + .eq('id', attendanceId); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + // Queue status update without photo URL — photo bytes are not persisted + // across sessions; user will need to re-upload photo when online. + _queueAttendanceUpdate(attendanceId, {'verification_status': status}); + debugPrint( + '[AttendanceController] uploadVerification status queued offline (photo not persisted): $attendanceId', + ); + } } - /// Mark an attendance log as skipped verification. + /// Mark an attendance log as skipped verification. Offline: queues the patch. Future skipVerification(String attendanceId) async { - await _client - .from('attendance_logs') - .update({'verification_status': 'skipped'}) - .eq('id', attendanceId); + try { + await _client + .from('attendance_logs') + .update({'verification_status': 'skipped'}) + .eq('id', attendanceId); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queueAttendanceUpdate(attendanceId, {'verification_status': 'skipped'}); + } + } + + // ----------------------------------------------------------------------- + // Queue helpers + // ----------------------------------------------------------------------- + + void _queuePendingCheckIn( + AttendanceLog log, + Map rpcParams, + ) { + final ref = _ref; + if (ref == null) return; + + final currentModels = List.from( + ref.read(offlinePendingCheckInsProvider), + ); + currentModels.add(log); + ref.read(offlinePendingCheckInsProvider.notifier).state = + List.unmodifiable(currentModels); + + final currentRaw = List>.from( + ref.read(offlinePendingCheckInRawProvider), + ); + currentRaw.add(rpcParams); + ref.read(offlinePendingCheckInRawProvider.notifier).state = + List.unmodifiable(currentRaw); + + mirrorBatchToBrick([log], tag: 'offline_checkin'); + } + + void _queueAttendanceUpdate( + String attendanceId, + Map fields, + ) { + final ref = _ref; + if (ref == null) return; + final current = Map>.from( + ref.read(offlinePendingAttendanceUpdatesProvider), + ); + current[attendanceId] = {...?current[attendanceId], ...fields}; + ref.read(offlinePendingAttendanceUpdatesProvider.notifier).state = current; } } diff --git a/lib/providers/chat_provider.dart b/lib/providers/chat_provider.dart index 8b146300..eea2f731 100644 --- a/lib/providers/chat_provider.dart +++ b/lib/providers/chat_provider.dart @@ -1,11 +1,30 @@ +import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:uuid/uuid.dart'; +import '../brick/cache_helpers.dart'; import '../models/chat_message.dart'; +import '../utils/app_time.dart'; +import '../utils/snackbar.dart' show isOfflineSaveError; +import 'connectivity_provider.dart'; import 'supabase_provider.dart'; import 'stream_recovery.dart'; import 'realtime_controller.dart'; +// --------------------------------------------------------------------------- +// Offline-pending state +// --------------------------------------------------------------------------- + +/// Pending chat messages keyed by threadId. +/// Messages sync on reconnect when the thread's provider is active. +final offlinePendingChatMessagesProvider = + StateProvider>>((ref) => const {}); + +// --------------------------------------------------------------------------- +// Read provider +// --------------------------------------------------------------------------- + /// Real-time chat messages for a swap request thread. final chatMessagesProvider = StreamProvider.family, String>(( ref, @@ -13,6 +32,19 @@ final chatMessagesProvider = StreamProvider.family, String>(( ) { final client = ref.watch(supabaseClientProvider); + final pendingByThread = ref.watch(offlinePendingChatMessagesProvider); + final pendingForThread = pendingByThread[threadId] ?? const []; + + List applyPending(List rows) { + if (pendingForThread.isEmpty) return rows; + final byId = {for (final r in rows) r.id: r}; + for (final p in pendingForThread) { + byId.putIfAbsent(p.id, () => p); + } + return byId.values.toList() + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + } + final wrapper = StreamRecoveryWrapper( stream: client .from('chat_messages') @@ -30,32 +62,126 @@ final chatMessagesProvider = StreamProvider.family, String>(( fromMap: ChatMessage.fromMap, channelName: 'chat_messages_$threadId', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + onOfflineData: () async { + final all = await cachedListFromBrick(); + final filtered = all + .where((m) => m.threadId == threadId) + .toList() + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + return applyPending(filtered); + }, + onCacheMirror: (rows) => + mirrorBatchToBrick(rows, tag: 'chat_messages'), ); ref.onDispose(wrapper.dispose); - return wrapper.stream.map((result) => result.data); + + // Reconnect-replay: fires when this thread's screen is active. + ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { + if (wasOnline == true || !isNowOnline) return; + + final pending = List.from( + (ref.read(offlinePendingChatMessagesProvider)[threadId] ?? []), + ); + if (pending.isEmpty) return; + + debugPrint( + '[chatMessagesProvider($threadId)] reconnected — syncing ${pending.length} message(s)', + ); + final synced = []; + for (final msg in pending) { + try { + await client.from('chat_messages').insert({ + 'id': msg.id, + 'thread_id': msg.threadId, + 'sender_id': msg.senderId, + 'body': msg.body, + }); + synced.add(msg.id); + } catch (e) { + final s = e.toString(); + if (s.contains('23505') || s.contains('duplicate key')) { + synced.add(msg.id); + } else { + debugPrint( + '[chatMessagesProvider] failed to sync message id=${msg.id}: $e', + ); + } + } + } + if (synced.isNotEmpty) { + final current = Map>.from( + ref.read(offlinePendingChatMessagesProvider), + ); + final remaining = + (current[threadId] ?? []).where((m) => !synced.contains(m.id)).toList(); + if (remaining.isEmpty) { + current.remove(threadId); + } else { + current[threadId] = remaining; + } + ref.read(offlinePendingChatMessagesProvider.notifier).state = + Map.unmodifiable(current); + } + }); + + return wrapper.stream.map((result) => applyPending(result.data)); }); +// --------------------------------------------------------------------------- +// Controller +// --------------------------------------------------------------------------- + final chatControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); - return ChatController(client); + return ChatController(client, ref); }); class ChatController { - ChatController(this._client); + ChatController(this._client, [this._ref]); final SupabaseClient _client; + final Ref? _ref; + /// Send a message to a thread. Offline: queues locally. Future sendMessage({ required String threadId, required String body, }) async { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); - await _client.from('chat_messages').insert({ - 'thread_id': threadId, - 'sender_id': userId, - 'body': body, - }); + + final id = const Uuid().v4(); + + try { + await _client.from('chat_messages').insert({ + 'id': id, + 'thread_id': threadId, + 'sender_id': userId, + 'body': body, + }); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + + final message = ChatMessage( + id: id, + threadId: threadId, + senderId: userId, + body: body, + createdAt: AppTime.now(), + ); + + final ref = _ref; + if (ref != null) { + final current = Map>.from( + ref.read(offlinePendingChatMessagesProvider), + ); + current[threadId] = [...(current[threadId] ?? []), message]; + ref.read(offlinePendingChatMessagesProvider.notifier).state = + Map.unmodifiable(current); + } + mirrorBatchToBrick([message], tag: 'offline_chat'); + debugPrint('[ChatController] sendMessage queued offline: $id'); + } } } diff --git a/lib/providers/connectivity_provider.dart b/lib/providers/connectivity_provider.dart new file mode 100644 index 00000000..0b33dd34 --- /dev/null +++ b/lib/providers/connectivity_provider.dart @@ -0,0 +1,87 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart' show debugPrint; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; + +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../brick/cache_warmer.dart'; +import '../brick/repository.dart'; +import 'stream_recovery.dart'; + +/// Current online/offline status. +/// Defaults to true so the UI doesn't flash an offline banner on startup. +final isOnlineProvider = StateProvider((ref) => true); + +/// Activating this provider starts a periodic connectivity poll. +/// Watch it from the root widget to keep it alive. +final connectivityMonitorProvider = Provider.autoDispose((ref) { + // Wire up the global online check for StreamRecoveryWrapper so all stream + // wrappers can suppress recovery while offline without per-instance setup. + StreamRecoveryWrapper.setIsOnlineCallback(() => ref.read(isOnlineProvider)); + + bool previouslyOnline = true; + + Future check() async { + final online = await ConnectivityMonitor.check(); + final wasOnline = ref.read(isOnlineProvider); + if (online != wasOnline) { + ref.read(isOnlineProvider.notifier).state = online; + } + if (previouslyOnline && !online) { + _onDisconnected(); + } + if (!previouslyOnline && online) { + _onReconnected(); + } + previouslyOnline = online; + } + + // Run immediately, then every 5 seconds. + check(); + final timer = Timer.periodic(const Duration(seconds: 5), (_) => check()); + ref.onDispose(timer.cancel); +}); + +/// Pings the app's own backend to determine online status. +class ConnectivityMonitor { + static Future check() async { + try { + final res = await http + .head(Uri.parse('https://tasq.crmc.ph')) + .timeout(const Duration(seconds: 5)); + return res.statusCode < 500; + } catch (_) { + return false; + } + } +} + +void _onDisconnected() { + debugPrint('[Connectivity] Went offline — pausing Brick request queue'); + // Stop the retry loop so the queue doesn't spam network errors while offline. + // Queued writes are preserved in SQLite and will sync when _onReconnected fires. + AppRepository.offlineQueue?.stop(); +} + +void _onReconnected() { + debugPrint('[Connectivity] Back online — queued writes will sync via Brick'); + if (!AppRepository.isConfigured) return; + + // Resume the Brick HTTP queue so queued writes (e.g. offline task creation) + // are replayed against Supabase now that the network is available again. + AppRepository.offlineQueue?.start(); + + // Restart any Supabase realtime streams that were suppressed while offline. + // Streams that disconnected due to no network are NOT in an error-retry loop + // (recovery was suppressed); this kick-starts them back to live updates. + StreamRecoveryWrapper.notifyAllOnlineRestored(); + + // Re-warm the Brick cache so the local SQLite mirror reflects any changes + // that happened on the server while we were offline. Fire-and-forget — it + // runs in the background and never throws. + final user = Supabase.instance.client.auth.currentUser; + if (user != null) { + CacheWarmer.warmAll(Supabase.instance.client); + } +} diff --git a/lib/providers/it_service_request_provider.dart b/lib/providers/it_service_request_provider.dart index 02ac38c0..fd2160dc 100644 --- a/lib/providers/it_service_request_provider.dart +++ b/lib/providers/it_service_request_provider.dart @@ -6,10 +6,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; + +import '../brick/cache_helpers.dart'; import '../models/it_service_request.dart'; import '../models/it_service_request_assignment.dart'; import '../models/it_service_request_activity_log.dart'; import '../models/it_service_request_action.dart'; +import '../utils/app_time.dart'; +import '../utils/snackbar.dart' show isOfflineSaveError; +import 'connectivity_provider.dart'; import 'profile_provider.dart'; import 'supabase_provider.dart'; import 'user_offices_provider.dart'; @@ -60,6 +66,27 @@ final itServiceRequestQueryProvider = StateProvider( (ref) => const ItServiceRequestQuery(), ); +// --------------------------------------------------------------------------- +// Offline-pending state +// --------------------------------------------------------------------------- + +/// IT Service Requests created offline (typed model — merged into live stream). +final offlinePendingItServiceRequestsProvider = + StateProvider>((ref) => const []); + +/// Raw RPC params for offline-created requests — used for reconnect replay. +final offlinePendingItServiceRequestRawProvider = + StateProvider>>((ref) => const []); + +/// Field-level updates to existing requests made offline, keyed by request id. +final offlinePendingItServiceRequestUpdatesProvider = + StateProvider>>((ref) => const {}); + +/// Pending action-taken entries submitted offline. +/// Each entry: {id, request_id, user_id, action_taken} +final offlinePendingIsrActionsProvider = + StateProvider>>((ref) => const []); + // --------------------------------------------------------------------------- // Stream providers // --------------------------------------------------------------------------- @@ -71,6 +98,24 @@ final itServiceRequestsProvider = StreamProvider>((ref) { final profile = ref.watch(currentProfileProvider).valueOrNull; final userOfficesAsync = ref.watch(userOfficesProvider); + final pendingNew = ref.watch(offlinePendingItServiceRequestsProvider); + final pendingUpdates = + ref.watch(offlinePendingItServiceRequestUpdatesProvider); + + List applyPending(List rows) { + final byId = {for (final r in rows) r.id: r}; + pendingUpdates.forEach((id, fields) { + final existing = byId[id]; + if (existing == null) return; + byId[id] = _applyUpdateFields(existing, fields); + }); + for (final p in pendingNew) { + byId.putIfAbsent(p.id, () => p); + } + return byId.values.toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + } + final wrapper = StreamRecoveryWrapper( stream: client .from('it_service_requests') @@ -87,12 +132,140 @@ final itServiceRequestsProvider = StreamProvider>((ref) { fromMap: ItServiceRequest.fromMap, channelName: 'it_service_requests', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + onOfflineData: () async { + final all = await cachedListFromBrick(); + all.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return applyPending(all); + }, + onCacheMirror: (rows) => + mirrorBatchToBrick(rows, tag: 'it_service_requests'), ); ref.onDispose(wrapper.dispose); + + // Reconnect-replay: POST queued items directly, bypassing Brick's queue. + ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { + if (wasOnline == true || !isNowOnline) return; + + // 1. Replay offline-created requests (via RPC) + final pendingRaw = List>.from( + ref.read(offlinePendingItServiceRequestRawProvider), + ); + if (pendingRaw.isNotEmpty) { + debugPrint( + '[itServiceRequestsProvider] reconnected — syncing ${pendingRaw.length} request(s)', + ); + final syncedIds = []; + for (final params in pendingRaw) { + final id = params['p_id'] as String; + try { + await client.rpc( + 'insert_it_service_request_with_number', + params: params, + ); + syncedIds.add(id); + } catch (e) { + final msg = e.toString(); + if (msg.contains('23505') || msg.contains('duplicate key')) { + syncedIds.add(id); + } else { + debugPrint( + '[itServiceRequestsProvider] failed to sync id=$id: $e', + ); + } + } + } + if (syncedIds.isNotEmpty) { + final remainingRaw = ref + .read(offlinePendingItServiceRequestRawProvider) + .where((p) => !syncedIds.contains(p['p_id'] as String)) + .toList(); + ref.read(offlinePendingItServiceRequestRawProvider.notifier).state = + List.unmodifiable(remainingRaw); + + final remainingModels = ref + .read(offlinePendingItServiceRequestsProvider) + .where((r) => !syncedIds.contains(r.id)) + .toList(); + ref.read(offlinePendingItServiceRequestsProvider.notifier).state = + List.unmodifiable(remainingModels); + } + } + + // 2. Replay offline field/status updates + final pendingUpdatesMap = Map>.from( + ref.read(offlinePendingItServiceRequestUpdatesProvider), + ); + if (pendingUpdatesMap.isNotEmpty) { + debugPrint( + '[itServiceRequestsProvider] reconnected — syncing ${pendingUpdatesMap.length} update(s)', + ); + final syncedIds = []; + for (final entry in pendingUpdatesMap.entries) { + try { + await client + .from('it_service_requests') + .update(entry.value) + .eq('id', entry.key); + syncedIds.add(entry.key); + } catch (e) { + debugPrint( + '[itServiceRequestsProvider] failed to sync update id=${entry.key}: $e', + ); + } + } + if (syncedIds.isNotEmpty) { + final remaining = Map>.from( + ref.read(offlinePendingItServiceRequestUpdatesProvider), + ); + for (final id in syncedIds) { + remaining.remove(id); + } + ref.read(offlinePendingItServiceRequestUpdatesProvider.notifier).state = + remaining; + } + } + + // 3. Replay pending action-taken entries + final pendingActions = List>.from( + ref.read(offlinePendingIsrActionsProvider), + ); + if (pendingActions.isNotEmpty) { + debugPrint( + '[itServiceRequestsProvider] reconnected — syncing ${pendingActions.length} action(s)', + ); + final syncedIds = []; + for (final action in pendingActions) { + final id = action['id'] as String; + try { + await client + .from('it_service_request_actions') + .upsert(action, onConflict: 'request_id,user_id'); + syncedIds.add(id); + } catch (e) { + final msg = e.toString(); + if (msg.contains('23505') || msg.contains('duplicate key')) { + syncedIds.add(id); + } else { + debugPrint( + '[itServiceRequestsProvider] failed to sync action id=$id: $e', + ); + } + } + } + if (syncedIds.isNotEmpty) { + final remaining = ref + .read(offlinePendingIsrActionsProvider) + .where((a) => !syncedIds.contains(a['id'] as String)) + .toList(); + ref.read(offlinePendingIsrActionsProvider.notifier).state = + List.unmodifiable(remaining); + } + } + }); + return wrapper.stream.map((result) { - var items = result.data; - // Standard users see only requests from their offices or created by them + var items = applyPending(result.data); if (profile != null && profile.role == 'standard') { final officeIds = (userOfficesAsync.valueOrNull ?? []) .where((a) => a.userId == userId) @@ -145,6 +318,15 @@ final itServiceRequestAssignmentsProvider = onStatusChanged: ref .read(realtimeControllerProvider) .handleChannelStatus, + onOfflineData: () async { + final all = await cachedListFromBrick(); + all.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return all; + }, + onCacheMirror: (rows) => mirrorBatchToBrick( + rows, + tag: 'isr_assignments', + ), ); ref.onDispose(wrapper.dispose); @@ -177,6 +359,19 @@ final itServiceRequestActivityLogsProvider = onStatusChanged: ref .read(realtimeControllerProvider) .handleChannelStatus, + onOfflineData: () async { + final all = await cachedListFromBrick(); + final filtered = all + .where((l) => l.requestId == requestId) + .toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return filtered; + }, + onCacheMirror: (rows) => + mirrorBatchToBrick( + rows, + tag: 'isr_activity_logs', + ), ); ref.onDispose(wrapper.dispose); @@ -209,6 +404,18 @@ final itServiceRequestActionsProvider = onStatusChanged: ref .read(realtimeControllerProvider) .handleChannelStatus, + onOfflineData: () async { + final all = await cachedListFromBrick(); + final filtered = all + .where((a) => a.requestId == requestId) + .toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return filtered; + }, + onCacheMirror: (rows) => mirrorBatchToBrick( + rows, + tag: 'isr_actions', + ), ); ref.onDispose(wrapper.dispose); @@ -222,15 +429,19 @@ final itServiceRequestActionsProvider = final itServiceRequestControllerProvider = Provider( (ref) { final client = ref.watch(supabaseClientProvider); - return ItServiceRequestController(client); + return ItServiceRequestController(client, ref); }, ); class ItServiceRequestController { - ItServiceRequestController(this._client); + ItServiceRequestController(this._client, [this._ref]); final SupabaseClient _client; + final Ref? _ref; /// Creates a new IT Service Request with auto-generated number. + /// + /// Returns `{'id': ..., 'request_number': ...}` on success. If offline, + /// queues locally and returns `{'id': localId, 'request_number': null, 'pending': true}`. Future> createRequest({ required String eventName, required List services, @@ -243,18 +454,22 @@ class ItServiceRequestController { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); + final offlineId = const Uuid().v4(); + final rpcParams = { + 'p_id': offlineId, + 'p_event_name': eventName, + 'p_services': services, + 'p_creator_id': userId, + 'p_office_id': officeId, + 'p_requested_by': requestedBy, + 'p_requested_by_user_id': requestedByUserId, + 'p_status': status, + }; + try { final result = await _client.rpc( 'insert_it_service_request_with_number', - params: { - 'p_event_name': eventName, - 'p_services': services, - 'p_creator_id': userId, - 'p_office_id': officeId, - 'p_requested_by': requestedBy, - 'p_requested_by_user_id': requestedByUserId, - 'p_status': status, - }, + params: rpcParams, ); final row = result is List @@ -269,7 +484,6 @@ class ItServiceRequestController { .eq('id', requestId); } - // Activity log await _client.from('it_service_request_activity_logs').insert({ 'request_id': requestId, 'actor_id': userId, @@ -278,12 +492,36 @@ class ItServiceRequestController { return {'id': requestId, 'request_number': row['request_number']}; } catch (e) { - debugPrint('createRequest error: $e'); - rethrow; + if (!isOfflineSaveError(e)) { + debugPrint('createRequest error: $e'); + rethrow; + } + + final now = DateTime.now().toUtc(); + final local = ItServiceRequest( + id: offlineId, + services: services, + eventName: eventName, + status: status, + outsidePremiseAllowed: false, + createdAt: now, + updatedAt: now, + creatorId: userId, + officeId: officeId, + requestedBy: requestedBy, + requestedByUserId: requestedByUserId, + servicesOther: servicesOther, + ); + + _queuePendingRequest(local, rpcParams); + debugPrint( + '[ItServiceRequestController] createRequest queued offline: $offlineId', + ); + return {'id': offlineId, 'request_number': null, 'pending': true}; } } - /// Updates IT Service Request fields. + /// Updates IT Service Request fields. Offline: queues the field patch. Future updateRequest({ required String requestId, String? eventName, @@ -345,22 +583,26 @@ class ItServiceRequestController { if (updates.isEmpty) return; - await _client - .from('it_service_requests') - .update(updates) - .eq('id', requestId); + try { + await _client + .from('it_service_requests') + .update(updates) + .eq('id', requestId); - // Log updated fields - final userId = _client.auth.currentUser?.id; - await _client.from('it_service_request_activity_logs').insert({ - 'request_id': requestId, - 'actor_id': userId, - 'action_type': 'updated', - 'meta': {'fields': updates.keys.toList()}, - }); + final userId = _client.auth.currentUser?.id; + await _client.from('it_service_request_activity_logs').insert({ + 'request_id': requestId, + 'actor_id': userId, + 'action_type': 'updated', + 'meta': {'fields': updates.keys.toList()}, + }); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queueRequestUpdate(requestId, updates); + } } - /// Update only the status of an IT Service Request. + /// Update only the status of an IT Service Request. Offline: queues. Future updateStatus({ required String requestId, required String status, @@ -383,44 +625,56 @@ class ItServiceRequestController { } } - await _client - .from('it_service_requests') - .update(updates) - .eq('id', requestId); + try { + await _client + .from('it_service_requests') + .update(updates) + .eq('id', requestId); - await _client.from('it_service_request_activity_logs').insert({ - 'request_id': requestId, - 'actor_id': userId, - 'action_type': 'status_changed', - 'meta': {'status': status}, - }); + await _client.from('it_service_request_activity_logs').insert({ + 'request_id': requestId, + 'actor_id': userId, + 'action_type': 'status_changed', + 'meta': {'status': status}, + }); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queueRequestUpdate(requestId, updates); + } } - /// Approve a request (admin only). Sets status to 'scheduled'. + /// Approve a request (admin only). Offline: queues the status patch. Future approveRequest({ required String requestId, required String approverName, }) async { final userId = _client.auth.currentUser?.id; - await _client - .from('it_service_requests') - .update({ - 'status': 'scheduled', - 'approved_by': approverName, - 'approved_by_user_id': userId, - 'approved_at': DateTime.now().toUtc().toIso8601String(), - }) - .eq('id', requestId); + final updates = { + 'status': 'scheduled', + 'approved_by': approverName, + 'approved_by_user_id': userId, + 'approved_at': DateTime.now().toUtc().toIso8601String(), + }; - await _client.from('it_service_request_activity_logs').insert({ - 'request_id': requestId, - 'actor_id': userId, - 'action_type': 'approved', - }); + try { + await _client + .from('it_service_requests') + .update(updates) + .eq('id', requestId); + + await _client.from('it_service_request_activity_logs').insert({ + 'request_id': requestId, + 'actor_id': userId, + 'action_type': 'approved', + }); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queueRequestUpdate(requestId, updates); + } } // ----------------------------------------------------------------------- - // Assignment management + // Assignment management (online-only — admin/dispatcher function) // ----------------------------------------------------------------------- Future assignStaff({ @@ -463,7 +717,7 @@ class ItServiceRequestController { } // ----------------------------------------------------------------------- - // Action Taken + // Action Taken — offline-safe // ----------------------------------------------------------------------- Future createOrUpdateAction({ @@ -473,31 +727,44 @@ class ItServiceRequestController { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); - // Check if action already exists for this user - final existing = await _client - .from('it_service_request_actions') - .select('id') - .eq('request_id', requestId) - .eq('user_id', userId) - .maybeSingle(); + final actionId = const Uuid().v4(); - if (existing != null) { - await _client + try { + final existing = await _client .from('it_service_request_actions') - .update({'action_taken': actionTaken}) - .eq('id', existing['id']); - return existing['id'] as String; - } else { - final result = await _client - .from('it_service_request_actions') - .insert({ - 'request_id': requestId, - 'user_id': userId, - 'action_taken': actionTaken, - }) .select('id') - .single(); - return result['id'] as String; + .eq('request_id', requestId) + .eq('user_id', userId) + .maybeSingle(); + + if (existing != null) { + await _client + .from('it_service_request_actions') + .update({'action_taken': actionTaken}) + .eq('id', existing['id']); + return existing['id'] as String; + } else { + final result = await _client + .from('it_service_request_actions') + .insert({ + 'id': actionId, + 'request_id': requestId, + 'user_id': userId, + 'action_taken': actionTaken, + }) + .select('id') + .single(); + return result['id'] as String; + } + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queuePendingAction({ + 'id': actionId, + 'request_id': requestId, + 'user_id': userId, + 'action_taken': actionTaken, + }); + return actionId; } } @@ -550,7 +817,6 @@ class ItServiceRequestController { } Future deleteEvidence({required String evidenceId}) async { - // Get path first final row = await _client .from('it_service_request_evidence') .select('file_path') @@ -630,7 +896,6 @@ class ItServiceRequestController { await _client.storage.from('it_service_attachments').remove([path]); } - /// List evidence attachments for a request from the database. Future>> listEvidence(String requestId) async { final rows = await _client .from('it_service_request_evidence') @@ -651,4 +916,151 @@ class ItServiceRequestController { ) .toList(); } + + // ----------------------------------------------------------------------- + // Queue helpers + // ----------------------------------------------------------------------- + + void _queuePendingRequest( + ItServiceRequest model, + Map rpcParams, + ) { + final ref = _ref; + if (ref == null) return; + + final currentModels = List.from( + ref.read(offlinePendingItServiceRequestsProvider), + ); + currentModels.add(model); + ref.read(offlinePendingItServiceRequestsProvider.notifier).state = + List.unmodifiable(currentModels); + + final currentRaw = List>.from( + ref.read(offlinePendingItServiceRequestRawProvider), + ); + currentRaw.add(rpcParams); + ref.read(offlinePendingItServiceRequestRawProvider.notifier).state = + List.unmodifiable(currentRaw); + + mirrorBatchToBrick([model], tag: 'offline_isr'); + } + + void _queueRequestUpdate(String requestId, Map fields) { + final ref = _ref; + if (ref == null) return; + final current = Map>.from( + ref.read(offlinePendingItServiceRequestUpdatesProvider), + ); + current[requestId] = {...?current[requestId], ...fields}; + ref.read(offlinePendingItServiceRequestUpdatesProvider.notifier).state = + current; + } + + void _queuePendingAction(Map action) { + final ref = _ref; + if (ref == null) return; + final current = List>.from( + ref.read(offlinePendingIsrActionsProvider), + ); + // Replace existing entry for the same request+user if present + final existing = current.indexWhere( + (a) => + a['request_id'] == action['request_id'] && + a['user_id'] == action['user_id'], + ); + if (existing >= 0) { + current[existing] = action; + } else { + current.add(action); + } + ref.read(offlinePendingIsrActionsProvider.notifier).state = + List.unmodifiable(current); + } +} + +// --------------------------------------------------------------------------- +// Field-patch helper (avoids copyWith pattern on a model with 25+ fields) +// --------------------------------------------------------------------------- + +ItServiceRequest _applyUpdateFields( + ItServiceRequest r, + Map f, +) { + DateTime? parseOpt(String key) { + if (!f.containsKey(key)) return null; + final v = f[key]; + return v == null ? null : AppTime.parse(v as String); + } + + return ItServiceRequest( + id: r.id, + requestNumber: r.requestNumber, + services: f.containsKey('services') + ? (f['services'] as List).cast() + : r.services, + servicesOther: f.containsKey('services_other') + ? f['services_other'] as String? + : r.servicesOther, + eventName: (f['event_name'] as String?) ?? r.eventName, + eventDetails: f.containsKey('event_details') + ? f['event_details'] as String? + : r.eventDetails, + eventDate: + f.containsKey('event_date') ? parseOpt('event_date') : r.eventDate, + eventEndDate: f.containsKey('event_end_date') + ? parseOpt('event_end_date') + : r.eventEndDate, + dryRunDate: f.containsKey('dry_run_date') + ? parseOpt('dry_run_date') + : r.dryRunDate, + dryRunEndDate: f.containsKey('dry_run_end_date') + ? parseOpt('dry_run_end_date') + : r.dryRunEndDate, + contactPerson: f.containsKey('contact_person') + ? f['contact_person'] as String? + : r.contactPerson, + contactNumber: f.containsKey('contact_number') + ? f['contact_number'] as String? + : r.contactNumber, + remarks: + f.containsKey('remarks') ? f['remarks'] as String? : r.remarks, + officeId: + f.containsKey('office_id') ? f['office_id'] as String? : r.officeId, + requestedBy: f.containsKey('requested_by') + ? f['requested_by'] as String? + : r.requestedBy, + requestedByUserId: f.containsKey('requested_by_user_id') + ? f['requested_by_user_id'] as String? + : r.requestedByUserId, + approvedBy: f.containsKey('approved_by') + ? f['approved_by'] as String? + : r.approvedBy, + approvedByUserId: f.containsKey('approved_by_user_id') + ? f['approved_by_user_id'] as String? + : r.approvedByUserId, + approvedAt: f.containsKey('approved_at') + ? parseOpt('approved_at') + : r.approvedAt, + status: (f['status'] as String?) ?? r.status, + outsidePremiseAllowed: + (f['outside_premise_allowed'] as bool?) ?? r.outsidePremiseAllowed, + cancellationReason: f.containsKey('cancellation_reason') + ? f['cancellation_reason'] as String? + : r.cancellationReason, + cancelledAt: f.containsKey('cancelled_at') + ? parseOpt('cancelled_at') + : r.cancelledAt, + creatorId: r.creatorId, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + completedAt: f.containsKey('completed_at') + ? parseOpt('completed_at') + : r.completedAt, + dateTimeReceived: f.containsKey('date_time_received') + ? parseOpt('date_time_received') + : r.dateTimeReceived, + dateTimeChecked: f.containsKey('date_time_checked') + ? parseOpt('date_time_checked') + : r.dateTimeChecked, + ); } diff --git a/lib/providers/leave_provider.dart b/lib/providers/leave_provider.dart index 7752d80e..9e954281 100644 --- a/lib/providers/leave_provider.dart +++ b/lib/providers/leave_provider.dart @@ -1,20 +1,42 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:uuid/uuid.dart'; +import '../brick/cache_helpers.dart'; import '../models/leave_of_absence.dart'; +import '../utils/app_time.dart'; +import '../utils/snackbar.dart' show isOfflineSaveError; +import 'connectivity_provider.dart'; import 'profile_provider.dart'; import 'supabase_provider.dart'; import 'stream_recovery.dart'; import 'realtime_controller.dart'; +// --------------------------------------------------------------------------- +// Offline-pending state +// --------------------------------------------------------------------------- + +/// Leaves filed offline that haven't been confirmed by Supabase yet. Merged +/// into [leavesProvider] so the UI shows them immediately. Cleared as the +/// realtime stream confirms each id. +final offlinePendingLeavesProvider = + StateProvider>((ref) => const []); + +/// Status changes (approve/reject/cancel) made offline, keyed by leave id. +/// On reconnect the listener PATCHes each entry and clears the map. +final offlinePendingLeaveUpdatesProvider = + StateProvider>>((ref) => const {}); + +// --------------------------------------------------------------------------- +// Read provider +// --------------------------------------------------------------------------- + /// All visible leaves (own for standard, all for admin/dispatcher/it_staff). /// /// Consumers should **not** treat every record as an active absence; the UI /// layers (dashboard, logbook) explicitly filter to `status == 'approved'` and -/// verify the leave overlaps the current time. This prevents rejected or -/// pending applications from inadvertently influencing schedules or status -/// computations. +/// verify the leave overlaps the current time. final leavesProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); final profileAsync = ref.watch(currentProfileProvider); @@ -27,6 +49,40 @@ final leavesProvider = StreamProvider>((ref) { profile.role == 'dispatcher' || profile.role == 'it_staff'; + // Watch pending state — provider rebuilds when offline writes arrive so the + // closed-over `pending*` values feed into `onOfflineData` synchronously. + final pendingNew = ref.watch(offlinePendingLeavesProvider); + final pendingUpdates = ref.watch(offlinePendingLeaveUpdatesProvider); + + List applyPending(List rows) { + final byId = {for (final r in rows) r.id: r}; + // Merge offline status updates onto existing leaves + pendingUpdates.forEach((id, fields) { + final existing = byId[id]; + if (existing == null) return; + byId[id] = LeaveOfAbsence( + id: existing.id, + userId: existing.userId, + leaveType: existing.leaveType, + justification: existing.justification, + startTime: existing.startTime, + endTime: existing.endTime, + status: (fields['status'] as String?) ?? existing.status, + filedBy: existing.filedBy, + createdAt: existing.createdAt, + ); + }); + // Append offline-created leaves not yet on the server + for (final p in pendingNew) { + if (hasFullAccess || p.userId == profile.id) { + byId.putIfAbsent(p.id, () => p); + } + } + final merged = byId.values.toList() + ..sort((a, b) => b.startTime.compareTo(a.startTime)); + return merged; + } + final wrapper = StreamRecoveryWrapper( stream: hasFullAccess ? client @@ -50,24 +106,113 @@ final leavesProvider = StreamProvider>((ref) { fromMap: LeaveOfAbsence.fromMap, channelName: 'leave_of_absence', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + onOfflineData: () async { + final all = await cachedListFromBrick(); + final filtered = hasFullAccess + ? all + : all.where((l) => l.userId == profile.id).toList(); + return applyPending(filtered); + }, + onCacheMirror: (rows) => + mirrorBatchToBrick(rows, tag: 'leave_of_absence'), ); ref.onDispose(wrapper.dispose); - return wrapper.stream.map((result) => result.data); + + // Reconnect-replay: directly POST queued items, bypassing Brick's queue + // which can block on stale entries (504, deadlock, etc.). + ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { + if (wasOnline == true || !isNowOnline) return; + + // 1. Replay offline-created leaves + final pending = List.from(ref.read(offlinePendingLeavesProvider)); + if (pending.isNotEmpty) { + debugPrint('[leavesProvider] reconnected — syncing ${pending.length} leave(s)'); + final synced = []; + for (final leave in pending) { + try { + await client.from('leave_of_absence').insert({ + 'id': leave.id, + 'user_id': leave.userId, + 'leave_type': leave.leaveType, + 'justification': leave.justification, + 'start_time': leave.startTime.toIso8601String(), + 'end_time': leave.endTime.toIso8601String(), + 'status': leave.status, + 'filed_by': leave.filedBy, + }); + synced.add(leave.id); + } catch (e) { + final msg = e.toString(); + if (msg.contains('23505') || msg.contains('duplicate key')) { + synced.add(leave.id); // already in DB — drop pending entry + } else { + debugPrint('[leavesProvider] failed to sync leave id=${leave.id}: $e'); + } + } + } + if (synced.isNotEmpty) { + final remaining = ref.read(offlinePendingLeavesProvider) + .where((l) => !synced.contains(l.id)) + .toList(); + ref.read(offlinePendingLeavesProvider.notifier).state = + List.unmodifiable(remaining); + } + } + + // 2. Replay offline status updates + final pendingMap = Map>.from( + ref.read(offlinePendingLeaveUpdatesProvider), + ); + if (pendingMap.isNotEmpty) { + debugPrint( + '[leavesProvider] reconnected — syncing ${pendingMap.length} status edit(s)', + ); + final syncedIds = []; + for (final entry in pendingMap.entries) { + try { + await client + .from('leave_of_absence') + .update(entry.value) + .eq('id', entry.key); + syncedIds.add(entry.key); + } catch (e) { + debugPrint('[leavesProvider] failed to sync edit id=${entry.key}: $e'); + } + } + if (syncedIds.isNotEmpty) { + final remaining = Map>.from( + ref.read(offlinePendingLeaveUpdatesProvider), + ); + for (final id in syncedIds) { + remaining.remove(id); + } + ref.read(offlinePendingLeaveUpdatesProvider.notifier).state = remaining; + } + } + }); + + return wrapper.stream.map((result) => applyPending(result.data)); }); +// --------------------------------------------------------------------------- +// Controller +// --------------------------------------------------------------------------- + final leaveControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); - return LeaveController(client); + return LeaveController(client, ref); }); class LeaveController { - LeaveController(this._client); + LeaveController(this._client, [this._ref]); final SupabaseClient _client; + final Ref? _ref; /// File a leave of absence for the current user. /// Caller controls auto-approval based on role policy. + /// Offline: queues a local row + raw payload, returns silently. Future fileLeave({ required String leaveType, required String justification, @@ -76,43 +221,90 @@ class LeaveController { required bool autoApprove, }) async { final uid = _client.auth.currentUser!.id; + final id = const Uuid().v4(); + final status = autoApprove ? 'approved' : 'pending'; final payload = { + 'id': id, 'user_id': uid, 'leave_type': leaveType, 'justification': justification, 'start_time': startTime.toIso8601String(), 'end_time': endTime.toIso8601String(), - 'status': autoApprove ? 'approved' : 'pending', + 'status': status, 'filed_by': uid, }; - final insertedRaw = await _client - .from('leave_of_absence') - .insert(payload) - .select() - .maybeSingle(); - final Map? inserted = insertedRaw is Map - ? insertedRaw - : null; + Map? inserted; + try { + final insertedRaw = await _client + .from('leave_of_absence') + .insert(payload) + .select() + .maybeSingle(); + inserted = insertedRaw is Map ? insertedRaw : null; + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queuePendingLeave( + LeaveOfAbsence( + id: id, + userId: uid, + leaveType: leaveType, + justification: justification, + startTime: startTime, + endTime: endTime, + status: status, + filedBy: uid, + createdAt: AppTime.now(), + ), + ); + return; // Don't try to notify admins — we're offline. + } // If this was filed as pending, notify admins for approval - final status = payload['status'] as String; if (status != 'pending') return; + await _notifyAdminsOfFiling(inserted: inserted, leaveId: id, actorId: uid); + } + void _queuePendingLeave(LeaveOfAbsence leave) { + final ref = _ref; + if (ref == null) return; + final current = List.from( + ref.read(offlinePendingLeavesProvider), + ); + current.add(leave); + ref.read(offlinePendingLeavesProvider.notifier).state = + List.unmodifiable(current); + mirrorBatchToBrick([leave], tag: 'offline_leave'); + } + + void _queueLeaveStatusUpdate(String leaveId, Map fields) { + final ref = _ref; + if (ref == null) return; + final current = Map>.from( + ref.read(offlinePendingLeaveUpdatesProvider), + ); + current[leaveId] = {...?current[leaveId], ...fields}; + ref.read(offlinePendingLeaveUpdatesProvider.notifier).state = current; + } + + Future _notifyAdminsOfFiling({ + required Map? inserted, + required String leaveId, + required String actorId, + }) async { try { final adminIds = await _fetchRoleUserIds( roles: const ['admin'], - excludeUserId: uid, + excludeUserId: actorId, ); if (adminIds.isEmpty) return; - // Resolve actor display name for nicer push text String actorName = 'Someone'; try { final p = await _client .from('profiles') .select('full_name,display_name,name') - .eq('id', uid) + .eq('id', actorId) .maybeSingle(); if (p != null) { if (p['full_name'] != null) { @@ -125,32 +317,24 @@ class LeaveController { } } catch (_) {} - final leaveId = (inserted ?? {})['id']?.toString() ?? ''; final title = 'Leave Filed for Approval'; final body = '$actorName filed a leave request that requires approval.'; - final notificationId = (inserted ?? {})['id'] - ?.toString(); + final notificationId = (inserted ?? {})['id']?.toString(); final dataPayload = { 'type': 'leave_filed', 'leave_id': leaveId, - ...?(notificationId != null - ? {'notification_id': notificationId} - : null), + ...?(notificationId != null ? {'notification_id': notificationId} : null), }; - await _client - .from('notifications') - .insert( + await _client.from('notifications').insert( adminIds - .map( - (userId) => { - 'user_id': userId, - 'actor_id': uid, - 'type': 'leave_filed', - 'leave_id': leaveId, - }, - ) + .map((userId) => { + 'user_id': userId, + 'actor_id': actorId, + 'type': 'leave_filed', + 'leave_id': leaveId, + }) .toList(), ); @@ -175,14 +359,21 @@ class LeaveController { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); - // Update status first; then notify the requester. - await _client - .from('leave_of_absence') - .update({'status': 'approved'}) - .eq('id', leaveId); + try { + await _client + .from('leave_of_absence') + .update({'status': 'approved'}) + .eq('id', leaveId); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queueLeaveStatusUpdate(leaveId, {'status': 'approved'}); + return; + } - // Notify requestor - await _notifyRequester(leaveId: leaveId, actorId: userId, approved: true); + // Notify requestor (best-effort) + try { + await _notifyRequester(leaveId: leaveId, actorId: userId, approved: true); + } catch (_) {} } /// Reject a leave request. @@ -190,13 +381,20 @@ class LeaveController { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); - await _client - .from('leave_of_absence') - .update({'status': 'rejected'}) - .eq('id', leaveId); + try { + await _client + .from('leave_of_absence') + .update({'status': 'rejected'}) + .eq('id', leaveId); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queueLeaveStatusUpdate(leaveId, {'status': 'rejected'}); + return; + } - // Notify requestor - await _notifyRequester(leaveId: leaveId, actorId: userId, approved: false); + try { + await _notifyRequester(leaveId: leaveId, actorId: userId, approved: false); + } catch (_) {} } Future _notifyRequester({ @@ -266,10 +464,15 @@ class LeaveController { /// Cancel an approved leave. Future cancelLeave(String leaveId) async { - await _client - .from('leave_of_absence') - .update({'status': 'cancelled'}) - .eq('id', leaveId); + try { + await _client + .from('leave_of_absence') + .update({'status': 'cancelled'}) + .eq('id', leaveId); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queueLeaveStatusUpdate(leaveId, {'status': 'cancelled'}); + } } Future> _fetchRoleUserIds({ diff --git a/lib/providers/notifications_provider.dart b/lib/providers/notifications_provider.dart index 8d56a688..6e4404ab 100644 --- a/lib/providers/notifications_provider.dart +++ b/lib/providers/notifications_provider.dart @@ -3,20 +3,65 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../utils/device_id.dart'; +import '../brick/cache_helpers.dart'; import '../models/notification_item.dart'; +import '../utils/app_time.dart'; +import '../utils/snackbar.dart' show isOfflineSaveError; +import 'connectivity_provider.dart'; import 'profile_provider.dart'; import 'supabase_provider.dart'; import 'stream_recovery.dart'; import 'realtime_controller.dart'; -import '../utils/app_time.dart'; + +// --------------------------------------------------------------------------- +// Offline-pending state +// --------------------------------------------------------------------------- + +/// IDs of notifications marked as read offline (optimistic update). +/// On reconnect, batch-patched and cleared. +final offlinePendingNotificationReadsProvider = + StateProvider>((ref) => const {}); + +/// Batch read-mark operations queued offline. +/// Each entry: {'filter_type': 'ticket'|'task', 'filter_id': ..., 'user_id': ...} +final offlinePendingBatchNotificationReadsProvider = + StateProvider>>((ref) => const []); + +// --------------------------------------------------------------------------- +// Read provider +// --------------------------------------------------------------------------- final notificationsProvider = StreamProvider>((ref) { final userId = ref.watch(currentUserIdProvider); - if (userId == null) { - return const Stream.empty(); - } + if (userId == null) return const Stream.empty(); final client = ref.watch(supabaseClientProvider); + final pendingReadIds = ref.watch(offlinePendingNotificationReadsProvider); + + List applyPending(List rows) { + if (pendingReadIds.isEmpty) return rows; + return rows.map((n) { + if (pendingReadIds.contains(n.id) && n.readAt == null) { + return NotificationItem( + id: n.id, + userId: n.userId, + actorId: n.actorId, + ticketId: n.ticketId, + taskId: n.taskId, + leaveId: n.leaveId, + passSlipId: n.passSlipId, + itServiceRequestId: n.itServiceRequestId, + announcementId: n.announcementId, + messageId: n.messageId, + type: n.type, + createdAt: n.createdAt, + readAt: AppTime.now(), + ); + } + return n; + }).toList(); + } + final wrapper = StreamRecoveryWrapper( stream: client .from('notifications') @@ -34,10 +79,87 @@ final notificationsProvider = StreamProvider>((ref) { fromMap: NotificationItem.fromMap, channelName: 'notifications', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + onOfflineData: () async { + final all = await cachedListFromBrick(); + final filtered = all.where((n) => n.userId == userId).toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return applyPending(filtered); + }, + onCacheMirror: (rows) => + mirrorBatchToBrick(rows, tag: 'notifications'), ); ref.onDispose(wrapper.dispose); - return wrapper.stream.map((result) => result.data); + + // Reconnect-replay: batch-patch read status for queued marks. + ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { + if (wasOnline == true || !isNowOnline) return; + + // 1. Batch-mark individual notification reads + final pendingIds = + Set.from(ref.read(offlinePendingNotificationReadsProvider)); + if (pendingIds.isNotEmpty) { + debugPrint( + '[notificationsProvider] reconnected — marking ${pendingIds.length} notification(s) read', + ); + try { + await client + .from('notifications') + .update({'read_at': AppTime.nowUtc().toIso8601String()}) + .inFilter('id', pendingIds.toList()) + .filter('read_at', 'is', null); + ref.read(offlinePendingNotificationReadsProvider.notifier).state = + const {}; + } catch (e) { + debugPrint( + '[notificationsProvider] failed to sync pending reads: $e', + ); + } + } + + // 2. Replay batch read-marks for tickets and tasks + final pendingBatch = List>.from( + ref.read(offlinePendingBatchNotificationReadsProvider), + ); + if (pendingBatch.isNotEmpty) { + debugPrint( + '[notificationsProvider] reconnected — syncing ${pendingBatch.length} batch read(s)', + ); + final synced = []; + for (var i = 0; i < pendingBatch.length; i++) { + final entry = pendingBatch[i]; + final filterType = entry['filter_type'] as String; + final filterId = entry['filter_id'] as String; + final uid = entry['user_id'] as String; + try { + final query = client + .from('notifications') + .update({'read_at': AppTime.nowUtc().toIso8601String()}) + .eq('user_id', uid) + .filter('read_at', 'is', null); + if (filterType == 'ticket') { + await query.eq('ticket_id', filterId); + } else { + await query.eq('task_id', filterId); + } + synced.add(i); + } catch (e) { + debugPrint( + '[notificationsProvider] failed to sync batch read entry $i: $e', + ); + } + } + if (synced.isNotEmpty) { + final remaining = pendingBatch + .whereIndexed((i, _) => !synced.contains(i)) + .toList(); + ref.read(offlinePendingBatchNotificationReadsProvider.notifier).state = + List.unmodifiable(remaining); + } + } + }); + + return wrapper.stream.map((result) => applyPending(result.data)); }); final unreadNotificationsCountProvider = Provider((ref) { @@ -52,18 +174,15 @@ final notificationsControllerProvider = Provider(( ref, ) { final client = ref.watch(supabaseClientProvider); - return NotificationsController(client); + return NotificationsController(client, ref); }); class NotificationsController { - NotificationsController(this._client); + NotificationsController(this._client, [this._ref]); final SupabaseClient _client; + final Ref? _ref; - /// Internal helper that inserts notification rows and sends pushes if - /// [targetUserIds] is provided. - /// Internal helper that inserts notification rows and optionally sends - /// FCM pushes. Callers should use [createNotification] instead. Future _createAndPush( List> rows, { List? targetUserIds, @@ -77,9 +196,6 @@ class NotificationsController { ); await _client.from('notifications').insert(rows); - // If target user IDs are provided, invoke client-side push - // flow by calling `sendPush`. We no longer rely on the DB trigger - // to call the edge function. if (targetUserIds == null || targetUserIds.isEmpty) return; debugPrint('notification rows inserted; invoking client push'); @@ -95,11 +211,6 @@ class NotificationsController { } } - /// Create a typed notification in the database. This method handles - /// inserting the row(s); a PostgreSQL trigger will forward the new row to - /// the `send_fcm` edge function, so clients do **not** directly invoke it - /// (avoids CORS/auth problems on web). The [pushTitle]/[pushBody] values - /// are still stored for the trigger payload. Future createNotification({ required List userIds, required String type, @@ -132,7 +243,6 @@ class NotificationsController { ); } - /// Convenience for mention-specific case; left for compatibility. Future createMentionNotifications({ required List userIds, required String actorId, @@ -158,36 +268,55 @@ class NotificationsController { ); } + /// Mark a single notification as read. Offline: queues locally. Future markRead(String id) async { - await _client - .from('notifications') - .update({'read_at': AppTime.nowUtc().toIso8601String()}) - .eq('id', id); + try { + await _client + .from('notifications') + .update({'read_at': AppTime.nowUtc().toIso8601String()}) + .eq('id', id); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queuePendingRead(id); + } } + /// Mark all unread notifications for a ticket as read. Offline: queues. Future markReadForTicket(String ticketId) async { final userId = _client.auth.currentUser?.id; if (userId == null) return; - await _client - .from('notifications') - .update({'read_at': AppTime.nowUtc().toIso8601String()}) - .eq('ticket_id', ticketId) - .eq('user_id', userId) - .filter('read_at', 'is', null); + try { + await _client + .from('notifications') + .update({'read_at': AppTime.nowUtc().toIso8601String()}) + .eq('ticket_id', ticketId) + .eq('user_id', userId) + .filter('read_at', 'is', null); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queuePendingBatchRead( + filterType: 'ticket', filterId: ticketId, userId: userId); + } } + /// Mark all unread notifications for a task as read. Offline: queues. Future markReadForTask(String taskId) async { final userId = _client.auth.currentUser?.id; if (userId == null) return; - await _client - .from('notifications') - .update({'read_at': AppTime.nowUtc().toIso8601String()}) - .eq('task_id', taskId) - .eq('user_id', userId) - .filter('read_at', 'is', null); + try { + await _client + .from('notifications') + .update({'read_at': AppTime.nowUtc().toIso8601String()}) + .eq('task_id', taskId) + .eq('user_id', userId) + .filter('read_at', 'is', null); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queuePendingBatchRead( + filterType: 'task', filterId: taskId, userId: userId); + } } - /// Store or update an FCM token for the current user. Future registerFcmToken(String token) async { final userId = _client.auth.currentUser?.id; if (userId == null) return; @@ -196,8 +325,6 @@ class NotificationsController { debugPrint('registerFcmToken: device id missing; skipping'); return; } - // upsert using a unique constraint on (user_id, device_id) so a single - // device keeps its token updated without overwriting other devices. final payload = { 'user_id': userId, 'device_id': deviceId, @@ -207,19 +334,16 @@ class NotificationsController { final res = await _client.from('fcm_tokens').upsert(payload); final dyn = res as dynamic; if (dyn.error != null) { - // duplicate key or RLS issue - just log it debugPrint('registerFcmToken error: ${dyn.error?.message ?? dyn.error}'); } else { debugPrint('registerFcmToken success for user=$userId token=$token'); } } - /// Remove an FCM token (e.g. when the user logs out or uninstalls). Future unregisterFcmToken(String token) async { final userId = _client.auth.currentUser?.id; if (userId == null) return; final deviceId = await DeviceId.getId(); - // Prefer to delete by device_id to avoid removing other devices' tokens. final res = await _client .from('fcm_tokens') .delete() @@ -234,7 +358,6 @@ class NotificationsController { } } - /// Send a push message via the `send_fcm` edge function. Future sendPush({ List? tokens, List? userIds, @@ -270,4 +393,52 @@ class NotificationsController { debugPrint('sendPush invocation error: $err'); } } + + // ----------------------------------------------------------------------- + // Queue helpers + // ----------------------------------------------------------------------- + + void _queuePendingRead(String id) { + final ref = _ref; + if (ref == null) return; + final current = Set.from( + ref.read(offlinePendingNotificationReadsProvider), + ); + current.add(id); + ref.read(offlinePendingNotificationReadsProvider.notifier).state = + Set.unmodifiable(current); + } + + void _queuePendingBatchRead({ + required String filterType, + required String filterId, + required String userId, + }) { + final ref = _ref; + if (ref == null) return; + final current = List>.from( + ref.read(offlinePendingBatchNotificationReadsProvider), + ); + current.add({ + 'filter_type': filterType, + 'filter_id': filterId, + 'user_id': userId, + }); + ref.read(offlinePendingBatchNotificationReadsProvider.notifier).state = + List.unmodifiable(current); + } +} + +// --------------------------------------------------------------------------- +// Extension helper for indexed iteration +// --------------------------------------------------------------------------- + +extension _IndexedWhere on List { + List whereIndexed(bool Function(int index, T item) test) { + final result = []; + for (var i = 0; i < length; i++) { + if (test(i, this[i])) result.add(this[i]); + } + return result; + } } diff --git a/lib/providers/pass_slip_provider.dart b/lib/providers/pass_slip_provider.dart index 03a1c6c1..8fc8ba64 100644 --- a/lib/providers/pass_slip_provider.dart +++ b/lib/providers/pass_slip_provider.dart @@ -1,13 +1,34 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:uuid/uuid.dart'; +import '../brick/cache_helpers.dart'; import '../models/pass_slip.dart'; +import '../utils/app_time.dart'; +import '../utils/snackbar.dart' show isOfflineSaveError; +import 'connectivity_provider.dart'; import 'profile_provider.dart'; import 'supabase_provider.dart'; import 'stream_recovery.dart'; import 'realtime_controller.dart'; +// --------------------------------------------------------------------------- +// Offline-pending state +// --------------------------------------------------------------------------- + +/// Pass slips filed offline that haven't been confirmed by Supabase yet. +final offlinePendingPassSlipsProvider = + StateProvider>((ref) => const []); + +/// Status edits (approve/reject/complete) made offline, keyed by slip id. +final offlinePendingPassSlipUpdatesProvider = + StateProvider>>((ref) => const {}); + +// --------------------------------------------------------------------------- +// Read provider +// --------------------------------------------------------------------------- + /// All visible pass slips (own for staff, all for admin/dispatcher). final passSlipsProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); @@ -18,6 +39,50 @@ final passSlipsProvider = StreamProvider>((ref) { final isAdmin = profile.role == 'admin' || profile.role == 'dispatcher'; final hasFullAccess = isAdmin || profile.role == 'programmer'; + final pendingNew = ref.watch(offlinePendingPassSlipsProvider); + final pendingUpdates = ref.watch(offlinePendingPassSlipUpdatesProvider); + + List applyPending(List rows) { + final byId = {for (final r in rows) r.id: r}; + pendingUpdates.forEach((id, fields) { + final existing = byId[id]; + if (existing == null) return; + byId[id] = PassSlip( + id: existing.id, + userId: existing.userId, + dutyScheduleId: existing.dutyScheduleId, + reason: existing.reason, + status: (fields['status'] as String?) ?? existing.status, + requestedAt: existing.requestedAt, + approvedBy: (fields['approved_by'] as String?) ?? existing.approvedBy, + approvedAt: fields.containsKey('approved_at') + ? (fields['approved_at'] is String + ? AppTime.parse(fields['approved_at'] as String) + : existing.approvedAt) + : existing.approvedAt, + slipStart: fields.containsKey('slip_start') + ? (fields['slip_start'] is String + ? AppTime.parse(fields['slip_start'] as String) + : existing.slipStart) + : existing.slipStart, + slipEnd: fields.containsKey('slip_end') + ? (fields['slip_end'] is String + ? AppTime.parse(fields['slip_end'] as String) + : existing.slipEnd) + : existing.slipEnd, + requestedStart: existing.requestedStart, + ); + }); + for (final p in pendingNew) { + if (hasFullAccess || p.userId == profile.id) { + byId.putIfAbsent(p.id, () => p); + } + } + final merged = byId.values.toList() + ..sort((a, b) => b.requestedAt.compareTo(a.requestedAt)); + return merged; + } + final wrapper = StreamRecoveryWrapper( stream: hasFullAccess ? client @@ -41,10 +106,90 @@ final passSlipsProvider = StreamProvider>((ref) { fromMap: PassSlip.fromMap, channelName: 'pass_slips', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + onOfflineData: () async { + final all = await cachedListFromBrick(); + final filtered = hasFullAccess + ? all + : all.where((s) => s.userId == profile.id).toList(); + return applyPending(filtered); + }, + onCacheMirror: (rows) => + mirrorBatchToBrick(rows, tag: 'pass_slips'), ); ref.onDispose(wrapper.dispose); - return wrapper.stream.map((result) => result.data); + + // Reconnect-replay + ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { + if (wasOnline == true || !isNowOnline) return; + + final pending = List.from(ref.read(offlinePendingPassSlipsProvider)); + if (pending.isNotEmpty) { + debugPrint('[passSlipsProvider] reconnected — syncing ${pending.length} slip(s)'); + final synced = []; + for (final slip in pending) { + try { + await client.from('pass_slips').insert({ + 'id': slip.id, + 'user_id': slip.userId, + 'duty_schedule_id': slip.dutyScheduleId, + 'reason': slip.reason, + 'status': slip.status, + 'requested_at': slip.requestedAt.toIso8601String(), + if (slip.requestedStart != null) + 'requested_start': slip.requestedStart!.toIso8601String(), + }); + synced.add(slip.id); + } catch (e) { + final msg = e.toString(); + if (msg.contains('23505') || msg.contains('duplicate key')) { + synced.add(slip.id); + } else { + debugPrint('[passSlipsProvider] failed to sync slip id=${slip.id}: $e'); + } + } + } + if (synced.isNotEmpty) { + final remaining = ref.read(offlinePendingPassSlipsProvider) + .where((s) => !synced.contains(s.id)) + .toList(); + ref.read(offlinePendingPassSlipsProvider.notifier).state = + List.unmodifiable(remaining); + } + } + + final pendingMap = Map>.from( + ref.read(offlinePendingPassSlipUpdatesProvider), + ); + if (pendingMap.isNotEmpty) { + debugPrint( + '[passSlipsProvider] reconnected — syncing ${pendingMap.length} status edit(s)', + ); + final syncedIds = []; + for (final entry in pendingMap.entries) { + try { + await client + .from('pass_slips') + .update(entry.value) + .eq('id', entry.key); + syncedIds.add(entry.key); + } catch (e) { + debugPrint('[passSlipsProvider] failed to sync edit id=${entry.key}: $e'); + } + } + if (syncedIds.isNotEmpty) { + final remaining = Map>.from( + ref.read(offlinePendingPassSlipUpdatesProvider), + ); + for (final id in syncedIds) { + remaining.remove(id); + } + ref.read(offlinePendingPassSlipUpdatesProvider.notifier).state = remaining; + } + } + }); + + return wrapper.stream.map((result) => applyPending(result.data)); }); /// Currently active pass slip for the logged-in user (approved, not completed). @@ -65,15 +210,42 @@ final activePassSlipsProvider = Provider>((ref) { return slips.where((s) => s.isActive).toList(); }); +// --------------------------------------------------------------------------- +// Controller +// --------------------------------------------------------------------------- + final passSlipControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); - return PassSlipController(client); + return PassSlipController(client, ref); }); class PassSlipController { - PassSlipController(this._client); + PassSlipController(this._client, [this._ref]); final SupabaseClient _client; + final Ref? _ref; + + void _queuePendingSlip(PassSlip slip) { + final ref = _ref; + if (ref == null) return; + final current = List.from( + ref.read(offlinePendingPassSlipsProvider), + ); + current.add(slip); + ref.read(offlinePendingPassSlipsProvider.notifier).state = + List.unmodifiable(current); + mirrorBatchToBrick([slip], tag: 'offline_pass_slip'); + } + + void _queueSlipStatusUpdate(String slipId, Map fields) { + final ref = _ref; + if (ref == null) return; + final current = Map>.from( + ref.read(offlinePendingPassSlipUpdatesProvider), + ); + current[slipId] = {...?current[slipId], ...fields}; + ref.read(offlinePendingPassSlipUpdatesProvider.notifier).state = current; + } Future requestSlip({ required String dutyScheduleId, @@ -82,26 +254,42 @@ class PassSlipController { }) async { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); + final id = const Uuid().v4(); + final now = DateTime.now().toUtc(); final payload = { + 'id': id, 'user_id': userId, 'duty_schedule_id': dutyScheduleId, 'reason': reason, 'status': 'pending', - 'requested_at': DateTime.now().toUtc().toIso8601String(), + 'requested_at': now.toIso8601String(), if (requestedStart != null) 'requested_start': requestedStart.toUtc().toIso8601String(), }; - final insertedRaw = await _client - .from('pass_slips') - .insert(payload) - .select() - .maybeSingle(); - final Map? inserted = insertedRaw is Map - ? insertedRaw - : null; + Map? inserted; + try { + final insertedRaw = await _client + .from('pass_slips') + .insert(payload) + .select() + .maybeSingle(); + inserted = insertedRaw is Map ? insertedRaw : null; + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queuePendingSlip(PassSlip( + id: id, + userId: userId, + dutyScheduleId: dutyScheduleId, + reason: reason, + status: 'pending', + requestedAt: AppTime.toAppTime(now), + requestedStart: requestedStart, + )); + return; + } - // Notify admins for approval + // Notify admins for approval (best-effort) try { final adminIds = await _fetchRoleUserIds( roles: const ['admin'], @@ -109,7 +297,6 @@ class PassSlipController { ); if (adminIds.isEmpty) return; - // Resolve actor display name for nice push text String actorName = 'Someone'; try { final p = await _client @@ -128,32 +315,25 @@ class PassSlipController { } } catch (_) {} - final slipId = (inserted ?? {})['id']?.toString() ?? ''; + final slipId = (inserted ?? {})['id']?.toString() ?? id; final title = 'Pass Slip Filed for Approval'; final body = '$actorName filed a pass slip that requires approval.'; - final notificationId = (inserted ?? {})['id'] - ?.toString(); + final notificationId = (inserted ?? {})['id']?.toString(); final dataPayload = { 'type': 'pass_slip_filed', 'pass_slip_id': slipId, - ...?(notificationId != null - ? {'notification_id': notificationId} - : null), + ...?(notificationId != null ? {'notification_id': notificationId} : null), }; - await _client - .from('notifications') - .insert( + await _client.from('notifications').insert( adminIds - .map( - (adminId) => { - 'user_id': adminId, - 'actor_id': userId, - 'type': 'pass_slip_filed', - 'pass_slip_id': slipId, - }, - ) + .map((adminId) => { + 'user_id': adminId, + 'actor_id': userId, + 'type': 'pass_slip_filed', + 'pass_slip_id': slipId, + }) .toList(), ); @@ -169,7 +349,6 @@ class PassSlipController { debugPrint('pass slip send_fcm result: $res'); } catch (e) { debugPrint('pass slip send_fcm error: $e'); - // Non-fatal: keep slip request working even if send_fcm fails } } @@ -177,49 +356,67 @@ class PassSlipController { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); - // Determine slip start time based on requested_start final nowUtc = DateTime.now().toUtc(); - String slipStartIso = nowUtc.toIso8601String(); - final row = await _client - .from('pass_slips') - .select('requested_start') - .eq('id', slipId) - .maybeSingle(); - if (row != null && row['requested_start'] != null) { - final requestedStart = DateTime.parse(row['requested_start'] as String); - if (requestedStart.isAfter(nowUtc)) { - slipStartIso = requestedStart.toIso8601String(); + // Try to determine slip start time online; if offline use now. + String slipStartIso = nowUtc.toIso8601String(); + try { + final row = await _client + .from('pass_slips') + .select('requested_start') + .eq('id', slipId) + .maybeSingle(); + if (row != null && row['requested_start'] != null) { + final requestedStart = DateTime.parse(row['requested_start'] as String); + if (requestedStart.isAfter(nowUtc)) { + slipStartIso = requestedStart.toIso8601String(); + } } + } catch (_) { + // Stick with nowUtc. } - await _client - .from('pass_slips') - .update({ - 'status': 'approved', - 'approved_by': userId, - 'approved_at': nowUtc.toIso8601String(), - 'slip_start': slipStartIso, - }) - .eq('id', slipId); + final fields = { + 'status': 'approved', + 'approved_by': userId, + 'approved_at': nowUtc.toIso8601String(), + 'slip_start': slipStartIso, + }; - await _notifyRequester(slipId: slipId, actorId: userId, approved: true); + try { + await _client.from('pass_slips').update(fields).eq('id', slipId); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queueSlipStatusUpdate(slipId, fields); + return; + } + + try { + await _notifyRequester(slipId: slipId, actorId: userId, approved: true); + } catch (_) {} } Future rejectSlip(String slipId) async { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); - await _client - .from('pass_slips') - .update({ - 'status': 'rejected', - 'approved_by': userId, - 'approved_at': DateTime.now().toUtc().toIso8601String(), - }) - .eq('id', slipId); + final fields = { + 'status': 'rejected', + 'approved_by': userId, + 'approved_at': DateTime.now().toUtc().toIso8601String(), + }; - await _notifyRequester(slipId: slipId, actorId: userId, approved: false); + try { + await _client.from('pass_slips').update(fields).eq('id', slipId); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queueSlipStatusUpdate(slipId, fields); + return; + } + + try { + await _notifyRequester(slipId: slipId, actorId: userId, approved: false); + } catch (_) {} } Future _notifyRequester({ @@ -288,13 +485,16 @@ class PassSlipController { } Future completeSlip(String slipId) async { - await _client - .from('pass_slips') - .update({ - 'status': 'completed', - 'slip_end': DateTime.now().toUtc().toIso8601String(), - }) - .eq('id', slipId); + final fields = { + 'status': 'completed', + 'slip_end': DateTime.now().toUtc().toIso8601String(), + }; + try { + await _client.from('pass_slips').update(fields).eq('id', slipId); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queueSlipStatusUpdate(slipId, fields); + } } Future> _fetchRoleUserIds({ diff --git a/lib/providers/profile_provider.dart b/lib/providers/profile_provider.dart index 638af48d..aa1b5d0f 100644 --- a/lib/providers/profile_provider.dart +++ b/lib/providers/profile_provider.dart @@ -1,14 +1,26 @@ import 'dart:async'; import 'dart:typed_data'; +import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import '../brick/cache_helpers.dart'; import '../models/profile.dart'; +import '../utils/snackbar.dart' show isOfflineSaveError; import 'auth_provider.dart'; +import 'connectivity_provider.dart'; import 'supabase_provider.dart'; import 'stream_recovery.dart'; +// --------------------------------------------------------------------------- +// Offline-pending state +// --------------------------------------------------------------------------- + +/// Pending full-name updates made offline, keyed by userId. +final offlinePendingProfileUpdatesProvider = + StateProvider>((ref) => const {}); + final currentUserIdProvider = Provider((ref) { final authState = ref.watch(authStateChangesProvider); // Be explicit about loading/error to avoid dynamic dispatch problems. @@ -37,9 +49,52 @@ final currentProfileProvider = StreamProvider((ref) { return data == null ? [] : [Profile.fromMap(data)]; }, fromMap: Profile.fromMap, + onOfflineData: () async { + final all = await cachedListFromBrick(); + return all.where((p) => p.id == userId).toList(); + }, + onCacheMirror: (rows) => mirrorBatchToBrick( + rows.whereType().toList(), + tag: 'profile_current', + ), ); ref.onDispose(wrapper.dispose); + + ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { + if (wasOnline == true || !isNowOnline) return; + final pending = Map.from( + ref.read(offlinePendingProfileUpdatesProvider), + ); + if (pending.isEmpty) return; + debugPrint( + '[currentProfileProvider] reconnected — syncing ${pending.length} profile update(s)', + ); + final synced = []; + for (final entry in pending.entries) { + try { + await client + .from('profiles') + .update({'full_name': entry.value}) + .eq('id', entry.key); + synced.add(entry.key); + } catch (e) { + debugPrint( + '[currentProfileProvider] failed to sync full_name for ${entry.key}: $e', + ); + } + } + if (synced.isNotEmpty) { + final remaining = Map.from( + ref.read(offlinePendingProfileUpdatesProvider), + ); + for (final id in synced) { + remaining.remove(id); + } + ref.read(offlinePendingProfileUpdatesProvider.notifier).state = remaining; + } + }); + return wrapper.stream.map((result) { return result.data.isEmpty ? null : result.data.first; }); @@ -58,6 +113,13 @@ final profilesProvider = StreamProvider>((ref) { return data.map(Profile.fromMap).toList(); }, fromMap: Profile.fromMap, + onOfflineData: () async { + final all = await cachedListFromBrick(); + all.sort((a, b) => a.fullName.compareTo(b.fullName)); + return all; + }, + onCacheMirror: (rows) => + mirrorBatchToBrick(rows, tag: 'profiles'), ); ref.onDispose(wrapper.dispose); @@ -67,23 +129,36 @@ final profilesProvider = StreamProvider>((ref) { /// Controller for the current user's profile (update full name / password). final profileControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); - return ProfileController(client); + return ProfileController(client, ref); }); class ProfileController { - ProfileController(this._client); + ProfileController(this._client, [this._ref]); final SupabaseClient _client; + final Ref? _ref; /// Update the `profiles.full_name` for the given user id. + /// Offline: queues the update for replay on reconnect. Future updateFullName({ required String userId, required String fullName, }) async { - await _client - .from('profiles') - .update({'full_name': fullName}) - .eq('id', userId); + try { + await _client + .from('profiles') + .update({'full_name': fullName}) + .eq('id', userId); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + final ref = _ref; + if (ref == null) return; + final current = Map.from( + ref.read(offlinePendingProfileUpdatesProvider), + ); + current[userId] = fullName; + ref.read(offlinePendingProfileUpdatesProvider.notifier).state = current; + } } /// Update the current user's password (works for OAuth users too). diff --git a/lib/providers/realtime_controller.dart b/lib/providers/realtime_controller.dart index f8fe1ce5..64b2156a 100644 --- a/lib/providers/realtime_controller.dart +++ b/lib/providers/realtime_controller.dart @@ -1,19 +1,13 @@ -import 'dart:async'; - import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; - import 'stream_recovery.dart'; -import 'supabase_provider.dart'; final realtimeControllerProvider = ChangeNotifierProvider(( ref, ) { - final client = ref.watch(supabaseClientProvider); // ChangeNotifierProvider automatically disposes the notifier; no need // for ref.onDispose here — adding it causes a double-dispose assertion. - return RealtimeController(client); + return RealtimeController(); }); /// Per-channel realtime controller for UI skeleton indicators. @@ -27,42 +21,12 @@ final realtimeControllerProvider = ChangeNotifierProvider(( /// - Per-channel recovering state for pinpoint skeleton indicators /// - Auth token refreshes for realtime connections class RealtimeController extends ChangeNotifier { - final SupabaseClient _client; bool _disposed = false; /// Channels currently in a recovering/polling/stale state. final Set _recoveringChannels = {}; - StreamSubscription? _authSub; - - RealtimeController(this._client) { - _init(); - } - - void _init() { - try { - _authSub = _client.auth.onAuthStateChange.listen((data) { - final event = data.event; - if (event == AuthChangeEvent.tokenRefreshed) { - _ensureTokenFresh(); - } - }); - } catch (e) { - debugPrint('RealtimeController._init error: $e'); - } - } - - Future _ensureTokenFresh() async { - if (_disposed) return; - try { - final authDynamic = _client.auth as dynamic; - if (authDynamic.refreshSession != null) { - await authDynamic.refreshSession?.call(); - } - } catch (e) { - debugPrint('RealtimeController: token refresh failed: $e'); - } - } + RealtimeController(); // ── Per-channel status ───────────────────────────────────────────────── @@ -97,13 +61,17 @@ class RealtimeController extends ChangeNotifier { /// Convenience callback suitable for [StreamRecoveryWrapper.onStatusChanged]. /// - /// Routes [StreamConnectionStatus] to the appropriate mark method. - /// Both `connected` and `polling` are treated as "recovered" because - /// polling is a functional fallback that still delivers data — the user - /// doesn't need to see a reconnection indicator while data flows via REST. + /// Routes [StreamConnectionStatus] to the appropriate mark method: + /// - `connected` — live stream; no indicator needed. + /// - `polling` — REST fallback; data is fresh, no skeleton needed. + /// - `offline` — device has no internet; Brick cache is active. The offline + /// banner already communicates this state, so the reconnection skeleton + /// should NOT show — it would be misleading while real data is on screen. + /// - Everything else (`recovering`, `stale`, `failed`) — show skeleton. void handleChannelStatus(String channel, StreamConnectionStatus status) { if (status == StreamConnectionStatus.connected || - status == StreamConnectionStatus.polling) { + status == StreamConnectionStatus.polling || + status == StreamConnectionStatus.offline) { markChannelRecovered(channel); } else { markChannelRecovering(channel); @@ -126,7 +94,6 @@ class RealtimeController extends ChangeNotifier { @override void dispose() { _disposed = true; - _authSub?.cancel(); super.dispose(); } } diff --git a/lib/providers/services_provider.dart b/lib/providers/services_provider.dart index 27c9d1ed..58146000 100644 --- a/lib/providers/services_provider.dart +++ b/lib/providers/services_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../brick/cache_helpers.dart'; import '../models/service.dart'; import 'supabase_provider.dart'; import 'stream_recovery.dart'; @@ -17,6 +18,13 @@ final servicesProvider = StreamProvider>((ref) { fromMap: Service.fromMap, channelName: 'services', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + onOfflineData: () async { + final all = await cachedListFromBrick(); + all.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + return all; + }, + onCacheMirror: (rows) => + mirrorBatchToBrick(rows, tag: 'services'), ); ref.onDispose(wrapper.dispose); diff --git a/lib/providers/stream_recovery.dart b/lib/providers/stream_recovery.dart index 046df943..38d7b1f6 100644 --- a/lib/providers/stream_recovery.dart +++ b/lib/providers/stream_recovery.dart @@ -18,6 +18,12 @@ enum StreamConnectionStatus { /// Fatal error; stream will not recover without manual intervention. failed, + + /// Device is offline. The realtime subscription is paused; Brick's local + /// SQLite cache is the active data source. No reconnection is attempted + /// until [notifyOnlineRestored] is called. The UI should show the offline + /// banner but NOT a skeleton/loading indicator. + offline, } /// Represents the result of a polling attempt. @@ -70,7 +76,19 @@ typedef ChannelStatusCallback = /// and connection status tracking. Provides graceful degradation when the /// realtime connection fails. /// +/// ## Offline behaviour +/// +/// Recovery is **suppressed** when the device is offline (as reported by +/// [setIsOnlineCallback]). In that state the wrapper marks the channel as +/// `recovering` but does **not** schedule timers or fire polls — Brick's +/// offline queue handles local data until connectivity returns. +/// +/// When the device comes back online, call [notifyAllOnlineRestored] (done +/// automatically by `connectivity_provider._onReconnected`) to restart every +/// live wrapper whose stream is not already connected. +/// /// Error handling: +/// - **Offline**: recovery suppressed; restarts on [notifyAllOnlineRestored]. /// - **Timeout**: detected and handled internally with exponential backoff. /// - **ChannelRateLimitReached**: detected and handled with a longer minimum /// delay (5 s) before retrying. During recovery, a REST poll keeps data @@ -92,6 +110,39 @@ typedef ChannelStatusCallback = /// ); /// ``` class StreamRecoveryWrapper { + // ── Static online check ─────────────────────────────────────────────────── + + /// Returns whether the device currently has a working internet connection. + /// + /// Defaults to `() => true` (recovery enabled) until the connectivity + /// provider overrides it via [setIsOnlineCallback]. + static bool Function() _globalIsOnline = () => true; + + /// Configure the global online check. Call this once from + /// `connectivityMonitorProvider` so all wrappers suppress recovery while + /// offline without needing per-instance wiring. + static void setIsOnlineCallback(bool Function() fn) { + _globalIsOnline = fn; + } + + // ── Static registry (WeakReference so GC can collect disposed wrappers) ── + + static final List> _instances = []; + + /// Notify every live wrapper that connectivity has been restored. + /// + /// Wrappers whose stream is already [StreamConnectionStatus.connected] are + /// skipped. All others call [retry] to restart the Supabase realtime + /// subscription. Called by `connectivity_provider._onReconnected`. + static void notifyAllOnlineRestored() { + _instances.removeWhere((r) => r.target == null); + for (final ref in List.of(_instances)) { + ref.target?.notifyOnlineRestored(); + } + } + + // ───────────────────────────────────────────────────────────────────────── + final Stream>> _realtimeStream; final Future> Function() _onPollData; final T Function(Map) _fromMap; @@ -105,6 +156,17 @@ class StreamRecoveryWrapper { /// skeleton indicators in the UI. final ChannelStatusCallback? _onStatusChanged; + /// Optional callback to fetch data from the local offline cache (e.g. Brick + /// SQLite) when the device is offline. Called once when offline is first + /// detected to populate the stream so the UI shows cached data instead of + /// an empty list. If null, an empty offline result is emitted. + final Future> Function()? _onOfflineData; + + /// Optional callback invoked with each batch of live data so the provider + /// can mirror it into a local cache (e.g. Brick SQLite). Fires for every + /// realtime emission. Errors are caught and logged; mirroring is best-effort. + final void Function(List rows)? _onCacheMirror; + StreamConnectionStatus _connectionStatus = StreamConnectionStatus.connected; int _recoveryAttempts = 0; Timer? _pollingTimer; @@ -115,6 +177,11 @@ class StreamRecoveryWrapper { bool _disposed = false; bool _listening = false; + /// True once live data has been emitted at least once. Used to distinguish + /// "offline at startup (never received data)" from "reconnecting after + /// having had data" for the race-condition fix. + bool _hasEmittedData = false; + StreamRecoveryWrapper({ required Stream>> stream, required Future> Function() onPollData, @@ -122,11 +189,21 @@ class StreamRecoveryWrapper { StreamRecoveryConfig config = const StreamRecoveryConfig(), this.channelName = 'unknown', ChannelStatusCallback? onStatusChanged, + Future> Function()? onOfflineData, + void Function(List rows)? onCacheMirror, }) : _realtimeStream = stream, _onPollData = onPollData, _fromMap = fromMap, _config = config, - _onStatusChanged = onStatusChanged; + _onStatusChanged = onStatusChanged, + _onOfflineData = onOfflineData, + _onCacheMirror = onCacheMirror { + // Register in the static registry so [notifyAllOnlineRestored] can reach + // this instance without requiring call-site changes. + _instances.add(WeakReference(this)); + // Prune any dead references (disposed wrappers) to keep the list small. + _instances.removeWhere((r) => r.target == null); + } /// The wrapped stream that emits recovery results with metadata. /// @@ -161,6 +238,10 @@ class StreamRecoveryWrapper { void _onRealtimeData(List> rows) { if (_disposed) return; + // Mark that live data has been received at least once. This is used in + // the offline race-condition check in [_onRealtimeError]. + _hasEmittedData = true; + // When recovering, don't reset _recoveryAttempts immediately. // Supabase streams emit an initial REST fetch before the realtime // channel is established. If the channel keeps failing, resetting @@ -183,9 +264,22 @@ class StreamRecoveryWrapper { } _setStatus(StreamConnectionStatus.connected); + final converted = rows.map(_fromMap).toList(); + + // Mirror live data into the local cache so cold-launching offline still + // shows recent state. Best-effort — never throws into the consumer. + final mirror = _onCacheMirror; + if (mirror != null) { + try { + mirror(converted); + } catch (e) { + debugPrint('StreamRecoveryWrapper[$channelName]: mirror failed: $e'); + } + } + _emit( StreamRecoveryResult( - data: rows.map(_fromMap).toList(), + data: converted, connectionStatus: StreamConnectionStatus.connected, isStale: false, ), @@ -198,6 +292,37 @@ class StreamRecoveryWrapper { // Cancel any stability timer — the connection is not stable. _stabilityTimer?.cancel(); + // Determine whether this error means the device is offline. + // + // Two cases handled: + // 1. Connectivity monitor already updated → _globalIsOnline() is false. + // 2. Race condition at startup: the realtime stream fails (SocketException) + // BEFORE ConnectivityMonitor.check() completes (it takes up to 5 s). + // In this window _globalIsOnline() still returns the default `true` + // but the device is actually offline. We detect this by requiring: + // (a) the error is a raw network connectivity failure, AND + // (b) no live data has been received yet on this wrapper. + // If data was already flowing and then dropped, we follow normal + // recovery so the user gets their data back when connectivity + // restores. + final isOffline = !_globalIsOnline() || + (!_hasEmittedData && _isNetworkConnectivityError(error)); + + if (isOffline) { + debugPrint( + 'StreamRecoveryWrapper[$channelName]: offline — suppressing recovery', + ); + _setStatus(StreamConnectionStatus.offline); + // Emit offline data so the StreamProvider exits AsyncLoading state. + // Without this emission the provider stays in loading indefinitely when + // offline at startup, causing the skeleton shimmer to show even though + // Brick's SQLite cache has data. + if (!_hasEmittedData) { + unawaited(_emitOfflineData()); + } + return; + } + final isRateLimit = _isRateLimitError(error); final isTimeout = _isTimeoutError(error); final tag = isRateLimit @@ -260,6 +385,21 @@ class StreamRecoveryWrapper { void _onRealtimeDone() { if (_disposed) return; debugPrint('StreamRecoveryWrapper[$channelName]: stream completed'); + + // If offline, the channel closed because the network dropped. Suppress + // the restart — [notifyOnlineRestored] will handle it when we're back. + if (!_globalIsOnline()) { + debugPrint( + 'StreamRecoveryWrapper[$channelName]: offline — suppressing stream restart', + ); + _setStatus(StreamConnectionStatus.offline); + // Emit offline data to unblock any StreamProvider still in AsyncLoading. + if (!_hasEmittedData) { + unawaited(_emitOfflineData()); + } + return; + } + // Attempt to reconnect once if the stream closes unexpectedly. if (_recoveryAttempts < _config.maxRecoveryAttempts) { _onRealtimeError(StateError('realtime stream completed unexpectedly')); @@ -282,8 +422,23 @@ class StreamRecoveryWrapper { Future _pollOnce() async { if (_disposed) return; + // Don't poll when offline — it will fail and mislead the status tracker. + if (!_globalIsOnline()) return; try { final data = await _onPollData(); + + // Mirror polled data into the local cache too. + final mirror = _onCacheMirror; + if (mirror != null) { + try { + mirror(data); + } catch (e) { + debugPrint( + 'StreamRecoveryWrapper[$channelName]: poll mirror failed: $e', + ); + } + } + _emit( StreamRecoveryResult( data: data, @@ -330,6 +485,58 @@ class StreamRecoveryWrapper { // ── Helpers ─────────────────────────────────────────────────────────── + /// Returns true when [error] is a raw network-connectivity failure + /// (socket closed, no route to host, DNS failure, etc.) rather than an + /// application-level error from the server. + /// + /// Used in the startup race-condition fix: if the realtime stream errors + /// with a connectivity failure before the connectivity monitor has updated + /// [_globalIsOnline], we treat it as offline so the device shows cached + /// data instead of entering the recovery/shimmer loop. + static bool _isNetworkConnectivityError(Object error) { + final msg = error.toString().toLowerCase(); + return msg.contains('socketexception') || + msg.contains('connection refused') || + msg.contains('network is unreachable') || + msg.contains('no route to host') || + msg.contains('failed host lookup') || + msg.contains('connection reset') || + msg.contains('broken pipe') || + msg.contains('connection timed out') || + (msg.contains('clientexception') && msg.contains('network')) || + msg.contains('errno = 111') || // ECONNREFUSED + msg.contains('errno = 101'); // ENETUNREACH + } + + /// Fetches data from the offline cache (via [_onOfflineData] callback when + /// provided, otherwise an empty list) and emits it as an + /// [StreamConnectionStatus.offline] result. + /// + /// Called once when the device first goes offline so that consuming + /// [StreamProvider]s exit [AsyncLoading] and the UI shows cached content + /// (or an empty list) instead of the skeleton shimmer. + Future _emitOfflineData() async { + if (_disposed) return; + List cached = const []; + if (_onOfflineData != null) { + try { + cached = await _onOfflineData(); + } catch (e) { + debugPrint( + 'StreamRecoveryWrapper[$channelName]: offline cache read failed: $e', + ); + cached = const []; + } + } + _emit( + StreamRecoveryResult( + data: cached, + connectionStatus: StreamConnectionStatus.offline, + isStale: cached.isNotEmpty, + ), + ); + } + void _emit(StreamRecoveryResult result) { if (!_disposed && _controller != null && !_controller!.isClosed) { _controller!.add(result); @@ -358,6 +565,20 @@ class StreamRecoveryWrapper { _startRealtimeSubscription(); } + /// Called by [notifyAllOnlineRestored] when connectivity is restored. + /// + /// Only restarts the subscription if the channel is not already connected. + /// Wrappers that were mid-backoff-timer while offline resume from scratch + /// rather than continuing an already-stale timer. + void notifyOnlineRestored() { + if (_disposed) return; + if (_connectionStatus == StreamConnectionStatus.connected) return; + debugPrint( + 'StreamRecoveryWrapper[$channelName]: back online — restarting stream', + ); + retry(); + } + /// Clean up all resources and notify the status callback that this /// channel is no longer active, preventing ghost entries in the /// [RealtimeController]'s recovering-channels set. diff --git a/lib/providers/sync_queue_provider.dart b/lib/providers/sync_queue_provider.dart new file mode 100644 index 00000000..00ea69a8 --- /dev/null +++ b/lib/providers/sync_queue_provider.dart @@ -0,0 +1,107 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'announcements_provider.dart'; +import 'attendance_provider.dart'; +import 'chat_provider.dart'; +import 'it_service_request_provider.dart'; +import 'leave_provider.dart'; +import 'notifications_provider.dart'; +import 'pass_slip_provider.dart'; +import 'profile_provider.dart'; +import 'tasks_provider.dart'; +import 'tickets_provider.dart'; +import 'user_offices_provider.dart'; + +/// Total count of items waiting to sync across all modules. +/// Used by the offline banner and reconnect overlay for dynamic messaging. +final pendingSyncCountProvider = Provider((ref) { + // Tasks + final tasks = ref.watch(offlinePendingTasksProvider).length; + final taskUpdates = ref.watch(offlinePendingTaskUpdatesProvider).length; + final messages = ref.watch(offlinePendingMessagesProvider) + .values + .fold(0, (sum, list) => sum + list.length); + final assignments = ref.watch(offlinePendingAssignmentsProvider).length; + final activityLogs = + ref.watch(offlinePendingActivityLogItemsProvider).length; + + // Leave + final leaves = ref.watch(offlinePendingLeavesProvider).length; + final leaveUpdates = ref.watch(offlinePendingLeaveUpdatesProvider).length; + + // Pass slips + final passSlips = ref.watch(offlinePendingPassSlipsProvider).length; + final passSlipUpdates = + ref.watch(offlinePendingPassSlipUpdatesProvider).length; + + // Announcements + final announcements = ref.watch(offlinePendingAnnouncementsProvider).length; + final announcementUpdates = + ref.watch(offlinePendingAnnouncementUpdatesProvider).length; + final announcementComments = + ref.watch(offlinePendingAnnouncementCommentsProvider).length; + + // IT Service Requests + final itRequests = + ref.watch(offlinePendingItServiceRequestsProvider).length; + final itRequestUpdates = + ref.watch(offlinePendingItServiceRequestUpdatesProvider).length; + final isrActions = ref.watch(offlinePendingIsrActionsProvider).length; + + // Attendance + final checkIns = ref.watch(offlinePendingCheckInsProvider).length; + final attendanceUpdates = + ref.watch(offlinePendingAttendanceUpdatesProvider).length; + + // Notifications + final notifReads = + ref.watch(offlinePendingNotificationReadsProvider).length; + final notifBatchReads = + ref.watch(offlinePendingBatchNotificationReadsProvider).length; + + // Profile + final profileUpdates = + ref.watch(offlinePendingProfileUpdatesProvider).length; + + // Tickets + final tickets = ref.watch(offlinePendingTicketsProvider).length; + final ticketUpdates = + ref.watch(offlinePendingTicketUpdatesProvider).length; + + // Offices + final offices = ref.watch(offlinePendingOfficesProvider).length; + + // User offices + final officeOps = ref.watch(offlinePendingUserOfficeOpsProvider).length; + + // Chat + final chatMessages = ref.watch(offlinePendingChatMessagesProvider) + .values + .fold(0, (sum, list) => sum + list.length); + + return tasks + + taskUpdates + + messages + + assignments + + activityLogs + + leaves + + leaveUpdates + + passSlips + + passSlipUpdates + + announcements + + announcementUpdates + + announcementComments + + itRequests + + itRequestUpdates + + isrActions + + checkIns + + attendanceUpdates + + notifReads + + notifBatchReads + + profileUpdates + + tickets + + ticketUpdates + + offices + + officeOps + + chatMessages; +}); diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index cc6383ed..bcd6a8f0 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -2,11 +2,16 @@ 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'; @@ -320,10 +325,227 @@ final tasksProvider = StreamProvider>((ref) { fromMap: Task.fromMap, channelName: 'tasks', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + onOfflineData: () async { + if (!AppRepository.isConfigured) return []; + try { + return await AppRepository.instance.get( + policy: OfflineFirstGetPolicy.localOnly, + ); + } catch (_) { + return []; + } + }, + onCacheMirror: (rows) => mirrorBatchToBrick(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(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>.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)', + ); + } + for (final task in pending) { + try { + final syncPayload = { + '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 (Brick may have synced it first) — safe to + // leave cleanup to the realtime stream listener in tasks_list_screen. + debugPrint( + '[tasksProvider] pending task already in DB id=${task.id}', + ); + } else { + debugPrint( + '[tasksProvider] failed to sync pending task id=${task.id}: $e', + ); + } + } + } + + // ── 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 = []; + for (final entry in updatesForExisting) { + try { + // action_taken may be a string (Quill delta JSON); convert for DB. + final dbPayload = Map.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>.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>>.from( + ref.read(offlinePendingMessagesProvider), + ); + if (pendingMsgs.isNotEmpty) { + debugPrint( + '[tasksProvider] reconnected — syncing messages for ' + '${pendingMsgs.length} task(s)', + ); + final syncedTaskIds = []; + 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>>.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>.from( + ref.read(offlinePendingAssignmentsProvider), + ); + if (pendingAssigns.isNotEmpty) { + debugPrint( + '[tasksProvider] reconnected — syncing assignments for ' + '${pendingAssigns.length} task(s)', + ); + final syncedAssignIds = []; + 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>.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>.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. @@ -348,6 +570,36 @@ final tasksProvider = StreamProvider>((ref) { // 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 cached = await AppRepository.instance.get( + policy: OfflineFirstGetPolicy.localOnly, + ); + 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) + .cast>() + .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. @@ -558,15 +810,78 @@ final tasksQueryProvider = StateProvider((ref) => const TaskQuery()); /// Derived provider that selects a single [Task] by ID from the tasks list. /// -/// Because [Task] implements `==`, this provider only notifies watchers when -/// the specific task's data actually changes — not when unrelated tasks in the -/// list are updated. Use this in detail screens to avoid full-list rebuilds. +/// 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>>((ref) => const {}); + final taskByIdProvider = Provider.family((ref, taskId) { - return ref + 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((ref, taskId) { + return ref.watch(offlinePendingTasksProvider).any((t) => t.id == taskId); }); /// Derived provider that selects a [Task] linked to a given ticket ID. @@ -605,6 +920,15 @@ final taskActivityLogsProvider = StreamProvider.family, 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( stream: client .from('task_activity_logs') @@ -624,26 +948,84 @@ final taskActivityLogsProvider = 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(); + 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( + rows, + tag: 'task_activity_logs', + ), ); ref.onDispose(wrapper.dispose); - return wrapper.stream.map((result) => _dedupeActivityLogs(result.data)); + 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(( ref, ) { final client = ref.watch(supabaseClientProvider); - return TaskAssignmentsController(client); + 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>((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>>>((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>>((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>>((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>((ref) => const []); + final tasksControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); - return TasksController(client); + return TasksController(client, ref); }); class TasksController { - TasksController(this._client); + TasksController(this._client, [this._ref]); // Supabase storage bucket for task action images. Ensure this bucket exists // with public read access. @@ -653,6 +1035,9 @@ class TasksController { // 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 _isTaskCurrentlyPaused(String taskId) async { try { @@ -679,6 +1064,15 @@ class TasksController { } Future 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') @@ -710,6 +1104,15 @@ class TasksController { } Future 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') @@ -773,11 +1176,90 @@ class TasksController { // 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(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.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; @@ -814,7 +1296,40 @@ class TasksController { 'createTask via RPC assigned number=$assignedNumber id=$taskId', ); } catch (e) { - // RPC not available or failed; fallback to client insert with retry + 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(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? insertData; for (var attempt = 0; attempt < maxAttempts; attempt++) { @@ -829,7 +1344,22 @@ class TasksController { 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))); @@ -1179,6 +1709,44 @@ class TasksController { 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 = {'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? taskRow; try { final row = await _client @@ -1403,7 +1971,25 @@ class TasksController { return; } - await _client.from('tasks').update(payload).eq('id', taskId); + 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.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 { @@ -1478,7 +2064,20 @@ class TasksController { if (officeId != null) payload['office_id'] = officeId; if (ticketId != null) payload['ticket_id'] = ticketId; if (payload.isEmpty) return; - await _client.from('tasks').update(payload).eq('id', taskId); + 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 { @@ -1492,6 +2091,68 @@ class TasksController { } 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 payload) { + final ref = _ref; + if (ref == null) return; + final current = Map>.from( + ref.read(offlinePendingTaskUpdatesProvider), + ); + final existing = Map.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>.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 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>.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.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( + [logModel], + tag: 'offline_activity_log', + ); + } + // Auto-assignment logic executed once on creation. Future _autoAssignTask({ required String taskId, @@ -1792,9 +2453,31 @@ class _Candidate { } class TaskAssignmentsController { - TaskAssignmentsController(this._client); + TaskAssignmentsController(this._client, [this._ref]); final SupabaseClient _client; + final Ref? _ref; + + void _trackOfflineAssignment(String taskId, List userIds) { + final ref = _ref; + if (ref == null) return; + final current = Map>.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>.from( + ref.read(offlinePendingAssignmentsProvider), + ); + if (current.remove(taskId) != null) { + ref.read(offlinePendingAssignmentsProvider.notifier).state = current; + } + } Future replaceAssignments({ required String taskId, @@ -1807,15 +2490,93 @@ class TaskAssignmentsController { final toAdd = nextIds.difference(currentIds).toList(); final toRemove = currentIds.difference(nextIds).toList(); - if (toAdd.isNotEmpty) { - final rows = toAdd - .map((userId) => {'task_id': taskId, 'user_id': userId}) - .toList(); - await _client.from('task_assignments').insert(rows); + // 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; + } - // Insert activity log(s) for assignment(s). - try { - final actorId = _client.auth.currentUser?.id; + 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>.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.from( + logRef.read(offlinePendingActivityLogItemsProvider), + ); + currentItems.add(logModel); + logRef.read(offlinePendingActivityLogItemsProvider.notifier).state = + List.unmodifiable(currentItems); + mirrorBatchToBrick( + [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 _safeLogAssignmentChange({ + required String taskId, + required List toAdd, + required List toRemove, + }) async { + try { + final actorId = _client.auth.currentUser?.id; + if (toAdd.isNotEmpty) { final logRows = toAdd .map( (userId) => { @@ -1827,31 +2588,29 @@ class TaskAssignmentsController { ) .toList(); await _insertActivityRows(_client, logRows); - } catch (_) { - // non-fatal } - - await _notifyAssigned(taskId: taskId, ticketId: ticketId, userIds: toAdd); - } - if (toRemove.isNotEmpty) { - await _client - .from('task_assignments') - .delete() - .eq('task_id', taskId) - .inFilter('user_id', toRemove); - - // Record a reassignment event (who removed -> who added) - try { - final actorId = _client.auth.currentUser?.id; + if (toRemove.isNotEmpty) { await _insertActivityRows(_client, { 'task_id': taskId, 'actor_id': actorId, 'action_type': 'reassigned', 'meta': {'from': toRemove, 'to': toAdd}, }); - } catch (_) { - // non-fatal } + } catch (_) { + // non-fatal + } + } + + Future _safeNotifyAssigned({ + required String taskId, + required String? ticketId, + required List userIds, + }) async { + try { + await _notifyAssigned(taskId: taskId, ticketId: ticketId, userIds: userIds); + } catch (_) { + // non-fatal — never let notify errors propagate } } @@ -1979,10 +2738,53 @@ class TaskAssignmentsController { required String taskId, required String userId, }) async { - await _client - .from('task_assignments') - .delete() - .eq('task_id', taskId) - .eq('user_id', userId); + 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>.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 []; + } else { + current[taskId] = updated; + } + ref.read(offlinePendingAssignmentsProvider.notifier).state = current; + } + + void _ensureOfflineReplacementWithoutUser(String taskId) { + final ref = _ref; + if (ref == null) return; + final current = Map>.from( + ref.read(offlinePendingAssignmentsProvider), + ); + if (current.containsKey(taskId)) return; + current[taskId] = const []; + ref.read(offlinePendingAssignmentsProvider.notifier).state = current; } } diff --git a/lib/providers/tickets_provider.dart b/lib/providers/tickets_provider.dart index 6761e00b..c7d90f25 100644 --- a/lib/providers/tickets_provider.dart +++ b/lib/providers/tickets_provider.dart @@ -5,6 +5,11 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; +import 'package:uuid/uuid.dart'; + +import '../brick/cache_helpers.dart'; +import '../brick/repository.dart'; +import '../utils/snackbar.dart' show isOfflineSaveError; import '../models/office.dart'; import '../models/ticket.dart'; import '../models/ticket_message.dart'; @@ -12,11 +17,27 @@ import 'profile_provider.dart'; import 'supabase_provider.dart'; import 'user_offices_provider.dart'; import 'tasks_provider.dart'; +import 'connectivity_provider.dart'; import 'stream_recovery.dart'; import 'realtime_controller.dart'; +/// Pending offices created while offline. +final offlinePendingOfficesProvider = + StateProvider>((ref) => const []); + final officesProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); + final pendingOffices = ref.watch(offlinePendingOfficesProvider); + + List applyPending(List rows) { + if (pendingOffices.isEmpty) return rows; + final byId = {for (final r in rows) r.id: r}; + for (final p in pendingOffices) { + byId.putIfAbsent(p.id, () => p); + } + return byId.values.toList() + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + } final wrapper = StreamRecoveryWrapper( stream: client.from('offices').stream(primaryKey: ['id']).order('name'), @@ -27,10 +48,49 @@ final officesProvider = StreamProvider>((ref) { fromMap: Office.fromMap, channelName: 'offices', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + onOfflineData: () async { + final all = await cachedListFromBrick(); + all.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + return applyPending(all); + }, + onCacheMirror: (rows) => + mirrorBatchToBrick(rows, tag: 'offices'), ); ref.onDispose(wrapper.dispose); - return wrapper.stream.map((result) => result.data); + + // Reconnect-replay for offline-created offices. + ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { + if (wasOnline == true || !isNowOnline) return; + final pending = List.from( + ref.read(offlinePendingOfficesProvider), + ); + if (pending.isEmpty) return; + final synced = []; + for (final office in pending) { + try { + final payload = {'id': office.id, 'name': office.name}; + if (office.serviceId != null) payload['service_id'] = office.serviceId; + await client.from('offices').insert(payload); + synced.add(office.id); + } catch (e) { + final s = e.toString(); + if (s.contains('23505') || s.contains('duplicate key')) { + synced.add(office.id); + } + } + } + if (synced.isNotEmpty) { + final remaining = ref + .read(offlinePendingOfficesProvider) + .where((o) => !synced.contains(o.id)) + .toList(); + ref.read(offlinePendingOfficesProvider.notifier).state = + List.unmodifiable(remaining); + } + }); + + return wrapper.stream.map((result) => applyPending(result.data)); }); final officesOnceProvider = FutureProvider>((ref) async { @@ -70,7 +130,7 @@ final officesQueryProvider = StateProvider( final officesControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); - return OfficesController(client); + return OfficesController(client, ref); }); /// Ticket query parameters for server-side pagination and filtering. @@ -210,10 +270,90 @@ final ticketsProvider = StreamProvider>((ref) { fromMap: Ticket.fromMap, channelName: 'tickets', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + onOfflineData: () => cachedListFromBrick(), + onCacheMirror: (rows) => + mirrorBatchToBrick(rows, tag: 'tickets'), ); ref.onDispose(wrapper.dispose); + // Reconnect-replay: POST queued items directly, bypassing Brick's queue. + ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { + if (wasOnline == true || !isNowOnline) return; + + // 1. Replay offline-created tickets + final pendingRaw = List>.from( + ref.read(offlinePendingTicketRawProvider), + ); + if (pendingRaw.isNotEmpty) { + debugPrint( + '[ticketsProvider] reconnected — syncing ${pendingRaw.length} ticket(s)', + ); + final syncedIds = []; + for (final row in pendingRaw) { + final id = row['id'] as String; + try { + await client.from('tickets').insert(row); + syncedIds.add(id); + } catch (e) { + final msg = e.toString(); + if (msg.contains('23505') || msg.contains('duplicate key')) { + syncedIds.add(id); + } else { + debugPrint('[ticketsProvider] failed to sync ticket id=$id: $e'); + } + } + } + if (syncedIds.isNotEmpty) { + final remainingRaw = ref + .read(offlinePendingTicketRawProvider) + .where((r) => !syncedIds.contains(r['id'] as String)) + .toList(); + ref.read(offlinePendingTicketRawProvider.notifier).state = + List.unmodifiable(remainingRaw); + final remainingModels = ref + .read(offlinePendingTicketsProvider) + .where((t) => !syncedIds.contains(t.id)) + .toList(); + ref.read(offlinePendingTicketsProvider.notifier).state = + List.unmodifiable(remainingModels); + } + } + + // 2. Replay offline ticket updates + final pendingUpdates = Map>.from( + ref.read(offlinePendingTicketUpdatesProvider), + ); + if (pendingUpdates.isNotEmpty) { + debugPrint( + '[ticketsProvider] reconnected — syncing ${pendingUpdates.length} ticket update(s)', + ); + final syncedIds = []; + for (final entry in pendingUpdates.entries) { + try { + await client + .from('tickets') + .update(entry.value) + .eq('id', entry.key); + syncedIds.add(entry.key); + } catch (e) { + debugPrint( + '[ticketsProvider] failed to sync update id=${entry.key}: $e', + ); + } + } + if (syncedIds.isNotEmpty) { + final remaining = Map>.from( + ref.read(offlinePendingTicketUpdatesProvider), + ); + for (final id in syncedIds) { + remaining.remove(id); + } + ref.read(offlinePendingTicketUpdatesProvider.notifier).state = remaining; + } + } + }); + var lastResultHash = ''; Timer? debounceTimer; // broadcast() so Riverpod and any other listener can both receive events. @@ -239,6 +379,35 @@ final ticketsProvider = StreamProvider>((ref) { // prevents a duplicate rebuild if both arrive with identical data. unawaited( Future(() async { + // When offline, seed from Brick local SQLite cache instead of network. + if (!ref.read(isOnlineProvider) && AppRepository.isConfigured) { + try { + final cached = await AppRepository.instance.get( + policy: OfflineFirstGetPolicy.localOnly, + ); + debugPrint('[ticketsProvider] offline seed: ${cached.length} tickets from local cache'); + final payload = _buildTicketPayload( + tickets: cached, + isGlobal: isGlobal, + allowedOfficeIds: allowedOfficeIds, + query: query, + ); + final processed = await compute(_processTicketsInIsolate, payload); + final tickets = (processed as List) + .cast>() + .map(Ticket.fromMap) + .toList(); + final hash = tickets.fold('', (h, t) => '$h${t.id}'); + if (!controller.isClosed && hash != lastResultHash) { + lastResultHash = hash; + controller.add(tickets); + } + } catch (e) { + debugPrint('[ticketsProvider] 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. @@ -506,35 +675,91 @@ final taskMessagesProvider = StreamProvider.family, String>( }, ); +/// Tickets created offline that haven't synced to Supabase yet. +/// Merged into the live stream in the UI — cleared entry-by-entry as the +/// realtime stream delivers each synced UUID. +final offlinePendingTicketsProvider = + StateProvider>((ref) => const []); + +/// Raw INSERT payloads for offline-created tickets — used for reconnect replay. +final offlinePendingTicketRawProvider = + StateProvider>>((ref) => const []); + +/// Field-level updates to existing tickets made offline, keyed by ticket id. +final offlinePendingTicketUpdatesProvider = + StateProvider>>((ref) => const {}); + final ticketsControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); - return TicketsController(client); + return TicketsController(client, ref); }); class TicketsController { - TicketsController(this._client); + TicketsController(this._client, [this._ref]); final SupabaseClient _client; + final Ref? _ref; - Future createTicket({ + /// Creates a ticket. Returns the new [Ticket] so callers can add it to + /// [offlinePendingTicketsProvider] when offline. + Future createTicket({ required String subject, required String description, required String officeId, }) async { final actorId = _client.auth.currentUser?.id; - final data = await _client - .from('tickets') - .insert({ + // 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 id = const Uuid().v4(); + try { + final data = await _client + .from('tickets') + .insert({ + 'id': id, + 'subject': subject, + 'description': description, + 'office_id': officeId, + 'creator_id': actorId, + }) + .select('id') + .single(); + final ticketId = data['id'] as String?; + if (ticketId == null) return null; + unawaited(_notifyCreated(ticketId: ticketId, actorId: actorId)); + return null; // online path — ticket will arrive via realtime stream + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + final ticket = Ticket( + id: id, + subject: subject, + description: description, + officeId: officeId, + creatorId: actorId ?? '', + status: 'pending', + createdAt: DateTime.now().toUtc(), + respondedAt: null, + promotedAt: null, + closedAt: null, + ); + // Queue for reconnect replay and persist locally for cold-launch. + final ref = _ref; + if (ref != null) { + final currentRaw = List>.from( + ref.read(offlinePendingTicketRawProvider), + ); + currentRaw.add({ + 'id': id, 'subject': subject, 'description': description, 'office_id': officeId, - 'creator_id': _client.auth.currentUser?.id, - }) - .select('id') - .single(); - final ticketId = data['id'] as String?; - if (ticketId == null) return; - unawaited(_notifyCreated(ticketId: ticketId, actorId: actorId)); + 'creator_id': actorId, + }); + ref.read(offlinePendingTicketRawProvider.notifier).state = + List.unmodifiable(currentRaw); + } + mirrorBatchToBrick([ticket], tag: 'offline_ticket'); + return ticket; + } } Future _notifyCreated({ @@ -661,27 +886,66 @@ class TicketsController { required String? ticketId, required String content, }) async { + final senderId = _client.auth.currentUser?.id; final payload = { 'task_id': taskId, 'content': content, - 'sender_id': _client.auth.currentUser?.id, + 'sender_id': senderId, }; if (ticketId != null) { payload['ticket_id'] = ticketId; } - final data = await _client - .from('ticket_messages') - .insert(payload) - .select() - .single(); - return TicketMessage.fromMap(data); + try { + final data = await _client + .from('ticket_messages') + .insert(payload) + .select() + .single(); + return TicketMessage.fromMap(data); + } catch (e) { + if (isOfflineSaveError(e)) { + // Store message locally so it appears in the UI immediately and is + // replayed by tasksProvider's reconnect listener when back online. + final ref = _ref; + if (ref != null) { + final createdAt = DateTime.now().toUtc().toIso8601String(); + final msgPayload = Map.from(payload) + ..['created_at'] = createdAt; + final current = Map>>.from( + ref.read(offlinePendingMessagesProvider), + ); + current[taskId] = [...(current[taskId] ?? []), msgPayload]; + ref.read(offlinePendingMessagesProvider.notifier).state = current; + // Return a synthetic TicketMessage so the caller's mention logic + // still has a message object to work with. + return TicketMessage( + id: -DateTime.now().millisecondsSinceEpoch, + ticketId: ticketId, + taskId: taskId, + senderId: senderId, + content: content, + createdAt: DateTime.now(), + ); + } + } + rethrow; + } } Future updateTicketStatus({ required String ticketId, required String status, }) async { - await _client.from('tickets').update({'status': status}).eq('id', ticketId); + try { + await _client + .from('tickets') + .update({'status': status}) + .eq('id', ticketId); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queueTicketUpdate(ticketId, {'status': status}); + return; + } // If ticket is promoted, create a linked Task (only once) — the // TasksController.createTask already runs auto-assignment on creation. @@ -718,6 +982,7 @@ class TicketsController { } /// Update editable ticket fields such as subject, description, and office. + /// Offline: queues the field patch. Future updateTicket({ required String ticketId, String? subject, @@ -730,29 +995,59 @@ class TicketsController { if (officeId != null) payload['office_id'] = officeId; if (payload.isEmpty) return; - await _client.from('tickets').update(payload).eq('id', ticketId); - - // record an activity row for edit operations (best-effort) try { - final actorId = _client.auth.currentUser?.id; - await _client.from('ticket_messages').insert({ - 'ticket_id': ticketId, - 'sender_id': actorId, - 'content': 'Ticket updated', - }); - } catch (_) {} + await _client.from('tickets').update(payload).eq('id', ticketId); + // record an activity row for edit operations (best-effort) + try { + final actorId = _client.auth.currentUser?.id; + await _client.from('ticket_messages').insert({ + 'ticket_id': ticketId, + 'sender_id': actorId, + 'content': 'Ticket updated', + }); + } catch (_) {} + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queueTicketUpdate(ticketId, payload); + } + } + + void _queueTicketUpdate(String ticketId, Map fields) { + final ref = _ref; + if (ref == null) return; + final current = Map>.from( + ref.read(offlinePendingTicketUpdatesProvider), + ); + current[ticketId] = {...?current[ticketId], ...fields}; + ref.read(offlinePendingTicketUpdatesProvider.notifier).state = current; } } class OfficesController { - OfficesController(this._client); + OfficesController(this._client, [this._ref]); final SupabaseClient _client; + final Ref? _ref; Future createOffice({required String name, String? serviceId}) async { - final payload = {'name': name}; + final id = const Uuid().v4(); + final payload = {'id': id, 'name': name}; if (serviceId != null) payload['service_id'] = serviceId; - await _client.from('offices').insert(payload); + try { + await _client.from('offices').insert(payload); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + final ref = _ref; + if (ref == null) return; + final office = Office(id: id, name: name, serviceId: serviceId); + final current = List.from( + ref.read(offlinePendingOfficesProvider), + ); + current.add(office); + ref.read(offlinePendingOfficesProvider.notifier).state = + List.unmodifiable(current); + mirrorBatchToBrick([office], tag: 'offline_offices'); + } } Future updateOffice({ diff --git a/lib/providers/typing_provider.dart b/lib/providers/typing_provider.dart index 31c73262..b1ac89db 100644 --- a/lib/providers/typing_provider.dart +++ b/lib/providers/typing_provider.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'connectivity_provider.dart'; import 'supabase_provider.dart'; class TypingIndicatorState { @@ -37,24 +38,47 @@ final typingIndicatorProvider = String >((ref, ticketId) { final client = ref.watch(supabaseClientProvider); - final controller = TypingIndicatorController(client, ticketId); + final controller = TypingIndicatorController( + client, + ticketId, + isOnline: () => ref.read(isOnlineProvider), + ); + + // Pause the realtime channel when the device goes offline so Supabase's + // internal reconnection loop stops firing WebSocket errors. + // Resume (re-subscribe) when connectivity returns. + ref.listen(isOnlineProvider, (previous, next) { + if (next == false) { + controller.pauseForOffline(); + } else if (previous != true && next == true) { + // previous is null on first call after a provider rebuild — treat + // null and false both as "was offline" to ensure the channel resumes. + controller.resumeFromOffline(); + } + }); + return controller; }); class TypingIndicatorController extends StateNotifier { - TypingIndicatorController(this._client, this._ticketId) - : super( - const TypingIndicatorState( - userIds: {}, - channelStatus: 'init', - lastPayload: {}, - ), - ) { + TypingIndicatorController( + this._client, + this._ticketId, { + bool Function()? isOnline, + }) : _isOnline = isOnline ?? (() => true), + super( + const TypingIndicatorState( + userIds: {}, + channelStatus: 'init', + lastPayload: {}, + ), + ) { _initChannel(); } final SupabaseClient _client; final String _ticketId; + final bool Function() _isOnline; RealtimeChannel? _channel; Timer? _typingTimer; final Map _remoteTimeouts = {}; @@ -94,7 +118,9 @@ class TypingIndicatorController extends StateNotifier { try { if (_disposed || !mounted) return; state = state.copyWith(channelStatus: status.name); - if (error != null) { + if (error != null && _isOnline()) { + // Only log when online — offline WebSocket errors are expected and + // handled by pauseForOffline() which unsubscribes the channel. debugPrint('TypingIndicatorController: subscribe error: $error'); } } catch (e, st) { @@ -235,6 +261,31 @@ class TypingIndicatorController extends StateNotifier { ); } + /// Called when the device goes offline. + /// + /// Unsubscribes the Supabase channel so its internal WebSocket reconnection + /// loop stops firing errors. The channel object is retained so + /// [resumeFromOffline] can cleanly re-subscribe on the same ticket. + void pauseForOffline() { + if (_disposed) return; + debugPrint('TypingIndicatorController[$_ticketId]: offline — unsubscribing'); + _channel?.unsubscribe(); + } + + /// Called when the device comes back online. + /// + /// Tears down the stale channel and opens a fresh subscription so broadcast + /// events are received again without any missed-message gaps. + void resumeFromOffline() { + if (_disposed) return; + debugPrint( + 'TypingIndicatorController[$_ticketId]: back online — resubscribing', + ); + _channel?.unsubscribe(); + _channel = null; + _initChannel(); + } + // Exposed for tests only: simulate a remote typing broadcast. @visibleForTesting void debugSimulateRemoteTyping(String userId, {bool stop = false}) { diff --git a/lib/providers/user_offices_provider.dart b/lib/providers/user_offices_provider.dart index d8161a20..06107402 100644 --- a/lib/providers/user_offices_provider.dart +++ b/lib/providers/user_offices_provider.dart @@ -1,63 +1,186 @@ +import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import '../brick/cache_warmer.dart'; import '../models/user_office.dart'; +import '../utils/snackbar.dart' show isOfflineSaveError; +import 'connectivity_provider.dart'; import 'supabase_provider.dart'; import 'stream_recovery.dart'; import 'realtime_controller.dart'; +// --------------------------------------------------------------------------- +// Offline-pending state +// --------------------------------------------------------------------------- + +/// Queued assign/remove operations made offline. +/// Each entry: {'op': 'assign'|'remove', 'user_id': ..., 'office_id': ...} +final offlinePendingUserOfficeOpsProvider = + StateProvider>>((ref) => const []); + +// --------------------------------------------------------------------------- +// Read provider +// --------------------------------------------------------------------------- + final userOfficesProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); + final pendingOps = ref.watch(offlinePendingUserOfficeOpsProvider); + + List applyPending(List rows) { + if (pendingOps.isEmpty) return rows; + var result = List.from(rows); + for (final op in pendingOps) { + final userId = op['user_id'] as String; + final officeId = op['office_id'] as String; + if (op['op'] == 'assign') { + final exists = result.any( + (r) => r.userId == userId && r.officeId == officeId, + ); + if (!exists) result.add(UserOffice(userId: userId, officeId: officeId)); + } else if (op['op'] == 'remove') { + result.removeWhere( + (r) => r.userId == userId && r.officeId == officeId, + ); + } + } + return result; + } + final wrapper = StreamRecoveryWrapper( stream: client .from('user_offices') .stream(primaryKey: ['user_id', 'office_id']) .order('created_at'), onPollData: () async { - final data = await client - .from('user_offices') - .select() - .order('created_at'); + final data = await client.from('user_offices').select().order('created_at'); return data.map(UserOffice.fromMap).toList(); }, fromMap: UserOffice.fromMap, channelName: 'user_offices', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + onOfflineData: () async { + final rows = await CacheWarmer.readCachedUserOffices(); + return applyPending(rows.map(UserOffice.fromMap).toList()); + }, ); ref.onDispose(wrapper.dispose); - return wrapper.stream.map((result) => result.data); + + // Reconnect-replay + ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { + if (wasOnline == true || !isNowOnline) return; + final pending = List>.from( + ref.read(offlinePendingUserOfficeOpsProvider), + ); + if (pending.isEmpty) return; + debugPrint( + '[userOfficesProvider] reconnected — syncing ${pending.length} office op(s)', + ); + final synced = []; + for (var i = 0; i < pending.length; i++) { + final op = pending[i]; + final userId = op['user_id'] as String; + final officeId = op['office_id'] as String; + try { + if (op['op'] == 'assign') { + await client.from('user_offices').insert({ + 'user_id': userId, + 'office_id': officeId, + }); + } else { + await client + .from('user_offices') + .delete() + .eq('user_id', userId) + .eq('office_id', officeId); + } + synced.add(i); + } catch (e) { + final msg = e.toString(); + if (msg.contains('23505') || msg.contains('duplicate key')) { + synced.add(i); + } else { + debugPrint( + '[userOfficesProvider] failed to sync op $i: $e', + ); + } + } + } + if (synced.isNotEmpty) { + final remaining = >[]; + for (var i = 0; i < pending.length; i++) { + if (!synced.contains(i)) remaining.add(pending[i]); + } + ref.read(offlinePendingUserOfficeOpsProvider.notifier).state = + List.unmodifiable(remaining); + } + }); + + return wrapper.stream.map((result) => applyPending(result.data)); }); +// --------------------------------------------------------------------------- +// Controller +// --------------------------------------------------------------------------- + final userOfficesControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); - return UserOfficesController(client); + return UserOfficesController(client, ref); }); class UserOfficesController { - UserOfficesController(this._client); + UserOfficesController(this._client, [this._ref]); final SupabaseClient _client; + final Ref? _ref; + /// Assign a user to an office. Offline: queues the operation. Future assignUserOffice({ required String userId, required String officeId, }) async { - await _client.from('user_offices').insert({ - 'user_id': userId, - 'office_id': officeId, - }); + try { + await _client.from('user_offices').insert({ + 'user_id': userId, + 'office_id': officeId, + }); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queueOp('assign', userId, officeId); + } } + /// Remove a user from an office. Offline: queues the operation. Future removeUserOffice({ required String userId, required String officeId, }) async { - await _client - .from('user_offices') - .delete() - .eq('user_id', userId) - .eq('office_id', officeId); + try { + await _client + .from('user_offices') + .delete() + .eq('user_id', userId) + .eq('office_id', officeId); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + _queueOp('remove', userId, officeId); + } + } + + void _queueOp(String op, String userId, String officeId) { + final ref = _ref; + if (ref == null) return; + final current = List>.from( + ref.read(offlinePendingUserOfficeOpsProvider), + ); + // Dedupe: collapse redundant assign/remove pairs for the same user+office + current.removeWhere( + (e) => e['user_id'] == userId && e['office_id'] == officeId, + ); + current.add({'op': op, 'user_id': userId, 'office_id': officeId}); + ref.read(offlinePendingUserOfficeOpsProvider.notifier).state = + List.unmodifiable(current); } } diff --git a/lib/providers/workforce_provider.dart b/lib/providers/workforce_provider.dart index b2b4d165..99288390 100644 --- a/lib/providers/workforce_provider.dart +++ b/lib/providers/workforce_provider.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import '../brick/cache_helpers.dart'; import '../models/app_settings.dart'; import '../models/duty_schedule.dart'; import '../models/swap_request.dart'; @@ -52,6 +53,13 @@ final dutySchedulesProvider = StreamProvider>((ref) { fromMap: DutySchedule.fromMap, channelName: 'duty_schedules', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + onOfflineData: () async { + final all = await cachedListFromBrick(); + all.sort((a, b) => a.startTime.compareTo(b.startTime)); + return all; + }, + onCacheMirror: (rows) => + mirrorBatchToBrick(rows, tag: 'duty_schedules'), ); ref.onDispose(wrapper.dispose); @@ -150,6 +158,20 @@ final swapRequestsProvider = StreamProvider>((ref) { fromMap: SwapRequest.fromMap, channelName: 'swap_requests', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + onOfflineData: () async { + final all = await cachedListFromBrick(); + final filtered = isAdmin + ? all + : all + .where( + (r) => r.requesterId == profileId || r.recipientId == profileId, + ) + .toList(); + filtered.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return filtered; + }, + onCacheMirror: (rows) => + mirrorBatchToBrick(rows, tag: 'swap_requests'), ); ref.onDispose(wrapper.dispose); diff --git a/lib/screens/admin/offices_screen.dart b/lib/screens/admin/offices_screen.dart index 666d1ee2..cf460a65 100644 --- a/lib/screens/admin/offices_screen.dart +++ b/lib/screens/admin/offices_screen.dart @@ -258,30 +258,48 @@ class _OfficesScreenState extends ConsumerState { return; } setState(() => saving = true); - final controller = ref.read( - officesControllerProvider, - ); - if (office == null) { - await controller.createOffice( - name: name, - serviceId: selectedServiceId, - ); - } else { - await controller.updateOffice( - id: office.id, - name: name, - serviceId: selectedServiceId, - ); - } - ref.invalidate(officesProvider); - if (context.mounted) { - Navigator.of(dialogContext).pop(); - showSuccessSnackBar( - context, - office == null - ? 'Office "$name" has been created successfully.' - : 'Office "$name" has been updated successfully.', + try { + final controller = ref.read( + officesControllerProvider, ); + if (office == null) { + await controller.createOffice( + name: name, + serviceId: selectedServiceId, + ); + } else { + await controller.updateOffice( + id: office.id, + name: name, + serviceId: selectedServiceId, + ); + } + ref.invalidate(officesProvider); + if (context.mounted) { + Navigator.of(dialogContext).pop(); + showSuccessSnackBar( + context, + office == null + ? 'Office "$name" has been created successfully.' + : 'Office "$name" has been updated successfully.', + ); + } + } catch (e) { + if (!dialogContext.mounted) return; + if (isOfflineSaveError(e)) { + Navigator.of(dialogContext).pop(); + showSuccessSnackBarGlobal( + office == null + ? 'Office "$name" saved offline — will sync when connected.' + : 'Office "$name" update saved offline — will sync when connected.', + ); + } else { + showErrorSnackBar(context, 'Failed to save: $e'); + } + } finally { + if (dialogContext.mounted) { + setState(() => saving = false); + } } }, child: saving diff --git a/lib/screens/announcements/announcement_comments_section.dart b/lib/screens/announcements/announcement_comments_section.dart index e5d90b84..37452098 100644 --- a/lib/screens/announcements/announcement_comments_section.dart +++ b/lib/screens/announcements/announcement_comments_section.dart @@ -57,9 +57,12 @@ class _AnnouncementCommentsSectionState } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to post comment: $e')), - ); + if (isOfflineSaveError(e)) { + _controller.clear(); + showSuccessSnackBar(context, 'Comment saved offline — will sync when connected.'); + } else { + showErrorSnackBar(context, 'Failed to post comment: $e'); + } } } finally { if (mounted) setState(() => _sending = false); diff --git a/lib/screens/announcements/announcements_screen.dart b/lib/screens/announcements/announcements_screen.dart index ca0f187f..5d570477 100644 --- a/lib/screens/announcements/announcements_screen.dart +++ b/lib/screens/announcements/announcements_screen.dart @@ -14,6 +14,7 @@ import '../../widgets/app_page_header.dart'; import '../../widgets/m3_card.dart'; import '../../widgets/profile_avatar.dart'; import '../../widgets/responsive_body.dart'; +import '../../widgets/sync_pending_badge.dart'; import 'announcement_comments_section.dart'; import 'create_announcement_dialog.dart'; @@ -317,6 +318,14 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> { ref.watch(announcementCommentsProvider(widget.announcement.id)); final commentCount = commentsAsync.valueOrNull?.length ?? 0; + // Pending-sync state + final pendingNew = ref + .watch(offlinePendingAnnouncementsProvider) + .any((a) => a.id == widget.announcement.id); + final pendingUpdates = ref.watch(offlinePendingAnnouncementUpdatesProvider); + final isAnnouncementPending = + pendingNew || pendingUpdates.containsKey(widget.announcement.id); + // Rebuild UI when cooldown is active. if (_inCooldown) { Future.delayed(const Duration(milliseconds: 500), () { @@ -428,9 +437,21 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> { // Title + Body Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: Text( - widget.announcement.title, - style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + widget.announcement.title, + style: tt.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + if (isAnnouncementPending) ...[ + const SizedBox(width: 8), + SyncPendingBadge(isPending: true, compact: true), + ], + ], ), ), Padding( diff --git a/lib/screens/announcements/create_announcement_dialog.dart b/lib/screens/announcements/create_announcement_dialog.dart index aacce8ad..0a81fd79 100644 --- a/lib/screens/announcements/create_announcement_dialog.dart +++ b/lib/screens/announcements/create_announcement_dialog.dart @@ -269,7 +269,14 @@ class _CreateAnnouncementContentState ); } } catch (e) { - if (mounted) showErrorSnackBar(context, 'Failed to save: $e'); + if (mounted) { + if (isOfflineSaveError(e)) { + Navigator.of(context).pop(); + showSuccessSnackBarGlobal('Announcement saved offline — will sync when connected.'); + } else { + showErrorSnackBar(context, 'Failed to save: $e'); + } + } } finally { if (mounted) setState(() => _submitting = false); } @@ -537,7 +544,14 @@ class _BannerSettingsContentState if (mounted) Navigator.of(context).pop(); showSuccessSnackBarGlobal('Banner settings saved.'); } catch (e) { - if (mounted) showErrorSnackBar(context, 'Failed to save: $e'); + if (mounted) { + if (isOfflineSaveError(e)) { + Navigator.of(context).pop(); + showSuccessSnackBarGlobal('Banner settings saved offline — will sync when connected.'); + } else { + showErrorSnackBar(context, 'Failed to save: $e'); + } + } } finally { if (mounted) setState(() => _submitting = false); } diff --git a/lib/screens/attendance/attendance_screen.dart b/lib/screens/attendance/attendance_screen.dart index 259de944..972ceff0 100644 --- a/lib/screens/attendance/attendance_screen.dart +++ b/lib/screens/attendance/attendance_screen.dart @@ -41,6 +41,7 @@ import '../../widgets/gemini_button.dart'; import '../../widgets/multi_select_picker.dart'; import '../../widgets/app_page_header.dart'; import '../../widgets/responsive_body.dart'; +import '../../widgets/sync_pending_badge.dart'; class AttendanceScreen extends ConsumerStatefulWidget { const AttendanceScreen({super.key}); @@ -362,6 +363,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { final profile = ref.watch(currentProfileProvider).valueOrNull; final schedulesAsync = ref.watch(dutySchedulesProvider); final logsAsync = ref.watch(attendanceLogsProvider); + final pendingCheckIns = ref.watch(offlinePendingCheckInsProvider); final allowTracking = profile?.allowTracking ?? false; // local state for optimistic switch update. We only trust `_trackingLocal` // while a save is in flight – after that the server-side value (`allowTracking`) @@ -433,8 +435,12 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { // (Note: this override should not prevent the overtime check-in from being shown.) // Show overtime check-in when the user has no schedule today, or their last - // scheduled shift has already ended. + // scheduled shift has already ended. Guard with hasValue so the card never + // flashes during the initial stream loading phase (valueOrNull returns [] + // while loading, making hasScheduleToday false prematurely). final showOvertimeCard = + schedulesAsync.hasValue && + logsAsync.hasValue && (activeOvertimeLog.isEmpty && _overtimeLogId == null) && activeLog.isEmpty && (!hasScheduleToday || hasScheduleEnded); @@ -786,6 +792,15 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { ), ), ), + if (pendingCheckIns.any( + (l) => l.dutyScheduleId == schedule.id, + )) ...[ + SyncPendingBadge( + isPending: true, + compact: true, + ), + const SizedBox(width: 6), + ], _statusChip(context, statusLabel), ], ), @@ -4228,7 +4243,11 @@ class _MyScheduleTabState extends ConsumerState<_MyScheduleTab> { } if (!context.mounted) return; if (!alreadyProcessed) { - showErrorSnackBar(context, 'Failed: $e'); + if (isOfflineSaveError(e)) { + showSuccessSnackBar(context, 'Response saved offline — will sync when connected.'); + } else { + showErrorSnackBar(context, 'Failed: $e'); + } } } finally { // Refresh the swap list — the new provider fires an immediate REST poll @@ -4872,6 +4891,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> { final slipsAsync = ref.watch(passSlipsProvider); final profilesAsync = ref.watch(profilesProvider); final activeSlip = ref.watch(activePassSlipProvider); + final pendingSlips = ref.watch(offlinePendingPassSlipsProvider); final isAdmin = profile?.role == 'admin' || profile?.role == 'dispatcher'; final Map profileById = { @@ -4985,6 +5005,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> { slip, profileById, showActions: true, + isPending: pendingSlips.any((p) => p.id == slip.id), ), ), if (slips.where((s) => s.status == 'pending').isEmpty) @@ -5017,6 +5038,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> { slip, profileById, showActions: false, + isPending: pendingSlips.any((p) => p.id == slip.id), ), ), if (slips.isEmpty) @@ -5044,6 +5066,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> { PassSlip slip, Map profileById, { required bool showActions, + bool isPending = false, }) { final theme = Theme.of(context); final colors = theme.colorScheme; @@ -5073,6 +5096,10 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> { Row( children: [ Expanded(child: Text(name, style: theme.textTheme.titleSmall)), + if (isPending) ...[ + SyncPendingBadge(isPending: true, compact: true), + const SizedBox(width: 8), + ], Container( padding: const EdgeInsets.symmetric( horizontal: 10, @@ -5151,7 +5178,11 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> { } } catch (e) { if (mounted) { - showErrorSnackBar(context, 'Failed: $e'); + if (isOfflineSaveError(e)) { + showSuccessSnackBar(context, 'Approval saved offline — will sync when connected.'); + } else { + showErrorSnackBar(context, 'Failed: $e'); + } } } finally { if (mounted) setState(() => _submitting = false); @@ -5167,7 +5198,11 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> { } } catch (e) { if (mounted) { - showErrorSnackBar(context, 'Failed: $e'); + if (isOfflineSaveError(e)) { + showSuccessSnackBar(context, 'Rejection saved offline — will sync when connected.'); + } else { + showErrorSnackBar(context, 'Failed: $e'); + } } } finally { if (mounted) setState(() => _submitting = false); @@ -5183,7 +5218,11 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> { } } catch (e) { if (mounted) { - showErrorSnackBar(context, 'Failed: $e'); + if (isOfflineSaveError(e)) { + showSuccessSnackBar(context, 'Completion saved offline — will sync when connected.'); + } else { + showErrorSnackBar(context, 'Failed: $e'); + } } } finally { if (mounted) setState(() => _submitting = false); @@ -5212,6 +5251,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> { final profile = ref.watch(currentProfileProvider).valueOrNull; final leavesAsync = ref.watch(leavesProvider); final profilesAsync = ref.watch(profilesProvider); + final pendingLeavesList = ref.watch(offlinePendingLeavesProvider); if (profile == null) { return const Center(child: CircularProgressIndicator()); @@ -5259,6 +5299,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> { leave, profileById, showApproval: true, + isPending: pendingLeavesList.any((l) => l.id == leave.id), ), ), const SizedBox(height: 24), @@ -5292,6 +5333,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> { leave, profileById, showApproval: false, + isPending: pendingLeavesList.any((l) => l.id == leave.id), ), ), @@ -5314,6 +5356,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> { leave, profileById, showApproval: false, + isPending: pendingLeavesList.any((l) => l.id == leave.id), ), ), if (leaves @@ -5343,6 +5386,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> { LeaveOfAbsence leave, Map profileById, { required bool showApproval, + bool isPending = false, }) { final theme = Theme.of(context); final colors = theme.colorScheme; @@ -5372,6 +5416,10 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> { Row( children: [ Expanded(child: Text(name, style: theme.textTheme.titleSmall)), + if (isPending) ...[ + SyncPendingBadge(isPending: true, compact: true), + const SizedBox(width: 8), + ], Container( padding: const EdgeInsets.symmetric( horizontal: 10, @@ -5473,7 +5521,11 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> { } } catch (e) { if (mounted) { - showErrorSnackBar(context, 'Failed: $e'); + if (isOfflineSaveError(e)) { + showSuccessSnackBar(context, 'Approval saved offline — will sync when connected.'); + } else { + showErrorSnackBar(context, 'Failed: $e'); + } } } finally { if (mounted) setState(() => _submitting = false); @@ -5491,7 +5543,11 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> { } } catch (e) { if (mounted) { - showErrorSnackBar(context, 'Failed: $e'); + if (isOfflineSaveError(e)) { + showSuccessSnackBar(context, 'Rejection saved offline — will sync when connected.'); + } else { + showErrorSnackBar(context, 'Failed: $e'); + } } } finally { if (mounted) setState(() => _submitting = false); @@ -5509,7 +5565,11 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> { } } catch (e) { if (mounted) { - showErrorSnackBar(context, 'Failed: $e'); + if (isOfflineSaveError(e)) { + showSuccessSnackBar(context, 'Cancellation saved offline — will sync when connected.'); + } else { + showErrorSnackBar(context, 'Failed: $e'); + } } } finally { if (mounted) setState(() => _submitting = false); @@ -5618,7 +5678,14 @@ class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> { widget.onSubmitted(); } } catch (e) { - if (mounted) showErrorSnackBar(context, 'Failed: $e'); + if (!mounted) return; + if (isOfflineSaveError(e)) { + Navigator.of(context).pop(); + showSuccessSnackBarGlobal('Pass slip request saved offline — will sync when connected.'); + widget.onSubmitted(); + } else { + showErrorSnackBar(context, 'Failed: $e'); + } } finally { if (mounted) setState(() => _submitting = false); } @@ -5893,7 +5960,12 @@ class _FileLeaveDialogState extends ConsumerState<_FileLeaveDialog> { widget.onSubmitted(); } } catch (e) { - if (mounted) { + if (!mounted) return; + if (isOfflineSaveError(e)) { + Navigator.of(context).pop(); + showSuccessSnackBarGlobal('Leave request saved offline — will sync when connected.'); + widget.onSubmitted(); + } else { showErrorSnackBar(context, 'Failed to file leave: $e'); } } finally { diff --git a/lib/screens/it_service_requests/it_service_request_detail_screen.dart b/lib/screens/it_service_requests/it_service_request_detail_screen.dart index 508fe855..3a3d7670 100644 --- a/lib/screens/it_service_requests/it_service_request_detail_screen.dart +++ b/lib/screens/it_service_requests/it_service_request_detail_screen.dart @@ -1335,7 +1335,7 @@ class _ItServiceRequestDetailScreenState _finishSave(success: true); } catch (e) { debugPrint('Save field error: $e'); - _finishSave(success: false); + _finishSave(success: isOfflineSaveError(e)); } } @@ -1350,7 +1350,7 @@ class _ItServiceRequestDetailScreenState _finishSave(success: true); } catch (e) { debugPrint('Save datetime error: $e'); - _finishSave(success: false); + _finishSave(success: isOfflineSaveError(e)); } } @@ -1485,7 +1485,13 @@ class _ItServiceRequestDetailScreenState if (mounted) showSuccessSnackBar(context, 'Text improved successfully'); } catch (e) { - if (mounted) showErrorSnackBar(context, 'Error: $e'); + if (mounted) { + if (isOfflineSaveError(e)) { + showSuccessSnackBar(context, 'Saved offline — will sync when connected.'); + } else { + showErrorSnackBar(context, 'Error: $e'); + } + } } finally { setProcessing(false); } @@ -1525,7 +1531,13 @@ class _ItServiceRequestDetailScreenState if (mounted) showSuccessSnackBar(context, 'Request approved'); } catch (e) { - if (mounted) showErrorSnackBar(context, 'Error: $e'); + if (mounted) { + if (isOfflineSaveError(e)) { + showSuccessSnackBar(context, 'Approval saved offline — will sync when connected.'); + } else { + showErrorSnackBar(context, 'Error: $e'); + } + } } } @@ -1630,7 +1642,13 @@ class _ItServiceRequestDetailScreenState ); } } catch (e) { - if (mounted) showErrorSnackBar(context, 'Error: $e'); + if (mounted) { + if (isOfflineSaveError(e)) { + showSuccessSnackBar(context, 'Status change saved offline — will sync when connected.'); + } else { + showErrorSnackBar(context, 'Error: $e'); + } + } } } diff --git a/lib/screens/it_service_requests/it_service_requests_list_screen.dart b/lib/screens/it_service_requests/it_service_requests_list_screen.dart index 8c300e08..5bb01797 100644 --- a/lib/screens/it_service_requests/it_service_requests_list_screen.dart +++ b/lib/screens/it_service_requests/it_service_requests_list_screen.dart @@ -9,6 +9,7 @@ import '../../models/it_service_request.dart'; import '../../models/it_service_request_assignment.dart'; import '../../models/office.dart'; import '../../models/profile.dart'; +import '../../brick/cache_helpers.dart'; import '../../providers/it_service_request_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/realtime_controller.dart'; @@ -23,6 +24,7 @@ import '../../widgets/app_page_header.dart'; import '../../widgets/app_state_view.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/status_pill.dart'; +import '../../widgets/sync_pending_badge.dart'; class ItServiceRequestsListScreen extends ConsumerStatefulWidget { const ItServiceRequestsListScreen({super.key}); @@ -67,6 +69,26 @@ class _ItServiceRequestsListScreenState @override Widget build(BuildContext context) { final requestsAsync = ref.watch(itServiceRequestsProvider); + final offlinePending = ref.watch(offlinePendingItServiceRequestsProvider); + final pendingUpdates = + ref.watch(offlinePendingItServiceRequestUpdatesProvider); + final isrPendingIds = { + ...offlinePending.map((r) => r.id), + ...pendingUpdates.keys, + }; + + ref.listen>>(itServiceRequestsProvider, (_, next) { + final live = next.valueOrNull; + if (live == null || live.isEmpty) return; + final pending = ref.read(offlinePendingItServiceRequestsProvider); + if (pending.isEmpty) return; + final liveIds = live.map((r) => r.id).toSet(); + final stillPending = pending.where((r) => !liveIds.contains(r.id)).toList(); + if (stillPending.length != pending.length) { + ref.read(offlinePendingItServiceRequestsProvider.notifier).state = stillPending; + } + }); + final profileAsync = ref.watch(currentProfileProvider); final profilesAsync = ref.watch(profilesProvider); final officesAsync = ref.watch(officesProvider); @@ -131,8 +153,12 @@ class _ItServiceRequestsListScreenState ], ); } - final allRequests = + final liveRequests = requestsAsync.valueOrNull ?? []; + final liveIds = liveRequests.map((r) => r.id).toSet(); + final pendingOnly = + offlinePending.where((r) => !liveIds.contains(r.id)).toList(); + final allRequests = [...pendingOnly, ...liveRequests]; if (allRequests.isEmpty) { return const AppEmptyView( icon: Icons.miscellaneous_services_outlined, @@ -277,12 +303,14 @@ class _ItServiceRequestsListScreenState officeById: officeById, profileById: profileById, assignments: assignments, + pendingIds: isrPendingIds, ), _RequestList( requests: _applyFilters(allRequests), officeById: officeById, profileById: profileById, assignments: assignments, + pendingIds: isrPendingIds, ), ], ) @@ -291,6 +319,7 @@ class _ItServiceRequestsListScreenState officeById: officeById, profileById: profileById, assignments: assignments, + pendingIds: isrPendingIds, ), ), ], @@ -427,12 +456,31 @@ class _ItServiceRequestsListScreenState requestedByUserId: profile?.id, status: (profile?.role == 'standard') ? 'pending_approval' : 'draft', ); - if (context.mounted) { + final isOffline = data['pending'] == true; + if (isOffline) { + final localId = data['id'] as String; + final allCached = await cachedListFromBrick(); + final local = allCached.where((r) => r.id == localId).firstOrNull; + if (local != null && context.mounted) { + final current = ref.read(offlinePendingItServiceRequestsProvider); + ref.read(offlinePendingItServiceRequestsProvider.notifier).state = [ + local, + ...current, + ]; + } + if (context.mounted) { + showSuccessSnackBar( + context, + 'Request saved offline — will sync when connected.', + ); + } + } else if (context.mounted) { showSuccessSnackBar(context, 'Request created'); context.go('/it-service-requests/${data['id']}'); } } catch (e) { - if (context.mounted) showErrorSnackBar(context, 'Error: $e'); + if (!context.mounted) return; + showErrorSnackBar(context, 'Error: $e'); } } @@ -600,12 +648,14 @@ class _RequestList extends StatelessWidget { required this.officeById, required this.profileById, required this.assignments, + required this.pendingIds, }); final List requests; final Map officeById; final Map profileById; final List assignments; + final Set pendingIds; @override Widget build(BuildContext context) { @@ -631,6 +681,7 @@ class _RequestList extends StatelessWidget { request: request, officeName: office, assignedStaff: assignedStaff, + isPending: pendingIds.contains(request.id), ), ); }, @@ -643,11 +694,13 @@ class _RequestTile extends StatelessWidget { required this.request, this.officeName, required this.assignedStaff, + this.isPending = false, }); final ItServiceRequest request; final String? officeName; final List assignedStaff; + final bool isPending; @override Widget build(BuildContext context) { @@ -669,11 +722,22 @@ class _RequestTile extends StatelessWidget { ], ), const SizedBox(height: 8), - Text( - request.eventName, - style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600), - maxLines: 2, - overflow: TextOverflow.ellipsis, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + request.eventName, + style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (isPending) ...[ + const SizedBox(width: 8), + SyncPendingBadge(isPending: true, compact: true), + ], + ], ), const SizedBox(height: 4), if (request.services.isNotEmpty) diff --git a/lib/screens/tasks/it_job_checklist_tab.dart b/lib/screens/tasks/it_job_checklist_tab.dart index 0dc0a53d..4b36d38c 100644 --- a/lib/screens/tasks/it_job_checklist_tab.dart +++ b/lib/screens/tasks/it_job_checklist_tab.dart @@ -423,10 +423,15 @@ class _ItJobTileState extends ConsumerState<_ItJobTile> { // The realtime stream may arrive with a slight delay; clearing here // causes a visible revert flash before the stream catches up. } catch (e) { - // Revert optimistic state on failure only if (mounted) { - setState(() => _optimisticChecked = !val); - showErrorSnackBar(context, 'Failed to update: $e'); + if (isOfflineSaveError(e)) { + // Brick queued the write — keep optimistic state, just notify user + showSuccessSnackBar(context, 'Saved offline — will sync when connected.'); + } else { + // Revert optimistic state on real failure + setState(() => _optimisticChecked = !val); + showErrorSnackBar(context, 'Failed to update: $e'); + } } } } diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index 978f9799..c958e4c0 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -34,6 +34,7 @@ import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/status_pill.dart'; import '../../theme/app_surfaces.dart'; +import '../../widgets/sync_pending_badge.dart'; import '../../widgets/task_assignment_section.dart'; import '../../widgets/typing_dots.dart'; import '../../widgets/gemini_button.dart'; @@ -229,6 +230,25 @@ class _TaskDetailScreenState extends ConsumerState child: Center(child: Text('Task not found.')), ); } + + // When the live stream delivers this task (i.e. it just synced from offline), + // remove it from the pending list. This is a safety-net duplicate of the same + // listener in tasks_list_screen; it fires even when the list screen is not + // in the widget tree (e.g. deep-linked directly to this route). + ref.listen>>(tasksProvider, (_, next) { + final live = next.valueOrNull; + if (live == null) return; + final pending = ref.read(offlinePendingTasksProvider); + if (pending.isEmpty) return; + final liveIds = live.map((t) => t.id).toSet(); + final stillPending = + pending.where((t) => !liveIds.contains(t.id)).toList(); + if (stillPending.length != pending.length) { + ref.read(offlinePendingTasksProvider.notifier).state = stillPending; + } + }); + + final isPending = ref.watch(isTaskPendingProvider(task.id)); final ticketId = task.ticketId; final typingChannelId = task.id; final ticket = ticketId == null @@ -248,7 +268,21 @@ class _TaskDetailScreenState extends ConsumerState orElse: () => false, ); final showAssign = canAssign && task.status != 'completed'; - final assignments = assignmentsAsync.valueOrNull ?? []; + final liveAssignments = assignmentsAsync.valueOrNull ?? []; + final pendingAssignUserIds = + ref.watch(offlinePendingAssignmentsProvider)[task.id]; + final assignments = pendingAssignUserIds != null + ? [ + ...liveAssignments.where((a) => a.taskId != task.id), + ...pendingAssignUserIds.map( + (uid) => TaskAssignment( + taskId: task.id, + userId: uid, + createdAt: DateTime.now(), + ), + ), + ] + : liveAssignments; final profileById = { for (final profile in profilesAsync.valueOrNull ?? []) profile.id: profile, @@ -276,9 +310,25 @@ class _TaskDetailScreenState extends ConsumerState final typingState = ref.watch(typingIndicatorProvider(typingChannelId)); final canSendMessages = task.status != 'completed'; + final rawPendingMsgs = + ref.watch(offlinePendingMessagesProvider)[widget.taskId] ?? []; + final pendingOfflineMessages = List.generate(rawPendingMsgs.length, (i) { + final payload = rawPendingMsgs[i]; + return TicketMessage( + id: -(i + 1), + ticketId: payload['ticket_id'] as String?, + taskId: payload['task_id'] as String?, + senderId: payload['sender_id'] as String?, + content: payload['content'] as String? ?? '', + createdAt: payload['created_at'] != null + ? DateTime.tryParse(payload['created_at'] as String) ?? DateTime.now() + : DateTime.now(), + ); + }); final messagesAsync = _mergeMessages( taskMessagesAsync, ticketId == null ? null : ref.watch(ticketMessagesProvider(ticketId)), + pendingOffline: pendingOfflineMessages, ); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -404,6 +454,10 @@ class _TaskDetailScreenState extends ConsumerState final detailsContent = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (isPending) ...[ + const _TaskPendingBanner(), + const SizedBox(height: 12), + ], Center( child: Row( mainAxisSize: MainAxisSize.min, @@ -418,6 +472,7 @@ class _TaskDetailScreenState extends ConsumerState ?.copyWith(fontWeight: FontWeight.w700), ), ), + SyncPendingBadge(isPending: isPending, compact: true), const SizedBox(width: 8), Builder( builder: (ctx) { @@ -470,7 +525,9 @@ class _TaskDetailScreenState extends ConsumerState _MetaBadge(label: 'Office', value: officeName), _MetaBadge( label: 'Task #', - value: task.taskNumber ?? task.id, + value: isPending && task.taskNumber == null + ? 'Pending' + : (task.taskNumber ?? task.id), isMono: true, ), ], @@ -3495,10 +3552,23 @@ class _TaskDetailScreenState extends ConsumerState AsyncValue> _mergeMessages( AsyncValue> taskMessages, - AsyncValue>? ticketMessages, - ) { + AsyncValue>? ticketMessages, { + List pendingOffline = const [], + }) { + void addPending(Map byId) { + for (final m in pendingOffline) { + byId[m.id] = m; + } + } + if (ticketMessages == null) { - return taskMessages; + return taskMessages.whenData((msgs) { + if (pendingOffline.isEmpty) return msgs; + final byId = {for (final m in msgs) m.id: m}; + addPending(byId); + return byId.values.toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + }); } return taskMessages.when( data: (taskData) => ticketMessages.when( @@ -3507,6 +3577,7 @@ class _TaskDetailScreenState extends ConsumerState for (final message in taskData) message.id: message, for (final message in ticketData) message.id: message, }; + addPending(byId); final merged = byId.values.toList() ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); return AsyncValue.data(merged); @@ -4030,10 +4101,20 @@ class _TaskDetailScreenState extends ConsumerState showSuccessSnackBar(context, 'Task updated'); } catch (e) { if (!mounted) return; - showErrorSnackBar( - context, - 'Failed to update task: $e', - ); + if (isOfflineSaveError(e)) { + if (dialogContext.mounted) { + Navigator.of(dialogContext).pop(); + } + showSuccessSnackBar( + context, + 'Task update saved offline — will sync when connected.', + ); + } else { + showErrorSnackBar( + context, + 'Failed to update task: $e', + ); + } } finally { if (dialogContext.mounted) { setDialogState(() => saving = false); @@ -4274,7 +4355,17 @@ class _TaskDetailScreenState extends ConsumerState } } catch (e) { if (context.mounted) { - showErrorSnackBar(context, e.toString()); + if (isOfflineSaveError(e)) { + if (dialogContext.mounted) { + Navigator.of(dialogContext).pop(); + } + showSuccessSnackBar( + context, + 'Task cancellation saved offline — will sync when connected.', + ); + } else { + showErrorSnackBar(context, e.toString()); + } } } finally { if (context.mounted) { @@ -4321,9 +4412,14 @@ class _TaskDetailScreenState extends ConsumerState .read(tasksControllerProvider) .updateTaskStatus(taskId: task.id, status: value); } catch (e) { - // surface validation or other errors to user if (mounted) { - showErrorSnackBarGlobal(e.toString()); + if (isOfflineSaveError(e)) { + showSuccessSnackBarGlobal( + 'Status update saved offline — will sync when connected.', + ); + } else { + showErrorSnackBarGlobal(e.toString()); + } } } }, @@ -4622,6 +4718,66 @@ class _MetaBadge extends StatelessWidget { } } +class _TaskPendingBanner extends StatefulWidget { + const _TaskPendingBanner(); + + @override + State<_TaskPendingBanner> createState() => _TaskPendingBannerState(); +} + +class _TaskPendingBannerState extends State<_TaskPendingBanner> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + )..repeat(); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Material( + color: scheme.errorContainer, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + RotationTransition( + turns: _ctrl, + child: Icon( + Icons.sync_rounded, + color: scheme.onErrorContainer, + size: 16, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Pending sync — created offline. Edits will queue automatically.', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: scheme.onErrorContainer, + ), + ), + ), + ], + ), + ), + ); + } +} + extension _FirstOrNull on Iterable { T? get firstOrNull => isEmpty ? null : first; } diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index d1e59d46..c2f5d1bf 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -33,6 +33,7 @@ import '../../widgets/app_state_view.dart'; import '../../utils/subject_suggestions.dart'; import '../../widgets/gemini_button.dart'; import '../../widgets/gemini_animated_text_field.dart'; +import '../../widgets/sync_pending_badge.dart'; import 'it_job_checklist_tab.dart'; // request metadata options used in task creation/editing dialogs @@ -118,7 +119,25 @@ class _TasksListScreenState extends ConsumerState @override Widget build(BuildContext context) { final tasksAsync = ref.watch(tasksProvider); + final offlinePending = ref.watch(offlinePendingTasksProvider); + final pendingTaskUpdates = ref.watch(offlinePendingTaskUpdatesProvider); + final pendingAssignments = ref.watch(offlinePendingAssignmentsProvider); ref.watch(tasksQueryProvider); + + // When the live stream delivers a task whose UUID matches a pending offline + // task, remove it from the pending list — the sync completed successfully. + ref.listen>>(tasksProvider, (_, next) { + final live = next.valueOrNull; + if (live == null || live.isEmpty) return; + final pending = ref.read(offlinePendingTasksProvider); + if (pending.isEmpty) return; + final liveIds = live.map((t) => t.id).toSet(); + final stillPending = + pending.where((t) => !liveIds.contains(t.id)).toList(); + if (stillPending.length != pending.length) { + ref.read(offlinePendingTasksProvider.notifier).state = stillPending; + } + }); final ticketsAsync = ref.watch(ticketsProvider); final officesAsync = ref.watch(officesProvider); final profileAsync = ref.watch(currentProfileProvider); @@ -139,7 +158,10 @@ class _TasksListScreenState extends ConsumerState (!profilesAsync.hasValue && profilesAsync.isLoading) || (!assignmentsAsync.hasValue && assignmentsAsync.isLoading) || (!profileAsync.hasValue && profileAsync.isLoading); - final effectiveShowSkeleton = showSkeleton; + // Never skeleton when there are offline-pending tasks — those cards have + // real data to display and the outer Skeletonizer would replace their text + // with gray bones, making them indistinguishable from loading placeholders. + final effectiveShowSkeleton = showSkeleton && offlinePending.isEmpty; final canCreate = profileAsync.maybeWhen( data: (profile) => @@ -195,7 +217,46 @@ class _TasksListScreenState extends ConsumerState ); } - final tasks = tasksAsync.valueOrNull ?? []; + // Merge offline-pending tasks (created while offline) at the + // front of the list. Deduplicate by ID so when the realtime + // stream delivers the synced version, the pending copy drops out. + final liveTasks = tasksAsync.valueOrNull ?? []; + final liveIds = liveTasks.map((t) => t.id).toSet(); + final pendingOnly = + offlinePending.where((t) => !liveIds.contains(t.id)).toList(); + final tasks = [...pendingOnly, ...liveTasks].map((t) { + final upd = pendingTaskUpdates[t.id]; + if (upd == null || upd.isEmpty) return t; + final newStatus = upd['status'] as String?; + if (newStatus == null) return t; + return Task( + id: t.id, + ticketId: t.ticketId, + taskNumber: t.taskNumber, + title: t.title, + description: t.description, + officeId: t.officeId, + status: newStatus, + priority: t.priority, + queueOrder: t.queueOrder, + createdAt: t.createdAt, + creatorId: t.creatorId, + startedAt: t.startedAt, + completedAt: t.completedAt, + requestedBy: t.requestedBy, + notedBy: t.notedBy, + receivedBy: t.receivedBy, + requestType: t.requestType, + requestTypeOther: t.requestTypeOther, + requestCategory: t.requestCategory, + actionTaken: t.actionTaken, + cancellationReason: t.cancellationReason, + cancelledAt: t.cancelledAt, + itJobPrinted: t.itJobPrinted, + itJobPrintedAt: t.itJobPrintedAt, + itJobReceivedById: t.itJobReceivedById, + ); + }).toList(); final offices = officesAsync.valueOrNull ?? []; final officesSorted = List.from(offices) ..sort( @@ -241,6 +302,16 @@ class _TasksListScreenState extends ConsumerState .add(a.userId); } + // Apply offline-pending assignment overrides so list reflects + // changes made while disconnected before sync completes. + for (final entry in pendingAssignments.entries) { + final taskId = entry.key; + final userIds = entry.value; + latestAssigneeByTaskId[taskId] = + userIds.isNotEmpty ? userIds.first : null; + assignedUsersByTaskId[taskId] = userIds.toSet(); + } + final filteredTasks = _applyTaskFilters( tasks, ticketById: ticketById, @@ -365,7 +436,7 @@ class _TasksListScreenState extends ConsumerState onRowTap: (task) => context.go('/tasks/${task.id}'), summaryDashboard: summary, filterHeader: filterHeader, - skeletonMode: effectiveShowSkeleton, + skeletonMode: effectiveShowSkeleton && tasksList.isEmpty, onRequestRefresh: () { // For server-side pagination, update the query provider ref.read(tasksQueryProvider.notifier).state = @@ -386,10 +457,27 @@ class _TasksListScreenState extends ConsumerState final ticket = task.ticketId == null ? null : ticketById[task.ticketId]; - return Text( - task.title.isNotEmpty - ? task.title - : (ticket?.subject ?? 'Task ${task.id}'), + final isPending = + offlinePending.any((t) => t.id == task.id); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + task.title.isNotEmpty + ? task.title + : (ticket?.subject ?? + 'Task ${task.id}'), + ), + ), + if (isPending) ...[ + const SizedBox(width: 6), + SyncPendingBadge( + isPending: true, + compact: true, + ), + ], + ], ); }, ), @@ -461,6 +549,8 @@ class _TasksListScreenState extends ConsumerState typingIndicatorProvider(task.id), ); final showTyping = typingState.userIds.isNotEmpty; + final isTaskPending = + offlinePending.any((t) => t.id == task.id); return M3PressScale( onTap: () => context.go('/tasks/${task.id}'), @@ -480,6 +570,10 @@ class _TasksListScreenState extends ConsumerState subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (isTaskPending) ...[ + SyncPendingBadge(isPending: true), + const SizedBox(height: 4), + ], Text(subtitle), const SizedBox(height: 2), Text('Assigned: $assigned'), @@ -530,17 +624,21 @@ class _TasksListScreenState extends ConsumerState } final currentUserId = profileAsync.valueOrNull?.id; - final myTasks = currentUserId == null - ? [] - : filteredTasks - .where( - (t) => - assignedUsersByTaskId[t.id]?.contains( - currentUserId, - ) ?? - false, - ) - .toList(); + // pendingOnly tasks were created by the current user on this + // device — no creatorId check needed; they always go in My Tasks. + final pendingIds = {for (final t in pendingOnly) t.id}; + final myTasks = [ + ...pendingOnly, + if (currentUserId != null) + ...filteredTasks.where( + (t) => + !pendingIds.contains(t.id) && + (assignedUsersByTaskId[t.id]?.contains( + currentUserId, + ) ?? + false), + ), + ]; return Column( mainAxisSize: MainAxisSize.max, @@ -964,21 +1062,37 @@ class _TasksListScreenState extends ConsumerState return; } setState(() => saving = true); - await ref - .read(tasksControllerProvider) - .createTask( - title: title, - description: description, - officeId: officeId, - requestType: selectedRequestType, - requestTypeOther: requestTypeOther, - requestCategory: selectedRequestCategory, + try { + await ref + .read(tasksControllerProvider) + .createTask( + title: title, + description: description, + officeId: officeId, + requestType: selectedRequestType, + requestTypeOther: requestTypeOther, + requestCategory: selectedRequestCategory, + ); + if (context.mounted) { + Navigator.of(dialogContext).pop(); + showSuccessSnackBarGlobal( + 'Task "$title" has been created successfully.', ); - if (context.mounted) { - Navigator.of(dialogContext).pop(); - showSuccessSnackBarGlobal( - 'Task "$title" has been created successfully.', - ); + } + } catch (e) { + if (!dialogContext.mounted) return; + if (isOfflineSaveError(e)) { + Navigator.of(dialogContext).pop(); + showSuccessSnackBarGlobal( + 'Task "$title" saved offline — will sync when connected.', + ); + } else { + showErrorSnackBarGlobal('Failed to create task: $e'); + } + } finally { + if (dialogContext.mounted) { + setState(() => saving = false); + } } }, child: saving @@ -1150,43 +1264,23 @@ class _StatusSummaryRow extends StatelessWidget { @override Widget build(BuildContext context) { - if (counts.isEmpty) { - return const SizedBox.shrink(); - } + if (counts.isEmpty) return const SizedBox.shrink(); final entries = counts.entries.toList() ..sort((a, b) => a.key.compareTo(b.key)); - return LayoutBuilder( - builder: (context, constraints) { - final maxWidth = constraints.maxWidth; - final maxPerRow = maxWidth >= 1000 - ? 4 - : maxWidth >= 720 - ? 3 - : maxWidth >= 480 - ? 2 - : entries.length; - final perRow = entries.length < maxPerRow ? entries.length : maxPerRow; - final spacing = maxWidth < 480 ? 8.0 : 12.0; - final itemWidth = perRow == 0 - ? maxWidth - : (maxWidth - spacing * (perRow - 1)) / perRow; - - return Wrap( - spacing: spacing, - runSpacing: spacing, - children: [ - for (final entry in entries) - SizedBox( - width: itemWidth, - child: _StatusSummaryCard( - status: entry.key, - count: entry.value, - ), - ), + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (int i = 0; i < entries.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + _StatusSummaryCard( + status: entries[i].key, + count: entries[i].value, + ), ], - ); - }, + ], + ), ); } } @@ -1214,32 +1308,28 @@ class _StatusSummaryCard extends StatelessWidget { 'completed' => scheme.onPrimaryContainer, _ => scheme.onSurfaceVariant, }; + final label = status.replaceAll('_', ' ').toUpperCase(); - // M3 Expressive: filled card with semantic tonal color, no shadow. - return Card( + return Material( color: background, - elevation: 0, - shadowColor: Colors.transparent, - // summary cards are compact — use compact token for consistent density - shape: AppSurfaces.of(context).compactShape, + borderRadius: BorderRadius.circular(20), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( - status.toUpperCase(), + label, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: foreground, fontWeight: FontWeight.w600, letterSpacing: 0.4, ), ), - const SizedBox(height: 6), + const SizedBox(width: 6), Text( count.toString(), - style: Theme.of(context).textTheme.headlineSmall?.copyWith( + style: Theme.of(context).textTheme.labelLarge?.copyWith( color: foreground, fontWeight: FontWeight.w700, ), diff --git a/lib/screens/teams/teams_screen.dart b/lib/screens/teams/teams_screen.dart index 686c7a7b..d32acae3 100644 --- a/lib/screens/teams/teams_screen.dart +++ b/lib/screens/teams/teams_screen.dart @@ -518,9 +518,15 @@ class _TeamsScreenState extends ConsumerState { } } catch (e) { if (!mounted) return; - // ignore: use_build_context_synchronously - showErrorSnackBar(context, 'Failed to save team: $e'); - return; + final offline = isOfflineSaveError(e); + if (offline) { + // ignore: use_build_context_synchronously + showSuccessSnackBar(context, 'Team saved offline — will sync when connected.'); + } else { + // ignore: use_build_context_synchronously + showErrorSnackBar(context, 'Failed to save team: $e'); + return; + } } ref.invalidate(teamsProvider); @@ -660,9 +666,13 @@ class _TeamsScreenState extends ConsumerState { ref.invalidate(teamMembersProvider); } catch (e) { if (!mounted) return; - // ignore: use_build_context_synchronously - showErrorSnackBar(ctx, 'Failed to delete team: $e'); - return; + if (isOfflineSaveError(e)) { + // ignore: use_build_context_synchronously + showSuccessSnackBar(ctx, 'Delete queued offline — will sync when connected.'); + } else { + // ignore: use_build_context_synchronously + showErrorSnackBar(ctx, 'Failed to delete team: $e'); + } } } } diff --git a/lib/screens/tickets/ticket_detail_screen.dart b/lib/screens/tickets/ticket_detail_screen.dart index fc73507c..e91e94b7 100644 --- a/lib/screens/tickets/ticket_detail_screen.dart +++ b/lib/screens/tickets/ticket_detail_screen.dart @@ -987,10 +987,20 @@ class _TicketDetailScreenState extends ConsumerState { ); } catch (e) { if (!screenContext.mounted) return; - showErrorSnackBar( - screenContext, - 'Failed to update ticket: $e', - ); + if (isOfflineSaveError(e)) { + if (dialogContext.mounted) { + Navigator.of(dialogContext).pop(); + } + showSuccessSnackBar( + screenContext, + 'Ticket update saved offline — will sync when connected.', + ); + } else { + showErrorSnackBar( + screenContext, + 'Failed to update ticket: $e', + ); + } } finally { if (dialogContext.mounted) { setDialogState(() => saving = false); diff --git a/lib/screens/tickets/tickets_list_screen.dart b/lib/screens/tickets/tickets_list_screen.dart index 31c80635..7f0cbbb8 100644 --- a/lib/screens/tickets/tickets_list_screen.dart +++ b/lib/screens/tickets/tickets_list_screen.dart @@ -23,6 +23,7 @@ import '../../theme/app_surfaces.dart'; import '../../utils/snackbar.dart'; import '../../widgets/app_page_header.dart'; import '../../widgets/app_state_view.dart'; +import '../../widgets/sync_pending_badge.dart'; class TicketsListScreen extends ConsumerStatefulWidget { const TicketsListScreen({super.key}); @@ -62,6 +63,22 @@ class _TicketsListScreenState extends ConsumerState { final realtime = ref.watch(realtimeControllerProvider); final ticketsAsync = ref.watch(ticketsProvider); + final offlinePending = ref.watch(offlinePendingTicketsProvider); + + // When the live stream delivers a ticket whose UUID matches a pending offline + // ticket, remove it — sync completed successfully. + ref.listen>>(ticketsProvider, (_, next) { + final live = next.valueOrNull; + if (live == null || live.isEmpty) return; + final pending = ref.read(offlinePendingTicketsProvider); + if (pending.isEmpty) return; + final liveIds = live.map((t) => t.id).toSet(); + final stillPending = pending.where((t) => !liveIds.contains(t.id)).toList(); + if (stillPending.length != pending.length) { + ref.read(offlinePendingTicketsProvider.notifier).state = stillPending; + } + }); + final officesAsync = ref.watch(officesProvider); final notificationsAsync = ref.watch(notificationsProvider); final profilesAsync = ref.watch(profilesProvider); @@ -100,7 +117,13 @@ class _TicketsListScreenState extends ConsumerState { ); } - final tickets = ticketsAsync.valueOrNull ?? []; + final liveTickets = ticketsAsync.valueOrNull ?? []; + final liveIds = liveTickets.map((t) => t.id).toSet(); + final pendingOnly = + offlinePending.where((t) => !liveIds.contains(t.id)).toList(); + final tickets = [...pendingOnly, ...liveTickets]; + final pendingTicketIds = + offlinePending.map((t) => t.id).toSet(); final officeById = { for (final office in officesAsync.valueOrNull ?? []) office.id: office, @@ -267,7 +290,18 @@ class _TicketsListScreenState extends ConsumerState { ), TasQColumn( header: 'Subject', - cellBuilder: (context, ticket) => Text(ticket.subject), + cellBuilder: (context, ticket) { + final isPending = + pendingTicketIds.contains(ticket.id); + if (!isPending) return Text(ticket.subject); + return Row( + children: [ + Flexible(child: Text(ticket.subject)), + const SizedBox(width: 6), + SyncPendingBadge(isPending: true, compact: true), + ], + ); + }, ), TasQColumn( header: 'Office', @@ -300,6 +334,8 @@ class _TicketsListScreenState extends ConsumerState { ticket.creatorId, ); final hasMention = unreadByTicketId[ticket.id] == true; + final isTicketPending = + pendingTicketIds.contains(ticket.id); final typingState = ref.watch( typingIndicatorProvider(ticket.id), ); @@ -332,6 +368,13 @@ class _TicketsListScreenState extends ConsumerState { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ + if (isTicketPending) ...[ + SyncPendingBadge( + isPending: true, + compact: true, + ), + const SizedBox(width: 4), + ], _StatusBadge(status: ticket.status), if (showTyping) ...[ const SizedBox(width: 6), @@ -496,21 +539,38 @@ class _TicketsListScreenState extends ConsumerState { return; } setState(() => saving = true); - await ref - .read(ticketsControllerProvider) - .createTicket( - subject: subject, - description: description, - officeId: selectedOffice!.id, + try { + final pendingTicket = await ref + .read(ticketsControllerProvider) + .createTicket( + subject: subject, + description: description, + officeId: selectedOffice!.id, + ); + if (pendingTicket != null) { + // Offline: show immediately in the list until sync. + final current = ref.read(offlinePendingTicketsProvider); + ref.read(offlinePendingTicketsProvider.notifier).state = [ + pendingTicket, + ...current, + ]; + } + if (context.mounted) { + Navigator.of(dialogContext).pop(); + showSuccessSnackBar( + context, + pendingTicket != null + ? 'Ticket "$subject" saved offline — will sync when connected.' + : 'Ticket "$subject" has been created successfully.', ); - // Supabase stream will emit the new ticket — no explicit - // invalidation required and avoids a temporary reload. - if (context.mounted) { - Navigator.of(dialogContext).pop(); - showSuccessSnackBar( - context, - 'Ticket "$subject" has been created successfully.', - ); + } + } catch (e) { + if (!dialogContext.mounted) return; + showErrorSnackBarGlobal('Failed to create ticket: $e'); + } finally { + if (dialogContext.mounted) { + setState(() => saving = false); + } } }, child: saving @@ -616,43 +676,23 @@ class _StatusSummaryRow extends StatelessWidget { @override Widget build(BuildContext context) { - if (counts.isEmpty) { - return const SizedBox.shrink(); - } + if (counts.isEmpty) return const SizedBox.shrink(); final entries = counts.entries.toList() ..sort((a, b) => a.key.compareTo(b.key)); - return LayoutBuilder( - builder: (context, constraints) { - final maxWidth = constraints.maxWidth; - final maxPerRow = maxWidth >= 1000 - ? 4 - : maxWidth >= 720 - ? 3 - : maxWidth >= 480 - ? 2 - : entries.length; - final perRow = entries.length < maxPerRow ? entries.length : maxPerRow; - final spacing = maxWidth < 480 ? 8.0 : 12.0; - final itemWidth = perRow == 0 - ? maxWidth - : (maxWidth - spacing * (perRow - 1)) / perRow; - - return Wrap( - spacing: spacing, - runSpacing: spacing, - children: [ - for (final entry in entries) - SizedBox( - width: itemWidth, - child: _StatusSummaryCard( - status: entry.key, - count: entry.value, - ), - ), + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (int i = 0; i < entries.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + _StatusSummaryCard( + status: entries[i].key, + count: entries[i].value, + ), ], - ); - }, + ], + ), ); } } @@ -680,32 +720,28 @@ class _StatusSummaryCard extends StatelessWidget { 'closed' => scheme.onPrimaryContainer, _ => scheme.onSurfaceVariant, }; + final label = status.replaceAll('_', ' ').toUpperCase(); - // M3 Expressive: filled card with semantic tonal color, no shadow. - return Card( + return Material( color: background, - elevation: 0, - shadowColor: Colors.transparent, - // summary cards are compact — use compact token for consistent density - shape: AppSurfaces.of(context).compactShape, + borderRadius: BorderRadius.circular(20), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( - status.toUpperCase(), + label, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: foreground, fontWeight: FontWeight.w600, letterSpacing: 0.4, ), ), - const SizedBox(height: 6), + const SizedBox(width: 6), Text( count.toString(), - style: Theme.of(context).textTheme.headlineSmall?.copyWith( + style: Theme.of(context).textTheme.labelLarge?.copyWith( color: foreground, fontWeight: FontWeight.w700, ), diff --git a/lib/screens/workforce/workforce_screen.dart b/lib/screens/workforce/workforce_screen.dart index 914c5112..5bae24cf 100644 --- a/lib/screens/workforce/workforce_screen.dart +++ b/lib/screens/workforce/workforce_screen.dart @@ -682,7 +682,11 @@ class _ScheduleTile extends ConsumerWidget { ref.invalidate(dutySchedulesProvider); } catch (e) { if (!context.mounted) return; - showErrorSnackBar(context, 'Update failed: $e'); + if (isOfflineSaveError(e)) { + showSuccessSnackBar(context, 'Schedule saved offline — will sync when connected.'); + } else { + showErrorSnackBar(context, 'Update failed: $e'); + } } } diff --git a/lib/utils/snackbar.dart b/lib/utils/snackbar.dart index 633e82ca..025e3dee 100644 --- a/lib/utils/snackbar.dart +++ b/lib/utils/snackbar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:awesome_snackbar_content/awesome_snackbar_content.dart'; import 'package:flutter/foundation.dart'; +import 'package:supabase_flutter/supabase_flutter.dart' show PostgrestException; /// A global messenger key used to show snackbars from contexts without a /// Scaffold (e.g. dialogs, background callbacks, or tests). @@ -218,3 +219,55 @@ void showWarningSnackBarGlobal(String message) => showAwesomeSnackBarGlobal( message: message, snackType: SnackType.warning, ); + +/// Returns true when [error] is a network-connectivity failure (socket closed, +/// no route to host, failed DNS lookup, etc.). +/// +/// When this is true in a Brick-enabled app, the write was already queued in +/// the offline SQLite store and will sync automatically when the device +/// reconnects — so the UI should treat it as a success rather than an error. +bool isOfflineSaveError(Object error) { + if (error is PostgrestException) { + // Null code = no HTTP response received at all (pure network failure). + if (error.code == null) return true; + + // Brick's RestOfflineQueueClient catches network errors and returns a + // synthetic StreamedResponse(501, body: 'unknown internal error') rather + // than rethrowing. PostgREST then parses this as + // PostgrestException(code: '501', message: 'unknown internal error'). + // This is the primary offline indicator when Supabase is used with Brick. + if (error.code == '501' && error.message == 'unknown internal error') { + return true; + } + + // Fallback: Supabase may wrap a SocketException in a PostgrestException + // message field before it reaches Brick's queue (rare, but defensive). + final pmsg = error.message.toLowerCase(); + if (pmsg.contains('socketexception') || + pmsg.contains('failed host lookup') || + pmsg.contains('connection refused') || + pmsg.contains('network is unreachable') || + pmsg.contains('no route to host') || + pmsg.contains('connection timed out') || + pmsg.contains('failed to connect')) { + return true; + } + } + + // Non-PostgrestException network errors (SocketException, ClientException) + // that propagate before Brick's HTTP interceptor can catch them. + final msg = error.toString().toLowerCase(); + return msg.contains('socketexception') || + msg.contains('socket') && msg.contains('exception') || + msg.contains('connection refused') || + msg.contains('network is unreachable') || + msg.contains('no route to host') || + msg.contains('failed host lookup') || + msg.contains('connection reset') || + msg.contains('broken pipe') || + msg.contains('connection timed out') || + msg.contains('clientexception') && msg.contains('network') || + msg.contains('failed to connect') || + msg.contains('errno = 111') || // ECONNREFUSED + msg.contains('errno = 101'); // ENETUNREACH +} diff --git a/lib/widgets/offline_banner.dart b/lib/widgets/offline_banner.dart new file mode 100644 index 00000000..be348e28 --- /dev/null +++ b/lib/widgets/offline_banner.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/connectivity_provider.dart'; +import '../providers/sync_queue_provider.dart'; + +/// Wraps [child] with a polished M3 status banner driven by connectivity + sync state. +/// +/// Three states: +/// 1. Online + 0 pending → no banner (slides away) +/// 2. Online + N pending → tertiary-toned "Syncing N item(s)…" with spinner + progress bar +/// 3. Offline → error-toned "Offline — N item(s) queued" (count omitted when 0) +class OfflineBanner extends ConsumerWidget { + final Widget child; + + const OfflineBanner({super.key, required this.child}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.watch(connectivityMonitorProvider); + final isOnline = ref.watch(isOnlineProvider); + final pendingCount = ref.watch(pendingSyncCountProvider); + final scheme = Theme.of(context).colorScheme; + + Widget? banner; + + if (!isOnline) { + final label = pendingCount > 0 + ? 'Offline — $pendingCount item${pendingCount == 1 ? '' : 's'} queued' + : 'No internet — changes saved locally'; + banner = _BannerStrip( + key: const ValueKey('offline'), + backgroundColor: scheme.errorContainer, + foregroundColor: scheme.onErrorContainer, + icon: Icon(Icons.wifi_off_rounded, color: scheme.onErrorContainer, size: 18), + label: label, + showProgress: false, + ); + } else if (pendingCount > 0) { + final label = 'Syncing $pendingCount item${pendingCount == 1 ? '' : 's'}…'; + banner = _BannerStrip( + key: const ValueKey('syncing'), + backgroundColor: scheme.tertiaryContainer, + foregroundColor: scheme.onTertiaryContainer, + icon: _SpinningIcon( + icon: Icons.sync_rounded, + color: scheme.onTertiaryContainer, + size: 18, + ), + label: label, + showProgress: true, + ); + } + + return Column( + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + transitionBuilder: (child, animation) { + return SlideTransition( + position: Tween( + begin: const Offset(0, -1), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + )), + child: FadeTransition(opacity: animation, child: child), + ); + }, + child: banner ?? const SizedBox.shrink(key: ValueKey('none')), + ), + Expanded(child: child), + ], + ); + } +} + +class _BannerStrip extends StatelessWidget { + final Color backgroundColor; + final Color foregroundColor; + final Widget icon; + final String label; + final bool showProgress; + + const _BannerStrip({ + super.key, + required this.backgroundColor, + required this.foregroundColor, + required this.icon, + required this.label, + required this.showProgress, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: backgroundColor, + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)), + child: SafeArea( + bottom: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, showProgress ? 6 : 8), + child: Row( + children: [ + icon, + const SizedBox(width: 10), + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + if (showProgress) + ClipRRect( + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(12), + ), + child: LinearProgressIndicator( + minHeight: 2, + backgroundColor: backgroundColor, + valueColor: AlwaysStoppedAnimation( + foregroundColor.withValues(alpha: 0.6), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _SpinningIcon extends StatefulWidget { + final IconData icon; + final Color color; + final double size; + + const _SpinningIcon({ + required this.icon, + required this.color, + required this.size, + }); + + @override + State<_SpinningIcon> createState() => _SpinningIconState(); +} + +class _SpinningIconState extends State<_SpinningIcon> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + )..repeat(); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RotationTransition( + turns: _ctrl, + child: Icon(widget.icon, color: widget.color, size: widget.size), + ); + } +} diff --git a/lib/widgets/reconnect_overlay.dart b/lib/widgets/reconnect_overlay.dart index 99d3524f..33452f47 100644 --- a/lib/widgets/reconnect_overlay.dart +++ b/lib/widgets/reconnect_overlay.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../providers/realtime_controller.dart'; +import '../providers/sync_queue_provider.dart'; /// Subtle, non-blocking connection status indicator. /// Shows in the bottom-right corner when streams are recovering/stale. @@ -18,12 +19,19 @@ class ReconnectIndicator extends ConsumerWidget { return const SizedBox.shrink(); } + final pendingCount = ref.watch(pendingSyncCountProvider); + // Build a human-readable label for recovering channels. final channels = ctrl.recoveringChannels; - final label = channels.length <= 2 + final channelLabel = channels.length <= 2 ? channels.map(_humanize).join(', ') : '${channels.length} channels'; + final syncSuffix = pendingCount > 0 + ? ' — syncing $pendingCount item${pendingCount == 1 ? '' : 's'}' + : ''; + final label = 'Reconnecting $channelLabel$syncSuffix…'; + return Positioned( bottom: 16, right: 16, @@ -60,7 +68,7 @@ class ReconnectIndicator extends ConsumerWidget { const SizedBox(width: 8), Flexible( child: Text( - 'Reconnecting $label…', + label, style: Theme.of(context).textTheme.labelSmall, overflow: TextOverflow.ellipsis, ), diff --git a/lib/widgets/sync_pending_badge.dart b/lib/widgets/sync_pending_badge.dart new file mode 100644 index 00000000..f4051ee6 --- /dev/null +++ b/lib/widgets/sync_pending_badge.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/connectivity_provider.dart'; + +/// Two-state "this item is not yet synced" indicator. +/// +/// Renders nothing when [isPending] is false. Otherwise shows: +/// - **Amber** chip with a static sync icon when the device is offline +/// ("Pending sync" — queued, will sync when network returns). +/// - **Blue** chip with a rotating sync icon when the device is online +/// ("Syncing…" — replay-in-flight or Brick queue draining). +/// +/// Use [compact] for dense list rows where only the icon is needed. +class SyncPendingBadge extends ConsumerWidget { + /// Whether the entity associated with this badge is still queued for sync. + /// Caller is responsible for deriving this from the appropriate state + /// provider (e.g., `offlinePendingTasksProvider.any((t) => t.id == taskId)`). + final bool isPending; + + /// Compact (icon-only) variant for dense list rows. + final bool compact; + + /// Optional override label — defaults to "Pending sync" (offline) or + /// "Syncing…" (online). + final String? label; + + const SyncPendingBadge({ + super.key, + required this.isPending, + this.compact = false, + this.label, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (!isPending) return const SizedBox.shrink(); + + final isOnline = ref.watch(isOnlineProvider); + final scheme = Theme.of(context).colorScheme; + + final Color bg; + final Color fg; + final IconData icon; + final String text; + final bool spin; + + if (isOnline) { + // Replay-in-flight: blue, animated. + bg = scheme.primaryContainer; + fg = scheme.onPrimaryContainer; + icon = Icons.sync; + text = label ?? 'Syncing…'; + spin = true; + } else { + // Queued offline: amber, static. + bg = Colors.amber.shade100; + fg = Colors.amber.shade900; + icon = Icons.sync; + text = label ?? 'Pending sync'; + spin = false; + } + + final iconWidget = spin + ? _RotatingIcon(icon: icon, color: fg, size: compact ? 12 : 14) + : Icon(icon, color: fg, size: compact ? 12 : 14); + + if (compact) { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(8), + ), + child: iconWidget, + ); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + iconWidget, + const SizedBox(width: 4), + Text( + text, + style: TextStyle( + color: fg, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + ], + ), + ); + } +} + +class _RotatingIcon extends StatefulWidget { + final IconData icon; + final Color color; + final double size; + const _RotatingIcon({ + required this.icon, + required this.color, + required this.size, + }); + + @override + State<_RotatingIcon> createState() => _RotatingIconState(); +} + +class _RotatingIconState extends State<_RotatingIcon> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = + AnimationController(vsync: this, duration: const Duration(seconds: 2)) + ..repeat(); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RotationTransition( + turns: _ctrl, + child: Icon(widget.icon, color: widget.color, size: widget.size), + ); + } +} diff --git a/lib/widgets/task_assignment_section.dart b/lib/widgets/task_assignment_section.dart index 202ba5dc..8a03b051 100644 --- a/lib/widgets/task_assignment_section.dart +++ b/lib/widgets/task_assignment_section.dart @@ -3,10 +3,12 @@ import '../theme/m3_motion.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/profile.dart'; +import '../models/task_assignment.dart'; import '../providers/profile_provider.dart'; import '../providers/tasks_provider.dart'; import '../theme/app_surfaces.dart'; import '../utils/snackbar.dart'; +import 'sync_pending_badge.dart'; class TaskAssignmentSection extends ConsumerWidget { const TaskAssignmentSection({ @@ -30,7 +32,21 @@ class TaskAssignmentSection extends ConsumerWidget { .where((task) => task.id == taskId) .map((task) => task.ticketId) .firstOrNull; - final assignments = assignmentsAsync.valueOrNull ?? []; + final liveAssignments = assignmentsAsync.valueOrNull ?? []; + final pendingUserIds = + ref.watch(offlinePendingAssignmentsProvider)[taskId]; + final assignments = pendingUserIds != null + ? [ + ...liveAssignments.where((a) => a.taskId != taskId), + ...pendingUserIds.map( + (uid) => TaskAssignment( + taskId: taskId, + userId: uid, + createdAt: DateTime.now(), + ), + ), + ] + : liveAssignments; final itStaff = profiles.where((profile) => profile.role == 'it_staff').toList() @@ -90,16 +106,42 @@ class TaskAssignmentSection extends ConsumerWidget { final label = profile?.fullName.isNotEmpty == true ? profile!.fullName : assignment.userId; - return InputChip( - label: Text(label), - onDeleted: canAssign - ? () => ref - .read(taskAssignmentsControllerProvider) - .removeAssignment( - taskId: taskId, - userId: assignment.userId, - ) - : null, + final isPendingAssignment = + pendingUserIds?.contains(assignment.userId) ?? false; + return Stack( + clipBehavior: Clip.none, + children: [ + InputChip( + label: Text(label), + onDeleted: canAssign + ? () async { + try { + await ref + .read(taskAssignmentsControllerProvider) + .removeAssignment( + taskId: taskId, + userId: assignment.userId, + ); + } catch (e) { + if (context.mounted) { + showErrorSnackBar( + context, + 'Failed to remove assignment', + ); + } + } + } + : null, + ), + Positioned( + top: -6, + right: -6, + child: SyncPendingBadge( + isPending: isPendingAssignment, + compact: true, + ), + ), + ], ); }).toList(), ), diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index bf999e9d..29fde400 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -19,6 +19,7 @@ import package_info_plus import printing import quill_native_bridge_macos import shared_preferences_foundation +import sqflite_darwin import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -36,5 +37,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) QuillNativeBridgePlugin.register(with: registry.registrar(forPlugin: "QuillNativeBridgePlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 491fae61..f9aed953 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "5b7468c326d2f8a4f630056404ca0d291ade42918f4a3c6233618e724f39da8e" + url: "https://pub.dev" + source: hosted + version: "92.0.0" _flutterfire_internals: dependency: transitive description: @@ -17,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "70e4b1ef8003c64793a9e268a551a82869a8a96f39deb73dea28084b0e8bf75e" + url: "https://pub.dev" + source: hosted + version: "9.0.0" app_links: dependency: transitive description: @@ -161,6 +177,158 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + brick_build: + dependency: transitive + description: + name: brick_build + sha256: "1c147c6db1792931ead3476594ed4cae7e43762b04ebe755e3414439c9f4b7ca" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + brick_core: + dependency: transitive + description: + name: brick_core + sha256: d73a7946bf2e5cf60337d456f3ec806500f4e96c446a15fbe022ed0431a0be96 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + brick_json_generators: + dependency: transitive + description: + name: brick_json_generators + sha256: ebc0de4d8c3cf5547da79c73684ba82cd155c8e58d343b29d0e2c3c51f3e3ff8 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + brick_offline_first: + dependency: "direct main" + description: + name: brick_offline_first + sha256: acafff734c8c193d5ff99df077cdfc7dba523067a662461e5e28facf42d42fb2 + url: "https://pub.dev" + source: hosted + version: "4.0.2" + brick_offline_first_build: + dependency: transitive + description: + name: brick_offline_first_build + sha256: dd4182fbabf5bfbeac55c045692878848b04a54b5e8be9956a3f442d10e95366 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + brick_offline_first_with_rest: + dependency: "direct main" + description: + name: brick_offline_first_with_rest + sha256: "634ec36a43c443d85b407da305fed6fa85a93c3647851500f7759030339e50fc" + url: "https://pub.dev" + source: hosted + version: "4.0.1" + brick_offline_first_with_supabase: + dependency: "direct main" + description: + name: brick_offline_first_with_supabase + sha256: "15b6f4747759133a95ca6d2d7a867773546d0c9293267eda2d31dfc5166c17c1" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + brick_offline_first_with_supabase_build: + dependency: "direct dev" + description: + name: brick_offline_first_with_supabase_build + sha256: b65fafaf010790fb0979b71921ad028a98ee151a37ab5214444e147ec8829577 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + brick_rest: + dependency: transitive + description: + name: brick_rest + sha256: "41420702a24d2597bfd40004412061709cfd7201a32035e10e6cbc9e4780f59b" + url: "https://pub.dev" + source: hosted + version: "4.0.0+1" + brick_sqlite: + dependency: "direct main" + description: + name: brick_sqlite + sha256: ccd020fc662a90aac7139bc5a8e77291709ead7f5a7fa758711a738a57dc3aa4 + url: "https://pub.dev" + source: hosted + version: "4.1.0+1" + brick_sqlite_generators: + dependency: transitive + description: + name: brick_sqlite_generators + sha256: "2d065f1cb3bb91d8236b3f3969e427aee0b828dde2f71a353dd051a79f5f839a" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + brick_supabase: + dependency: "direct main" + description: + name: brick_supabase + sha256: d99306dc90fe93d63dece4c4efe94b3760674bed2a2e4749792567fae2de5d98 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + brick_supabase_generators: + dependency: transitive + description: + name: brick_supabase_generators + sha256: c55aa90f8284fd14c63733c18defac5d4728bb43cec0b567cac3a17381b2707d + url: "https://pub.dev" + source: hosted + version: "2.1.0" + build: + dependency: transitive + description: + name: build + sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c + url: "https://pub.dev" + source: hosted + version: "4.0.5" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "521daf8d189deb79ba474e43a696b41c49fb3987818dbacf3308f1e03673a75e" + url: "https://pub.dev" + source: hosted + version: "2.13.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af" + url: "https://pub.dev" + source: hosted + version: "8.12.5" camera: dependency: transitive description: @@ -249,6 +417,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" collection: dependency: transitive description: @@ -321,6 +497,14 @@ packages: url: "https://pub.dev" source: hosted version: "10.8.3" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + url: "https://pub.dev" + source: hosted + version: "3.1.3" dbus: dependency: transitive description: @@ -917,6 +1101,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.18.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" gtk: dependency: transitive description: @@ -949,6 +1141,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" http_parser: dependency: transitive description: @@ -1037,6 +1237,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" json_annotation: dependency: transitive description: @@ -1189,6 +1397,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" package_info_plus: dependency: "direct main" description: @@ -1405,6 +1621,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" posix: dependency: transitive description: @@ -1445,6 +1669,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" qr: dependency: transitive description: @@ -1613,6 +1845,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" skeletonizer: dependency: "direct main" description: @@ -1626,6 +1874,14 @@ packages: description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "732792cfd197d2161a65bb029606a46e0a18ff30ef9e141a7a82172b05ea8ecd" + url: "https://pub.dev" + source: hosted + version: "4.2.2" source_span: dependency: transitive description: @@ -1634,6 +1890,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + url: "https://pub.dev" + source: hosted + version: "2.4.2+3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: @@ -1858,6 +2154,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" web: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f7a2e08f..9d53d2fe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,12 +54,20 @@ dependencies: package_info_plus: ^9.0.0 pub_semver: ^2.1.1 ota_update: ^7.1.0 + brick_offline_first: ^4.0.0 + brick_offline_first_with_supabase: ^2.1.0 + brick_offline_first_with_rest: ^4.0.0 + brick_sqlite: ^4.0.0 + brick_supabase: ^2.1.0 + sqflite: ^2.4.0 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 flutter_launcher_icons: ^0.13.1 + build_runner: ^2.4.0 + brick_offline_first_with_supabase_build: ^2.1.0 flutter: uses-material-design: true diff --git a/supabase/migrations/20260323000000_fix_enqueue_all_notifications.sql b/supabase/migrations/20260323000000_fix_enqueue_all_notifications.sql index afacaafc..7b7ed896 100644 --- a/supabase/migrations/20260323000000_fix_enqueue_all_notifications.sql +++ b/supabase/migrations/20260323000000_fix_enqueue_all_notifications.sql @@ -1,18 +1,27 @@ --- Migration: Definitive enqueue_all_notifications() dispatcher +-- Migration: Definitive enqueue_all_notifications() dispatcher + self-hosted fix -- -- WHY THIS EXISTS: --- 20260322_finalize_notification_functions.sql has an underscore at character --- position 9 of its filename, which sorts AFTER all 20260322NNNNNN_* files --- (underscore ASCII 95 > digits ASCII 48-57). This means _finalize_ always --- runs LAST in the 20260322 group, overriding enqueue_all_notifications() --- with a version that omits: --- - enqueue_announcement_banner_notifications() (added in 20260322210000) --- - enqueue_pass_slip_expired_notifications() (added in 20260322150000) +-- 1. Sort-order bug: 20260322_finalize_notification_functions.sql has an +-- underscore at filename position 9, which sorts AFTER all 20260322NNNNNN_* +-- files (underscore ASCII 95 > digits 48-57). It therefore ran LAST and +-- overrode enqueue_all_notifications() with a version that omitted: +-- - enqueue_announcement_banner_notifications() (added in 20260322210000) +-- - enqueue_pass_slip_expired_notifications() (added in 20260322150000) +-- This migration (20260323000000) sorts after all 20260322* files and +-- restores the authoritative 10-type definition. -- --- This migration (20260323000000) sorts AFTER all 20260322* files and --- redefines enqueue_all_notifications() as the single authoritative definition --- that includes all 10 notification types. +-- 2. Self-hosted Docker: process_notification_queue() fires the edge function +-- via pg_net (HTTP), but on self-hosted the vault secrets and GUC fallbacks +-- are often not configured, so the function returns early and the edge +-- function is never triggered. enqueue_all_notifications() therefore never +-- runs and scheduled_notifications is never populated. +-- Fix: a dedicated pg_cron job calls enqueue_all_notifications() directly +-- (pure SQL, no HTTP, no vault required). The edge function is still called +-- for FCM delivery — only the enqueue step is decoupled. +-- ============================================================================ +-- 1. Authoritative master dispatcher (all 10 types) +-- ============================================================================ CREATE OR REPLACE FUNCTION public.enqueue_all_notifications() RETURNS void LANGUAGE plpgsql AS $$ BEGIN @@ -28,3 +37,40 @@ BEGIN PERFORM public.enqueue_announcement_banner_notifications(); END; $$; + +-- ============================================================================ +-- 2. Dedicated enqueue cron job (self-hosted compatible) +-- ============================================================================ +-- This job calls enqueue_all_notifications() directly inside PostgreSQL every +-- minute. It requires only pg_cron — no pg_net, no vault, no HTTP. +-- +-- On Supabase cloud: both this job and the edge-function trigger run, which +-- is safe because every enqueue function is idempotent via +-- ON CONFLICT DO NOTHING. +-- On self-hosted Docker: this job ensures scheduled_notifications is populated +-- even when the pg_net → edge-function path is broken. +-- For FCM delivery you still need the edge function to +-- run; use the system crontab as shown below if pg_net +-- is not configured: +-- +-- */1 * * * * curl -sS -X POST \ +-- "http://localhost:8000/functions/v1/process_scheduled_notifications" \ +-- -H "Authorization: Bearer " \ +-- -H "Content-Type: application/json" -d '{}' +-- +-- Self-hosted vault / GUC setup (run once, required for pg_net path): +-- SELECT vault.create_secret('http://localhost:8000', 'supabase_url'); +-- SELECT vault.create_secret('', 'service_role_key'); +-- -- or via GUC (no vault extension required): +-- ALTER DATABASE postgres SET "app.settings.supabase_url" = 'http://localhost:8000'; +-- ALTER DATABASE postgres SET "app.settings.service_role_key" = ''; +DO $$ +BEGIN + PERFORM cron.schedule( + 'notification_enqueue_every_min', + '*/1 * * * *', + 'SELECT public.enqueue_all_notifications();' + ); +EXCEPTION WHEN others THEN + RAISE NOTICE 'pg_cron not available (%). Use the system crontab to call the edge function directly instead.', SQLERRM; +END $$; diff --git a/supabase/migrations/20260323010000_fix_enum_cast_in_enqueue_functions.sql b/supabase/migrations/20260323010000_fix_enum_cast_in_enqueue_functions.sql new file mode 100644 index 00000000..59b378b6 --- /dev/null +++ b/supabase/migrations/20260323010000_fix_enum_cast_in_enqueue_functions.sql @@ -0,0 +1,156 @@ +-- Migration: Fix enum literal validation errors in enqueue functions +-- +-- Problem: PostgreSQL validates enum literals against the enum type at +-- execution time, even when no rows match. If a status value like 'closed' +-- or 'in_progress_dry_run' was added to the enum in a later migration that +-- hasn't been applied to a given deployment (e.g. self-hosted Docker), the +-- entire enqueue_all_notifications() call aborts with: +-- ERROR 22P02: invalid input value for enum : "" +-- This prevents ALL notification types from being enqueued, not just the one +-- that references the missing value. +-- +-- Fix: cast the enum column to ::text before comparing. Text comparison +-- bypasses enum validation entirely and works regardless of which enum values +-- exist on any given deployment. +-- +-- Affected functions and the risky literals: +-- enqueue_overtime_checkout_notifications → task_status 'closed' +-- enqueue_overtime_idle_notifications → it_service_request_status 'in_progress_dry_run' +-- enqueue_isr_event_notifications → it_service_request_status 'in_progress_dry_run' +-- enqueue_isr_evidence_notifications → it_service_request_status 'in_progress' (safe but cast for consistency) + +CREATE OR REPLACE FUNCTION public.enqueue_overtime_checkout_notifications() +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + rec RECORD; + v_last_completed timestamptz; +BEGIN + FOR rec IN + SELECT ds.id AS schedule_id, ds.user_id, al.check_in_at + FROM public.duty_schedules ds + JOIN public.attendance_logs al ON al.duty_schedule_id = ds.id AND al.user_id = ds.user_id + WHERE ds.shift_type = 'overtime' + AND ds.status IN ('arrival', 'late') + AND al.check_in_at IS NOT NULL + AND al.check_out_at IS NULL + AND al.check_in_at <= now() - interval '1 hour' + AND NOT EXISTS ( + SELECT 1 FROM public.task_assignments ta + JOIN public.tasks t ON t.id = ta.task_id + WHERE ta.user_id = ds.user_id AND t.status::text = 'in_progress' + ) + LOOP + SELECT MAX(t.completed_at) INTO v_last_completed + FROM public.task_assignments ta + JOIN public.tasks t ON t.id = ta.task_id + WHERE ta.user_id = rec.user_id + AND t.status::text IN ('completed', 'closed') + AND t.completed_at IS NOT NULL + AND t.completed_at >= rec.check_in_at; + + IF v_last_completed IS NOT NULL AND v_last_completed <= now() - interval '30 minutes' THEN + INSERT INTO public.scheduled_notifications + (schedule_id, user_id, notify_type, scheduled_for) + VALUES + (rec.schedule_id, rec.user_id, 'overtime_checkout_30', now()) + ON CONFLICT DO NOTHING; + END IF; + END LOOP; +END; +$$; + +CREATE OR REPLACE FUNCTION public.enqueue_overtime_idle_notifications() +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + rec RECORD; +BEGIN + FOR rec IN + SELECT ds.id AS schedule_id, ds.user_id + FROM public.duty_schedules ds + JOIN public.attendance_logs al ON al.duty_schedule_id = ds.id AND al.user_id = ds.user_id + WHERE ds.shift_type = 'overtime' + AND ds.status IN ('arrival', 'late') + AND al.check_in_at IS NOT NULL + AND al.check_out_at IS NULL + AND al.check_in_at <= now() - interval '15 minutes' + AND NOT EXISTS ( + SELECT 1 FROM public.task_assignments ta + JOIN public.tasks t ON t.id = ta.task_id + WHERE ta.user_id = ds.user_id AND t.status::text = 'in_progress' + ) + AND NOT EXISTS ( + SELECT 1 FROM public.it_service_request_assignments isra + JOIN public.it_service_requests isr ON isr.id = isra.request_id + WHERE isra.user_id = ds.user_id + AND isr.status::text IN ('in_progress', 'in_progress_dry_run') + ) + LOOP + INSERT INTO public.scheduled_notifications + (schedule_id, user_id, notify_type, scheduled_for) + VALUES + (rec.schedule_id, rec.user_id, 'overtime_idle_15', now()) + ON CONFLICT DO NOTHING; + END LOOP; +END; +$$; + +CREATE OR REPLACE FUNCTION public.enqueue_isr_event_notifications() +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + rec RECORD; +BEGIN + FOR rec IN + SELECT isr.id AS request_id, isr.event_date, isra.user_id + FROM public.it_service_requests isr + JOIN public.it_service_request_assignments isra ON isra.request_id = isr.id + WHERE isr.status::text IN ('scheduled', 'in_progress_dry_run') + AND isr.event_date IS NOT NULL + AND isr.event_date BETWEEN now() + interval '60 minutes' - interval '90 seconds' + AND now() + interval '60 minutes' + interval '90 seconds' + LOOP + INSERT INTO public.scheduled_notifications + (it_service_request_id, user_id, notify_type, scheduled_for) + VALUES + (rec.request_id, rec.user_id, 'isr_event_60', rec.event_date - interval '1 hour') + ON CONFLICT DO NOTHING; + END LOOP; +END; +$$; + +CREATE OR REPLACE FUNCTION public.enqueue_isr_evidence_notifications() +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + rec RECORD; + v_today_doy int := EXTRACT(DOY FROM now())::int; +BEGIN + FOR rec IN + SELECT isr.id AS request_id, isra.user_id + FROM public.it_service_requests isr + JOIN public.it_service_request_assignments isra ON isra.request_id = isr.id + WHERE isr.status::text IN ('completed', 'in_progress') + AND ( + NOT EXISTS ( + SELECT 1 FROM public.it_service_request_evidence e + WHERE e.request_id = isr.id AND e.user_id = isra.user_id + ) + OR + NOT EXISTS ( + SELECT 1 FROM public.it_service_request_actions a + WHERE a.request_id = isr.id AND a.user_id = isra.user_id + AND a.action_taken IS NOT NULL AND TRIM(a.action_taken) != '' + ) + ) + AND EXISTS ( + SELECT 1 FROM public.attendance_logs al + WHERE al.user_id = isra.user_id + AND al.check_in_at::date = now()::date + ) + LOOP + INSERT INTO public.scheduled_notifications + (it_service_request_id, user_id, notify_type, scheduled_for, epoch) + VALUES + (rec.request_id, rec.user_id, 'isr_evidence_daily', now(), v_today_doy) + ON CONFLICT DO NOTHING; + END LOOP; +END; +$$; diff --git a/supabase/migrations/20260418000000_attendance_log_sync_trigger.sql b/supabase/migrations/20260418000000_attendance_log_sync_trigger.sql new file mode 100644 index 00000000..1066280d --- /dev/null +++ b/supabase/migrations/20260418000000_attendance_log_sync_trigger.sql @@ -0,0 +1,86 @@ +-- Trigger to replicate attendance_check_in RPC side-effects when a new +-- attendance_log row is inserted directly (e.g. from Brick's offline queue +-- after sync). The RPC path is unchanged; this trigger only fires on plain +-- INSERT and is a no-op when the RPC already handled the duty_schedule update +-- (idempotent: ON CONFLICT (id) DO NOTHING is handled by Brick). +-- +-- Side-effects replicated from attendance_check_in RPC: +-- 1. duty_schedules.check_in_at ← attendance_log.check_in_at +-- 2. duty_schedules.check_in_location ← lat/lng +-- 3. duty_schedules.status ← 'arrival' if on time, 'late' if past start +-- +-- For check-out, the trigger replicates attendance_check_out side-effects: +-- 1. duty_schedules.check_out_at ← attendance_log.check_out_at +-- 2. duty_schedules.check_out_location ← lat/lng +-- 3. duty_schedules.status ← 'completed' + +CREATE OR REPLACE FUNCTION public.attendance_log_after_upsert() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_schedule duty_schedules%ROWTYPE; +BEGIN + -- Skip if we have no duty_schedule_id (shouldn't happen, but be defensive) + IF NEW.duty_schedule_id IS NULL THEN + RETURN NEW; + END IF; + + SELECT * INTO v_schedule FROM duty_schedules WHERE id = NEW.duty_schedule_id; + IF NOT FOUND THEN + RETURN NEW; + END IF; + + -- ── Check-in side-effects ──────────────────────────────────────────────── + -- Only apply when check_in_at changed or this is a new row. + IF (TG_OP = 'INSERT') OR + (TG_OP = 'UPDATE' AND + (OLD.check_in_at IS DISTINCT FROM NEW.check_in_at OR + OLD.check_in_lat IS DISTINCT FROM NEW.check_in_lat)) THEN + + -- Skip if duty_schedules already has a check_in_at (RPC already ran) + IF v_schedule.check_in_at IS NULL THEN + UPDATE duty_schedules + SET + check_in_at = NEW.check_in_at, + check_in_location = jsonb_build_object( + 'latitude', NEW.check_in_lat, + 'longitude', NEW.check_in_lng + ), + status = CASE + WHEN NEW.check_in_at <= v_schedule.start_time + THEN 'arrival' + ELSE 'late' + END + WHERE id = NEW.duty_schedule_id; + END IF; + END IF; + + -- ── Check-out side-effects ─────────────────────────────────────────────── + IF NEW.check_out_at IS NOT NULL AND + (TG_OP = 'INSERT' OR OLD.check_out_at IS DISTINCT FROM NEW.check_out_at) THEN + + UPDATE duty_schedules + SET + check_out_at = NEW.check_out_at, + check_out_location = jsonb_build_object( + 'latitude', NEW.check_out_lat, + 'longitude', NEW.check_out_lng + ), + status = 'completed' + WHERE id = NEW.duty_schedule_id + AND check_out_at IS NULL; -- idempotent: only if not already checked out + END IF; + + RETURN NEW; +END; +$$; + +-- Fire AFTER INSERT and AFTER UPDATE so both offline-queued inserts and +-- subsequent Brick upserts (when check_out_at is filled in) are handled. +DROP TRIGGER IF EXISTS attendance_logs_after_upsert ON attendance_logs; +CREATE TRIGGER attendance_logs_after_upsert + AFTER INSERT OR UPDATE ON attendance_logs + FOR EACH ROW + EXECUTE FUNCTION public.attendance_log_after_upsert(); diff --git a/test/announcements_test.dart b/test/announcements_test.dart index bd72722f..5f8c3148 100644 --- a/test/announcements_test.dart +++ b/test/announcements_test.dart @@ -25,19 +25,12 @@ class _FakeQuery implements Future>> { Map? _insertPayload; Map? _updatePayload; - String? _eqField; - dynamic _eqValue; - String? _inField; List? _inValues; - bool _selectCalled = false; bool _isDelete = false; _TableLog get _log => _tables.putIfAbsent(_table, _TableLog.new); - _FakeQuery select([String? _]) { - _selectCalled = true; - return this; - } + _FakeQuery select([String? _]) => this; _FakeQuery insert(Map payload) { _insertPayload = Map.from(payload); @@ -55,8 +48,6 @@ class _FakeQuery implements Future>> { } _FakeQuery eq(String field, dynamic value) { - _eqField = field; - _eqValue = value; if (_updatePayload != null) { _log.updates.add({..._updatePayload!, '__eq': {field: value}}); _updatePayload = null; @@ -68,7 +59,6 @@ class _FakeQuery implements Future>> { } _FakeQuery inFilter(String field, List values) { - _inField = field; _inValues = values.map((v) => v.toString()).toList(); return this; } @@ -437,10 +427,7 @@ void main() { AppTime.initialize(); fakeClient = _FakeSupabaseClient(userId: 'author-1'); fakeNotif = _FakeNotificationsController(); - controller = AnnouncementsController( - fakeClient as dynamic, - fakeNotif as dynamic, - ); + controller = AnnouncementsController(fakeClient, fakeNotif); }); // ── createAnnouncement – template guard ──────────────────────────── diff --git a/test/layout_smoke_test.dart b/test/layout_smoke_test.dart index dfea70b4..deea2a30 100644 --- a/test/layout_smoke_test.dart +++ b/test/layout_smoke_test.dart @@ -99,7 +99,7 @@ class _FakeTicketsController implements TicketsController { onCreate; @override - Future createTicket({ + Future createTicket({ required String subject, required String description, required String officeId, @@ -111,6 +111,7 @@ class _FakeTicketsController implements TicketsController { officeId: officeId, ); } + return null; } @override diff --git a/test/offline_sync_test.dart b/test/offline_sync_test.dart new file mode 100644 index 00000000..3151b986 --- /dev/null +++ b/test/offline_sync_test.dart @@ -0,0 +1,227 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tasq/providers/connectivity_provider.dart'; +import 'package:tasq/widgets/offline_banner.dart'; + +// --------------------------------------------------------------------------- +// Offline / Connectivity Tests +// +// Three test tiers: +// 1. Unit — pure provider state (no Flutter, no Supabase) +// 2. Widget — OfflineBanner visibility +// 3. Integration stubs — AppRepository + SQLite (require Supabase setup, +// skipped in CI; see comments for manual verification steps) +// --------------------------------------------------------------------------- + +void main() { + // ────────────────────────────────────────────────────────────────────────── + // Tier 1 — Provider unit tests + // ────────────────────────────────────────────────────────────────────────── + group('isOnlineProvider', () { + test('starts as true (online by default)', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect(container.read(isOnlineProvider), isTrue); + }); + + test('can be set to false (simulate going offline)', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(isOnlineProvider.notifier).state = false; + expect(container.read(isOnlineProvider), isFalse); + }); + + test('can transition false → true (simulate reconnect)', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(isOnlineProvider.notifier).state = false; + expect(container.read(isOnlineProvider), isFalse); + + container.read(isOnlineProvider.notifier).state = true; + expect(container.read(isOnlineProvider), isTrue); + }); + + test('listeners are notified on state change', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final states = []; + container.listen(isOnlineProvider, (_, next) => states.add(next)); + + container.read(isOnlineProvider.notifier).state = false; + container.read(isOnlineProvider.notifier).state = true; + container.read(isOnlineProvider.notifier).state = false; + + expect(states, equals([false, true, false])); + }); + }); + + // ────────────────────────────────────────────────────────────────────────── + // Tier 2 — Widget tests for OfflineBanner + // ────────────────────────────────────────────────────────────────────────── + group('OfflineBanner widget', () { + /// Builds OfflineBanner inside a ProviderScope that overrides + /// [isOnlineProvider] to [online] and [connectivityMonitorProvider] + /// to a no-op so no real HTTP calls are made. + Widget buildBanner({required bool online}) { + return ProviderScope( + overrides: [ + isOnlineProvider.overrideWith((ref) => online), + // Suppress the real connectivity poll — no network in tests. + connectivityMonitorProvider.overrideWith((_) {}), + ], + child: const MaterialApp( + home: Scaffold( + body: OfflineBanner(child: SizedBox.expand()), + ), + ), + ); + } + + testWidgets('hides banner when online', (tester) async { + await tester.pumpWidget(buildBanner(online: true)); + await tester.pump(); + + expect(find.byIcon(Icons.wifi_off), findsNothing); + expect( + find.text( + 'No internet \u2014 changes saved locally, will sync automatically', + ), + findsNothing, + ); + }); + + testWidgets('shows banner when offline', (tester) async { + await tester.pumpWidget(buildBanner(online: false)); + await tester.pump(); + + expect(find.byIcon(Icons.wifi_off), findsOneWidget); + expect( + find.text( + 'No internet \u2014 changes saved locally, will sync automatically', + ), + findsOneWidget, + ); + }); + + testWidgets('banner transitions from offline to online', (tester) async { + // Use UncontrolledProviderScope so we can update provider state in-place + // without replacing the widget tree (which would reset AnimatedSize). + final container = ProviderContainer( + overrides: [connectivityMonitorProvider.overrideWith((_) {})], + ); + addTearDown(container.dispose); + + container.read(isOnlineProvider.notifier).state = false; + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: Scaffold(body: OfflineBanner(child: SizedBox.expand())), + ), + ), + ); + await tester.pump(); + expect(find.byIcon(Icons.wifi_off), findsOneWidget); + + // Go online — update provider state, then let AnimatedSize animate. + container.read(isOnlineProvider.notifier).state = true; + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.wifi_off), findsNothing); + }); + + testWidgets('banner transitions from online to offline', (tester) async { + final container = ProviderContainer( + overrides: [connectivityMonitorProvider.overrideWith((_) {})], + ); + addTearDown(container.dispose); + + // Start online (default is true). + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: Scaffold(body: OfflineBanner(child: SizedBox.expand())), + ), + ), + ); + await tester.pump(); + expect(find.byIcon(Icons.wifi_off), findsNothing); + + // Go offline — update provider state, then let AnimatedSize animate. + container.read(isOnlineProvider.notifier).state = false; + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.wifi_off), findsOneWidget); + }); + + testWidgets('child widget is always rendered regardless of connectivity', + (tester) async { + const childKey = Key('test-child'); + + for (final online in [true, false]) { + await tester.pumpWidget( + ProviderScope( + overrides: [ + isOnlineProvider.overrideWith((ref) => online), + connectivityMonitorProvider.overrideWith((_) {}), + ], + child: const MaterialApp( + home: Scaffold( + body: OfflineBanner( + child: SizedBox.expand(key: childKey), + ), + ), + ), + ), + ); + await tester.pump(); + expect(find.byKey(childKey), findsOneWidget, + reason: 'child must be visible when online=$online'); + } + }); + }); + + // ────────────────────────────────────────────────────────────────────────── + // Tier 3 — Integration stubs (AppRepository + SQLite + Supabase) + // + // These tests are NOT run automatically because they require: + // • a real (or local) Supabase instance + // • the full AppRepository.configure() call with a valid client + // + // To run manually: + // 1. Start a local Supabase dev stack: `supabase start` + // 2. Set SUPABASE_URL / SUPABASE_ANON_KEY env vars + // 3. Run: flutter test test/offline_sync_test.dart --name "integration" + // + // Scenario A — offline create → sync + // 1. Set AppRepository offline (no network / airplane mode mock) + // 2. Call AppRepository.instance.upsert(task) + // 3. Read back with OfflineFirstGetPolicy.localOnly → expect task present + // 4. Restore network + // 5. Wait for Brick HTTP queue to replay + // 6. Read with OfflineFirstGetPolicy.requireRemote → expect task synced + // 7. Verify task_number is now server-assigned (not null) + // + // Scenario B — online read → go offline → read from cache + // 1. Hydrate: AppRepository.instance.get() (pulls from Supabase) + // 2. Simulate offline (cut network) + // 3. AppRepository.instance.get(policy: localOnly) → expect cached + // 4. No exception thrown + // + // Scenario C — task number placeholder + // 1. Create task offline → task.taskNumber == null + // 2. UI should display 'OFFLINE-001' (handled by displayTaskNumber helper) + // 3. After sync → task.taskNumber != null → UI shows 'YYYY-MM-#####' + // ────────────────────────────────────────────────────────────────────────── + group('integration (manual only)', () { + // Placeholder so the group shows up in test output with a clear note. + test('see file comments for manual integration test instructions', () { + // This test intentionally passes — it documents the manual steps above. + }); + }); +} diff --git a/test/realtime_controller_test.dart b/test/realtime_controller_test.dart index c44f39f3..3410f7bc 100644 --- a/test/realtime_controller_test.dart +++ b/test/realtime_controller_test.dart @@ -1,27 +1,13 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:tasq/providers/realtime_controller.dart'; import 'package:tasq/providers/stream_recovery.dart'; -// --------------------------------------------------------------------------- -// RealtimeController only accesses `_client.auth.onAuthStateChange` during -// `_init()`, which wraps the subscription in a try-catch. We use a minimal -// SupabaseClient pointed at localhost — the auth stream subscription will -// either return an empty stream or throw (caught internally), so no network -// activity occurs during tests. -// --------------------------------------------------------------------------- - void main() { group('RealtimeController', () { late RealtimeController controller; setUp(() { - // SupabaseClient constructor does not connect eagerly; _init() catches - // any exception thrown when accessing auth.onAuthStateChange. - controller = RealtimeController( - SupabaseClient('http://localhost', 'test-anon-key', - authOptions: const AuthClientOptions(autoRefreshToken: false)), - ); + controller = RealtimeController(); }); tearDown(() {