Offline Support

This commit is contained in:
2026-04-27 06:20:39 +08:00
parent 7d8851a94a
commit 48cd3bcd60
121 changed files with 14798 additions and 2214 deletions
+18
View File
@@ -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"
+20
View File
@@ -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)"
}
]
}
+12
View File
@@ -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
+7
View File
@@ -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
+14
View File
@@ -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
+11 -2
View File
@@ -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()),
),
);
}
}
@@ -0,0 +1,319 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<Announcement> _$AnnouncementFromSupabase(
Map<String, dynamic> 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<String>(),
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<Map<String, dynamic>> _$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<Announcement> _$AnnouncementFromSqlite(
Map<String, dynamic> 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<String>(),
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<Map<String, dynamic>> _$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<Announcement> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
Announcement instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'Announcement';
@override
Future<Announcement> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$AnnouncementFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
Announcement input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$AnnouncementToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Announcement> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$AnnouncementFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
Announcement input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$AnnouncementToSqlite(
input,
provider: provider,
repository: repository,
);
}
@@ -0,0 +1,183 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<AnnouncementComment> _$AnnouncementCommentFromSupabase(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<AnnouncementComment> _$AnnouncementCommentFromSqlite(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<AnnouncementComment> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
AnnouncementComment instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'AnnouncementComment';
@override
Future<AnnouncementComment> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$AnnouncementCommentFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
AnnouncementComment input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$AnnouncementCommentToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<AnnouncementComment> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$AnnouncementCommentFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
AnnouncementComment input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$AnnouncementCommentToSqlite(
input,
provider: provider,
repository: repository,
);
}
@@ -0,0 +1,357 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<AttendanceLog> _$AttendanceLogFromSupabase(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<AttendanceLog> _$AttendanceLogFromSqlite(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<AttendanceLog> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
AttendanceLog instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'AttendanceLog';
@override
Future<AttendanceLog> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$AttendanceLogFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
AttendanceLog input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$AttendanceLogToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<AttendanceLog> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$AttendanceLogFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
AttendanceLog input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$AttendanceLogToSqlite(
input,
provider: provider,
repository: repository,
);
}
@@ -0,0 +1,182 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<ChatMessage> _$ChatMessageFromSupabase(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<ChatMessage> _$ChatMessageFromSqlite(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<ChatMessage> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
ChatMessage instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'ChatMessage';
@override
Future<ChatMessage> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ChatMessageFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
ChatMessage input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ChatMessageToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<ChatMessage> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ChatMessageFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
ChatMessage input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ChatMessageToSqlite(
input,
provider: provider,
repository: repository,
);
}
@@ -0,0 +1,265 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<DutySchedule> _$DutyScheduleFromSupabase(
Map<String, dynamic> 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<String>(),
swapRequestId: data['swap_request_id'] == null
? null
: data['swap_request_id'] as String?,
);
}
Future<Map<String, dynamic>> _$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<DutySchedule> _$DutyScheduleFromSqlite(
Map<String, dynamic> 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<String>(),
swapRequestId: data['swap_request_id'] == null
? null
: data['swap_request_id'] as String?,
)..primaryKey = data['_brick_id'] as int;
}
Future<Map<String, dynamic>> _$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<DutySchedule> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
DutySchedule instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'DutySchedule';
@override
Future<DutySchedule> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$DutyScheduleFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
DutySchedule input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$DutyScheduleToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<DutySchedule> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$DutyScheduleFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
DutySchedule input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$DutyScheduleToSqlite(
input,
provider: provider,
repository: repository,
);
}
@@ -0,0 +1,201 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<ItServiceRequestAction> _$ItServiceRequestActionFromSupabase(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<ItServiceRequestAction> _$ItServiceRequestActionFromSqlite(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<ItServiceRequestAction> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
ItServiceRequestAction instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'ItServiceRequestAction';
@override
Future<ItServiceRequestAction> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ItServiceRequestActionFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
ItServiceRequestAction input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ItServiceRequestActionToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<ItServiceRequestAction> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ItServiceRequestActionFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
ItServiceRequestAction input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ItServiceRequestActionToSqlite(
input,
provider: provider,
repository: repository,
);
}
@@ -0,0 +1,197 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<ItServiceRequestActivityLog> _$ItServiceRequestActivityLogFromSupabase(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<ItServiceRequestActivityLog> _$ItServiceRequestActivityLogFromSqlite(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<ItServiceRequestActivityLog> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
ItServiceRequestActivityLog instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'ItServiceRequestActivityLog';
@override
Future<ItServiceRequestActivityLog> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ItServiceRequestActivityLogFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
ItServiceRequestActivityLog input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ItServiceRequestActivityLogToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<ItServiceRequestActivityLog> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ItServiceRequestActivityLogFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
ItServiceRequestActivityLog input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ItServiceRequestActivityLogToSqlite(
input,
provider: provider,
repository: repository,
);
}
@@ -0,0 +1,635 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<ItServiceRequest> _$ItServiceRequestFromSupabase(
Map<String, dynamic> 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<String>(),
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<Map<String, dynamic>> _$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<ItServiceRequest> _$ItServiceRequestFromSqlite(
Map<String, dynamic> 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<String>(),
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<Map<String, dynamic>> _$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<ItServiceRequest> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
ItServiceRequest instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'ItServiceRequest';
@override
Future<ItServiceRequest> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ItServiceRequestFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
ItServiceRequest input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ItServiceRequestToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<ItServiceRequest> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ItServiceRequestFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
ItServiceRequest input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ItServiceRequestToSqlite(
input,
provider: provider,
repository: repository,
);
}
@@ -0,0 +1,169 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<ItServiceRequestAssignment> _$ItServiceRequestAssignmentFromSupabase(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<ItServiceRequestAssignment> _$ItServiceRequestAssignmentFromSqlite(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<ItServiceRequestAssignment> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
ItServiceRequestAssignment instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'ItServiceRequestAssignment';
@override
Future<ItServiceRequestAssignment> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ItServiceRequestAssignmentFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
ItServiceRequestAssignment input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ItServiceRequestAssignmentToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<ItServiceRequestAssignment> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ItServiceRequestAssignmentFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
ItServiceRequestAssignment input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ItServiceRequestAssignmentToSqlite(
input,
provider: provider,
repository: repository,
);
}
@@ -0,0 +1,239 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<LeaveOfAbsence> _$LeaveOfAbsenceFromSupabase(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<LeaveOfAbsence> _$LeaveOfAbsenceFromSqlite(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<LeaveOfAbsence> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
LeaveOfAbsence instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'LeaveOfAbsence';
@override
Future<LeaveOfAbsence> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$LeaveOfAbsenceFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
LeaveOfAbsence input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$LeaveOfAbsenceToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<LeaveOfAbsence> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$LeaveOfAbsenceFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
LeaveOfAbsence input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$LeaveOfAbsenceToSqlite(
input,
provider: provider,
repository: repository,
);
}
@@ -0,0 +1,315 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<NotificationItem> _$NotificationItemFromSupabase(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<NotificationItem> _$NotificationItemFromSqlite(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<NotificationItem> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
NotificationItem instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'NotificationItem';
@override
Future<NotificationItem> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$NotificationItemFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
NotificationItem input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$NotificationItemToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<NotificationItem> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$NotificationItemFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
NotificationItem input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$NotificationItemToSqlite(
input,
provider: provider,
repository: repository,
);
}
+155
View File
@@ -0,0 +1,155 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<Office> _$OfficeFromSupabase(
Map<String, dynamic> 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<Map<String, dynamic>> _$OfficeToSupabase(
Office instance, {
required SupabaseProvider provider,
OfflineFirstWithSupabaseRepository? repository,
}) async {
return {
'id': instance.id,
'name': instance.name,
'service_id': instance.serviceId,
};
}
Future<Office> _$OfficeFromSqlite(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<Office> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
Office instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'Office';
@override
Future<Office> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$OfficeFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
Office input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$OfficeToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Office> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$OfficeFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
Office input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async =>
await _$OfficeToSqlite(input, provider: provider, repository: repository);
}
+302
View File
@@ -0,0 +1,302 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<PassSlip> _$PassSlipFromSupabase(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<PassSlip> _$PassSlipFromSqlite(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<PassSlip> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
PassSlip instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'PassSlip';
@override
Future<PassSlip> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$PassSlipFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
PassSlip input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$PassSlipToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<PassSlip> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$PassSlipFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
PassSlip input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$PassSlipToSqlite(
input,
provider: provider,
repository: repository,
);
}
+240
View File
@@ -0,0 +1,240 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<Profile> _$ProfileFromSupabase(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<Profile> _$ProfileFromSqlite(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<Profile> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
Profile instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'Profile';
@override
Future<Profile> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProfileFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
Profile input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProfileToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Profile> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProfileFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
Profile input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProfileToSqlite(
input,
provider: provider,
repository: repository,
);
}
+129
View File
@@ -0,0 +1,129 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<Service> _$ServiceFromSupabase(
Map<String, dynamic> data, {
required SupabaseProvider provider,
OfflineFirstWithSupabaseRepository? repository,
}) async {
return Service(id: data['id'] as String, name: data['name'] as String);
}
Future<Map<String, dynamic>> _$ServiceToSupabase(
Service instance, {
required SupabaseProvider provider,
OfflineFirstWithSupabaseRepository? repository,
}) async {
return {'id': instance.id, 'name': instance.name};
}
Future<Service> _$ServiceFromSqlite(
Map<String, dynamic> 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<Map<String, dynamic>> _$ServiceToSqlite(
Service instance, {
required SqliteProvider provider,
OfflineFirstWithSupabaseRepository? repository,
}) async {
return {'id': instance.id, 'name': instance.name};
}
/// Construct a [Service]
class ServiceAdapter extends OfflineFirstWithSupabaseAdapter<Service> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
Service instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'Service';
@override
Future<Service> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ServiceFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
Service input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ServiceToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Service> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ServiceFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
Service input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ServiceToSqlite(
input,
provider: provider,
repository: repository,
);
}
@@ -0,0 +1,332 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<SwapRequest> _$SwapRequestFromSupabase(
Map<String, dynamic> 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<String>(),
approvedBy: data['approved_by'] == null
? null
: data['approved_by'] as String?,
);
}
Future<Map<String, dynamic>> _$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<SwapRequest> _$SwapRequestFromSqlite(
Map<String, dynamic> 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<String>(),
approvedBy: data['approved_by'] == null
? null
: data['approved_by'] as String?,
)..primaryKey = data['_brick_id'] as int;
}
Future<Map<String, dynamic>> _$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<SwapRequest> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
SwapRequest instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'SwapRequest';
@override
Future<SwapRequest> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$SwapRequestFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
SwapRequest input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$SwapRequestToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<SwapRequest> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$SwapRequestFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
SwapRequest input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$SwapRequestToSqlite(
input,
provider: provider,
repository: repository,
);
}
@@ -0,0 +1,197 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<TaskActivityLog> _$TaskActivityLogFromSupabase(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<TaskActivityLog> _$TaskActivityLogFromSqlite(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<TaskActivityLog> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
TaskActivityLog instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'TaskActivityLog';
@override
Future<TaskActivityLog> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$TaskActivityLogFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
TaskActivityLog input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$TaskActivityLogToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<TaskActivityLog> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$TaskActivityLogFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
TaskActivityLog input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$TaskActivityLogToSqlite(
input,
provider: provider,
repository: repository,
);
}
+529
View File
@@ -0,0 +1,529 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<Task> _$TaskFromSupabase(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<Task> _$TaskFromSqlite(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<Task> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
Task instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'Task';
@override
Future<Task> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$TaskFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
Task input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async =>
await _$TaskToSupabase(input, provider: provider, repository: repository);
@override
Future<Task> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async =>
await _$TaskFromSqlite(input, provider: provider, repository: repository);
@override
Future<Map<String, dynamic>> toSqlite(
Task input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async =>
await _$TaskToSqlite(input, provider: provider, repository: repository);
}
+187
View File
@@ -0,0 +1,187 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<Team> _$TeamFromSupabase(
Map<String, dynamic> 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<String>(),
createdAt: DateTime.parse(data['created_at'] as String),
color: data['color'] == null ? null : data['color'] as String?,
);
}
Future<Map<String, dynamic>> _$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<Team> _$TeamFromSqlite(
Map<String, dynamic> 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<String>(),
createdAt: DateTime.parse(data['created_at'] as String),
color: data['color'] == null ? null : data['color'] as String?,
)..primaryKey = data['_brick_id'] as int;
}
Future<Map<String, dynamic>> _$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<Team> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
Team instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'Team';
@override
Future<Team> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$TeamFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
Team input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async =>
await _$TeamToSupabase(input, provider: provider, repository: repository);
@override
Future<Team> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async =>
await _$TeamFromSqlite(input, provider: provider, repository: repository);
@override
Future<Map<String, dynamic>> toSqlite(
Team input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async =>
await _$TeamToSqlite(input, provider: provider, repository: repository);
}
+277
View File
@@ -0,0 +1,277 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<Ticket> _$TicketFromSupabase(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<Ticket> _$TicketFromSqlite(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<Ticket> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
Ticket instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'Ticket';
@override
Future<Ticket> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$TicketFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
Ticket input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$TicketToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Ticket> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$TicketFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
Ticket input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async =>
await _$TicketToSqlite(input, provider: provider, repository: repository);
}
+114
View File
@@ -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<Type, SupabaseAdapter<SupabaseModel>> 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<Type, SqliteAdapter<SqliteModel>> 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);
+63
View File
@@ -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<Task>(),
/// ```
Future<List<T>> cachedListFromBrick<T extends OfflineFirstWithSupabaseModel>() async {
if (!AppRepository.isConfigured) {
debugPrint('[cachedListFromBrick<$T>] BRICK NOT CONFIGURED — returning []');
return const [];
}
try {
final rows = await AppRepository.instance.get<T>(
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<Task>(rows, tag: 'tasks'),
/// ```
void mirrorBatchToBrick<T extends OfflineFirstWithSupabaseModel>(
List<T> 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<T>(row, repository: AppRepository.instance);
} catch (e) {
debugPrint('[$tag] mirror upsert<$T> failed: $e');
}
}
});
}
+360
View File
@@ -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<void>? _inflight;
/// Warm every cacheable table. Safe to call repeatedly.
static Future<void> 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<void> _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<void>([
_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<void> _warmReference(SupabaseClient client) async {
await Future.wait<void>([
_safe('offices', () async {
final rows = await client.from('offices').select();
for (final row in rows) {
await _localUpsert<Office>(Office.fromMap(row));
}
}),
_safe('services', () async {
final rows = await client.from('services').select();
for (final row in rows) {
await _localUpsert<Service>(Service.fromMap(row));
}
}),
_safe('profiles', () async {
final rows = await client.from('profiles').select();
for (final row in rows) {
await _localUpsert<Profile>(Profile.fromMap(row));
}
}),
_safe('teams', () async {
final rows = await client.from('teams').select();
for (final row in rows) {
await _localUpsert<Team>(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<List<Map<String, dynamic>>> 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<Map<String, dynamic>>();
} catch (_) {
return const [];
}
}
/// Read cached user_offices rows from SharedPreferences.
static Future<List<Map<String, dynamic>>> 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<Map<String, dynamic>>();
} catch (_) {
return const [];
}
}
// ------------------------------------------------------------------
// Per-module warms — windowed by created_at >= since
// ------------------------------------------------------------------
static Future<void> _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>(Task.fromMap(row));
}
}
static Future<void> _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>(Ticket.fromMap(row));
}
}
static Future<void> _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>(ItServiceRequest.fromMap(row));
}
}
static Future<void> _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>(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>(
AnnouncementComment.fromMap(row),
);
}
} catch (e) {
debugPrint('[CacheWarmer] announcement_comments failed: $e');
}
}
static Future<void> _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>(AttendanceLog.fromMap(row));
}
}
static Future<void> _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>(PassSlip.fromMap(row));
}
}
static Future<void> _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>(LeaveOfAbsence.fromMap(row));
}
}
static Future<void> _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>(DutySchedule.fromMap(row));
}
}
static Future<void> _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>(SwapRequest.fromMap(row));
}
}
static Future<void> _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>(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<void> _localUpsert<T extends OfflineFirstWithSupabaseModel>(
T model,
) async {
await AppRepository.instance.sqliteProvider
.upsert<T>(model, repository: AppRepository.instance);
}
static Future<void> _safe(String label, Future<void> Function() task) async {
try {
await task();
} catch (e, st) {
debugPrint('[CacheWarmer] $label failed: $e');
assert(() {
debugPrint('$st');
return true;
}());
}
}
}
+502
View File
@@ -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<MigrationCommand> _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<MigrationCommand> _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,
);
}
@@ -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<MigrationCommand> _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<MigrationCommand> _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,
);
}
+478
View File
@@ -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 = <Migration>{
const Migration20260411041808(),
const Migration20260411042152(),
};
/// A consumable database structure including the latest generated migration.
final schema = Schema(
20260411042152,
generatorVersion: 1,
tables: <SchemaTable>{
SchemaTable(
'Announcement',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
SchemaTable(
'AnnouncementComment',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
SchemaTable(
'AttendanceLog',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
SchemaTable(
'ChatMessage',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
SchemaTable(
'DutySchedule',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
SchemaTable(
'ItServiceRequest',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
SchemaTable(
'ItServiceRequestAction',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
SchemaTable(
'ItServiceRequestActivityLog',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
SchemaTable(
'ItServiceRequestAssignment',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
SchemaTable(
'LeaveOfAbsence',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
SchemaTable(
'NotificationItem',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
SchemaTable(
'Office',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
SchemaTable(
'PassSlip',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
SchemaTable(
'Profile',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
SchemaTable(
'Service',
columns: <SchemaColumn>{
SchemaColumn(
'_brick_id',
Column.integer,
autoincrement: true,
nullable: false,
isPrimaryKey: true,
),
SchemaColumn('id', Column.varchar),
SchemaColumn('name', Column.varchar),
},
indices: <SchemaIndex>{},
),
SchemaTable(
'SwapRequest',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
SchemaTable(
'Task',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
SchemaTable(
'TaskActivityLog',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
SchemaTable(
'Team',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
SchemaTable(
'Ticket',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
},
);
+160
View File
@@ -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<void> 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<List<T>> mirroredStream<T extends OfflineFirstWithSupabaseModel>(
Stream<List<T>> 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<T>(item).catchError((Object e) {
debugPrint('[AppRepository] mirror upsert<$T> failed: $e');
return item;
});
}
yield batch;
}
}
}
+39
View File
@@ -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<void> 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<void> 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<void> 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<void> 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();
+1 -111
View File
@@ -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<String> 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<String, dynamic> map) {
final rolesRaw = map['visible_roles'];
final roles = rolesRaw is List ? rolesRaw.cast<String>() : <String>[];
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';
+95
View File
@@ -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<String> 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<String, dynamic> map) {
final rolesRaw = map['visible_roles'];
final roles = rolesRaw is List ? rolesRaw.cast<String>() : <String>[];
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?,
);
}
}
+1 -47
View File
@@ -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<String, dynamic> 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';
@@ -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<String, dynamic> 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),
);
}
}
+1 -68
View File
@@ -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<String, dynamic> 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';
+79
View File
@@ -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<String, dynamic> 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?,
);
}
}
+1 -33
View File
@@ -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<String, dynamic> 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<String, dynamic> toJson() => {
'thread_id': threadId,
'sender_id': senderId,
'body': body,
};
}
export 'chat_message.model.dart';
+39
View File
@@ -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<String, dynamic> 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<String, dynamic> toJson() => {
'thread_id': threadId,
'sender_id': senderId,
'body': body,
};
}
+1 -55
View File
@@ -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<String> relieverIds;
final String? swapRequestId;
factory DutySchedule.fromMap(Map<String, dynamic> 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()
: <String>[];
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';
+67
View File
@@ -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<String> 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<String, dynamic> 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()
: <String>[];
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?,
);
}
}
+1 -259
View File
@@ -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<String> 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<String, dynamic> map) {
List<String> 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';
+268
View File
@@ -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<String> 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<String, dynamic> map) {
List<String> 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),
);
}
}
+1 -42
View File
@@ -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<String, dynamic> 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';
@@ -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<String, dynamic> 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),
);
}
}
@@ -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<String, dynamic>? meta;
final DateTime createdAt;
factory ItServiceRequestActivityLog.fromMap(Map<String, dynamic> 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<String, dynamic>? meta;
final rawMeta = map['meta'];
if (rawMeta is Map<String, dynamic>) {
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<String, dynamic>) {
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';
@@ -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<String, dynamic>? 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<String, dynamic>? 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<String, dynamic> 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<String, dynamic>? meta;
final rawMeta = map['meta'];
if (rawMeta is Map<String, dynamic>) {
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<String, dynamic>) {
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,
);
}
}
+1 -24
View File
@@ -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<String, dynamic> 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';
@@ -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<String, dynamic> 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),
);
}
}
+1 -54
View File
@@ -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<String, dynamic> 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';
+59
View File
@@ -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<String, dynamic> 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;
}
}
}
+1 -55
View File
@@ -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<String, dynamic> 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';
+65
View File
@@ -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<String, dynamic> 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),
);
}
}
+1 -15
View File
@@ -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<String, dynamic> map) {
return Office(
id: map['id'] as String,
name: map['name'] as String? ?? '',
serviceId: map['service_id'] as String?,
);
}
}
export 'office.model.dart';
+21
View File
@@ -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<String, dynamic> map) {
return Office(
id: map['id'] as String,
name: map['name'] as String? ?? '',
serviceId: map['service_id'] as String?,
);
}
}
+1 -62
View File
@@ -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<String, dynamic> 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';
+71
View File
@@ -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<String, dynamic> 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),
);
}
}
+1 -38
View File
@@ -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<String, dynamic> 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';
+47
View File
@@ -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<String, dynamic> 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),
);
}
}
+1 -10
View File
@@ -1,10 +1 @@
class Service {
Service({required this.id, required this.name});
final String id;
final String name;
factory Service.fromMap(Map<String, dynamic> map) {
return Service(id: map['id'] as String, name: map['name'] as String? ?? '');
}
}
export 'service.model.dart';
+19
View File
@@ -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<String, dynamic> map) {
return Service(
id: map['id'] as String,
name: map['name'] as String? ?? '',
);
}
}
+1 -62
View File
@@ -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<String>? relieverIds;
final String? approvedBy;
factory SwapRequest.fromMap(Map<String, dynamic> 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 <String>[],
approvedBy: map['approved_by'] as String?,
);
}
}
export 'swap_request.model.dart';
+72
View File
@@ -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<String>? 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<String, dynamic> 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 <String>[],
approvedBy: map['approved_by'] as String?,
);
}
}
+1 -186
View File
@@ -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<String, dynamic> 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';
+159
View File
@@ -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<String, dynamic> 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(); }
})(),
);
}
}
+1 -106
View File
@@ -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<String, dynamic>? meta;
final DateTime createdAt;
factory TaskActivityLog.fromMap(Map<String, dynamic> 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<dynamic,dynamic>, JSON-encoded string, List, or null
Map<String, dynamic>? meta;
final rawMeta = map['meta'];
if (rawMeta is Map<String, dynamic>) {
meta = rawMeta;
} else if (rawMeta is Map) {
// convert dynamic-key map to Map<String, dynamic>
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<String, dynamic>) {
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';
+102
View File
@@ -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<String, dynamic>? 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<String, dynamic>? 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<String, dynamic> 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<String, dynamic>? meta;
final rawMeta = map['meta'];
if (rawMeta is Map<String, dynamic>) {
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<String, dynamic>) {
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,
);
}
}
+1 -42
View File
@@ -1,42 +1 @@
// Extension to add members property to Team
import '../models/team_member.dart';
extension TeamMembersExtension on Team {
List<String> members(List<TeamMember> 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<String> officeIds;
final DateTime createdAt;
final String? color;
factory Team.fromMap(Map<String, dynamic> 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() ??
<String>[],
createdAt: DateTime.parse(map['created_at'] as String),
color: map['color'] as String?,
);
}
}
export 'team.model.dart';
+47
View File
@@ -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<String> members(List<TeamMember> 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<String> 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<String, dynamic> 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() ??
<String>[],
createdAt: DateTime.parse(map['created_at'] as String),
color: map['color'] as String?,
);
}
}
+1 -78
View File
@@ -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<String, dynamic> 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';
+71
View File
@@ -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<String, dynamic> 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),
);
}
}
+370 -62
View File
@@ -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<List<Announcement>>((ref) => const []);
/// Field-level updates to existing announcements made offline, keyed by id.
final offlinePendingAnnouncementUpdatesProvider =
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
/// Comments added offline, not yet confirmed by Supabase.
final offlinePendingAnnouncementCommentsProvider =
StateProvider<List<AnnouncementComment>>((ref) => const []);
// ---------------------------------------------------------------------------
// Read providers
// ---------------------------------------------------------------------------
/// Streams all announcements visible to the current user (RLS filtered).
final announcementsProvider = StreamProvider<List<Announcement>>((ref) {
final userId = ref.watch(currentUserIdProvider);
@@ -31,6 +54,53 @@ final announcementsProvider = StreamProvider<List<Announcement>>((ref) {
final client = ref.watch(supabaseClientProvider);
final pendingNew = ref.watch(offlinePendingAnnouncementsProvider);
final pendingUpdates = ref.watch(offlinePendingAnnouncementUpdatesProvider);
List<Announcement> applyPending(List<Announcement> 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<String>?) ??
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<Announcement>(
stream: client
.from('announcements')
@@ -47,10 +117,138 @@ final announcementsProvider = StreamProvider<List<Announcement>>((ref) {
fromMap: Announcement.fromMap,
channelName: 'announcements',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<Announcement>();
return applyPending(all);
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<Announcement>(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<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
// 1. Replay offline-created announcements
final pendingList =
List<Announcement>.from(ref.read(offlinePendingAnnouncementsProvider));
if (pendingList.isNotEmpty) {
debugPrint(
'[announcementsProvider] reconnected — syncing ${pendingList.length} announcement(s)',
);
final synced = <String>[];
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<String, Map<String, dynamic>>.from(
ref.read(offlinePendingAnnouncementUpdatesProvider),
);
if (pendingUpdatesMap.isNotEmpty) {
debugPrint(
'[announcementsProvider] reconnected — syncing ${pendingUpdatesMap.length} update(s)',
);
final syncedIds = <String>[];
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<String, Map<String, dynamic>>.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<AnnouncementComment>.from(
ref.read(offlinePendingAnnouncementCommentsProvider),
);
if (pendingComments.isNotEmpty) {
debugPrint(
'[announcementsProvider] reconnected — syncing ${pendingComments.length} comment(s)',
);
final synced = <String>[];
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<AnnouncementComment> applyPending(List<AnnouncementComment> 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<AnnouncementComment>(
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<AnnouncementComment>();
final filtered = all
.where((c) => c.announcementId == announcementId)
.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
return applyPending(filtered);
},
onCacheMirror: (rows) => mirrorBatchToBrick<AnnouncementComment>(
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<List<Announcement>>((ref) {
final all = ref.watch(announcementsProvider).valueOrNull ?? [];
return all.where((a) => !a.isTemplate && a.isBannerActive).toList();
});
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
final announcementsControllerProvider =
Provider<AnnouncementsController>((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<void> 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<void> 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<void> 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 = <String, dynamic>{
'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<void> dismissBanner(String id) async {
await _client.from('announcements').update({
final payload = <String, dynamic>{
'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<void> 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<String, dynamic>);
}
/// Add a comment to an announcement.
/// Notifies the announcement author and all previous commenters.
/// Add a comment. Offline: queues the comment locally.
Future<void> 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 = <String>{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<void> 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<void> 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<Announcement>.from(
ref.read(offlinePendingAnnouncementsProvider),
);
current.add(announcement);
ref.read(offlinePendingAnnouncementsProvider.notifier).state =
List.unmodifiable(current);
mirrorBatchToBrick<Announcement>([announcement], tag: 'offline_announcement');
}
void _queueAnnouncementUpdate(String id, Map<String, dynamic> fields) {
final ref = _ref;
if (ref == null) return;
final current = Map<String, Map<String, dynamic>>.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<AnnouncementComment>.from(
ref.read(offlinePendingAnnouncementCommentsProvider),
);
current.add(comment);
ref.read(offlinePendingAnnouncementCommentsProvider.notifier).state =
List.unmodifiable(current);
mirrorBatchToBrick<AnnouncementComment>([comment],
tag: 'offline_announcement_comment');
}
}
+349 -46
View File
@@ -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<ReportDateRange>((ref) {
final now = AppTime.now();
final today = DateTime(now.year, now.month, now.day);
@@ -25,12 +30,32 @@ final attendanceDateRangeProvider = StateProvider<ReportDateRange>((ref) {
/// Filter for logbook users (multi-select). If empty, all users are shown.
final attendanceUserFilterProvider = StateProvider<List<String>>((ref) => []);
// ---------------------------------------------------------------------------
// Offline-pending state
// ---------------------------------------------------------------------------
/// Check-ins created offline (typed model — merged into live stream).
final offlinePendingCheckInsProvider =
StateProvider<List<AttendanceLog>>((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<List<Map<String, dynamic>>>((ref) => const []);
/// Attendance field updates made offline, keyed by attendance log id.
/// Covers: checkouts, verification status updates, skipVerification.
final offlinePendingAttendanceUpdatesProvider =
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
// ---------------------------------------------------------------------------
// Read provider
// ---------------------------------------------------------------------------
/// All visible attendance logs (own for standard, all for admin/dispatcher/it_staff).
final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((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<List<AttendanceLog>>((ref) {
profileRole == 'dispatcher' ||
profileRole == 'it_staff';
final pendingCheckIns = ref.watch(offlinePendingCheckInsProvider);
final pendingUpdates = ref.watch(offlinePendingAttendanceUpdatesProvider);
List<AttendanceLog> applyPending(List<AttendanceLog> 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<AttendanceLog>(
stream: hasFullAccess
? client
@@ -68,55 +143,225 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
fromMap: AttendanceLog.fromMap,
channelName: 'attendance_logs',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<AttendanceLog>();
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<AttendanceLog>(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<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
// 1. Replay offline check-ins
final pendingRaw = List<Map<String, dynamic>>.from(
ref.read(offlinePendingCheckInRawProvider),
);
if (pendingRaw.isNotEmpty) {
debugPrint(
'[attendanceLogsProvider] reconnected — syncing ${pendingRaw.length} check-in(s)',
);
final syncedLocalIds = <String>[];
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<String, Map<String, dynamic>>.from(
ref.read(offlinePendingAttendanceUpdatesProvider),
);
if (pendingUpdatesMap.isNotEmpty) {
debugPrint(
'[attendanceLogsProvider] reconnected — syncing ${pendingUpdatesMap.length} attendance update(s)',
);
final syncedIds = <String>[];
for (final entry in pendingUpdatesMap.entries) {
final attendanceId = entry.key;
final fields = Map<String, dynamic>.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<String, Map<String, dynamic>>.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<AttendanceController>((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<String?> 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<void> 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<String?> 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<void> 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<void> 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<String, dynamic> rpcParams,
) {
final ref = _ref;
if (ref == null) return;
final currentModels = List<AttendanceLog>.from(
ref.read(offlinePendingCheckInsProvider),
);
currentModels.add(log);
ref.read(offlinePendingCheckInsProvider.notifier).state =
List.unmodifiable(currentModels);
final currentRaw = List<Map<String, dynamic>>.from(
ref.read(offlinePendingCheckInRawProvider),
);
currentRaw.add(rpcParams);
ref.read(offlinePendingCheckInRawProvider.notifier).state =
List.unmodifiable(currentRaw);
mirrorBatchToBrick<AttendanceLog>([log], tag: 'offline_checkin');
}
void _queueAttendanceUpdate(
String attendanceId,
Map<String, dynamic> fields,
) {
final ref = _ref;
if (ref == null) return;
final current = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingAttendanceUpdatesProvider),
);
current[attendanceId] = {...?current[attendanceId], ...fields};
ref.read(offlinePendingAttendanceUpdatesProvider.notifier).state = current;
}
}
+134 -8
View File
@@ -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<Map<String, List<ChatMessage>>>((ref) => const {});
// ---------------------------------------------------------------------------
// Read provider
// ---------------------------------------------------------------------------
/// Real-time chat messages for a swap request thread.
final chatMessagesProvider = StreamProvider.family<List<ChatMessage>, String>((
ref,
@@ -13,6 +32,19 @@ final chatMessagesProvider = StreamProvider.family<List<ChatMessage>, String>((
) {
final client = ref.watch(supabaseClientProvider);
final pendingByThread = ref.watch(offlinePendingChatMessagesProvider);
final pendingForThread = pendingByThread[threadId] ?? const [];
List<ChatMessage> applyPending(List<ChatMessage> 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<ChatMessage>(
stream: client
.from('chat_messages')
@@ -30,32 +62,126 @@ final chatMessagesProvider = StreamProvider.family<List<ChatMessage>, String>((
fromMap: ChatMessage.fromMap,
channelName: 'chat_messages_$threadId',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<ChatMessage>();
final filtered = all
.where((m) => m.threadId == threadId)
.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
return applyPending(filtered);
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<ChatMessage>(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<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
final pending = List<ChatMessage>.from(
(ref.read(offlinePendingChatMessagesProvider)[threadId] ?? []),
);
if (pending.isEmpty) return;
debugPrint(
'[chatMessagesProvider($threadId)] reconnected — syncing ${pending.length} message(s)',
);
final synced = <String>[];
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<String, List<ChatMessage>>.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<ChatController>((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<void> 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<String, List<ChatMessage>>.from(
ref.read(offlinePendingChatMessagesProvider),
);
current[threadId] = [...(current[threadId] ?? []), message];
ref.read(offlinePendingChatMessagesProvider.notifier).state =
Map.unmodifiable(current);
}
mirrorBatchToBrick<ChatMessage>([message], tag: 'offline_chat');
debugPrint('[ChatController] sendMessage queued offline: $id');
}
}
}
+87
View File
@@ -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<bool>((ref) => true);
/// Activating this provider starts a periodic connectivity poll.
/// Watch it from the root widget to keep it alive.
final connectivityMonitorProvider = Provider.autoDispose<void>((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<void> 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<bool> 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);
}
}
+493 -81
View File
@@ -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<ItServiceRequestQuery>(
(ref) => const ItServiceRequestQuery(),
);
// ---------------------------------------------------------------------------
// Offline-pending state
// ---------------------------------------------------------------------------
/// IT Service Requests created offline (typed model merged into live stream).
final offlinePendingItServiceRequestsProvider =
StateProvider<List<ItServiceRequest>>((ref) => const []);
/// Raw RPC params for offline-created requests used for reconnect replay.
final offlinePendingItServiceRequestRawProvider =
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
/// Field-level updates to existing requests made offline, keyed by request id.
final offlinePendingItServiceRequestUpdatesProvider =
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
/// Pending action-taken entries submitted offline.
/// Each entry: {id, request_id, user_id, action_taken}
final offlinePendingIsrActionsProvider =
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
// ---------------------------------------------------------------------------
// Stream providers
// ---------------------------------------------------------------------------
@@ -71,6 +98,24 @@ final itServiceRequestsProvider = StreamProvider<List<ItServiceRequest>>((ref) {
final profile = ref.watch(currentProfileProvider).valueOrNull;
final userOfficesAsync = ref.watch(userOfficesProvider);
final pendingNew = ref.watch(offlinePendingItServiceRequestsProvider);
final pendingUpdates =
ref.watch(offlinePendingItServiceRequestUpdatesProvider);
List<ItServiceRequest> applyPending(List<ItServiceRequest> 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<ItServiceRequest>(
stream: client
.from('it_service_requests')
@@ -87,12 +132,140 @@ final itServiceRequestsProvider = StreamProvider<List<ItServiceRequest>>((ref) {
fromMap: ItServiceRequest.fromMap,
channelName: 'it_service_requests',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<ItServiceRequest>();
all.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return applyPending(all);
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<ItServiceRequest>(rows, tag: 'it_service_requests'),
);
ref.onDispose(wrapper.dispose);
// Reconnect-replay: POST queued items directly, bypassing Brick's queue.
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
// 1. Replay offline-created requests (via RPC)
final pendingRaw = List<Map<String, dynamic>>.from(
ref.read(offlinePendingItServiceRequestRawProvider),
);
if (pendingRaw.isNotEmpty) {
debugPrint(
'[itServiceRequestsProvider] reconnected — syncing ${pendingRaw.length} request(s)',
);
final syncedIds = <String>[];
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<String, Map<String, dynamic>>.from(
ref.read(offlinePendingItServiceRequestUpdatesProvider),
);
if (pendingUpdatesMap.isNotEmpty) {
debugPrint(
'[itServiceRequestsProvider] reconnected — syncing ${pendingUpdatesMap.length} update(s)',
);
final syncedIds = <String>[];
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<String, Map<String, dynamic>>.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<Map<String, dynamic>>.from(
ref.read(offlinePendingIsrActionsProvider),
);
if (pendingActions.isNotEmpty) {
debugPrint(
'[itServiceRequestsProvider] reconnected — syncing ${pendingActions.length} action(s)',
);
final syncedIds = <String>[];
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<ItServiceRequestAssignment>();
all.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return all;
},
onCacheMirror: (rows) => mirrorBatchToBrick<ItServiceRequestAssignment>(
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<ItServiceRequestActivityLog>();
final filtered = all
.where((l) => l.requestId == requestId)
.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return filtered;
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<ItServiceRequestActivityLog>(
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<ItServiceRequestAction>();
final filtered = all
.where((a) => a.requestId == requestId)
.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return filtered;
},
onCacheMirror: (rows) => mirrorBatchToBrick<ItServiceRequestAction>(
rows,
tag: 'isr_actions',
),
);
ref.onDispose(wrapper.dispose);
@@ -222,15 +429,19 @@ final itServiceRequestActionsProvider =
final itServiceRequestControllerProvider = Provider<ItServiceRequestController>(
(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<Map<String, dynamic>> createRequest({
required String eventName,
required List<String> 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<void> 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<void> 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<void> 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 = <String, dynamic>{
'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<void> assignStaff({
@@ -463,7 +717,7 @@ class ItServiceRequestController {
}
// -----------------------------------------------------------------------
// Action Taken
// Action Taken offline-safe
// -----------------------------------------------------------------------
Future<String> 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<void> 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<List<Map<String, dynamic>>> 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<String, dynamic> rpcParams,
) {
final ref = _ref;
if (ref == null) return;
final currentModels = List<ItServiceRequest>.from(
ref.read(offlinePendingItServiceRequestsProvider),
);
currentModels.add(model);
ref.read(offlinePendingItServiceRequestsProvider.notifier).state =
List.unmodifiable(currentModels);
final currentRaw = List<Map<String, dynamic>>.from(
ref.read(offlinePendingItServiceRequestRawProvider),
);
currentRaw.add(rpcParams);
ref.read(offlinePendingItServiceRequestRawProvider.notifier).state =
List.unmodifiable(currentRaw);
mirrorBatchToBrick<ItServiceRequest>([model], tag: 'offline_isr');
}
void _queueRequestUpdate(String requestId, Map<String, dynamic> fields) {
final ref = _ref;
if (ref == null) return;
final current = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingItServiceRequestUpdatesProvider),
);
current[requestId] = {...?current[requestId], ...fields};
ref.read(offlinePendingItServiceRequestUpdatesProvider.notifier).state =
current;
}
void _queuePendingAction(Map<String, dynamic> action) {
final ref = _ref;
if (ref == null) return;
final current = List<Map<String, dynamic>>.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<String, dynamic> 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<dynamic>).cast<String>()
: 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,
);
}
+256 -53
View File
@@ -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<List<LeaveOfAbsence>>((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<Map<String, Map<String, dynamic>>>((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<List<LeaveOfAbsence>>((ref) {
final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider);
@@ -27,6 +49,40 @@ final leavesProvider = StreamProvider<List<LeaveOfAbsence>>((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<LeaveOfAbsence> applyPending(List<LeaveOfAbsence> 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<LeaveOfAbsence>(
stream: hasFullAccess
? client
@@ -50,24 +106,113 @@ final leavesProvider = StreamProvider<List<LeaveOfAbsence>>((ref) {
fromMap: LeaveOfAbsence.fromMap,
channelName: 'leave_of_absence',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<LeaveOfAbsence>();
final filtered = hasFullAccess
? all
: all.where((l) => l.userId == profile.id).toList();
return applyPending(filtered);
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<LeaveOfAbsence>(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<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
// 1. Replay offline-created leaves
final pending = List<LeaveOfAbsence>.from(ref.read(offlinePendingLeavesProvider));
if (pending.isNotEmpty) {
debugPrint('[leavesProvider] reconnected — syncing ${pending.length} leave(s)');
final synced = <String>[];
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<String, Map<String, dynamic>>.from(
ref.read(offlinePendingLeaveUpdatesProvider),
);
if (pendingMap.isNotEmpty) {
debugPrint(
'[leavesProvider] reconnected — syncing ${pendingMap.length} status edit(s)',
);
final syncedIds = <String>[];
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<String, Map<String, dynamic>>.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<LeaveController>((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<void> 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<String, dynamic>? inserted = insertedRaw is Map<String, dynamic>
? insertedRaw
: null;
Map<String, dynamic>? inserted;
try {
final insertedRaw = await _client
.from('leave_of_absence')
.insert(payload)
.select()
.maybeSingle();
inserted = insertedRaw is Map<String, dynamic> ? 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<LeaveOfAbsence>.from(
ref.read(offlinePendingLeavesProvider),
);
current.add(leave);
ref.read(offlinePendingLeavesProvider.notifier).state =
List.unmodifiable(current);
mirrorBatchToBrick<LeaveOfAbsence>([leave], tag: 'offline_leave');
}
void _queueLeaveStatusUpdate(String leaveId, Map<String, dynamic> fields) {
final ref = _ref;
if (ref == null) return;
final current = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingLeaveUpdatesProvider),
);
current[leaveId] = {...?current[leaveId], ...fields};
ref.read(offlinePendingLeaveUpdatesProvider.notifier).state = current;
}
Future<void> _notifyAdminsOfFiling({
required Map<String, dynamic>? 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 ?? <String, dynamic>{})['id']?.toString() ?? '';
final title = 'Leave Filed for Approval';
final body = '$actorName filed a leave request that requires approval.';
final notificationId = (inserted ?? <String, dynamic>{})['id']
?.toString();
final notificationId = (inserted ?? <String, dynamic>{})['id']?.toString();
final dataPayload = <String, dynamic>{
'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<void> _notifyRequester({
@@ -266,10 +464,15 @@ class LeaveController {
/// Cancel an approved leave.
Future<void> 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<List<String>> _fetchRoleUserIds({
+214 -43
View File
@@ -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<Set<String>>((ref) => const {});
/// Batch read-mark operations queued offline.
/// Each entry: {'filter_type': 'ticket'|'task', 'filter_id': ..., 'user_id': ...}
final offlinePendingBatchNotificationReadsProvider =
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
// ---------------------------------------------------------------------------
// Read provider
// ---------------------------------------------------------------------------
final notificationsProvider = StreamProvider<List<NotificationItem>>((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<NotificationItem> applyPending(List<NotificationItem> 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<NotificationItem>(
stream: client
.from('notifications')
@@ -34,10 +79,87 @@ final notificationsProvider = StreamProvider<List<NotificationItem>>((ref) {
fromMap: NotificationItem.fromMap,
channelName: 'notifications',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<NotificationItem>();
final filtered = all.where((n) => n.userId == userId).toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return applyPending(filtered);
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<NotificationItem>(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<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
// 1. Batch-mark individual notification reads
final pendingIds =
Set<String>.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<Map<String, dynamic>>.from(
ref.read(offlinePendingBatchNotificationReadsProvider),
);
if (pendingBatch.isNotEmpty) {
debugPrint(
'[notificationsProvider] reconnected — syncing ${pendingBatch.length} batch read(s)',
);
final synced = <int>[];
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<int>((ref) {
@@ -52,18 +174,15 @@ final notificationsControllerProvider = Provider<NotificationsController>((
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<void> _createAndPush(
List<Map<String, dynamic>> rows, {
List<String>? 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<void> createNotification({
required List<String> userIds,
required String type,
@@ -132,7 +243,6 @@ class NotificationsController {
);
}
/// Convenience for mention-specific case; left for compatibility.
Future<void> createMentionNotifications({
required List<String> userIds,
required String actorId,
@@ -158,36 +268,55 @@ class NotificationsController {
);
}
/// Mark a single notification as read. Offline: queues locally.
Future<void> 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<void> 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<void> 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<void> 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<void> 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<void> sendPush({
List<String>? tokens,
List<String>? 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<String>.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<Map<String, dynamic>>.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<T> on List<T> {
List<T> whereIndexed(bool Function(int index, T item) test) {
final result = <T>[];
for (var i = 0; i < length; i++) {
if (test(i, this[i])) result.add(this[i]);
}
return result;
}
}
+269 -69
View File
@@ -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<List<PassSlip>>((ref) => const []);
/// Status edits (approve/reject/complete) made offline, keyed by slip id.
final offlinePendingPassSlipUpdatesProvider =
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
// ---------------------------------------------------------------------------
// Read provider
// ---------------------------------------------------------------------------
/// All visible pass slips (own for staff, all for admin/dispatcher).
final passSlipsProvider = StreamProvider<List<PassSlip>>((ref) {
final client = ref.watch(supabaseClientProvider);
@@ -18,6 +39,50 @@ final passSlipsProvider = StreamProvider<List<PassSlip>>((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<PassSlip> applyPending(List<PassSlip> 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<PassSlip>(
stream: hasFullAccess
? client
@@ -41,10 +106,90 @@ final passSlipsProvider = StreamProvider<List<PassSlip>>((ref) {
fromMap: PassSlip.fromMap,
channelName: 'pass_slips',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<PassSlip>();
final filtered = hasFullAccess
? all
: all.where((s) => s.userId == profile.id).toList();
return applyPending(filtered);
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<PassSlip>(rows, tag: 'pass_slips'),
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
// Reconnect-replay
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
final pending = List<PassSlip>.from(ref.read(offlinePendingPassSlipsProvider));
if (pending.isNotEmpty) {
debugPrint('[passSlipsProvider] reconnected — syncing ${pending.length} slip(s)');
final synced = <String>[];
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<String, Map<String, dynamic>>.from(
ref.read(offlinePendingPassSlipUpdatesProvider),
);
if (pendingMap.isNotEmpty) {
debugPrint(
'[passSlipsProvider] reconnected — syncing ${pendingMap.length} status edit(s)',
);
final syncedIds = <String>[];
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<String, Map<String, dynamic>>.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<List<PassSlip>>((ref) {
return slips.where((s) => s.isActive).toList();
});
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
final passSlipControllerProvider = Provider<PassSlipController>((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<PassSlip>.from(
ref.read(offlinePendingPassSlipsProvider),
);
current.add(slip);
ref.read(offlinePendingPassSlipsProvider.notifier).state =
List.unmodifiable(current);
mirrorBatchToBrick<PassSlip>([slip], tag: 'offline_pass_slip');
}
void _queueSlipStatusUpdate(String slipId, Map<String, dynamic> fields) {
final ref = _ref;
if (ref == null) return;
final current = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingPassSlipUpdatesProvider),
);
current[slipId] = {...?current[slipId], ...fields};
ref.read(offlinePendingPassSlipUpdatesProvider.notifier).state = current;
}
Future<void> 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<String, dynamic>? inserted = insertedRaw is Map<String, dynamic>
? insertedRaw
: null;
Map<String, dynamic>? inserted;
try {
final insertedRaw = await _client
.from('pass_slips')
.insert(payload)
.select()
.maybeSingle();
inserted = insertedRaw is Map<String, dynamic> ? 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 ?? <String, dynamic>{})['id']?.toString() ?? '';
final slipId = (inserted ?? <String, dynamic>{})['id']?.toString() ?? id;
final title = 'Pass Slip Filed for Approval';
final body = '$actorName filed a pass slip that requires approval.';
final notificationId = (inserted ?? <String, dynamic>{})['id']
?.toString();
final notificationId = (inserted ?? <String, dynamic>{})['id']?.toString();
final dataPayload = <String, dynamic>{
'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<void> 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<void> _notifyRequester({
@@ -288,13 +485,16 @@ class PassSlipController {
}
Future<void> 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<List<String>> _fetchRoleUserIds({
+81 -6
View File
@@ -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<Map<String, String>>((ref) => const {});
final currentUserIdProvider = Provider<String?>((ref) {
final authState = ref.watch(authStateChangesProvider);
// Be explicit about loading/error to avoid dynamic dispatch problems.
@@ -37,9 +49,52 @@ final currentProfileProvider = StreamProvider<Profile?>((ref) {
return data == null ? [] : [Profile.fromMap(data)];
},
fromMap: Profile.fromMap,
onOfflineData: () async {
final all = await cachedListFromBrick<Profile>();
return all.where((p) => p.id == userId).toList();
},
onCacheMirror: (rows) => mirrorBatchToBrick<Profile>(
rows.whereType<Profile>().toList(),
tag: 'profile_current',
),
);
ref.onDispose(wrapper.dispose);
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
final pending = Map<String, String>.from(
ref.read(offlinePendingProfileUpdatesProvider),
);
if (pending.isEmpty) return;
debugPrint(
'[currentProfileProvider] reconnected — syncing ${pending.length} profile update(s)',
);
final synced = <String>[];
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<String, String>.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<List<Profile>>((ref) {
return data.map(Profile.fromMap).toList();
},
fromMap: Profile.fromMap,
onOfflineData: () async {
final all = await cachedListFromBrick<Profile>();
all.sort((a, b) => a.fullName.compareTo(b.fullName));
return all;
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<Profile>(rows, tag: 'profiles'),
);
ref.onDispose(wrapper.dispose);
@@ -67,23 +129,36 @@ final profilesProvider = StreamProvider<List<Profile>>((ref) {
/// Controller for the current user's profile (update full name / password).
final profileControllerProvider = Provider<ProfileController>((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<void> 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<String, String>.from(
ref.read(offlinePendingProfileUpdatesProvider),
);
current[userId] = fullName;
ref.read(offlinePendingProfileUpdatesProvider.notifier).state = current;
}
}
/// Update the current user's password (works for OAuth users too).
+11 -44
View File
@@ -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<RealtimeController>((
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<RealtimeController>((
/// - 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<String> _recoveringChannels = {};
StreamSubscription<AuthState>? _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<void> _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();
}
}
+8
View File
@@ -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<List<Service>>((ref) {
fromMap: Service.fromMap,
channelName: 'services',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<Service>();
all.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return all;
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<Service>(rows, tag: 'services'),
);
ref.onDispose(wrapper.dispose);
+223 -2
View File
@@ -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<T> {
// 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<WeakReference<StreamRecoveryWrapper>> _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<List<Map<String, dynamic>>> _realtimeStream;
final Future<List<T>> Function() _onPollData;
final T Function(Map<String, dynamic>) _fromMap;
@@ -105,6 +156,17 @@ class StreamRecoveryWrapper<T> {
/// 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<List<T>> 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<T> rows)? _onCacheMirror;
StreamConnectionStatus _connectionStatus = StreamConnectionStatus.connected;
int _recoveryAttempts = 0;
Timer? _pollingTimer;
@@ -115,6 +177,11 @@ class StreamRecoveryWrapper<T> {
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<List<Map<String, dynamic>>> stream,
required Future<List<T>> Function() onPollData,
@@ -122,11 +189,21 @@ class StreamRecoveryWrapper<T> {
StreamRecoveryConfig config = const StreamRecoveryConfig(),
this.channelName = 'unknown',
ChannelStatusCallback? onStatusChanged,
Future<List<T>> Function()? onOfflineData,
void Function(List<T> 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<T> {
void _onRealtimeData(List<Map<String, dynamic>> 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<T> {
}
_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<T>(
data: rows.map(_fromMap).toList(),
data: converted,
connectionStatus: StreamConnectionStatus.connected,
isStale: false,
),
@@ -198,6 +292,37 @@ class StreamRecoveryWrapper<T> {
// 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<T> {
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<T> {
Future<void> _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<T>(
data: data,
@@ -330,6 +485,58 @@ class StreamRecoveryWrapper<T> {
// 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<void> _emitOfflineData() async {
if (_disposed) return;
List<T> cached = const [];
if (_onOfflineData != null) {
try {
cached = await _onOfflineData();
} catch (e) {
debugPrint(
'StreamRecoveryWrapper[$channelName]: offline cache read failed: $e',
);
cached = const [];
}
}
_emit(
StreamRecoveryResult<T>(
data: cached,
connectionStatus: StreamConnectionStatus.offline,
isStale: cached.isNotEmpty,
),
);
}
void _emit(StreamRecoveryResult<T> result) {
if (!_disposed && _controller != null && !_controller!.isClosed) {
_controller!.add(result);
@@ -358,6 +565,20 @@ class StreamRecoveryWrapper<T> {
_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.
+107
View File
@@ -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<int>((ref) {
// Tasks
final tasks = ref.watch(offlinePendingTasksProvider).length;
final taskUpdates = ref.watch(offlinePendingTaskUpdatesProvider).length;
final messages = ref.watch(offlinePendingMessagesProvider)
.values
.fold<int>(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<int>(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;
});
File diff suppressed because it is too large Load Diff
+331 -36
View File
@@ -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<List<Office>>((ref) => const []);
final officesProvider = StreamProvider<List<Office>>((ref) {
final client = ref.watch(supabaseClientProvider);
final pendingOffices = ref.watch(offlinePendingOfficesProvider);
List<Office> applyPending(List<Office> 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<Office>(
stream: client.from('offices').stream(primaryKey: ['id']).order('name'),
@@ -27,10 +48,49 @@ final officesProvider = StreamProvider<List<Office>>((ref) {
fromMap: Office.fromMap,
channelName: 'offices',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<Office>();
all.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return applyPending(all);
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<Office>(rows, tag: 'offices'),
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
// Reconnect-replay for offline-created offices.
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
final pending = List<Office>.from(
ref.read(offlinePendingOfficesProvider),
);
if (pending.isEmpty) return;
final synced = <String>[];
for (final office in pending) {
try {
final payload = <String, dynamic>{'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<List<Office>>((ref) async {
@@ -70,7 +130,7 @@ final officesQueryProvider = StateProvider<OfficeQuery>(
final officesControllerProvider = Provider<OfficesController>((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<List<Ticket>>((ref) {
fromMap: Ticket.fromMap,
channelName: 'tickets',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () => cachedListFromBrick<Ticket>(),
onCacheMirror: (rows) =>
mirrorBatchToBrick<Ticket>(rows, tag: 'tickets'),
);
ref.onDispose(wrapper.dispose);
// Reconnect-replay: POST queued items directly, bypassing Brick's queue.
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
// 1. Replay offline-created tickets
final pendingRaw = List<Map<String, dynamic>>.from(
ref.read(offlinePendingTicketRawProvider),
);
if (pendingRaw.isNotEmpty) {
debugPrint(
'[ticketsProvider] reconnected — syncing ${pendingRaw.length} ticket(s)',
);
final syncedIds = <String>[];
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<String, Map<String, dynamic>>.from(
ref.read(offlinePendingTicketUpdatesProvider),
);
if (pendingUpdates.isNotEmpty) {
debugPrint(
'[ticketsProvider] reconnected — syncing ${pendingUpdates.length} ticket update(s)',
);
final syncedIds = <String>[];
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<String, Map<String, dynamic>>.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<List<Ticket>>((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<Ticket>(
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<dynamic>)
.cast<Map<String, dynamic>>()
.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<List<TicketMessage>, 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<List<Ticket>>((ref) => const []);
/// Raw INSERT payloads for offline-created tickets used for reconnect replay.
final offlinePendingTicketRawProvider =
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
/// Field-level updates to existing tickets made offline, keyed by ticket id.
final offlinePendingTicketUpdatesProvider =
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
final ticketsControllerProvider = Provider<TicketsController>((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<void> createTicket({
/// Creates a ticket. Returns the new [Ticket] so callers can add it to
/// [offlinePendingTicketsProvider] when offline.
Future<Ticket?> 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<Map<String, dynamic>>.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>([ticket], tag: 'offline_ticket');
return ticket;
}
}
Future<void> _notifyCreated({
@@ -661,27 +886,66 @@ class TicketsController {
required String? ticketId,
required String content,
}) async {
final senderId = _client.auth.currentUser?.id;
final payload = <String, dynamic>{
'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<String, dynamic>.from(payload)
..['created_at'] = createdAt;
final current = Map<String, List<Map<String, dynamic>>>.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<void> 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<void> 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<String, dynamic> fields) {
final ref = _ref;
if (ref == null) return;
final current = Map<String, Map<String, dynamic>>.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<void> createOffice({required String name, String? serviceId}) async {
final payload = {'name': name};
final id = const Uuid().v4();
final payload = <String, dynamic>{'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<Office>.from(
ref.read(offlinePendingOfficesProvider),
);
current.add(office);
ref.read(offlinePendingOfficesProvider.notifier).state =
List.unmodifiable(current);
mirrorBatchToBrick<Office>([office], tag: 'offline_offices');
}
}
Future<void> updateOffice({
+61 -10
View File
@@ -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<bool>(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<TypingIndicatorState> {
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<String, Timer> _remoteTimeouts = {};
@@ -94,7 +118,9 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
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<TypingIndicatorState> {
);
}
/// 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}) {
+139 -16
View File
@@ -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<List<Map<String, dynamic>>>((ref) => const []);
// ---------------------------------------------------------------------------
// Read provider
// ---------------------------------------------------------------------------
final userOfficesProvider = StreamProvider<List<UserOffice>>((ref) {
final client = ref.watch(supabaseClientProvider);
final pendingOps = ref.watch(offlinePendingUserOfficeOpsProvider);
List<UserOffice> applyPending(List<UserOffice> rows) {
if (pendingOps.isEmpty) return rows;
var result = List<UserOffice>.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<UserOffice>(
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<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
final pending = List<Map<String, dynamic>>.from(
ref.read(offlinePendingUserOfficeOpsProvider),
);
if (pending.isEmpty) return;
debugPrint(
'[userOfficesProvider] reconnected — syncing ${pending.length} office op(s)',
);
final synced = <int>[];
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 = <Map<String, dynamic>>[];
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<UserOfficesController>((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<void> 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<void> 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<Map<String, dynamic>>.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);
}
}
+22
View File
@@ -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<List<DutySchedule>>((ref) {
fromMap: DutySchedule.fromMap,
channelName: 'duty_schedules',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<DutySchedule>();
all.sort((a, b) => a.startTime.compareTo(b.startTime));
return all;
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<DutySchedule>(rows, tag: 'duty_schedules'),
);
ref.onDispose(wrapper.dispose);
@@ -150,6 +158,20 @@ final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) {
fromMap: SwapRequest.fromMap,
channelName: 'swap_requests',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<SwapRequest>();
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<SwapRequest>(rows, tag: 'swap_requests'),
);
ref.onDispose(wrapper.dispose);
+41 -23
View File
@@ -258,30 +258,48 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
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
@@ -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);
@@ -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(
@@ -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);
}
+82 -10
View File
@@ -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<String, Profile> 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<String, Profile> 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<String, Profile> 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 {
@@ -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');
}
}
}
}
@@ -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<AsyncValue<List<ItServiceRequest>>>(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 ?? <ItServiceRequest>[];
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<ItServiceRequest>();
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<ItServiceRequest> requests;
final Map<String, Office> officeById;
final Map<String, Profile> profileById;
final List<ItServiceRequestAssignment> assignments;
final Set<String> 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<String> 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)
+8 -3
View File
@@ -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');
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More