Compare commits
23 Commits
783c5eb0be
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e49b52949c | |||
| 65a42039ee | |||
| d813ee45a2 | |||
| e2ddc9a3ae | |||
| 5e20d14f23 | |||
| 5d818e0d4f | |||
| a91c049353 | |||
| c31187ef7c | |||
| 1768ed7b04 | |||
| ce0be25136 | |||
| 7475dbca41 | |||
| f39bc2cc06 | |||
| 7f38086237 | |||
| 5d0a66bb52 | |||
| 82141dd61c | |||
| 425fe8e683 | |||
| 4b9b0c7a20 | |||
| e9d1af867a | |||
| 30b301765b | |||
| 4d0d4d5ab3 | |||
| ccedf6e5f0 | |||
| d068887354 | |||
| 858520bd8d |
@@ -107,8 +107,8 @@
|
||||
"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)"
|
||||
"statusMessage": "Cleaning up and updating graph...",
|
||||
"command": "Write-Host \"[Hook] Cleaning project root...\"; Push-Location $env:CLAUDE_PROJECT_DIR; git ls-files --others --exclude-standard | Where-Object { $_ -notmatch '/' -and (Test-Path -LiteralPath $_) -and (Get-Item -LiteralPath $_).Length -eq 0 } | ForEach-Object { Remove-Item -LiteralPath $_ -Force; Write-Host \"[Cleanup] Removed: $_\" }; $nf = Join-Path $env:CLAUDE_PROJECT_DIR '$null'; if (Test-Path -LiteralPath $nf) { Remove-Item -LiteralPath $nf -Force; Write-Host '[Cleanup] Removed: $null artifact' }; Pop-Location; Write-Host \"[Hook] Regenerating graphify...\"; graphify 2>$null; $wshell = New-Object -ComObject WScript.Shell; $wshell.Popup(\"Claude finished. Project cleaned + graph updated.\", 5, \"Claude Code Task Complete\", 64)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+2
-1
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"deno.enable": true,
|
||||
"git.ignoreLimitWarning": true
|
||||
"git.ignoreLimitWarning": true,
|
||||
"cmake.ignoreCMakeListsMissing": true
|
||||
}
|
||||
+91
-113
@@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint, kIsWeb;
|
||||
import 'package:flutter/foundation.dart' show ValueNotifier, debugPrint, kIsWeb;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
@@ -45,6 +45,9 @@ class CacheWarmer {
|
||||
static const _kTeamMembersKey = 'cache_team_members_json';
|
||||
static const _kUserOfficesKey = 'cache_user_offices_json';
|
||||
|
||||
/// Prefix for per-table row-count stamps written after each successful warm.
|
||||
static const _kCountPrefix = 'cache_count_';
|
||||
|
||||
/// Foreground timer handle so [start]/[stop] is idempotent.
|
||||
static Timer? _refreshTimer;
|
||||
|
||||
@@ -52,6 +55,9 @@ class CacheWarmer {
|
||||
static bool _hasWarmedOnce = false;
|
||||
static bool get hasWarmedOnce => _hasWarmedOnce;
|
||||
|
||||
/// Fires true when a warm starts, false when it completes.
|
||||
static final ValueNotifier<bool> warmingNotifier = ValueNotifier<bool>(false);
|
||||
|
||||
/// Single-flight guard to prevent overlapping warms.
|
||||
static Future<void>? _inflight;
|
||||
|
||||
@@ -62,9 +68,21 @@ class CacheWarmer {
|
||||
final existing = _inflight;
|
||||
if (existing != null) return existing;
|
||||
|
||||
warmingNotifier.value = true;
|
||||
final future = _runWarmAll(client);
|
||||
_inflight = future;
|
||||
return future.whenComplete(() => _inflight = null);
|
||||
// Guarantee the warming flag is cleared within 90 s even if an HTTP request
|
||||
// hangs (no per-request timeout on the Supabase client by default).
|
||||
return future
|
||||
.timeout(
|
||||
const Duration(seconds: 90),
|
||||
onTimeout: () =>
|
||||
debugPrint('[CacheWarmer] warmAll timeout — clearing warming flag'),
|
||||
)
|
||||
.whenComplete(() {
|
||||
_inflight = null;
|
||||
warmingNotifier.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> _runWarmAll(SupabaseClient client) async {
|
||||
@@ -121,24 +139,28 @@ class CacheWarmer {
|
||||
for (final row in rows) {
|
||||
await _localUpsert<Office>(Office.fromMap(row));
|
||||
}
|
||||
await _writeCount('offices', rows.length);
|
||||
}),
|
||||
_safe('services', () async {
|
||||
final rows = await client.from('services').select();
|
||||
for (final row in rows) {
|
||||
await _localUpsert<Service>(Service.fromMap(row));
|
||||
}
|
||||
await _writeCount('services', rows.length);
|
||||
}),
|
||||
_safe('profiles', () async {
|
||||
final rows = await client.from('profiles').select();
|
||||
for (final row in rows) {
|
||||
await _localUpsert<Profile>(Profile.fromMap(row));
|
||||
}
|
||||
await _writeCount('profiles', rows.length);
|
||||
}),
|
||||
_safe('teams', () async {
|
||||
final rows = await client.from('teams').select();
|
||||
for (final row in rows) {
|
||||
await _localUpsert<Team>(Team.fromMap(row));
|
||||
}
|
||||
await _writeCount('teams', rows.length);
|
||||
}),
|
||||
// Association tables have composite PKs, so we cache them as JSON in
|
||||
// SharedPreferences instead of Brick. The list is small and read-only.
|
||||
@@ -146,6 +168,7 @@ class CacheWarmer {
|
||||
final rows = await client.from('team_members').select();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kTeamMembersKey, jsonEncode(rows));
|
||||
await _writeCount('team_members', rows.length);
|
||||
}),
|
||||
_safe('user_offices', () async {
|
||||
final rows = await client.from('user_offices').select();
|
||||
@@ -189,147 +212,77 @@ class CacheWarmer {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
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));
|
||||
}
|
||||
final rows = await client.from('tasks').select().gte('created_at', sinceIso);
|
||||
for (final row in rows) { await _localUpsert<Task>(Task.fromMap(row)); }
|
||||
await _writeCount('tasks', rows.length);
|
||||
}
|
||||
|
||||
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> _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)); }
|
||||
await _writeCount('tickets', rows.length);
|
||||
}
|
||||
|
||||
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> _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)); }
|
||||
await _writeCount('it_service_requests', rows.length);
|
||||
}
|
||||
|
||||
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.
|
||||
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)); }
|
||||
await _writeCount('announcements', rows.length);
|
||||
// Also pull comments 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),
|
||||
);
|
||||
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> _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)); }
|
||||
await _writeCount('attendance_logs', rows.length);
|
||||
}
|
||||
|
||||
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> _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)); }
|
||||
await _writeCount('pass_slips', rows.length);
|
||||
}
|
||||
|
||||
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> _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)); }
|
||||
await _writeCount('leave_of_absence', rows.length);
|
||||
}
|
||||
|
||||
static Future<void> _warmDutySchedules(
|
||||
SupabaseClient client,
|
||||
String sinceIso,
|
||||
) async {
|
||||
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));
|
||||
}
|
||||
final rows = await client.from('duty_schedules').select().gte('start_time', sinceIso);
|
||||
for (final row in rows) { await _localUpsert<DutySchedule>(DutySchedule.fromMap(row)); }
|
||||
await _writeCount('duty_schedules', rows.length);
|
||||
}
|
||||
|
||||
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> _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)); }
|
||||
await _writeCount('swap_requests', rows.length);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
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)); }
|
||||
await _writeCount('notifications', rows.length);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -357,4 +310,29 @@ class CacheWarmer {
|
||||
}());
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the number of rows cached for [table] to SharedPreferences.
|
||||
/// Called at the end of each per-table warm so the Offline Readiness screen
|
||||
/// can display cache coverage without issuing SQLite count queries.
|
||||
static Future<void> _writeCount(String table, int count) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('$_kCountPrefix$table', count);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Returns cached row counts written by the last successful warm, keyed by
|
||||
/// table name. A value of -1 means the table has never been warmed.
|
||||
static Future<Map<String, int>> readCacheCounts() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
const tables = [
|
||||
'tasks', 'tickets', 'announcements', 'attendance_logs',
|
||||
'duty_schedules', 'pass_slips', 'leave_of_absence', 'profiles',
|
||||
'teams', 'team_members', 'it_service_requests', 'notifications',
|
||||
'swap_requests', 'offices', 'services',
|
||||
];
|
||||
return {
|
||||
for (final t in tables) t: prefs.getInt('$_kCountPrefix$t') ?? -1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+17
-10
@@ -268,6 +268,21 @@ Future<void> main() async {
|
||||
debugPrint('dotenv load failed or timed out: $e');
|
||||
}
|
||||
|
||||
// Read VAPID_KEY once at startup. trim() handles trailing whitespace / BOM
|
||||
// artifacts that the .env parser may leave, which would cause isEmpty to
|
||||
// return true even when the key is present with a non-empty value.
|
||||
final vapidKey = kIsWeb ? (dotenv.env['VAPID_KEY']?.trim() ?? '') : '';
|
||||
if (kIsWeb) {
|
||||
if (vapidKey.isEmpty) {
|
||||
debugPrint(
|
||||
'Web FCM: VAPID_KEY not set in .env — web push notifications disabled. '
|
||||
'Add VAPID_KEY=<key> from Firebase Console → Project Settings → Cloud Messaging.',
|
||||
);
|
||||
} else {
|
||||
debugPrint('Web FCM: VAPID_KEY loaded (${vapidKey.length} chars).');
|
||||
}
|
||||
}
|
||||
|
||||
AppTime.initialize(location: 'Asia/Manila');
|
||||
|
||||
final supabaseUrl = dotenv.env['SUPABASE_URL'] ?? '';
|
||||
@@ -336,17 +351,9 @@ Future<void> main() async {
|
||||
final event = data.event;
|
||||
|
||||
// Web: register FCM token for iOS 16.4+ PWA push support.
|
||||
// Requires the VAPID key from Firebase Console → Project Settings →
|
||||
// Cloud Messaging → Web Push certificates → Key pair.
|
||||
// Add VAPID_KEY=<your_key> to your .env file.
|
||||
// vapidKey was read once at startup from dotenv — see boot sequence above.
|
||||
if (kIsWeb) {
|
||||
final vapidKey = dotenv.env['VAPID_KEY'] ?? '';
|
||||
if (vapidKey.isEmpty) {
|
||||
debugPrint(
|
||||
'Web FCM: VAPID_KEY not set in .env — skipping token registration.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (vapidKey.isEmpty) return; // already warned at startup
|
||||
if (event == AuthChangeEvent.signedIn) {
|
||||
try {
|
||||
final token = await FirebaseMessaging.instance.getToken(
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';
|
||||
import 'package:brick_supabase/brick_supabase.dart';
|
||||
|
||||
enum NetworkDeviceStatus {
|
||||
online,
|
||||
offline,
|
||||
warning,
|
||||
unknown;
|
||||
|
||||
String get wire {
|
||||
switch (this) {
|
||||
case NetworkDeviceStatus.online:
|
||||
return 'online';
|
||||
case NetworkDeviceStatus.offline:
|
||||
return 'offline';
|
||||
case NetworkDeviceStatus.warning:
|
||||
return 'warning';
|
||||
case NetworkDeviceStatus.unknown:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case NetworkDeviceStatus.online:
|
||||
return 'Online';
|
||||
case NetworkDeviceStatus.offline:
|
||||
return 'Offline';
|
||||
case NetworkDeviceStatus.warning:
|
||||
return 'Warning';
|
||||
case NetworkDeviceStatus.unknown:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
static NetworkDeviceStatus fromWire(String? value) {
|
||||
switch (value) {
|
||||
case 'online':
|
||||
return NetworkDeviceStatus.online;
|
||||
case 'offline':
|
||||
return NetworkDeviceStatus.offline;
|
||||
case 'warning':
|
||||
return NetworkDeviceStatus.warning;
|
||||
default:
|
||||
return NetworkDeviceStatus.unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NetworkDeviceKind {
|
||||
router,
|
||||
switchDevice,
|
||||
ap,
|
||||
firewall,
|
||||
server,
|
||||
endpoint,
|
||||
patchPanel,
|
||||
other;
|
||||
|
||||
String get wire {
|
||||
switch (this) {
|
||||
case NetworkDeviceKind.router:
|
||||
return 'router';
|
||||
case NetworkDeviceKind.switchDevice:
|
||||
return 'switch';
|
||||
case NetworkDeviceKind.ap:
|
||||
return 'ap';
|
||||
case NetworkDeviceKind.firewall:
|
||||
return 'firewall';
|
||||
case NetworkDeviceKind.server:
|
||||
return 'server';
|
||||
case NetworkDeviceKind.endpoint:
|
||||
return 'endpoint';
|
||||
case NetworkDeviceKind.patchPanel:
|
||||
return 'patch_panel';
|
||||
case NetworkDeviceKind.other:
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case NetworkDeviceKind.router:
|
||||
return 'Router';
|
||||
case NetworkDeviceKind.switchDevice:
|
||||
return 'Switch';
|
||||
case NetworkDeviceKind.ap:
|
||||
return 'Access Point';
|
||||
case NetworkDeviceKind.firewall:
|
||||
return 'Firewall';
|
||||
case NetworkDeviceKind.server:
|
||||
return 'Server';
|
||||
case NetworkDeviceKind.endpoint:
|
||||
return 'Endpoint';
|
||||
case NetworkDeviceKind.patchPanel:
|
||||
return 'Patch Panel';
|
||||
case NetworkDeviceKind.other:
|
||||
return 'Other';
|
||||
}
|
||||
}
|
||||
|
||||
static NetworkDeviceKind fromWire(String? value) {
|
||||
switch (value) {
|
||||
case 'router':
|
||||
return NetworkDeviceKind.router;
|
||||
case 'switch':
|
||||
return NetworkDeviceKind.switchDevice;
|
||||
case 'ap':
|
||||
return NetworkDeviceKind.ap;
|
||||
case 'firewall':
|
||||
return NetworkDeviceKind.firewall;
|
||||
case 'server':
|
||||
return NetworkDeviceKind.server;
|
||||
case 'endpoint':
|
||||
return NetworkDeviceKind.endpoint;
|
||||
case 'patch_panel':
|
||||
return NetworkDeviceKind.patchPanel;
|
||||
default:
|
||||
return NetworkDeviceKind.other;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NetworkDeviceRole {
|
||||
core,
|
||||
distribution,
|
||||
access,
|
||||
edge,
|
||||
endpoint;
|
||||
|
||||
String get wire {
|
||||
switch (this) {
|
||||
case NetworkDeviceRole.core:
|
||||
return 'core';
|
||||
case NetworkDeviceRole.distribution:
|
||||
return 'distribution';
|
||||
case NetworkDeviceRole.access:
|
||||
return 'access';
|
||||
case NetworkDeviceRole.edge:
|
||||
return 'edge';
|
||||
case NetworkDeviceRole.endpoint:
|
||||
return 'endpoint';
|
||||
}
|
||||
}
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case NetworkDeviceRole.core:
|
||||
return 'Core';
|
||||
case NetworkDeviceRole.distribution:
|
||||
return 'Distribution';
|
||||
case NetworkDeviceRole.access:
|
||||
return 'Access';
|
||||
case NetworkDeviceRole.edge:
|
||||
return 'Edge';
|
||||
case NetworkDeviceRole.endpoint:
|
||||
return 'Endpoint';
|
||||
}
|
||||
}
|
||||
|
||||
static NetworkDeviceRole? fromWire(String? value) {
|
||||
switch (value) {
|
||||
case 'core':
|
||||
return NetworkDeviceRole.core;
|
||||
case 'distribution':
|
||||
return NetworkDeviceRole.distribution;
|
||||
case 'access':
|
||||
return NetworkDeviceRole.access;
|
||||
case 'edge':
|
||||
return NetworkDeviceRole.edge;
|
||||
case 'endpoint':
|
||||
return NetworkDeviceRole.endpoint;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NetworkImportSource {
|
||||
manual,
|
||||
aiImport,
|
||||
agent;
|
||||
|
||||
String get wire {
|
||||
switch (this) {
|
||||
case NetworkImportSource.manual:
|
||||
return 'manual';
|
||||
case NetworkImportSource.aiImport:
|
||||
return 'ai_import';
|
||||
case NetworkImportSource.agent:
|
||||
return 'agent';
|
||||
}
|
||||
}
|
||||
|
||||
static NetworkImportSource fromWire(String? value) {
|
||||
switch (value) {
|
||||
case 'ai_import':
|
||||
return NetworkImportSource.aiImport;
|
||||
case 'agent':
|
||||
return NetworkImportSource.agent;
|
||||
default:
|
||||
return NetworkImportSource.manual;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ConnectOfflineFirstWithSupabase(
|
||||
supabaseConfig: SupabaseSerializable(tableName: 'network_devices'),
|
||||
)
|
||||
class NetworkDevice extends OfflineFirstWithSupabaseModel {
|
||||
final String id;
|
||||
final String name;
|
||||
final NetworkDeviceKind kind;
|
||||
final NetworkDeviceRole? role;
|
||||
final String? vendor;
|
||||
final String? model;
|
||||
final String? serial;
|
||||
final String? mgmtIp;
|
||||
final String? mac;
|
||||
final String? locationId;
|
||||
final NetworkImportSource importSource;
|
||||
final NetworkDeviceStatus status;
|
||||
final String? notes;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
NetworkDevice({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.kind,
|
||||
this.role,
|
||||
this.vendor,
|
||||
this.model,
|
||||
this.serial,
|
||||
this.mgmtIp,
|
||||
this.mac,
|
||||
this.locationId,
|
||||
this.importSource = NetworkImportSource.manual,
|
||||
this.status = NetworkDeviceStatus.unknown,
|
||||
this.notes,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory NetworkDevice.fromMap(Map<String, dynamic> map) {
|
||||
return NetworkDevice(
|
||||
id: map['id'] as String,
|
||||
name: (map['name'] as String?) ?? '',
|
||||
kind: NetworkDeviceKind.fromWire(map['kind'] as String?),
|
||||
role: NetworkDeviceRole.fromWire(map['role'] as String?),
|
||||
vendor: map['vendor'] as String?,
|
||||
model: map['model'] as String?,
|
||||
serial: map['serial'] as String?,
|
||||
mgmtIp: map['mgmt_ip']?.toString(),
|
||||
mac: map['mac']?.toString(),
|
||||
locationId: map['location_id'] as String?,
|
||||
importSource: NetworkImportSource.fromWire(map['import_source'] as String?),
|
||||
status: NetworkDeviceStatus.fromWire(map['status'] as String?),
|
||||
notes: map['notes'] as String?,
|
||||
createdAt:
|
||||
DateTime.tryParse(map['created_at']?.toString() ?? '') ?? DateTime.now(),
|
||||
updatedAt:
|
||||
DateTime.tryParse(map['updated_at']?.toString() ?? '') ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toInsertMap() => {
|
||||
'name': name,
|
||||
'kind': kind.wire,
|
||||
if (role != null) 'role': role!.wire,
|
||||
if (vendor != null) 'vendor': vendor,
|
||||
if (model != null) 'model': model,
|
||||
if (serial != null) 'serial': serial,
|
||||
if (mgmtIp != null) 'mgmt_ip': mgmtIp,
|
||||
if (mac != null) 'mac': mac,
|
||||
if (locationId != null) 'location_id': locationId,
|
||||
'import_source': importSource.wire,
|
||||
'status': status.wire,
|
||||
if (notes != null) 'notes': notes,
|
||||
};
|
||||
|
||||
Map<String, dynamic> toUpdateMap() => {
|
||||
'name': name,
|
||||
'kind': kind.wire,
|
||||
'role': role?.wire,
|
||||
'vendor': vendor,
|
||||
'model': model,
|
||||
'serial': serial,
|
||||
'mgmt_ip': mgmtIp,
|
||||
'mac': mac,
|
||||
'location_id': locationId,
|
||||
'status': status.wire,
|
||||
'notes': notes,
|
||||
};
|
||||
|
||||
NetworkDevice copyWith({
|
||||
String? name,
|
||||
NetworkDeviceKind? kind,
|
||||
NetworkDeviceRole? role,
|
||||
String? vendor,
|
||||
String? model,
|
||||
String? serial,
|
||||
String? mgmtIp,
|
||||
String? mac,
|
||||
String? locationId,
|
||||
NetworkDeviceStatus? status,
|
||||
String? notes,
|
||||
}) {
|
||||
return NetworkDevice(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
kind: kind ?? this.kind,
|
||||
role: role ?? this.role,
|
||||
vendor: vendor ?? this.vendor,
|
||||
model: model ?? this.model,
|
||||
serial: serial ?? this.serial,
|
||||
mgmtIp: mgmtIp ?? this.mgmtIp,
|
||||
mac: mac ?? this.mac,
|
||||
locationId: locationId ?? this.locationId,
|
||||
importSource: importSource,
|
||||
status: status ?? this.status,
|
||||
notes: notes ?? this.notes,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';
|
||||
import 'package:brick_supabase/brick_supabase.dart';
|
||||
|
||||
enum NetworkImportFileKind {
|
||||
pdf,
|
||||
image,
|
||||
vsdx,
|
||||
csv,
|
||||
xlsx,
|
||||
docx,
|
||||
other;
|
||||
|
||||
String get wire {
|
||||
switch (this) {
|
||||
case NetworkImportFileKind.pdf:
|
||||
return 'pdf';
|
||||
case NetworkImportFileKind.image:
|
||||
return 'image';
|
||||
case NetworkImportFileKind.vsdx:
|
||||
return 'vsdx';
|
||||
case NetworkImportFileKind.csv:
|
||||
return 'csv';
|
||||
case NetworkImportFileKind.xlsx:
|
||||
return 'xlsx';
|
||||
case NetworkImportFileKind.docx:
|
||||
return 'docx';
|
||||
case NetworkImportFileKind.other:
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
static NetworkImportFileKind fromExtension(String filename) {
|
||||
final lower = filename.toLowerCase();
|
||||
if (lower.endsWith('.pdf')) return NetworkImportFileKind.pdf;
|
||||
if (lower.endsWith('.vsdx') || lower.endsWith('.vsd')) {
|
||||
return NetworkImportFileKind.vsdx;
|
||||
}
|
||||
if (lower.endsWith('.csv')) return NetworkImportFileKind.csv;
|
||||
if (lower.endsWith('.xlsx') || lower.endsWith('.xls')) {
|
||||
return NetworkImportFileKind.xlsx;
|
||||
}
|
||||
if (lower.endsWith('.docx') || lower.endsWith('.doc')) {
|
||||
return NetworkImportFileKind.docx;
|
||||
}
|
||||
if (lower.endsWith('.png') ||
|
||||
lower.endsWith('.jpg') ||
|
||||
lower.endsWith('.jpeg') ||
|
||||
lower.endsWith('.webp') ||
|
||||
lower.endsWith('.heic')) {
|
||||
return NetworkImportFileKind.image;
|
||||
}
|
||||
return NetworkImportFileKind.other;
|
||||
}
|
||||
}
|
||||
|
||||
enum NetworkImportStatus {
|
||||
pending,
|
||||
extracting,
|
||||
review,
|
||||
applied,
|
||||
discarded,
|
||||
failed;
|
||||
|
||||
String get wire {
|
||||
switch (this) {
|
||||
case NetworkImportStatus.pending:
|
||||
return 'pending';
|
||||
case NetworkImportStatus.extracting:
|
||||
return 'extracting';
|
||||
case NetworkImportStatus.review:
|
||||
return 'review';
|
||||
case NetworkImportStatus.applied:
|
||||
return 'applied';
|
||||
case NetworkImportStatus.discarded:
|
||||
return 'discarded';
|
||||
case NetworkImportStatus.failed:
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
static NetworkImportStatus fromWire(String? value) {
|
||||
switch (value) {
|
||||
case 'extracting':
|
||||
return NetworkImportStatus.extracting;
|
||||
case 'review':
|
||||
return NetworkImportStatus.review;
|
||||
case 'applied':
|
||||
return NetworkImportStatus.applied;
|
||||
case 'discarded':
|
||||
return NetworkImportStatus.discarded;
|
||||
case 'failed':
|
||||
return NetworkImportStatus.failed;
|
||||
default:
|
||||
return NetworkImportStatus.pending;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ConnectOfflineFirstWithSupabase(
|
||||
supabaseConfig: SupabaseSerializable(tableName: 'network_imports'),
|
||||
)
|
||||
class NetworkImport extends OfflineFirstWithSupabaseModel {
|
||||
final String id;
|
||||
final String storagePath;
|
||||
final NetworkImportFileKind fileKind;
|
||||
final NetworkImportStatus status;
|
||||
final Map<String, dynamic>? aiResult;
|
||||
final String? errorMessage;
|
||||
final List<String> appliedDeviceIds;
|
||||
final List<String> appliedLinkIds;
|
||||
final DateTime? appliedAt;
|
||||
final String createdBy;
|
||||
final DateTime createdAt;
|
||||
|
||||
NetworkImport({
|
||||
required this.id,
|
||||
required this.storagePath,
|
||||
required this.fileKind,
|
||||
required this.status,
|
||||
this.aiResult,
|
||||
this.errorMessage,
|
||||
this.appliedDeviceIds = const [],
|
||||
this.appliedLinkIds = const [],
|
||||
this.appliedAt,
|
||||
required this.createdBy,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory NetworkImport.fromMap(Map<String, dynamic> map) {
|
||||
List<String> idList(dynamic raw) {
|
||||
if (raw is List) {
|
||||
return raw.map((v) => v.toString()).toList();
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
return NetworkImport(
|
||||
id: map['id'] as String,
|
||||
storagePath: (map['storage_path'] as String?) ?? '',
|
||||
fileKind: _fileKindFromWire(map['file_kind'] as String?),
|
||||
status: NetworkImportStatus.fromWire(map['status'] as String?),
|
||||
aiResult: map['ai_result'] is Map<String, dynamic>
|
||||
? map['ai_result'] as Map<String, dynamic>
|
||||
: null,
|
||||
errorMessage: map['error_message'] as String?,
|
||||
appliedDeviceIds: idList(map['applied_device_ids']),
|
||||
appliedLinkIds: idList(map['applied_link_ids']),
|
||||
appliedAt: map['applied_at'] == null
|
||||
? null
|
||||
: DateTime.tryParse(map['applied_at']?.toString() ?? ''),
|
||||
createdBy: map['created_by'] as String,
|
||||
createdAt:
|
||||
DateTime.tryParse(map['created_at']?.toString() ?? '') ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
static NetworkImportFileKind _fileKindFromWire(String? value) {
|
||||
switch (value) {
|
||||
case 'pdf':
|
||||
return NetworkImportFileKind.pdf;
|
||||
case 'image':
|
||||
return NetworkImportFileKind.image;
|
||||
case 'vsdx':
|
||||
return NetworkImportFileKind.vsdx;
|
||||
case 'csv':
|
||||
return NetworkImportFileKind.csv;
|
||||
case 'xlsx':
|
||||
return NetworkImportFileKind.xlsx;
|
||||
case 'docx':
|
||||
return NetworkImportFileKind.docx;
|
||||
default:
|
||||
return NetworkImportFileKind.other;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';
|
||||
import 'package:brick_supabase/brick_supabase.dart';
|
||||
|
||||
enum NetworkLinkKind {
|
||||
copper,
|
||||
fiber,
|
||||
wireless,
|
||||
virtual,
|
||||
unknown;
|
||||
|
||||
String get wire {
|
||||
switch (this) {
|
||||
case NetworkLinkKind.copper:
|
||||
return 'copper';
|
||||
case NetworkLinkKind.fiber:
|
||||
return 'fiber';
|
||||
case NetworkLinkKind.wireless:
|
||||
return 'wireless';
|
||||
case NetworkLinkKind.virtual:
|
||||
return 'virtual';
|
||||
case NetworkLinkKind.unknown:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case NetworkLinkKind.copper:
|
||||
return 'Copper';
|
||||
case NetworkLinkKind.fiber:
|
||||
return 'Fiber';
|
||||
case NetworkLinkKind.wireless:
|
||||
return 'Wireless';
|
||||
case NetworkLinkKind.virtual:
|
||||
return 'Virtual';
|
||||
case NetworkLinkKind.unknown:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
static NetworkLinkKind? fromWire(String? value) {
|
||||
switch (value) {
|
||||
case 'copper':
|
||||
return NetworkLinkKind.copper;
|
||||
case 'fiber':
|
||||
return NetworkLinkKind.fiber;
|
||||
case 'wireless':
|
||||
return NetworkLinkKind.wireless;
|
||||
case 'virtual':
|
||||
return NetworkLinkKind.virtual;
|
||||
case 'unknown':
|
||||
return NetworkLinkKind.unknown;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ConnectOfflineFirstWithSupabase(
|
||||
supabaseConfig: SupabaseSerializable(tableName: 'network_links'),
|
||||
)
|
||||
class NetworkLink extends OfflineFirstWithSupabaseModel {
|
||||
final String id;
|
||||
final String portA;
|
||||
final String portB;
|
||||
final NetworkLinkKind? linkKind;
|
||||
final String? cableLabel;
|
||||
final String? notes;
|
||||
final DateTime createdAt;
|
||||
|
||||
NetworkLink({
|
||||
required this.id,
|
||||
required this.portA,
|
||||
required this.portB,
|
||||
this.linkKind,
|
||||
this.cableLabel,
|
||||
this.notes,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory NetworkLink.fromMap(Map<String, dynamic> map) {
|
||||
return NetworkLink(
|
||||
id: map['id'] as String,
|
||||
portA: map['port_a'] as String,
|
||||
portB: map['port_b'] as String,
|
||||
linkKind: NetworkLinkKind.fromWire(map['link_kind'] as String?),
|
||||
cableLabel: map['cable_label'] as String?,
|
||||
notes: map['notes'] as String?,
|
||||
createdAt:
|
||||
DateTime.tryParse(map['created_at']?.toString() ?? '') ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Insert helper that enforces the canonical port_a < port_b ordering
|
||||
/// required by the database CHECK constraint.
|
||||
static Map<String, dynamic> insertMapFor({
|
||||
required String portA,
|
||||
required String portB,
|
||||
NetworkLinkKind? linkKind,
|
||||
String? cableLabel,
|
||||
String? notes,
|
||||
}) {
|
||||
final ordered = portA.compareTo(portB) < 0 ? [portA, portB] : [portB, portA];
|
||||
return {
|
||||
'port_a': ordered[0],
|
||||
'port_b': ordered[1],
|
||||
'link_kind': ?linkKind?.wire,
|
||||
'cable_label': ?cableLabel,
|
||||
'notes': ?notes,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';
|
||||
import 'package:brick_supabase/brick_supabase.dart';
|
||||
|
||||
enum NetworkLocationKind {
|
||||
building,
|
||||
floor,
|
||||
room,
|
||||
rack,
|
||||
wallJack,
|
||||
other;
|
||||
|
||||
String get wire {
|
||||
switch (this) {
|
||||
case NetworkLocationKind.building:
|
||||
return 'building';
|
||||
case NetworkLocationKind.floor:
|
||||
return 'floor';
|
||||
case NetworkLocationKind.room:
|
||||
return 'room';
|
||||
case NetworkLocationKind.rack:
|
||||
return 'rack';
|
||||
case NetworkLocationKind.wallJack:
|
||||
return 'wall_jack';
|
||||
case NetworkLocationKind.other:
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
static NetworkLocationKind fromWire(String? value) {
|
||||
switch (value) {
|
||||
case 'building':
|
||||
return NetworkLocationKind.building;
|
||||
case 'floor':
|
||||
return NetworkLocationKind.floor;
|
||||
case 'room':
|
||||
return NetworkLocationKind.room;
|
||||
case 'rack':
|
||||
return NetworkLocationKind.rack;
|
||||
case 'wall_jack':
|
||||
return NetworkLocationKind.wallJack;
|
||||
default:
|
||||
return NetworkLocationKind.other;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ConnectOfflineFirstWithSupabase(
|
||||
supabaseConfig: SupabaseSerializable(tableName: 'network_locations'),
|
||||
)
|
||||
class NetworkLocation extends OfflineFirstWithSupabaseModel {
|
||||
final String id;
|
||||
final String siteId;
|
||||
final String? parentId;
|
||||
final String name;
|
||||
final NetworkLocationKind kind;
|
||||
final String? positionLabel;
|
||||
final String? notes;
|
||||
final DateTime createdAt;
|
||||
|
||||
NetworkLocation({
|
||||
required this.id,
|
||||
required this.siteId,
|
||||
this.parentId,
|
||||
required this.name,
|
||||
required this.kind,
|
||||
this.positionLabel,
|
||||
this.notes,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory NetworkLocation.fromMap(Map<String, dynamic> map) {
|
||||
return NetworkLocation(
|
||||
id: map['id'] as String,
|
||||
siteId: map['site_id'] as String,
|
||||
parentId: map['parent_id'] as String?,
|
||||
name: (map['name'] as String?) ?? '',
|
||||
kind: NetworkLocationKind.fromWire(map['kind'] as String?),
|
||||
positionLabel: map['position_label'] as String?,
|
||||
notes: map['notes'] as String?,
|
||||
createdAt:
|
||||
DateTime.tryParse(map['created_at']?.toString() ?? '') ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toInsertMap() => {
|
||||
'site_id': siteId,
|
||||
if (parentId != null) 'parent_id': parentId,
|
||||
'name': name,
|
||||
'kind': kind.wire,
|
||||
if (positionLabel != null) 'position_label': positionLabel,
|
||||
if (notes != null) 'notes': notes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';
|
||||
import 'package:brick_supabase/brick_supabase.dart';
|
||||
|
||||
enum NetworkPortKind {
|
||||
rj45,
|
||||
sfp,
|
||||
sfpPlus,
|
||||
console,
|
||||
power,
|
||||
wireless,
|
||||
virtual,
|
||||
other;
|
||||
|
||||
String get wire {
|
||||
switch (this) {
|
||||
case NetworkPortKind.rj45:
|
||||
return 'rj45';
|
||||
case NetworkPortKind.sfp:
|
||||
return 'sfp';
|
||||
case NetworkPortKind.sfpPlus:
|
||||
return 'sfp_plus';
|
||||
case NetworkPortKind.console:
|
||||
return 'console';
|
||||
case NetworkPortKind.power:
|
||||
return 'power';
|
||||
case NetworkPortKind.wireless:
|
||||
return 'wireless';
|
||||
case NetworkPortKind.virtual:
|
||||
return 'virtual';
|
||||
case NetworkPortKind.other:
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case NetworkPortKind.rj45:
|
||||
return 'RJ45';
|
||||
case NetworkPortKind.sfp:
|
||||
return 'SFP';
|
||||
case NetworkPortKind.sfpPlus:
|
||||
return 'SFP+';
|
||||
case NetworkPortKind.console:
|
||||
return 'Console';
|
||||
case NetworkPortKind.power:
|
||||
return 'Power';
|
||||
case NetworkPortKind.wireless:
|
||||
return 'Wireless';
|
||||
case NetworkPortKind.virtual:
|
||||
return 'Virtual';
|
||||
case NetworkPortKind.other:
|
||||
return 'Other';
|
||||
}
|
||||
}
|
||||
|
||||
static NetworkPortKind? fromWire(String? value) {
|
||||
switch (value) {
|
||||
case 'rj45':
|
||||
return NetworkPortKind.rj45;
|
||||
case 'sfp':
|
||||
return NetworkPortKind.sfp;
|
||||
case 'sfp_plus':
|
||||
return NetworkPortKind.sfpPlus;
|
||||
case 'console':
|
||||
return NetworkPortKind.console;
|
||||
case 'power':
|
||||
return NetworkPortKind.power;
|
||||
case 'wireless':
|
||||
return NetworkPortKind.wireless;
|
||||
case 'virtual':
|
||||
return NetworkPortKind.virtual;
|
||||
case 'other':
|
||||
return NetworkPortKind.other;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ConnectOfflineFirstWithSupabase(
|
||||
supabaseConfig: SupabaseSerializable(tableName: 'network_ports'),
|
||||
)
|
||||
class NetworkPort extends OfflineFirstWithSupabaseModel {
|
||||
final String id;
|
||||
final String deviceId;
|
||||
final String portNumber;
|
||||
final NetworkPortKind? portKind;
|
||||
final int? speedMbps;
|
||||
final int? accessVlan;
|
||||
final bool isTrunk;
|
||||
final List<int> trunkVlans;
|
||||
final String? notes;
|
||||
|
||||
NetworkPort({
|
||||
required this.id,
|
||||
required this.deviceId,
|
||||
required this.portNumber,
|
||||
this.portKind,
|
||||
this.speedMbps,
|
||||
this.accessVlan,
|
||||
this.isTrunk = false,
|
||||
this.trunkVlans = const [],
|
||||
this.notes,
|
||||
});
|
||||
|
||||
factory NetworkPort.fromMap(Map<String, dynamic> map) {
|
||||
final rawTrunk = map['trunk_vlans'];
|
||||
final trunk = <int>[];
|
||||
if (rawTrunk is List) {
|
||||
for (final v in rawTrunk) {
|
||||
if (v is int) {
|
||||
trunk.add(v);
|
||||
} else if (v is num) {
|
||||
trunk.add(v.toInt());
|
||||
} else {
|
||||
final parsed = int.tryParse(v?.toString() ?? '');
|
||||
if (parsed != null) trunk.add(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
return NetworkPort(
|
||||
id: map['id'] as String,
|
||||
deviceId: map['device_id'] as String,
|
||||
portNumber: (map['port_number'] as String?) ?? '',
|
||||
portKind: NetworkPortKind.fromWire(map['port_kind'] as String?),
|
||||
speedMbps: map['speed_mbps'] is int
|
||||
? map['speed_mbps'] as int
|
||||
: int.tryParse(map['speed_mbps']?.toString() ?? ''),
|
||||
accessVlan: map['access_vlan'] is int
|
||||
? map['access_vlan'] as int
|
||||
: int.tryParse(map['access_vlan']?.toString() ?? ''),
|
||||
isTrunk: (map['is_trunk'] as bool?) ?? false,
|
||||
trunkVlans: trunk,
|
||||
notes: map['notes'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toInsertMap() => {
|
||||
'device_id': deviceId,
|
||||
'port_number': portNumber,
|
||||
if (portKind != null) 'port_kind': portKind!.wire,
|
||||
if (speedMbps != null) 'speed_mbps': speedMbps,
|
||||
if (accessVlan != null) 'access_vlan': accessVlan,
|
||||
'is_trunk': isTrunk,
|
||||
if (trunkVlans.isNotEmpty) 'trunk_vlans': trunkVlans,
|
||||
if (notes != null) 'notes': notes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';
|
||||
import 'package:brick_supabase/brick_supabase.dart';
|
||||
|
||||
@ConnectOfflineFirstWithSupabase(
|
||||
supabaseConfig: SupabaseSerializable(tableName: 'network_sites'),
|
||||
)
|
||||
class NetworkSite extends OfflineFirstWithSupabaseModel {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? address;
|
||||
final String? notes;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final String? createdBy;
|
||||
|
||||
NetworkSite({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.address,
|
||||
this.notes,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.createdBy,
|
||||
});
|
||||
|
||||
factory NetworkSite.fromMap(Map<String, dynamic> map) {
|
||||
return NetworkSite(
|
||||
id: map['id'] as String,
|
||||
name: (map['name'] as String?) ?? '',
|
||||
address: map['address'] as String?,
|
||||
notes: map['notes'] as String?,
|
||||
createdAt:
|
||||
DateTime.tryParse(map['created_at']?.toString() ?? '') ?? DateTime.now(),
|
||||
updatedAt:
|
||||
DateTime.tryParse(map['updated_at']?.toString() ?? '') ?? DateTime.now(),
|
||||
createdBy: map['created_by'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toInsertMap() => {
|
||||
'name': name,
|
||||
if (address != null) 'address': address,
|
||||
if (notes != null) 'notes': notes,
|
||||
if (createdBy != null) 'created_by': createdBy,
|
||||
};
|
||||
|
||||
Map<String, dynamic> toUpdateMap() => {
|
||||
'name': name,
|
||||
'address': address,
|
||||
'notes': notes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';
|
||||
import 'package:brick_supabase/brick_supabase.dart';
|
||||
|
||||
@ConnectOfflineFirstWithSupabase(
|
||||
supabaseConfig: SupabaseSerializable(tableName: 'network_vlans'),
|
||||
)
|
||||
class NetworkVlan extends OfflineFirstWithSupabaseModel {
|
||||
final String id;
|
||||
final int vlanId;
|
||||
final String name;
|
||||
final String? description;
|
||||
final String? color;
|
||||
|
||||
NetworkVlan({
|
||||
required this.id,
|
||||
required this.vlanId,
|
||||
required this.name,
|
||||
this.description,
|
||||
this.color,
|
||||
});
|
||||
|
||||
factory NetworkVlan.fromMap(Map<String, dynamic> map) {
|
||||
return NetworkVlan(
|
||||
id: map['id'] as String,
|
||||
vlanId: map['vlan_id'] is int
|
||||
? map['vlan_id'] as int
|
||||
: int.tryParse(map['vlan_id']?.toString() ?? '') ?? 0,
|
||||
name: (map['name'] as String?) ?? '',
|
||||
description: map['description'] as String?,
|
||||
color: map['color'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toInsertMap() => {
|
||||
'vlan_id': vlanId,
|
||||
'name': name,
|
||||
if (description != null) 'description': description,
|
||||
if (color != null) 'color': color,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'network_device.dart';
|
||||
import 'network_link.dart';
|
||||
import 'network_location.dart';
|
||||
import 'network_port.dart';
|
||||
import 'network_site.dart';
|
||||
import 'network_vlan.dart';
|
||||
|
||||
/// Client-side aggregate view-model assembled from raw network_* rows.
|
||||
///
|
||||
/// The canvas consumes this directly rather than re-querying tables for every
|
||||
/// pan/zoom event. Built once per site by [TopologyGraph.build] and cached in
|
||||
/// the topology provider.
|
||||
class TopologyNode {
|
||||
TopologyNode({
|
||||
required this.device,
|
||||
required this.ports,
|
||||
});
|
||||
|
||||
final NetworkDevice device;
|
||||
final List<NetworkPort> ports;
|
||||
|
||||
String get id => device.id;
|
||||
}
|
||||
|
||||
class TopologyEdge {
|
||||
TopologyEdge({
|
||||
required this.link,
|
||||
required this.fromDeviceId,
|
||||
required this.toDeviceId,
|
||||
required this.fromPortLabel,
|
||||
required this.toPortLabel,
|
||||
});
|
||||
|
||||
final NetworkLink link;
|
||||
final String fromDeviceId;
|
||||
final String toDeviceId;
|
||||
final String fromPortLabel;
|
||||
final String toPortLabel;
|
||||
}
|
||||
|
||||
enum TopologyViewMode {
|
||||
physical,
|
||||
logical;
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case TopologyViewMode.physical:
|
||||
return 'Physical';
|
||||
case TopologyViewMode.logical:
|
||||
return 'Logical';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TopologyGraph {
|
||||
TopologyGraph({
|
||||
required this.site,
|
||||
required this.locations,
|
||||
required this.nodes,
|
||||
required this.edges,
|
||||
required this.vlans,
|
||||
});
|
||||
|
||||
final NetworkSite? site;
|
||||
final List<NetworkLocation> locations;
|
||||
final List<TopologyNode> nodes;
|
||||
final List<TopologyEdge> edges;
|
||||
final List<NetworkVlan> vlans;
|
||||
|
||||
bool get isEmpty => nodes.isEmpty;
|
||||
|
||||
/// Build a graph for a specific site from raw tables.
|
||||
///
|
||||
/// Filters devices to those in the site's location subtree. If [site] is
|
||||
/// null, all devices with no location are included (orphan view).
|
||||
static TopologyGraph build({
|
||||
required NetworkSite? site,
|
||||
required List<NetworkLocation> allLocations,
|
||||
required List<NetworkDevice> allDevices,
|
||||
required List<NetworkPort> allPorts,
|
||||
required List<NetworkLink> allLinks,
|
||||
required List<NetworkVlan> allVlans,
|
||||
}) {
|
||||
final siteLocations = site == null
|
||||
? <NetworkLocation>[]
|
||||
: allLocations.where((l) => l.siteId == site.id).toList();
|
||||
final siteLocationIds = siteLocations.map((l) => l.id).toSet();
|
||||
|
||||
final siteDevices = site == null
|
||||
? allDevices.where((d) => d.locationId == null).toList()
|
||||
: allDevices
|
||||
.where((d) =>
|
||||
d.locationId != null && siteLocationIds.contains(d.locationId))
|
||||
.toList();
|
||||
final siteDeviceIds = siteDevices.map((d) => d.id).toSet();
|
||||
|
||||
final portsByDevice = <String, List<NetworkPort>>{};
|
||||
for (final port in allPorts) {
|
||||
if (!siteDeviceIds.contains(port.deviceId)) continue;
|
||||
portsByDevice.putIfAbsent(port.deviceId, () => []).add(port);
|
||||
}
|
||||
|
||||
final portToDevice = <String, String>{};
|
||||
final portLabel = <String, String>{};
|
||||
for (final port in allPorts) {
|
||||
portToDevice[port.id] = port.deviceId;
|
||||
portLabel[port.id] = port.portNumber;
|
||||
}
|
||||
|
||||
final nodes = siteDevices
|
||||
.map((d) => TopologyNode(
|
||||
device: d,
|
||||
ports: portsByDevice[d.id] ?? const [],
|
||||
))
|
||||
.toList();
|
||||
|
||||
final edges = <TopologyEdge>[];
|
||||
for (final link in allLinks) {
|
||||
final fromDeviceId = portToDevice[link.portA];
|
||||
final toDeviceId = portToDevice[link.portB];
|
||||
if (fromDeviceId == null || toDeviceId == null) continue;
|
||||
if (!siteDeviceIds.contains(fromDeviceId) ||
|
||||
!siteDeviceIds.contains(toDeviceId)) {
|
||||
continue;
|
||||
}
|
||||
edges.add(TopologyEdge(
|
||||
link: link,
|
||||
fromDeviceId: fromDeviceId,
|
||||
toDeviceId: toDeviceId,
|
||||
fromPortLabel: portLabel[link.portA] ?? '',
|
||||
toPortLabel: portLabel[link.portB] ?? '',
|
||||
));
|
||||
}
|
||||
|
||||
return TopologyGraph(
|
||||
site: site,
|
||||
locations: siteLocations,
|
||||
nodes: nodes,
|
||||
edges: edges,
|
||||
vlans: allVlans,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -175,19 +175,57 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
||||
for (final params in pendingRaw) {
|
||||
final localId = params['localId'] as String;
|
||||
try {
|
||||
await client.rpc(
|
||||
final serverId = await client.rpc(
|
||||
'attendance_check_in',
|
||||
params: {
|
||||
'p_duty_id': params['p_duty_id'],
|
||||
'p_lat': params['p_lat'],
|
||||
'p_lng': params['p_lng'],
|
||||
'p_accuracy': params['p_accuracy'],
|
||||
'p_is_mocked': params['p_is_mocked'],
|
||||
},
|
||||
);
|
||||
) as String?;
|
||||
syncedLocalIds.add(localId);
|
||||
// Re-key any pending verification update from local UUID → server UUID
|
||||
// so the PATCH in the updates replay hits the real attendance_logs row.
|
||||
if (serverId != null) {
|
||||
final updates = ref.read(offlinePendingAttendanceUpdatesProvider);
|
||||
if (updates.containsKey(localId)) {
|
||||
final updated = Map<String, Map<String, dynamic>>.from(updates);
|
||||
updated[serverId] = updated.remove(localId)!;
|
||||
ref
|
||||
.read(offlinePendingAttendanceUpdatesProvider.notifier)
|
||||
.state = updated;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
final msg = e.toString();
|
||||
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
||||
syncedLocalIds.add(localId);
|
||||
// Duplicate key means the log already exists on the server.
|
||||
// Look up its real ID so any pending verification photo can be re-keyed.
|
||||
try {
|
||||
final existing = await client
|
||||
.from('attendance_logs')
|
||||
.select('id')
|
||||
.eq('duty_schedule_id', params['p_duty_id'] as String)
|
||||
.order('check_in_at', ascending: false)
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
final serverId = existing?['id'] as String?;
|
||||
if (serverId != null) {
|
||||
final updates =
|
||||
ref.read(offlinePendingAttendanceUpdatesProvider);
|
||||
if (updates.containsKey(localId)) {
|
||||
final updated =
|
||||
Map<String, Map<String, dynamic>>.from(updates);
|
||||
updated[serverId] = updated.remove(localId)!;
|
||||
ref
|
||||
.read(offlinePendingAttendanceUpdatesProvider.notifier)
|
||||
.state = updated;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
} else {
|
||||
debugPrint(
|
||||
'[attendanceLogsProvider] failed to sync check-in localId=$localId: $e',
|
||||
@@ -237,6 +275,8 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
||||
'p_lat': fields['check_out_lat'],
|
||||
'p_lng': fields['check_out_lng'],
|
||||
'p_justification': fields['check_out_justification'],
|
||||
'p_accuracy': fields['check_out_accuracy'],
|
||||
'p_is_mocked': fields['check_out_is_mocked'],
|
||||
},
|
||||
);
|
||||
syncedIds.add(attendanceId);
|
||||
@@ -354,12 +394,20 @@ class AttendanceController {
|
||||
required String dutyScheduleId,
|
||||
required double lat,
|
||||
required double lng,
|
||||
double accuracy = 0,
|
||||
bool isMocked = false,
|
||||
String shiftType = 'normal',
|
||||
}) async {
|
||||
try {
|
||||
final data = await _client.rpc(
|
||||
'attendance_check_in',
|
||||
params: {'p_duty_id': dutyScheduleId, 'p_lat': lat, 'p_lng': lng},
|
||||
params: {
|
||||
'p_duty_id': dutyScheduleId,
|
||||
'p_lat': lat,
|
||||
'p_lng': lng,
|
||||
'p_accuracy': accuracy,
|
||||
'p_is_mocked': isMocked,
|
||||
},
|
||||
);
|
||||
return data as String?;
|
||||
} catch (e) {
|
||||
@@ -382,7 +430,14 @@ class AttendanceController {
|
||||
|
||||
_queuePendingCheckIn(
|
||||
log,
|
||||
{'p_duty_id': dutyScheduleId, 'p_lat': lat, 'p_lng': lng, 'localId': id},
|
||||
{
|
||||
'p_duty_id': dutyScheduleId,
|
||||
'p_lat': lat,
|
||||
'p_lng': lng,
|
||||
'p_accuracy': accuracy,
|
||||
'p_is_mocked': isMocked,
|
||||
'localId': id,
|
||||
},
|
||||
);
|
||||
debugPrint('[AttendanceController] checkIn queued offline: $id');
|
||||
return id;
|
||||
@@ -395,6 +450,8 @@ class AttendanceController {
|
||||
required double lat,
|
||||
required double lng,
|
||||
String? justification,
|
||||
double accuracy = 0,
|
||||
bool isMocked = false,
|
||||
}) async {
|
||||
try {
|
||||
await _client.rpc(
|
||||
@@ -404,6 +461,8 @@ class AttendanceController {
|
||||
'p_lat': lat,
|
||||
'p_lng': lng,
|
||||
'p_justification': justification,
|
||||
'p_accuracy': accuracy,
|
||||
'p_is_mocked': isMocked,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -415,6 +474,8 @@ class AttendanceController {
|
||||
'check_out_lat': lat,
|
||||
'check_out_lng': lng,
|
||||
'check_out_justification': justification,
|
||||
'check_out_accuracy': accuracy,
|
||||
'check_out_is_mocked': isMocked,
|
||||
});
|
||||
debugPrint(
|
||||
'[AttendanceController] checkOut queued offline: $attendanceId',
|
||||
@@ -427,10 +488,18 @@ class AttendanceController {
|
||||
required double lat,
|
||||
required double lng,
|
||||
String? justification,
|
||||
double accuracy = 0,
|
||||
bool isMocked = false,
|
||||
}) async {
|
||||
final data = await _client.rpc(
|
||||
'overtime_check_in',
|
||||
params: {'p_lat': lat, 'p_lng': lng, 'p_justification': justification},
|
||||
params: {
|
||||
'p_lat': lat,
|
||||
'p_lng': lng,
|
||||
'p_justification': justification,
|
||||
'p_accuracy': accuracy,
|
||||
'p_is_mocked': isMocked,
|
||||
},
|
||||
);
|
||||
return data as String?;
|
||||
}
|
||||
|
||||
@@ -163,6 +163,21 @@ class ChatController {
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
|
||||
final ref = _ref;
|
||||
if (ref != null) {
|
||||
final existing =
|
||||
ref.read(offlinePendingChatMessagesProvider)[threadId] ?? [];
|
||||
// Idempotency guard: suppress if the same body was queued within the
|
||||
// last 10 s — prevents duplicate messages from rapid taps while offline.
|
||||
final cutoff = AppTime.now().subtract(const Duration(seconds: 10));
|
||||
if (existing.any((m) => m.body == body && m.createdAt.isAfter(cutoff))) {
|
||||
debugPrint(
|
||||
'[ChatController] duplicate offline message suppressed for thread=$threadId',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final message = ChatMessage(
|
||||
id: id,
|
||||
threadId: threadId,
|
||||
@@ -171,13 +186,13 @@ class ChatController {
|
||||
createdAt: AppTime.now(),
|
||||
);
|
||||
|
||||
final ref = _ref;
|
||||
if (ref != null) {
|
||||
final ref2 = _ref;
|
||||
if (ref2 != null) {
|
||||
final current = Map<String, List<ChatMessage>>.from(
|
||||
ref.read(offlinePendingChatMessagesProvider),
|
||||
ref2.read(offlinePendingChatMessagesProvider),
|
||||
);
|
||||
current[threadId] = [...(current[threadId] ?? []), message];
|
||||
ref.read(offlinePendingChatMessagesProvider.notifier).state =
|
||||
ref2.read(offlinePendingChatMessagesProvider.notifier).state =
|
||||
Map.unmodifiable(current);
|
||||
}
|
||||
mirrorBatchToBrick<ChatMessage>([message], tag: 'offline_chat');
|
||||
|
||||
@@ -64,7 +64,7 @@ class ConnectivityMonitor {
|
||||
}
|
||||
try {
|
||||
final res = await http
|
||||
.head(Uri.parse('https://tasq.crmc.ph'))
|
||||
.head(Uri.parse('https://tasq.supabase.crmc.ph'))
|
||||
.timeout(const Duration(seconds: 5));
|
||||
return res.statusCode < 500;
|
||||
} catch (_) {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/network/network_device.dart';
|
||||
import '../../models/network/network_link.dart';
|
||||
import '../../models/network/network_port.dart';
|
||||
import '../../models/network/network_vlan.dart';
|
||||
import '../supabase_provider.dart';
|
||||
|
||||
final networkDevicesProvider = FutureProvider<List<NetworkDevice>>((ref) async {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final rows = await client.from('network_devices').select().order('name');
|
||||
return (rows as List)
|
||||
.map((r) => NetworkDevice.fromMap(r as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
final networkPortsProvider = FutureProvider<List<NetworkPort>>((ref) async {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final rows = await client.from('network_ports').select().order('port_number');
|
||||
return (rows as List)
|
||||
.map((r) => NetworkPort.fromMap(r as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
final networkLinksProvider = FutureProvider<List<NetworkLink>>((ref) async {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final rows = await client.from('network_links').select();
|
||||
return (rows as List)
|
||||
.map((r) => NetworkLink.fromMap(r as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
final networkVlansProvider = FutureProvider<List<NetworkVlan>>((ref) async {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final rows = await client.from('network_vlans').select().order('vlan_id');
|
||||
return (rows as List)
|
||||
.map((r) => NetworkVlan.fromMap(r as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
final networkDeviceByIdProvider =
|
||||
FutureProvider.family<NetworkDevice?, String>((ref, id) async {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final row = await client
|
||||
.from('network_devices')
|
||||
.select()
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
if (row == null) return null;
|
||||
return NetworkDevice.fromMap(row);
|
||||
});
|
||||
|
||||
final networkPortsByDeviceProvider =
|
||||
FutureProvider.family<List<NetworkPort>, String>((ref, deviceId) async {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final rows = await client
|
||||
.from('network_ports')
|
||||
.select()
|
||||
.eq('device_id', deviceId)
|
||||
.order('port_number');
|
||||
return (rows as List)
|
||||
.map((r) => NetworkPort.fromMap(r as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
class NetworkDevicesController {
|
||||
NetworkDevicesController(this.ref);
|
||||
final Ref ref;
|
||||
|
||||
Future<NetworkDevice> createDevice({
|
||||
required String name,
|
||||
required NetworkDeviceKind kind,
|
||||
NetworkDeviceRole? role,
|
||||
String? vendor,
|
||||
String? model,
|
||||
String? serial,
|
||||
String? mgmtIp,
|
||||
String? mac,
|
||||
String? locationId,
|
||||
NetworkImportSource importSource = NetworkImportSource.manual,
|
||||
NetworkDeviceStatus status = NetworkDeviceStatus.unknown,
|
||||
String? notes,
|
||||
}) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
final inserted = await client
|
||||
.from('network_devices')
|
||||
.insert({
|
||||
'name': name,
|
||||
'kind': kind.wire,
|
||||
'role': ?role?.wire,
|
||||
'vendor': ?vendor,
|
||||
'model': ?model,
|
||||
'serial': ?serial,
|
||||
'mgmt_ip': ?mgmtIp,
|
||||
'mac': ?mac,
|
||||
'location_id': ?locationId,
|
||||
'import_source': importSource.wire,
|
||||
'status': status.wire,
|
||||
'notes': ?notes,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
ref.invalidate(networkDevicesProvider);
|
||||
return NetworkDevice.fromMap(inserted);
|
||||
}
|
||||
|
||||
Future<void> updateDevice(NetworkDevice device) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
await client
|
||||
.from('network_devices')
|
||||
.update(device.toUpdateMap())
|
||||
.eq('id', device.id);
|
||||
ref.invalidate(networkDevicesProvider);
|
||||
ref.invalidate(networkDeviceByIdProvider(device.id));
|
||||
}
|
||||
|
||||
Future<void> deleteDevice(String id) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
await client.from('network_devices').delete().eq('id', id);
|
||||
ref.invalidate(networkDevicesProvider);
|
||||
ref.invalidate(networkPortsProvider);
|
||||
ref.invalidate(networkLinksProvider);
|
||||
}
|
||||
|
||||
Future<NetworkPort> createPort({
|
||||
required String deviceId,
|
||||
required String portNumber,
|
||||
NetworkPortKind? portKind,
|
||||
int? speedMbps,
|
||||
int? accessVlan,
|
||||
bool isTrunk = false,
|
||||
List<int> trunkVlans = const [],
|
||||
String? notes,
|
||||
}) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
final inserted = await client
|
||||
.from('network_ports')
|
||||
.insert({
|
||||
'device_id': deviceId,
|
||||
'port_number': portNumber,
|
||||
'port_kind': ?portKind?.wire,
|
||||
'speed_mbps': ?speedMbps,
|
||||
'access_vlan': ?accessVlan,
|
||||
'is_trunk': isTrunk,
|
||||
if (trunkVlans.isNotEmpty) 'trunk_vlans': trunkVlans,
|
||||
'notes': ?notes,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
ref.invalidate(networkPortsProvider);
|
||||
ref.invalidate(networkPortsByDeviceProvider(deviceId));
|
||||
return NetworkPort.fromMap(inserted);
|
||||
}
|
||||
|
||||
Future<void> deletePort(String id, {String? deviceId}) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
await client.from('network_ports').delete().eq('id', id);
|
||||
ref.invalidate(networkPortsProvider);
|
||||
ref.invalidate(networkLinksProvider);
|
||||
if (deviceId != null) {
|
||||
ref.invalidate(networkPortsByDeviceProvider(deviceId));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updatePort({
|
||||
required String id,
|
||||
required String deviceId,
|
||||
required String portNumber,
|
||||
NetworkPortKind? portKind,
|
||||
int? speedMbps,
|
||||
int? accessVlan,
|
||||
bool isTrunk = false,
|
||||
List<int> trunkVlans = const [],
|
||||
String? notes,
|
||||
}) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
await client.from('network_ports').update({
|
||||
'port_number': portNumber,
|
||||
'port_kind': portKind?.wire,
|
||||
'speed_mbps': speedMbps,
|
||||
'access_vlan': accessVlan,
|
||||
'is_trunk': isTrunk,
|
||||
'trunk_vlans': trunkVlans.isEmpty ? null : trunkVlans,
|
||||
'notes': notes,
|
||||
}).eq('id', id);
|
||||
ref.invalidate(networkPortsProvider);
|
||||
ref.invalidate(networkPortsByDeviceProvider(deviceId));
|
||||
}
|
||||
|
||||
Future<NetworkLink> createLink({
|
||||
required String portA,
|
||||
required String portB,
|
||||
NetworkLinkKind? linkKind,
|
||||
String? cableLabel,
|
||||
String? notes,
|
||||
}) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
final inserted = await client
|
||||
.from('network_links')
|
||||
.insert(NetworkLink.insertMapFor(
|
||||
portA: portA,
|
||||
portB: portB,
|
||||
linkKind: linkKind,
|
||||
cableLabel: cableLabel,
|
||||
notes: notes,
|
||||
))
|
||||
.select()
|
||||
.single();
|
||||
ref.invalidate(networkLinksProvider);
|
||||
return NetworkLink.fromMap(inserted);
|
||||
}
|
||||
|
||||
Future<void> deleteLink(String id) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
await client.from('network_links').delete().eq('id', id);
|
||||
ref.invalidate(networkLinksProvider);
|
||||
}
|
||||
|
||||
Future<NetworkVlan> createVlan({
|
||||
required int vlanId,
|
||||
required String name,
|
||||
String? description,
|
||||
String? color,
|
||||
}) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
final inserted = await client
|
||||
.from('network_vlans')
|
||||
.insert({
|
||||
'vlan_id': vlanId,
|
||||
'name': name,
|
||||
'description': ?description,
|
||||
'color': ?color,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
ref.invalidate(networkVlansProvider);
|
||||
return NetworkVlan.fromMap(inserted);
|
||||
}
|
||||
|
||||
Future<void> deleteVlan(String id) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
await client.from('network_vlans').delete().eq('id', id);
|
||||
ref.invalidate(networkVlansProvider);
|
||||
}
|
||||
}
|
||||
|
||||
final networkDevicesControllerProvider =
|
||||
Provider<NetworkDevicesController>((ref) => NetworkDevicesController(ref));
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../../models/network/network_import.dart';
|
||||
import '../supabase_provider.dart';
|
||||
|
||||
final networkImportsProvider = FutureProvider<List<NetworkImport>>((ref) async {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final rows = await client
|
||||
.from('network_imports')
|
||||
.select()
|
||||
.order('created_at', ascending: false);
|
||||
return (rows as List)
|
||||
.map((r) => NetworkImport.fromMap(r as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
final networkImportByIdProvider =
|
||||
FutureProvider.family<NetworkImport?, String>((ref, id) async {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final row = await client
|
||||
.from('network_imports')
|
||||
.select()
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
if (row == null) return null;
|
||||
return NetworkImport.fromMap(row);
|
||||
});
|
||||
|
||||
class NetworkImportResult {
|
||||
NetworkImportResult({required this.status, this.aiResult, this.errorMessage});
|
||||
|
||||
final NetworkImportStatus status;
|
||||
final Map<String, dynamic>? aiResult;
|
||||
final String? errorMessage;
|
||||
}
|
||||
|
||||
class NetworkImportController {
|
||||
NetworkImportController(this.ref);
|
||||
final Ref ref;
|
||||
|
||||
/// Upload a source document, create a network_imports row, and trigger the
|
||||
/// extract_topology Edge Function. Returns the import_id immediately so the
|
||||
/// UI can navigate to the extraction-progress screen.
|
||||
Future<String> uploadAndQueueExtraction({
|
||||
required Uint8List bytes,
|
||||
required String filename,
|
||||
required String createdBy,
|
||||
}) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
final fileKind = NetworkImportFileKind.fromExtension(filename);
|
||||
final sanitized = filename
|
||||
.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_')
|
||||
.substring(0, filename.length > 80 ? 80 : filename.length);
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final storagePath = '$createdBy/${timestamp}_$sanitized';
|
||||
|
||||
await client.storage.from('network-imports').uploadBinary(
|
||||
storagePath,
|
||||
bytes,
|
||||
fileOptions: const FileOptions(upsert: false),
|
||||
);
|
||||
|
||||
final inserted = await client
|
||||
.from('network_imports')
|
||||
.insert({
|
||||
'storage_path': storagePath,
|
||||
'file_kind': fileKind.wire,
|
||||
'status': NetworkImportStatus.pending.wire,
|
||||
'created_by': createdBy,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
ref.invalidate(networkImportsProvider);
|
||||
return inserted['id'] as String;
|
||||
}
|
||||
|
||||
/// Invoke the extract_topology Edge Function and return the parsed result.
|
||||
///
|
||||
/// Caller is expected to already have polled/awaited the import being in
|
||||
/// pending status. Long-running (5–60s) — UI should show progress.
|
||||
Future<NetworkImportResult> runExtraction(String importId) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
final response = await client.functions.invoke(
|
||||
'extract_topology',
|
||||
body: {'import_id': importId},
|
||||
);
|
||||
|
||||
final data = response.data;
|
||||
if (data is! Map) {
|
||||
return NetworkImportResult(
|
||||
status: NetworkImportStatus.failed,
|
||||
errorMessage: 'Unexpected response from extract_topology',
|
||||
);
|
||||
}
|
||||
final asMap = Map<String, dynamic>.from(data);
|
||||
|
||||
if (asMap['error'] != null) {
|
||||
ref.invalidate(networkImportByIdProvider(importId));
|
||||
ref.invalidate(networkImportsProvider);
|
||||
return NetworkImportResult(
|
||||
status: NetworkImportStatus.failed,
|
||||
errorMessage: asMap['error'].toString(),
|
||||
);
|
||||
}
|
||||
|
||||
final statusStr = asMap['status']?.toString() ?? 'failed';
|
||||
final aiResult = asMap['ai_result'] is Map
|
||||
? Map<String, dynamic>.from(asMap['ai_result'] as Map)
|
||||
: null;
|
||||
|
||||
ref.invalidate(networkImportByIdProvider(importId));
|
||||
ref.invalidate(networkImportsProvider);
|
||||
|
||||
return NetworkImportResult(
|
||||
status: NetworkImportStatus.fromWire(statusStr),
|
||||
aiResult: aiResult,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> discard(String importId) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
await client
|
||||
.from('network_imports')
|
||||
.update({'status': NetworkImportStatus.discarded.wire})
|
||||
.eq('id', importId);
|
||||
ref.invalidate(networkImportsProvider);
|
||||
ref.invalidate(networkImportByIdProvider(importId));
|
||||
}
|
||||
|
||||
Future<void> markApplied({
|
||||
required String importId,
|
||||
required List<String> deviceIds,
|
||||
required List<String> linkIds,
|
||||
}) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
await client
|
||||
.from('network_imports')
|
||||
.update({
|
||||
'status': NetworkImportStatus.applied.wire,
|
||||
'applied_device_ids': deviceIds,
|
||||
'applied_link_ids': linkIds,
|
||||
'applied_at': DateTime.now().toUtc().toIso8601String(),
|
||||
})
|
||||
.eq('id', importId);
|
||||
ref.invalidate(networkImportsProvider);
|
||||
ref.invalidate(networkImportByIdProvider(importId));
|
||||
}
|
||||
}
|
||||
|
||||
final networkImportControllerProvider =
|
||||
Provider<NetworkImportController>((ref) => NetworkImportController(ref));
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/network/network_site.dart';
|
||||
import '../../models/network/network_location.dart';
|
||||
import '../supabase_provider.dart';
|
||||
|
||||
final networkSitesProvider = FutureProvider<List<NetworkSite>>((ref) async {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final rows = await client.from('network_sites').select().order('name');
|
||||
return (rows as List)
|
||||
.map((r) => NetworkSite.fromMap(r as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
final networkLocationsProvider = FutureProvider<List<NetworkLocation>>((ref) async {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final rows = await client.from('network_locations').select().order('name');
|
||||
return (rows as List)
|
||||
.map((r) => NetworkLocation.fromMap(r as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
final selectedSiteIdProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
class NetworkSitesController {
|
||||
NetworkSitesController(this.ref);
|
||||
final Ref ref;
|
||||
|
||||
Future<NetworkSite> createSite({
|
||||
required String name,
|
||||
String? address,
|
||||
String? notes,
|
||||
String? createdBy,
|
||||
}) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
final inserted = await client
|
||||
.from('network_sites')
|
||||
.insert({
|
||||
'name': name,
|
||||
'address': ?address,
|
||||
'notes': ?notes,
|
||||
'created_by': ?createdBy,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
ref.invalidate(networkSitesProvider);
|
||||
return NetworkSite.fromMap(inserted);
|
||||
}
|
||||
|
||||
Future<void> updateSite(NetworkSite site) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
await client.from('network_sites').update(site.toUpdateMap()).eq('id', site.id);
|
||||
ref.invalidate(networkSitesProvider);
|
||||
}
|
||||
|
||||
Future<void> deleteSite(String id) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
await client.from('network_sites').delete().eq('id', id);
|
||||
ref.invalidate(networkSitesProvider);
|
||||
}
|
||||
|
||||
Future<NetworkLocation> createLocation({
|
||||
required String siteId,
|
||||
String? parentId,
|
||||
required String name,
|
||||
required NetworkLocationKind kind,
|
||||
String? positionLabel,
|
||||
String? notes,
|
||||
}) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
final inserted = await client
|
||||
.from('network_locations')
|
||||
.insert({
|
||||
'site_id': siteId,
|
||||
'parent_id': ?parentId,
|
||||
'name': name,
|
||||
'kind': kind.wire,
|
||||
'position_label': ?positionLabel,
|
||||
'notes': ?notes,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
ref.invalidate(networkLocationsProvider);
|
||||
return NetworkLocation.fromMap(inserted);
|
||||
}
|
||||
|
||||
/// Returns an existing root location for the site, or creates a "Main"
|
||||
/// location of kind=other if none exist. Used by the device assignment flow
|
||||
/// so admins can pick a site without having to think about location hierarchy.
|
||||
Future<NetworkLocation> ensureDefaultLocation(String siteId) async {
|
||||
final locations = await ref.read(networkLocationsProvider.future);
|
||||
for (final l in locations) {
|
||||
if (l.siteId == siteId) return l;
|
||||
}
|
||||
return createLocation(
|
||||
siteId: siteId,
|
||||
name: 'Main',
|
||||
kind: NetworkLocationKind.other,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final networkSitesControllerProvider =
|
||||
Provider<NetworkSitesController>((ref) => NetworkSitesController(ref));
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/network/network_site.dart';
|
||||
import '../../models/network/topology_graph.dart';
|
||||
import 'network_devices_provider.dart';
|
||||
import 'network_sites_provider.dart';
|
||||
|
||||
/// Aggregate topology for a specific site (or null for orphan devices).
|
||||
///
|
||||
/// This rebuilds whenever any of the underlying tables changes — invalidation
|
||||
/// flows from the granular providers (sites, locations, devices, ports, links,
|
||||
/// vlans) up through this aggregate.
|
||||
final topologyForSiteProvider =
|
||||
FutureProvider.family<TopologyGraph, String?>((ref, siteId) async {
|
||||
final sites = await ref.watch(networkSitesProvider.future);
|
||||
final locations = await ref.watch(networkLocationsProvider.future);
|
||||
final devices = await ref.watch(networkDevicesProvider.future);
|
||||
final ports = await ref.watch(networkPortsProvider.future);
|
||||
final links = await ref.watch(networkLinksProvider.future);
|
||||
final vlans = await ref.watch(networkVlansProvider.future);
|
||||
|
||||
NetworkSite? site;
|
||||
if (siteId != null) {
|
||||
for (final s in sites) {
|
||||
if (s.id == siteId) {
|
||||
site = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TopologyGraph.build(
|
||||
site: site,
|
||||
allLocations: locations,
|
||||
allDevices: devices,
|
||||
allPorts: ports,
|
||||
allLinks: links,
|
||||
allVlans: vlans,
|
||||
);
|
||||
});
|
||||
|
||||
final topologyViewModeProvider =
|
||||
StateProvider<TopologyViewMode>((ref) => TopologyViewMode.physical);
|
||||
@@ -317,6 +317,21 @@ class NotificationsController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark all unread notifications for the current user as read.
|
||||
Future<void> markAllRead() async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) return;
|
||||
try {
|
||||
await _client
|
||||
.from('notifications')
|
||||
.update({'read_at': AppTime.nowUtc().toIso8601String()})
|
||||
.eq('user_id', userId)
|
||||
.filter('read_at', 'is', null);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> registerFcmToken(String token) async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) return;
|
||||
@@ -419,6 +434,12 @@ class NotificationsController {
|
||||
final current = List<Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingBatchNotificationReadsProvider),
|
||||
);
|
||||
// Idempotency: skip if same (filter_type, filter_id) is already queued.
|
||||
// Mark-read is idempotent — queuing duplicates only inflates the banner count.
|
||||
if (current.any((e) =>
|
||||
e['filter_type'] == filterType && e['filter_id'] == filterId)) {
|
||||
return;
|
||||
}
|
||||
current.add({
|
||||
'filter_type': filterType,
|
||||
'filter_id': filterId,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../brick/cache_warmer.dart';
|
||||
import 'attendance_provider.dart';
|
||||
|
||||
class OfflineReadinessState {
|
||||
final bool hasWarmedOnce;
|
||||
final Map<String, int> cacheCounts;
|
||||
final int pendingCheckIns;
|
||||
final int pendingUpdates;
|
||||
final int pendingPhotos;
|
||||
|
||||
const OfflineReadinessState({
|
||||
required this.hasWarmedOnce,
|
||||
required this.cacheCounts,
|
||||
required this.pendingCheckIns,
|
||||
required this.pendingUpdates,
|
||||
required this.pendingPhotos,
|
||||
});
|
||||
|
||||
bool get isReady {
|
||||
if (!hasWarmedOnce) return false;
|
||||
const critical = ['tasks', 'profiles', 'duty_schedules', 'attendance_logs'];
|
||||
return critical.every((t) => (cacheCounts[t] ?? -1) >= 0);
|
||||
}
|
||||
|
||||
int get totalPending => pendingCheckIns + pendingUpdates;
|
||||
}
|
||||
|
||||
final offlineReadinessProvider =
|
||||
FutureProvider.autoDispose<OfflineReadinessState>((ref) async {
|
||||
final counts = await CacheWarmer.readCacheCounts();
|
||||
final pendingCheckIns = ref.watch(offlinePendingCheckInsProvider).length;
|
||||
final pendingUpdatesMap = ref.watch(offlinePendingAttendanceUpdatesProvider);
|
||||
final pendingUpdates = pendingUpdatesMap.length;
|
||||
final pendingPhotos = pendingUpdatesMap.values
|
||||
.where((f) => f.containsKey('_local_photo_path'))
|
||||
.length;
|
||||
|
||||
return OfflineReadinessState(
|
||||
hasWarmedOnce: CacheWarmer.hasWarmedOnce,
|
||||
cacheCounts: counts,
|
||||
pendingCheckIns: pendingCheckIns,
|
||||
pendingUpdates: pendingUpdates,
|
||||
pendingPhotos: pendingPhotos,
|
||||
);
|
||||
});
|
||||
@@ -105,3 +105,73 @@ final pendingSyncCountProvider = Provider<int>((ref) {
|
||||
officeOps +
|
||||
chatMessages;
|
||||
});
|
||||
|
||||
/// Per-category breakdown of pending-sync items (only non-zero entries).
|
||||
/// Keys are stable category identifiers used for icon/label lookup in the UI.
|
||||
final pendingSyncBreakdownProvider = Provider<Map<String, int>>((ref) {
|
||||
final result = <String, int>{};
|
||||
|
||||
final tasks = ref.watch(offlinePendingTasksProvider).length +
|
||||
ref.watch(offlinePendingTaskUpdatesProvider).length;
|
||||
if (tasks > 0) result['tasks'] = tasks;
|
||||
|
||||
final tickets = ref.watch(offlinePendingTicketsProvider).length +
|
||||
ref.watch(offlinePendingTicketUpdatesProvider).length;
|
||||
if (tickets > 0) result['tickets'] = tickets;
|
||||
|
||||
final messages = ref
|
||||
.watch(offlinePendingMessagesProvider)
|
||||
.values
|
||||
.fold<int>(0, (s, l) => s + l.length) +
|
||||
ref
|
||||
.watch(offlinePendingChatMessagesProvider)
|
||||
.values
|
||||
.fold<int>(0, (s, l) => s + l.length);
|
||||
if (messages > 0) result['messages'] = messages;
|
||||
|
||||
final assignments = ref.watch(offlinePendingAssignmentsProvider).length;
|
||||
if (assignments > 0) result['assignments'] = assignments;
|
||||
|
||||
final activityLogs =
|
||||
ref.watch(offlinePendingActivityLogItemsProvider).length;
|
||||
if (activityLogs > 0) result['activity_logs'] = activityLogs;
|
||||
|
||||
final checkIns = ref.watch(offlinePendingCheckInsProvider).length;
|
||||
if (checkIns > 0) result['check_ins'] = checkIns;
|
||||
|
||||
final attendanceUpdates =
|
||||
ref.watch(offlinePendingAttendanceUpdatesProvider).length;
|
||||
if (attendanceUpdates > 0) result['attendance'] = attendanceUpdates;
|
||||
|
||||
final leaves = ref.watch(offlinePendingLeavesProvider).length +
|
||||
ref.watch(offlinePendingLeaveUpdatesProvider).length;
|
||||
if (leaves > 0) result['leaves'] = leaves;
|
||||
|
||||
final passSlips = ref.watch(offlinePendingPassSlipsProvider).length +
|
||||
ref.watch(offlinePendingPassSlipUpdatesProvider).length;
|
||||
if (passSlips > 0) result['pass_slips'] = passSlips;
|
||||
|
||||
final announcements = ref.watch(offlinePendingAnnouncementsProvider).length +
|
||||
ref.watch(offlinePendingAnnouncementUpdatesProvider).length +
|
||||
ref.watch(offlinePendingAnnouncementCommentsProvider).length;
|
||||
if (announcements > 0) result['announcements'] = announcements;
|
||||
|
||||
final itRequests = ref.watch(offlinePendingItServiceRequestsProvider).length +
|
||||
ref.watch(offlinePendingItServiceRequestUpdatesProvider).length +
|
||||
ref.watch(offlinePendingIsrActionsProvider).length;
|
||||
if (itRequests > 0) result['it_requests'] = itRequests;
|
||||
|
||||
final notifReads = ref.watch(offlinePendingNotificationReadsProvider).length +
|
||||
ref.watch(offlinePendingBatchNotificationReadsProvider).length;
|
||||
if (notifReads > 0) result['notification_reads'] = notifReads;
|
||||
|
||||
final profileUpdates =
|
||||
ref.watch(offlinePendingProfileUpdatesProvider).length;
|
||||
if (profileUpdates > 0) result['profile_updates'] = profileUpdates;
|
||||
|
||||
final officeChanges = ref.watch(offlinePendingOfficesProvider).length +
|
||||
ref.watch(offlinePendingUserOfficeOpsProvider).length;
|
||||
if (officeChanges > 0) result['office_changes'] = officeChanges;
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -328,9 +328,10 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
|
||||
onOfflineData: () async {
|
||||
if (!AppRepository.isConfigured) return [];
|
||||
try {
|
||||
return await AppRepository.instance.get<Task>(
|
||||
final all = await AppRepository.instance.get<Task>(
|
||||
policy: OfflineFirstGetPolicy.localOnly,
|
||||
);
|
||||
return {for (final t in all) t.id: t}.values.toList();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
@@ -363,6 +364,7 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
|
||||
'[tasksProvider] reconnected — syncing ${pending.length} pending task(s)',
|
||||
);
|
||||
}
|
||||
final failedTaskIds = <String>{};
|
||||
for (final task in pending) {
|
||||
try {
|
||||
final syncPayload = <String, dynamic>{
|
||||
@@ -393,18 +395,26 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
|
||||
} catch (e) {
|
||||
final msg = e.toString();
|
||||
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
||||
// Task already in DB (Brick may have synced it first) — safe to
|
||||
// leave cleanup to the realtime stream listener in tasks_list_screen.
|
||||
// Task already in DB — treat as success so it's cleared from pending.
|
||||
debugPrint(
|
||||
'[tasksProvider] pending task already in DB id=${task.id}',
|
||||
);
|
||||
} else {
|
||||
failedTaskIds.add(task.id);
|
||||
debugPrint(
|
||||
'[tasksProvider] failed to sync pending task id=${task.id}: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clear successfully synced pending tasks. Don't rely on the
|
||||
// tasks_list_screen listener alone — it only fires when that screen is
|
||||
// active, leaving the banner stuck on other screens indefinitely.
|
||||
if (pending.isNotEmpty) {
|
||||
final remaining =
|
||||
pending.where((t) => failedTaskIds.contains(t.id)).toList();
|
||||
ref.read(offlinePendingTasksProvider.notifier).state = remaining;
|
||||
}
|
||||
|
||||
// ── 2. Sync offline field edits for EXISTING tasks (PATCH) ──────────
|
||||
// pendingUpdates already had pending-task entries removed in step 1.
|
||||
@@ -573,9 +583,10 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
|
||||
// When offline, seed from Brick local SQLite cache instead of network.
|
||||
if (!ref.read(isOnlineProvider) && AppRepository.isConfigured) {
|
||||
try {
|
||||
final cached = await AppRepository.instance.get<Task>(
|
||||
final all = await AppRepository.instance.get<Task>(
|
||||
policy: OfflineFirstGetPolicy.localOnly,
|
||||
);
|
||||
final cached = {for (final t in all) t.id: t}.values.toList();
|
||||
debugPrint('[tasksProvider] offline seed: ${cached.length} tasks from local cache');
|
||||
final payload = _buildTaskPayload(
|
||||
tasks: cached,
|
||||
@@ -2788,3 +2799,52 @@ class TaskAssignmentsController {
|
||||
ref.read(offlinePendingAssignmentsProvider.notifier).state = current;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Work Log helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/// All task_assignments rows where user_id = [userId].
|
||||
/// Used by the Work Log tab to identify which tasks the user is the assignee of.
|
||||
final taskAssignmentsForUserProvider =
|
||||
FutureProvider.autoDispose.family<List<TaskAssignment>, String>(
|
||||
(ref, userId) async {
|
||||
try {
|
||||
final data = await Supabase.instance.client
|
||||
.from('task_assignments')
|
||||
.select()
|
||||
.eq('user_id', userId);
|
||||
return data.map(TaskAssignment.fromMap).toList();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/// All task_activity_logs rows for the given task IDs — single batch query.
|
||||
/// Key is a sorted, comma-joined string of task IDs so Riverpod caches the
|
||||
/// provider correctly across rebuilds (List equality is reference-based in Dart).
|
||||
/// Returns logs in DESCENDING order (newest first).
|
||||
final taskActivityLogsForTasksProvider =
|
||||
FutureProvider.autoDispose.family<List<TaskActivityLog>, String>(
|
||||
(ref, taskIdsKey) async {
|
||||
if (taskIdsKey.isEmpty) return [];
|
||||
final taskIds = taskIdsKey.split(',');
|
||||
try {
|
||||
final data = await Supabase.instance.client
|
||||
.from('task_activity_logs')
|
||||
.select()
|
||||
.inFilter('task_id', taskIds)
|
||||
.order('created_at', ascending: false);
|
||||
return data.map(TaskActivityLog.fromMap).toList();
|
||||
} catch (_) {
|
||||
try {
|
||||
final all = await cachedListFromBrick<TaskActivityLog>();
|
||||
final idSet = taskIds.toSet();
|
||||
final filtered = all.where((l) => idSet.contains(l.taskId)).toList();
|
||||
filtered.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return filtered;
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'supabase_provider.dart';
|
||||
import 'stream_recovery.dart';
|
||||
import 'realtime_controller.dart';
|
||||
import '../brick/cache_helpers.dart';
|
||||
import '../brick/cache_warmer.dart';
|
||||
import '../models/team.dart';
|
||||
import '../models/team_member.dart';
|
||||
import 'realtime_controller.dart';
|
||||
import 'stream_recovery.dart';
|
||||
import 'supabase_provider.dart';
|
||||
|
||||
/// Real-time stream of teams with automatic recovery and graceful degradation.
|
||||
final teamsProvider = StreamProvider<List<Team>>((ref) {
|
||||
@@ -19,6 +21,7 @@ final teamsProvider = StreamProvider<List<Team>>((ref) {
|
||||
fromMap: Team.fromMap,
|
||||
channelName: 'teams',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async => cachedListFromBrick<Team>(),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
@@ -40,6 +43,10 @@ final teamMembersProvider = StreamProvider<List<TeamMember>>((ref) {
|
||||
fromMap: TeamMember.fromMap,
|
||||
channelName: 'team_members',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final rows = await CacheWarmer.readCachedTeamMembers();
|
||||
return rows.map(TeamMember.fromMap).toList();
|
||||
},
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
|
||||
@@ -30,7 +30,6 @@ final officesProvider = StreamProvider<List<Office>>((ref) {
|
||||
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);
|
||||
|
||||
@@ -25,6 +25,7 @@ final livePositionsProvider = StreamProvider<List<LivePosition>>((ref) {
|
||||
fromMap: LivePosition.fromMap,
|
||||
channelName: 'live_positions',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async => const [],
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
|
||||
@@ -30,6 +30,13 @@ import '../widgets/app_shell.dart';
|
||||
import '../screens/teams/teams_screen.dart';
|
||||
import '../screens/it_service_requests/it_service_requests_list_screen.dart';
|
||||
import '../screens/it_service_requests/it_service_request_detail_screen.dart';
|
||||
import '../screens/network_map/network_map_overview_screen.dart';
|
||||
import '../screens/network_map/network_map_site_screen.dart';
|
||||
import '../screens/network_map/network_map_device_screen.dart';
|
||||
import '../screens/network_map/network_map_device_edit_screen.dart';
|
||||
import '../screens/network_map/network_map_import_screen.dart';
|
||||
import '../screens/network_map/network_map_import_review_screen.dart';
|
||||
import '../screens/network_map/network_map_vlan_screen.dart';
|
||||
import '../theme/m3_motion.dart';
|
||||
|
||||
import '../utils/navigation.dart';
|
||||
@@ -77,6 +84,12 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
role == 'programmer' ||
|
||||
role == 'dispatcher' ||
|
||||
role == 'it_staff';
|
||||
final isNetworkMapRoute = state.matchedLocation.startsWith('/network-map');
|
||||
final hasNetworkMapAccess =
|
||||
role == 'admin' ||
|
||||
role == 'it_staff' ||
|
||||
role == 'dispatcher' ||
|
||||
role == 'programmer';
|
||||
|
||||
if (!isSignedIn && !isAuthRoute) {
|
||||
return '/login';
|
||||
@@ -102,6 +115,9 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
if (isReportsRoute && !hasReportsAccess) {
|
||||
return '/tickets';
|
||||
}
|
||||
if (isNetworkMapRoute && !hasNetworkMapAccess) {
|
||||
return '/tickets';
|
||||
}
|
||||
// Attendance & Whereabouts: not accessible to standard users
|
||||
final isStandardOnly = role == 'standard';
|
||||
final isAttendanceRoute =
|
||||
@@ -265,6 +281,80 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
child: const PermissionsScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/network-map',
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: const NetworkMapOverviewScreen(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'import',
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: const NetworkMapImportScreen(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':importId/review',
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: NetworkMapImportReviewScreen(
|
||||
importId: state.pathParameters['importId'] ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: 'vlans',
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: const NetworkMapVlanScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'site/:siteId',
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: NetworkMapSiteScreen(
|
||||
siteId: state.pathParameters['siteId'] ?? '',
|
||||
),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'device/new',
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: NetworkMapDeviceEditScreen(
|
||||
initialSiteId: state.pathParameters['siteId'],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: 'device/:deviceId',
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: NetworkMapDeviceScreen(
|
||||
deviceId: state.pathParameters['deviceId'] ?? '',
|
||||
),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'edit',
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: NetworkMapDeviceEditScreen(
|
||||
deviceId: state.pathParameters['deviceId'],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/notifications',
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,604 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:pdf/pdf.dart' as pdf;
|
||||
import 'package:printing/printing.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
// ─── DTOs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class DtrEntry {
|
||||
const DtrEntry({
|
||||
required this.date,
|
||||
required this.shiftLabel,
|
||||
required this.checkInAt,
|
||||
this.checkOutAt,
|
||||
required this.checkIn,
|
||||
required this.checkOut,
|
||||
required this.duration,
|
||||
required this.isAbsent,
|
||||
required this.isLeave,
|
||||
required this.isLate,
|
||||
this.leaveType,
|
||||
this.justification,
|
||||
this.passSlips = const [],
|
||||
});
|
||||
|
||||
final DateTime date;
|
||||
final String shiftLabel;
|
||||
final DateTime checkInAt;
|
||||
final DateTime? checkOutAt;
|
||||
final String checkIn;
|
||||
final String checkOut;
|
||||
final String duration;
|
||||
final bool isAbsent;
|
||||
final bool isLeave;
|
||||
final bool isLate;
|
||||
final String? leaveType;
|
||||
final String? justification;
|
||||
final List<DtrPassSlipEntry> passSlips;
|
||||
}
|
||||
|
||||
class DtrPassSlipEntry {
|
||||
const DtrPassSlipEntry({
|
||||
required this.reason,
|
||||
required this.timeRange,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
final String reason;
|
||||
final String timeRange;
|
||||
final String status;
|
||||
}
|
||||
|
||||
// ─── Public entry points ──────────────────────────────────────────────────
|
||||
|
||||
void showDtrPdfDialog(
|
||||
BuildContext context, {
|
||||
required String employeeName,
|
||||
required String role,
|
||||
required DateTimeRange period,
|
||||
required List<DtrEntry> entries,
|
||||
}) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => _DtrPdfDialog(
|
||||
title: 'DTR – $employeeName',
|
||||
filename: 'DTR - $employeeName.pdf',
|
||||
buildBytes: () => _buildPdf([(employeeName, role, entries)], period),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showDtrPdfDialogMulti(
|
||||
BuildContext context, {
|
||||
required List<(String, String, List<DtrEntry>)> users,
|
||||
required DateTimeRange period,
|
||||
}) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => _DtrPdfDialog(
|
||||
title: 'DTR Export (${users.length} employees)',
|
||||
filename: 'DTR Export.pdf',
|
||||
buildBytes: () => _buildPdf(users, period),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List> buildDtrPdfBytes({
|
||||
required String employeeName,
|
||||
required String role,
|
||||
required DateTimeRange period,
|
||||
required List<DtrEntry> entries,
|
||||
}) =>
|
||||
_buildPdf([(employeeName, role, entries)], period);
|
||||
|
||||
// ─── PDF builder ──────────────────────────────────────────────────────────
|
||||
|
||||
Future<Uint8List> _buildPdf(
|
||||
List<(String, String, List<DtrEntry>)> users,
|
||||
DateTimeRange period,
|
||||
) async {
|
||||
final logoData = await rootBundle.load('assets/crmc_logo.png');
|
||||
final logoImage = pw.MemoryImage(logoData.buffer.asUint8List());
|
||||
|
||||
final regular = pw.Font.ttf(
|
||||
await rootBundle.load('assets/fonts/Roboto-Regular.ttf'),
|
||||
);
|
||||
final bold = pw.Font.ttf(
|
||||
await rootBundle.load('assets/fonts/Roboto-Bold.ttf'),
|
||||
);
|
||||
|
||||
final doc = pw.Document();
|
||||
|
||||
for (final (name, role, entries) in users) {
|
||||
// Sort ascending (oldest first) for a chronological time record.
|
||||
final sorted = [...entries]..sort((a, b) => a.date.compareTo(b.date));
|
||||
|
||||
doc.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: pdf.PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(32),
|
||||
theme: pw.ThemeData.withFont(base: regular, bold: bold),
|
||||
footer: (ctx) => pw.Align(
|
||||
alignment: pw.Alignment.centerRight,
|
||||
child: pw.Text(
|
||||
'Page ${ctx.pageNumber} of ${ctx.pagesCount}',
|
||||
style: pw.TextStyle(
|
||||
font: regular,
|
||||
fontSize: 8,
|
||||
color: pdf.PdfColors.grey600,
|
||||
),
|
||||
),
|
||||
),
|
||||
build: (ctx) => [
|
||||
_header(logoImage, regular, bold),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Center(
|
||||
child: pw.Text(
|
||||
'DAILY TIME RECORD (DTR)',
|
||||
style: pw.TextStyle(font: bold, fontSize: 14, letterSpacing: 1.5),
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 10),
|
||||
_employeeInfo(name, role, period, regular, bold),
|
||||
pw.SizedBox(height: 10),
|
||||
_attendanceTable(sorted, regular, bold),
|
||||
if (sorted.any((e) => e.passSlips.isNotEmpty)) ...[
|
||||
pw.SizedBox(height: 8),
|
||||
_passSlipsSection(sorted, regular, bold),
|
||||
],
|
||||
pw.SizedBox(height: 10),
|
||||
_summary(sorted, regular, bold),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
// ─── Section builders ─────────────────────────────────────────────────────
|
||||
|
||||
pw.Widget _header(
|
||||
pw.ImageProvider logo,
|
||||
pw.Font regular,
|
||||
pw.Font bold,
|
||||
) {
|
||||
return pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.center,
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
||||
children: [
|
||||
pw.Image(logo, width: 60, height: 60),
|
||||
pw.SizedBox(width: 16),
|
||||
pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
||||
children: [
|
||||
pw.Text(
|
||||
'Republic of the Philippines',
|
||||
style: pw.TextStyle(font: regular, fontSize: 9),
|
||||
),
|
||||
pw.Text(
|
||||
'Department of Health',
|
||||
style: pw.TextStyle(font: regular, fontSize: 9),
|
||||
),
|
||||
pw.Text(
|
||||
'Cotabato Regional and Medical Center',
|
||||
style: pw.TextStyle(font: regular, fontSize: 9),
|
||||
),
|
||||
pw.SizedBox(height: 3),
|
||||
pw.Text(
|
||||
'Integrated Hospital Operations and Management Program (IHOMP)',
|
||||
style: pw.TextStyle(font: bold, fontSize: 9),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _employeeInfo(
|
||||
String name,
|
||||
String role,
|
||||
DateTimeRange period,
|
||||
pw.Font regular,
|
||||
pw.Font bold,
|
||||
) {
|
||||
final fmt = DateFormat('MMMM d, yyyy');
|
||||
final periodStr = '${fmt.format(period.start)} – ${fmt.format(period.end)}';
|
||||
|
||||
pw.Widget infoRow(String label, String value) => pw.RichText(
|
||||
text: pw.TextSpan(
|
||||
children: [
|
||||
pw.TextSpan(
|
||||
text: '$label ',
|
||||
style: pw.TextStyle(font: bold, fontSize: 9),
|
||||
),
|
||||
pw.TextSpan(
|
||||
text: value,
|
||||
style: pw.TextStyle(font: regular, fontSize: 9),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return pw.Container(
|
||||
decoration: pw.BoxDecoration(
|
||||
border: pw.Border.all(width: 0.8, color: pdf.PdfColors.grey400),
|
||||
),
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: pw.Row(
|
||||
children: [
|
||||
pw.Expanded(
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
infoRow('Name:', name),
|
||||
pw.SizedBox(height: 3),
|
||||
infoRow('Position:', _roleLabel(role)),
|
||||
],
|
||||
),
|
||||
),
|
||||
pw.Expanded(
|
||||
child: infoRow('Period:', periodStr),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _attendanceTable(
|
||||
List<DtrEntry> entries,
|
||||
pw.Font regular,
|
||||
pw.Font bold,
|
||||
) {
|
||||
final dayFmt = DateFormat('EEE');
|
||||
final dateFmt = DateFormat('MMM d');
|
||||
|
||||
pw.Widget th(String text) => pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
child: pw.Text(
|
||||
text,
|
||||
style: pw.TextStyle(font: bold, fontSize: 8),
|
||||
),
|
||||
);
|
||||
|
||||
pw.Widget td(String text) => pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 3),
|
||||
child: pw.Text(
|
||||
text,
|
||||
style: pw.TextStyle(font: regular, fontSize: 8),
|
||||
),
|
||||
);
|
||||
|
||||
String statusText(DtrEntry e) {
|
||||
if (e.isAbsent) return 'Absent';
|
||||
if (e.isLeave) return _leaveTypeLabel(e.leaveType);
|
||||
if (e.isLate) return 'Late';
|
||||
if (e.shiftLabel == 'Overtime') return 'Overtime';
|
||||
if (e.duration == 'On duty') return 'Active';
|
||||
return 'Present';
|
||||
}
|
||||
|
||||
pdf.PdfColor? rowColor(DtrEntry e) {
|
||||
if (e.isAbsent) return pdf.PdfColor.fromHex('#FFEBEE');
|
||||
if (e.isLeave) return pdf.PdfColor.fromHex('#E3F2FD');
|
||||
if (e.shiftLabel == 'Overtime') return pdf.PdfColor.fromHex('#FFF8E1');
|
||||
if (e.isLate) return pdf.PdfColor.fromHex('#FFF3E0');
|
||||
return null;
|
||||
}
|
||||
|
||||
return pw.Table(
|
||||
border: pw.TableBorder.all(width: 0.5, color: pdf.PdfColors.grey400),
|
||||
columnWidths: const {
|
||||
0: pw.FixedColumnWidth(56),
|
||||
1: pw.FixedColumnWidth(28),
|
||||
2: pw.FixedColumnWidth(72),
|
||||
3: pw.FixedColumnWidth(60),
|
||||
4: pw.FixedColumnWidth(60),
|
||||
5: pw.FixedColumnWidth(50),
|
||||
6: pw.FlexColumnWidth(),
|
||||
},
|
||||
children: [
|
||||
pw.TableRow(
|
||||
decoration: const pw.BoxDecoration(color: pdf.PdfColors.grey200),
|
||||
children: [
|
||||
th('Date'),
|
||||
th('Day'),
|
||||
th('Shift'),
|
||||
th('Check-In'),
|
||||
th('Check-Out'),
|
||||
th('Hours'),
|
||||
th('Remarks'),
|
||||
],
|
||||
),
|
||||
for (final e in entries)
|
||||
pw.TableRow(
|
||||
decoration: pw.BoxDecoration(color: rowColor(e)),
|
||||
children: [
|
||||
td(dateFmt.format(e.date)),
|
||||
td(dayFmt.format(e.date)),
|
||||
td(e.shiftLabel),
|
||||
td(e.checkIn),
|
||||
td(e.checkOut),
|
||||
td(e.duration),
|
||||
td(statusText(e)),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _passSlipsSection(
|
||||
List<DtrEntry> entries,
|
||||
pw.Font regular,
|
||||
pw.Font bold,
|
||||
) {
|
||||
final dateFmt = DateFormat('MMM d (EEE)');
|
||||
final slips = <(String, DtrPassSlipEntry)>[];
|
||||
for (final e in entries) {
|
||||
for (final s in e.passSlips) {
|
||||
slips.add((dateFmt.format(e.date), s));
|
||||
}
|
||||
}
|
||||
|
||||
pw.Widget th(String text) => pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 3),
|
||||
child: pw.Text(text, style: pw.TextStyle(font: bold, fontSize: 8)),
|
||||
);
|
||||
|
||||
pw.Widget td(String text) => pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: pw.Text(text, style: pw.TextStyle(font: regular, fontSize: 7.5)),
|
||||
);
|
||||
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
'Pass Slips',
|
||||
style: pw.TextStyle(font: bold, fontSize: 9, letterSpacing: 0.5),
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Table(
|
||||
border: pw.TableBorder.all(width: 0.5, color: pdf.PdfColors.grey400),
|
||||
columnWidths: const {
|
||||
0: pw.FixedColumnWidth(88),
|
||||
1: pw.FixedColumnWidth(88),
|
||||
2: pw.FlexColumnWidth(),
|
||||
3: pw.FixedColumnWidth(60),
|
||||
},
|
||||
children: [
|
||||
pw.TableRow(
|
||||
decoration: const pw.BoxDecoration(color: pdf.PdfColors.grey200),
|
||||
children: [
|
||||
th('Date'),
|
||||
th('Time Range'),
|
||||
th('Reason'),
|
||||
th('Status'),
|
||||
],
|
||||
),
|
||||
for (final (date, slip) in slips)
|
||||
pw.TableRow(
|
||||
children: [
|
||||
td(date),
|
||||
td(slip.timeRange),
|
||||
td(slip.reason),
|
||||
td(slip.status),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _summary(List<DtrEntry> entries, pw.Font regular, pw.Font bold) {
|
||||
final present = entries.where((e) => !e.isAbsent && !e.isLeave).length;
|
||||
final absent = entries.where((e) => e.isAbsent).length;
|
||||
final lates = entries.where((e) => e.isLate).length;
|
||||
final leaves = entries.where((e) => e.isLeave).toList();
|
||||
|
||||
final leaveByType = <String, int>{};
|
||||
for (final l in leaves) {
|
||||
final label = _leaveTypeLabel(l.leaveType);
|
||||
leaveByType[label] = (leaveByType[label] ?? 0) + 1;
|
||||
}
|
||||
final leaveSummary = leaveByType.isEmpty
|
||||
? '${leaves.length} day(s)'
|
||||
: leaveByType.entries.map((e) => '${e.key}: ${e.value}').join(', ');
|
||||
|
||||
final otEntries =
|
||||
entries.where((e) => e.shiftLabel == 'Overtime' && e.checkOutAt != null);
|
||||
final otMinutes = otEntries.fold<int>(
|
||||
0,
|
||||
(sum, e) => sum + e.checkOutAt!.difference(e.checkInAt).inMinutes,
|
||||
);
|
||||
final otStr =
|
||||
otMinutes == 0 ? 'None' : '${otMinutes ~/ 60}h ${otMinutes % 60}m';
|
||||
final passSlipTotal =
|
||||
entries.fold<int>(0, (s, e) => s + e.passSlips.length);
|
||||
|
||||
pw.Widget cell(String label, String value) => pw.Expanded(
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(label, style: pw.TextStyle(font: bold, fontSize: 8)),
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(value, style: pw.TextStyle(font: regular, fontSize: 9)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return pw.Container(
|
||||
decoration: pw.BoxDecoration(
|
||||
border: pw.Border.all(width: 0.8, color: pdf.PdfColors.grey400),
|
||||
color: pdf.PdfColors.grey100,
|
||||
),
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
'SUMMARY',
|
||||
style: pw.TextStyle(font: bold, fontSize: 9, letterSpacing: 1),
|
||||
),
|
||||
pw.SizedBox(height: 6),
|
||||
pw.Row(
|
||||
children: [
|
||||
cell('Days Present', '$present'),
|
||||
cell('Absences', '$absent'),
|
||||
cell('Lates', '$lates'),
|
||||
cell('Overtime', otStr),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Row(
|
||||
children: [
|
||||
cell('Leaves', leaveSummary),
|
||||
cell('Pass Slips', '$passSlipTotal'),
|
||||
pw.Expanded(child: pw.SizedBox()),
|
||||
pw.Expanded(child: pw.SizedBox()),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
String _roleLabel(String role) {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'Administrator';
|
||||
case 'dispatcher':
|
||||
return 'Dispatcher';
|
||||
case 'programmer':
|
||||
return 'Programmer';
|
||||
case 'it_staff':
|
||||
return 'IT Staff';
|
||||
default:
|
||||
return role.isEmpty ? 'Staff' : role;
|
||||
}
|
||||
}
|
||||
|
||||
String _leaveTypeLabel(String? leaveType) {
|
||||
switch (leaveType) {
|
||||
case 'sick_leave':
|
||||
return 'Sick Leave';
|
||||
case 'vacation_leave':
|
||||
return 'Vacation Leave';
|
||||
case 'emergency_leave':
|
||||
return 'Emergency Leave';
|
||||
case 'parental_leave':
|
||||
return 'Parental Leave';
|
||||
default:
|
||||
return 'On Leave';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Dialog ───────────────────────────────────────────────────────────────
|
||||
|
||||
class _DtrPdfDialog extends StatefulWidget {
|
||||
const _DtrPdfDialog({
|
||||
required this.title,
|
||||
required this.filename,
|
||||
required this.buildBytes,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String filename;
|
||||
final Future<Uint8List> Function() buildBytes;
|
||||
|
||||
@override
|
||||
State<_DtrPdfDialog> createState() => _DtrPdfDialogState();
|
||||
}
|
||||
|
||||
class _DtrPdfDialogState extends State<_DtrPdfDialog> {
|
||||
late final Future<Uint8List> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = widget.buildBytes();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
content: SizedBox(
|
||||
width: 800,
|
||||
height: 900,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Print',
|
||||
icon: const Icon(Icons.print),
|
||||
onPressed: () async {
|
||||
final bytes = await _future;
|
||||
await Printing.layoutPdf(
|
||||
onLayout: (_) async => bytes,
|
||||
name: widget.filename,
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Download',
|
||||
icon: const Icon(Icons.download),
|
||||
onPressed: () async {
|
||||
final bytes = await _future;
|
||||
await Printing.sharePdf(
|
||||
bytes: bytes,
|
||||
filename: widget.filename,
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Close',
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: FutureBuilder<Uint8List>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text(snapshot.error.toString()));
|
||||
}
|
||||
final data = snapshot.data;
|
||||
if (data == null) return const SizedBox.shrink();
|
||||
return PdfViewer.data(data, sourceName: 'dtr.pdf');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/attendance_log.dart';
|
||||
import '../../models/it_service_request.dart';
|
||||
import '../../models/pass_slip.dart';
|
||||
import '../../models/task.dart';
|
||||
import '../../models/ticket.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Data class
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class LogbookDayActivityData {
|
||||
final String userId;
|
||||
final String name;
|
||||
final DateTime date;
|
||||
final String shiftLabel;
|
||||
final DateTime? scheduledStart;
|
||||
final DateTime? scheduledEnd;
|
||||
final bool isSwapped;
|
||||
final List<AttendanceLog> sessions;
|
||||
final List<PassSlip> passSlips;
|
||||
final List<Task> tasks;
|
||||
final List<Ticket> tickets;
|
||||
final List<ItServiceRequest> serviceRequests;
|
||||
|
||||
const LogbookDayActivityData({
|
||||
required this.userId,
|
||||
required this.name,
|
||||
required this.date,
|
||||
required this.shiftLabel,
|
||||
this.scheduledStart,
|
||||
this.scheduledEnd,
|
||||
this.isSwapped = false,
|
||||
required this.sessions,
|
||||
required this.passSlips,
|
||||
required this.tasks,
|
||||
required this.tickets,
|
||||
required this.serviceRequests,
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Timeline event model
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum _DayEventType {
|
||||
shiftScheduled,
|
||||
swapNote,
|
||||
checkIn,
|
||||
passSlipOut,
|
||||
passSlipReturn,
|
||||
checkOut,
|
||||
stillOnDuty,
|
||||
taskCreated,
|
||||
taskStarted,
|
||||
taskCompleted,
|
||||
ticketCreated,
|
||||
ticketResponded,
|
||||
ticketPromoted,
|
||||
isrCreated,
|
||||
isrUpdated,
|
||||
isrCompleted,
|
||||
}
|
||||
|
||||
class _DayEvent {
|
||||
final _DayEventType type;
|
||||
final DateTime time;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
|
||||
const _DayEvent({
|
||||
required this.type,
|
||||
required this.time,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Event assembly
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
List<_DayEvent> _buildEvents(LogbookDayActivityData data) {
|
||||
final events = <_DayEvent>[];
|
||||
final t = AppTime.formatTime;
|
||||
|
||||
if (data.scheduledStart != null) {
|
||||
events.add(_DayEvent(
|
||||
type: _DayEventType.shiftScheduled,
|
||||
time: data.scheduledStart!,
|
||||
title: 'Shift scheduled — ${data.shiftLabel}',
|
||||
subtitle: data.scheduledEnd != null
|
||||
? '${t(data.scheduledStart!)} – ${t(data.scheduledEnd!)}'
|
||||
: t(data.scheduledStart!),
|
||||
));
|
||||
if (data.isSwapped) {
|
||||
events.add(_DayEvent(
|
||||
type: _DayEventType.swapNote,
|
||||
time: data.scheduledStart!,
|
||||
title: 'Shift received via swap',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
final sortedSessions = [...data.sessions]
|
||||
..sort((a, b) => a.checkInAt.compareTo(b.checkInAt));
|
||||
|
||||
for (final session in sortedSessions) {
|
||||
events.add(_DayEvent(
|
||||
type: _DayEventType.checkIn,
|
||||
time: session.checkInAt,
|
||||
title: 'Checked in',
|
||||
subtitle: t(session.checkInAt),
|
||||
));
|
||||
|
||||
// Pass slips that started during this session window.
|
||||
final sessionEnd = session.checkOutAt ?? AppTime.now();
|
||||
for (final slip in data.passSlips) {
|
||||
if (slip.slipStart == null) continue;
|
||||
if (slip.slipStart!.isBefore(session.checkInAt) ||
|
||||
slip.slipStart!.isAfter(sessionEnd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
events.add(_DayEvent(
|
||||
type: _DayEventType.passSlipOut,
|
||||
time: slip.slipStart!,
|
||||
title: 'Pass slip — left',
|
||||
subtitle: slip.reason,
|
||||
));
|
||||
if (slip.slipEnd != null) {
|
||||
events.add(_DayEvent(
|
||||
type: _DayEventType.passSlipReturn,
|
||||
time: slip.slipEnd!,
|
||||
title: 'Pass slip — returned',
|
||||
subtitle: '${t(slip.slipStart!)} – ${t(slip.slipEnd!)}',
|
||||
));
|
||||
} else {
|
||||
events.add(_DayEvent(
|
||||
type: _DayEventType.passSlipReturn,
|
||||
time: slip.slipStart!.add(const Duration(seconds: 1)),
|
||||
title: 'Pass slip — still out',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (session.checkOutAt != null) {
|
||||
events.add(_DayEvent(
|
||||
type: _DayEventType.checkOut,
|
||||
time: session.checkOutAt!,
|
||||
title: 'Checked out',
|
||||
subtitle: t(session.checkOutAt!),
|
||||
));
|
||||
} else {
|
||||
events.add(_DayEvent(
|
||||
type: _DayEventType.stillOnDuty,
|
||||
time: AppTime.now(),
|
||||
title: 'Currently on duty',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for (final task in data.tasks) {
|
||||
if (task.startedAt != null) {
|
||||
events.add(_DayEvent(
|
||||
type: _DayEventType.taskStarted,
|
||||
time: task.startedAt!,
|
||||
title: 'Task started',
|
||||
subtitle: task.title,
|
||||
));
|
||||
} else if (task.completedAt != null) {
|
||||
events.add(_DayEvent(
|
||||
type: _DayEventType.taskCompleted,
|
||||
time: task.completedAt!,
|
||||
title: 'Task completed',
|
||||
subtitle: task.title,
|
||||
));
|
||||
} else {
|
||||
events.add(_DayEvent(
|
||||
type: _DayEventType.taskCreated,
|
||||
time: task.createdAt,
|
||||
title: 'Task created',
|
||||
subtitle: task.title,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for (final ticket in data.tickets) {
|
||||
if (ticket.promotedAt != null) {
|
||||
events.add(_DayEvent(
|
||||
type: _DayEventType.ticketPromoted,
|
||||
time: ticket.promotedAt!,
|
||||
title: 'Ticket promoted to task',
|
||||
subtitle: ticket.subject,
|
||||
));
|
||||
} else if (ticket.respondedAt != null) {
|
||||
events.add(_DayEvent(
|
||||
type: _DayEventType.ticketResponded,
|
||||
time: ticket.respondedAt!,
|
||||
title: 'Ticket responded',
|
||||
subtitle: ticket.subject,
|
||||
));
|
||||
} else {
|
||||
events.add(_DayEvent(
|
||||
type: _DayEventType.ticketCreated,
|
||||
time: ticket.createdAt,
|
||||
title: 'Ticket created',
|
||||
subtitle: ticket.subject,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for (final isr in data.serviceRequests) {
|
||||
if (isr.completedAt != null) {
|
||||
events.add(_DayEvent(
|
||||
type: _DayEventType.isrCompleted,
|
||||
time: isr.completedAt!,
|
||||
title: 'IT request completed',
|
||||
subtitle: isr.requestNumber,
|
||||
));
|
||||
} else {
|
||||
final isCreatedDay = _sameDay(isr.createdAt, data.date);
|
||||
events.add(_DayEvent(
|
||||
type: isCreatedDay ? _DayEventType.isrCreated : _DayEventType.isrUpdated,
|
||||
time: isCreatedDay ? isr.createdAt : isr.updatedAt,
|
||||
title: isCreatedDay ? 'IT request created' : 'IT request updated',
|
||||
subtitle: isr.requestNumber,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
events.sort((a, b) => a.time.compareTo(b.time));
|
||||
return events;
|
||||
}
|
||||
|
||||
bool _sameDay(DateTime a, DateTime b) =>
|
||||
a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Main sheet widget
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class LogbookDayActivitySheet extends ConsumerWidget {
|
||||
const LogbookDayActivitySheet({super.key, required this.data});
|
||||
|
||||
final LogbookDayActivityData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final events = _buildEvents(data);
|
||||
|
||||
final dateStr = _formatDate(data.date);
|
||||
final initials = _initials(data.name);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// ── Drag handle (mobile) ──────────────────────────────
|
||||
Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 12, bottom: 4),
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
// ── Header ───────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundColor: colors.primaryContainer,
|
||||
child: Text(
|
||||
initials,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colors.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(data.name,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
)),
|
||||
Text(dateStr,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// ── Summary chips ─────────────────────────────────────
|
||||
if (data.tasks.isNotEmpty ||
|
||||
data.tickets.isNotEmpty ||
|
||||
data.serviceRequests.isNotEmpty ||
|
||||
data.passSlips.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 10, 20, 0),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
if (data.tasks.isNotEmpty)
|
||||
_SummaryChip(
|
||||
label: '${data.tasks.length} task${data.tasks.length == 1 ? '' : 's'}',
|
||||
icon: Icons.task_alt_rounded,
|
||||
color: colors.secondaryContainer,
|
||||
onColor: colors.onSecondaryContainer,
|
||||
),
|
||||
if (data.tickets.isNotEmpty)
|
||||
_SummaryChip(
|
||||
label: '${data.tickets.length} ticket${data.tickets.length == 1 ? '' : 's'}',
|
||||
icon: Icons.confirmation_num_outlined,
|
||||
color: colors.tertiaryContainer,
|
||||
onColor: colors.onTertiaryContainer,
|
||||
),
|
||||
if (data.serviceRequests.isNotEmpty)
|
||||
_SummaryChip(
|
||||
label: '${data.serviceRequests.length} ISR${data.serviceRequests.length == 1 ? '' : 's'}',
|
||||
icon: Icons.computer_outlined,
|
||||
color: colors.surfaceContainerHighest,
|
||||
onColor: colors.onSurface,
|
||||
),
|
||||
if (data.passSlips.isNotEmpty)
|
||||
_SummaryChip(
|
||||
label: '${data.passSlips.length} pass slip${data.passSlips.length == 1 ? '' : 's'}',
|
||||
icon: Icons.badge_outlined,
|
||||
color: colors.primaryContainer,
|
||||
onColor: colors.onPrimaryContainer,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(height: 1),
|
||||
// ── Timeline ─────────────────────────────────────────
|
||||
Flexible(
|
||||
child: events.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No activity recorded for this day.',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||
itemCount: events.length,
|
||||
itemBuilder: (context, index) {
|
||||
return M3FadeSlideIn(
|
||||
delay: Duration(
|
||||
milliseconds: index.clamp(0, 14) * 45),
|
||||
child: _TimelineTile(
|
||||
event: events[index],
|
||||
isFirst: index == 0,
|
||||
isLast: index == events.length - 1,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatDate(DateTime d) {
|
||||
const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
const months = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||
];
|
||||
final wd = weekdays[d.weekday - 1];
|
||||
final mo = months[d.month - 1];
|
||||
return '$wd, $mo ${d.day}, ${d.year}';
|
||||
}
|
||||
|
||||
static String _initials(String name) {
|
||||
final parts = name.trim().split(RegExp(r'\s+'));
|
||||
if (parts.isEmpty) return '?';
|
||||
if (parts.length == 1) return parts[0][0].toUpperCase();
|
||||
return '${parts[0][0]}${parts[parts.length - 1][0]}'.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Summary chip
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _SummaryChip extends StatelessWidget {
|
||||
const _SummaryChip({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onColor,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final Color onColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: onColor),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: onColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Timeline tile
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _TimelineTile extends StatelessWidget {
|
||||
const _TimelineTile({
|
||||
required this.event,
|
||||
required this.isFirst,
|
||||
required this.isLast,
|
||||
});
|
||||
|
||||
final _DayEvent event;
|
||||
final bool isFirst;
|
||||
final bool isLast;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final dotColor = _dotColor(colors);
|
||||
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// ── Dot + connector line ──────────────────────────
|
||||
SizedBox(
|
||||
width: 28,
|
||||
child: Column(
|
||||
children: [
|
||||
// Top connector (hidden for first item)
|
||||
SizedBox(
|
||||
height: isFirst ? 6 : 0,
|
||||
width: 2,
|
||||
child: isFirst
|
||||
? null
|
||||
: ColoredBox(color: colors.outlineVariant),
|
||||
),
|
||||
// Dot
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: dotColor,
|
||||
border: Border.all(
|
||||
color: colors.surface,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bottom connector (hidden for last item)
|
||||
if (!isLast)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 2,
|
||||
child: ColoredBox(color: colors.outlineVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// ── Content ───────────────────────────────────────
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
event.title,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (event.subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
event.subtitle!,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
AppTime.formatTime(event.time),
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _dotColor(ColorScheme colors) {
|
||||
switch (event.type) {
|
||||
case _DayEventType.shiftScheduled:
|
||||
case _DayEventType.swapNote:
|
||||
return colors.outline;
|
||||
case _DayEventType.checkIn:
|
||||
case _DayEventType.checkOut:
|
||||
return colors.primary;
|
||||
case _DayEventType.passSlipOut:
|
||||
case _DayEventType.passSlipReturn:
|
||||
return colors.tertiary;
|
||||
case _DayEventType.stillOnDuty:
|
||||
return colors.primaryContainer;
|
||||
case _DayEventType.taskCreated:
|
||||
case _DayEventType.taskStarted:
|
||||
case _DayEventType.taskCompleted:
|
||||
return colors.secondary;
|
||||
case _DayEventType.ticketCreated:
|
||||
case _DayEventType.ticketResponded:
|
||||
case _DayEventType.ticketPromoted:
|
||||
return colors.secondaryContainer;
|
||||
case _DayEventType.isrCreated:
|
||||
case _DayEventType.isrUpdated:
|
||||
case _DayEventType.isrCompleted:
|
||||
return colors.tertiaryContainer;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
|
||||
// ─── DTOs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class WorkLogEntryDto {
|
||||
const WorkLogEntryDto({
|
||||
required this.type,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.start,
|
||||
this.end,
|
||||
required this.isCreatorEvent,
|
||||
required this.duration,
|
||||
});
|
||||
|
||||
// 'attendance' | 'task' | 'ticket' | 'isr' | 'passSlip' | 'vacant'
|
||||
final String type;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final DateTime start;
|
||||
final DateTime? end;
|
||||
final bool isCreatorEvent;
|
||||
final Duration duration;
|
||||
}
|
||||
|
||||
class WorkLogSummaryDto {
|
||||
const WorkLogSummaryDto({
|
||||
required this.totalAttendance,
|
||||
required this.totalTasks,
|
||||
required this.totalTickets,
|
||||
required this.totalIsrs,
|
||||
required this.totalPassSlip,
|
||||
required this.totalVacant,
|
||||
});
|
||||
|
||||
final Duration totalAttendance;
|
||||
final Duration totalTasks;
|
||||
final Duration totalTickets;
|
||||
final Duration totalIsrs;
|
||||
final Duration totalPassSlip;
|
||||
final Duration totalVacant;
|
||||
|
||||
Duration get totalActive =>
|
||||
totalAttendance + totalTasks + totalTickets + totalIsrs + totalPassSlip;
|
||||
}
|
||||
|
||||
class DailyWorkLogDto {
|
||||
const DailyWorkLogDto({
|
||||
required this.date,
|
||||
required this.entries,
|
||||
required this.summary,
|
||||
});
|
||||
|
||||
final DateTime date;
|
||||
final List<WorkLogEntryDto> entries;
|
||||
final WorkLogSummaryDto summary;
|
||||
|
||||
bool get hasData => entries.any((e) => !e.isCreatorEvent);
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
String _fmt(Duration d) {
|
||||
if (d.inMinutes < 1) return '< 1m';
|
||||
final h = d.inHours;
|
||||
final m = d.inMinutes % 60;
|
||||
return h > 0 ? '${h}h ${m}m' : '${m}m';
|
||||
}
|
||||
|
||||
String _fmtTime(DateTime dt) => DateFormat('h:mm a').format(dt);
|
||||
|
||||
Future<pw.Font> _font(String path) async {
|
||||
final data = await rootBundle.load(path);
|
||||
return pw.Font.ttf(data);
|
||||
}
|
||||
|
||||
String _typeLabel(String type) => switch (type) {
|
||||
'attendance' => 'Attendance',
|
||||
'task' => 'Task',
|
||||
'ticket' => 'Ticket',
|
||||
'isr' => 'ISR',
|
||||
'passSlip' => 'Pass Slip',
|
||||
'vacant' => 'Vacant',
|
||||
_ => type,
|
||||
};
|
||||
|
||||
pw.TableRow _headerRow(pw.Font bold, List<String> labels) => pw.TableRow(
|
||||
decoration: const pw.BoxDecoration(color: PdfColors.grey200),
|
||||
children: labels
|
||||
.map((l) => pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(4),
|
||||
child: pw.Text(l, style: pw.TextStyle(font: bold, fontSize: 8)),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
pw.Widget _cell(String text, {bool bold = false, pw.Font? boldFont}) =>
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(4),
|
||||
child: pw.Text(
|
||||
text,
|
||||
style: bold && boldFont != null
|
||||
? pw.TextStyle(font: boldFont, fontSize: 8)
|
||||
: const pw.TextStyle(fontSize: 8),
|
||||
),
|
||||
);
|
||||
|
||||
pw.TableRow _summaryRow(
|
||||
String label,
|
||||
Duration dur, {
|
||||
bool isBold = false,
|
||||
pw.Font? boldFont,
|
||||
}) =>
|
||||
pw.TableRow(children: [
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(4),
|
||||
child: pw.Text(
|
||||
label,
|
||||
style: isBold && boldFont != null
|
||||
? pw.TextStyle(font: boldFont, fontSize: 9)
|
||||
: const pw.TextStyle(fontSize: 9),
|
||||
),
|
||||
),
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(4),
|
||||
child: pw.Text(
|
||||
_fmt(dur),
|
||||
style: isBold && boldFont != null
|
||||
? pw.TextStyle(font: boldFont, fontSize: 9)
|
||||
: const pw.TextStyle(fontSize: 9),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
// ─── Daily PDF ────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<Uint8List> buildDailyWorkLogPdfBytes({
|
||||
required String personName,
|
||||
required DateTime date,
|
||||
required List<WorkLogEntryDto> entries,
|
||||
required WorkLogSummaryDto summary,
|
||||
}) async {
|
||||
final regular = await _font('assets/fonts/Roboto-Regular.ttf');
|
||||
final bold = await _font('assets/fonts/Roboto-Bold.ttf');
|
||||
final dateFmt = DateFormat('EEEE, MMMM d, yyyy');
|
||||
|
||||
final doc = pw.Document();
|
||||
doc.addPage(pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(32),
|
||||
theme: pw.ThemeData.withFont(base: regular, bold: bold),
|
||||
header: (ctx) => pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text('Work Log',
|
||||
style: pw.TextStyle(font: bold, fontSize: 18)),
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(personName, style: const pw.TextStyle(fontSize: 12)),
|
||||
pw.Text(dateFmt.format(date),
|
||||
style:
|
||||
const pw.TextStyle(fontSize: 11, color: PdfColors.grey700)),
|
||||
pw.Divider(thickness: 0.5),
|
||||
],
|
||||
),
|
||||
footer: (ctx) => pw.Align(
|
||||
alignment: pw.Alignment.centerRight,
|
||||
child: pw.Text(
|
||||
'Page ${ctx.pageNumber} of ${ctx.pagesCount} • Generated by Tasq',
|
||||
style:
|
||||
const pw.TextStyle(fontSize: 8, color: PdfColors.grey600),
|
||||
),
|
||||
),
|
||||
build: (ctx) => [
|
||||
pw.Table(
|
||||
border: pw.TableBorder.all(width: 0.4, color: PdfColors.grey400),
|
||||
columnWidths: const {
|
||||
0: pw.FixedColumnWidth(68),
|
||||
1: pw.FixedColumnWidth(68),
|
||||
2: pw.FixedColumnWidth(58),
|
||||
3: pw.FlexColumnWidth(),
|
||||
4: pw.FixedColumnWidth(44),
|
||||
},
|
||||
children: [
|
||||
_headerRow(bold, ['Start', 'End', 'Type', 'Description', 'Duration']),
|
||||
for (final e in entries.where((e) => !e.isCreatorEvent))
|
||||
pw.TableRow(children: [
|
||||
_cell(_fmtTime(e.start)),
|
||||
_cell(e.end != null ? _fmtTime(e.end!) : '—'),
|
||||
_cell(_typeLabel(e.type)),
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(4),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(e.title,
|
||||
style: const pw.TextStyle(fontSize: 8)),
|
||||
if (e.subtitle != null)
|
||||
pw.Text(e.subtitle!,
|
||||
style: const pw.TextStyle(
|
||||
fontSize: 7, color: PdfColors.grey700)),
|
||||
],
|
||||
),
|
||||
),
|
||||
_cell(_fmt(e.duration)),
|
||||
]),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 12),
|
||||
pw.Text('Summary',
|
||||
style: pw.TextStyle(font: bold, fontSize: 11)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Table(
|
||||
border: pw.TableBorder.all(width: 0.4, color: PdfColors.grey400),
|
||||
columnWidths: const {
|
||||
0: pw.FlexColumnWidth(),
|
||||
1: pw.FixedColumnWidth(60),
|
||||
},
|
||||
children: [
|
||||
if (summary.totalAttendance > Duration.zero)
|
||||
_summaryRow('Attendance', summary.totalAttendance, boldFont: bold),
|
||||
if (summary.totalTasks > Duration.zero)
|
||||
_summaryRow('Tasks', summary.totalTasks, boldFont: bold),
|
||||
if (summary.totalTickets > Duration.zero)
|
||||
_summaryRow('Tickets', summary.totalTickets, boldFont: bold),
|
||||
if (summary.totalIsrs > Duration.zero)
|
||||
_summaryRow('IT Service Requests', summary.totalIsrs, boldFont: bold),
|
||||
if (summary.totalPassSlip > Duration.zero)
|
||||
_summaryRow('Pass Slips', summary.totalPassSlip, boldFont: bold),
|
||||
_summaryRow('Total Active', summary.totalActive,
|
||||
isBold: true, boldFont: bold),
|
||||
],
|
||||
),
|
||||
],
|
||||
));
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
// ─── Multi-day Summary PDF ────────────────────────────────────────────────────
|
||||
|
||||
Future<Uint8List> buildMultiDayWorkLogPdfBytes({
|
||||
required String personName,
|
||||
required DateTime rangeStart,
|
||||
required DateTime rangeEnd,
|
||||
required String modeLabel,
|
||||
required List<DailyWorkLogDto> days,
|
||||
}) async {
|
||||
final regular = await _font('assets/fonts/Roboto-Regular.ttf');
|
||||
final bold = await _font('assets/fonts/Roboto-Bold.ttf');
|
||||
final dateFmt = DateFormat('MMM d, yyyy');
|
||||
final dayFmt = DateFormat('EEE, MMM d');
|
||||
|
||||
final doc = pw.Document();
|
||||
doc.addPage(pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(32),
|
||||
theme: pw.ThemeData.withFont(base: regular, bold: bold),
|
||||
header: (ctx) => pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text('Work Log — $modeLabel',
|
||||
style: pw.TextStyle(font: bold, fontSize: 18)),
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(personName, style: const pw.TextStyle(fontSize: 12)),
|
||||
pw.Text(
|
||||
'${dateFmt.format(rangeStart)} – '
|
||||
'${dateFmt.format(rangeEnd.subtract(const Duration(days: 1)))}',
|
||||
style:
|
||||
const pw.TextStyle(fontSize: 11, color: PdfColors.grey700),
|
||||
),
|
||||
pw.Divider(thickness: 0.5),
|
||||
],
|
||||
),
|
||||
footer: (ctx) => pw.Align(
|
||||
alignment: pw.Alignment.centerRight,
|
||||
child: pw.Text(
|
||||
'Page ${ctx.pageNumber} of ${ctx.pagesCount} • Generated by Tasq',
|
||||
style:
|
||||
const pw.TextStyle(fontSize: 8, color: PdfColors.grey600),
|
||||
),
|
||||
),
|
||||
build: (ctx) => [
|
||||
pw.Table(
|
||||
border: pw.TableBorder.all(width: 0.4, color: PdfColors.grey400),
|
||||
columnWidths: const {
|
||||
0: pw.FixedColumnWidth(80),
|
||||
1: pw.FixedColumnWidth(54),
|
||||
2: pw.FixedColumnWidth(40),
|
||||
3: pw.FixedColumnWidth(40),
|
||||
4: pw.FixedColumnWidth(36),
|
||||
5: pw.FixedColumnWidth(46),
|
||||
6: pw.FixedColumnWidth(38),
|
||||
7: pw.FixedColumnWidth(48),
|
||||
},
|
||||
children: [
|
||||
_headerRow(bold,
|
||||
['Date', 'Attend.', 'Tasks', 'Tickets', 'ISR', 'Pass Slip', 'Idle', 'Total']),
|
||||
for (final day in days)
|
||||
pw.TableRow(
|
||||
decoration: day.hasData
|
||||
? null
|
||||
: const pw.BoxDecoration(color: PdfColors.grey100),
|
||||
children: [
|
||||
_cell(dayFmt.format(day.date)),
|
||||
_cell(day.hasData ? _fmt(day.summary.totalAttendance) : '—'),
|
||||
_cell(day.hasData ? _fmt(day.summary.totalTasks) : '—'),
|
||||
_cell(day.hasData ? _fmt(day.summary.totalTickets) : '—'),
|
||||
_cell(day.hasData ? _fmt(day.summary.totalIsrs) : '—'),
|
||||
_cell(day.hasData ? _fmt(day.summary.totalPassSlip) : '—'),
|
||||
_cell(day.hasData ? _fmt(day.summary.totalVacant) : '—'),
|
||||
_cell(day.hasData ? _fmt(day.summary.totalActive) : '—'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
));
|
||||
return doc.save();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,673 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/network/network_device.dart';
|
||||
import '../../models/network/network_location.dart';
|
||||
import '../../providers/network_map/network_devices_provider.dart';
|
||||
import '../../providers/network_map/network_sites_provider.dart';
|
||||
import '../../widgets/app_state_view.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import 'widgets/port_edit_dialog.dart';
|
||||
import 'widgets/port_list_tile.dart';
|
||||
|
||||
/// Add or edit a device. If [deviceId] is null, creates a new device.
|
||||
class NetworkMapDeviceEditScreen extends ConsumerStatefulWidget {
|
||||
const NetworkMapDeviceEditScreen({
|
||||
super.key,
|
||||
this.deviceId,
|
||||
this.initialSiteId,
|
||||
});
|
||||
|
||||
final String? deviceId;
|
||||
final String? initialSiteId;
|
||||
|
||||
@override
|
||||
ConsumerState<NetworkMapDeviceEditScreen> createState() =>
|
||||
_NetworkMapDeviceEditScreenState();
|
||||
}
|
||||
|
||||
class _NetworkMapDeviceEditScreenState
|
||||
extends ConsumerState<NetworkMapDeviceEditScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _vendorCtrl = TextEditingController();
|
||||
final _modelCtrl = TextEditingController();
|
||||
final _serialCtrl = TextEditingController();
|
||||
final _mgmtIpCtrl = TextEditingController();
|
||||
final _macCtrl = TextEditingController();
|
||||
final _notesCtrl = TextEditingController();
|
||||
NetworkDeviceKind _kind = NetworkDeviceKind.switchDevice;
|
||||
NetworkDeviceRole? _role;
|
||||
NetworkDeviceStatus _status = NetworkDeviceStatus.unknown;
|
||||
String? _siteId;
|
||||
String? _locationId;
|
||||
bool _saving = false;
|
||||
bool _initialized = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_vendorCtrl.dispose();
|
||||
_modelCtrl.dispose();
|
||||
_serialCtrl.dispose();
|
||||
_mgmtIpCtrl.dispose();
|
||||
_macCtrl.dispose();
|
||||
_notesCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _prefillFromDevice(NetworkDevice d, List<NetworkLocation> locations) {
|
||||
if (_initialized) return;
|
||||
_nameCtrl.text = d.name;
|
||||
_vendorCtrl.text = d.vendor ?? '';
|
||||
_modelCtrl.text = d.model ?? '';
|
||||
_serialCtrl.text = d.serial ?? '';
|
||||
_mgmtIpCtrl.text = d.mgmtIp ?? '';
|
||||
_macCtrl.text = d.mac ?? '';
|
||||
_notesCtrl.text = d.notes ?? '';
|
||||
_kind = d.kind;
|
||||
_role = d.role;
|
||||
_status = d.status;
|
||||
if (d.locationId != null) {
|
||||
_locationId = d.locationId;
|
||||
for (final l in locations) {
|
||||
if (l.id == d.locationId) {
|
||||
_siteId = l.siteId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Resolve a location_id to save. Preference order:
|
||||
/// 1. Explicitly picked location.
|
||||
/// 2. Site picked but no specific location → use/create the site's default.
|
||||
/// 3. Nothing picked → null (unassigned).
|
||||
Future<String?> _resolveLocationIdToSave() async {
|
||||
if (_locationId != null) return _locationId;
|
||||
if (_siteId == null) return null;
|
||||
final loc = await ref
|
||||
.read(networkSitesControllerProvider)
|
||||
.ensureDefaultLocation(_siteId!);
|
||||
return loc.id;
|
||||
}
|
||||
|
||||
Future<void> _showAddLocationDialog() async {
|
||||
if (_siteId == null) return;
|
||||
final nameCtrl = TextEditingController();
|
||||
NetworkLocationKind kind = NetworkLocationKind.room;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setLocal) => AlertDialog(
|
||||
title: const Text('New location'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Name (e.g. Floor 3, Rack U-12)',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<NetworkLocationKind>(
|
||||
initialValue: kind,
|
||||
items: NetworkLocationKind.values
|
||||
.map((k) => DropdownMenuItem(
|
||||
value: k,
|
||||
child: Text(_locationKindLabel(k)),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) => setLocal(
|
||||
() => kind = v ?? NetworkLocationKind.room,
|
||||
),
|
||||
decoration: const InputDecoration(labelText: 'Kind'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (ok == true && nameCtrl.text.trim().isNotEmpty) {
|
||||
final created = await ref.read(networkSitesControllerProvider).createLocation(
|
||||
siteId: _siteId!,
|
||||
name: nameCtrl.text.trim(),
|
||||
kind: kind,
|
||||
);
|
||||
if (mounted) setState(() => _locationId = created.id);
|
||||
}
|
||||
}
|
||||
|
||||
String _locationKindLabel(NetworkLocationKind k) {
|
||||
switch (k) {
|
||||
case NetworkLocationKind.building:
|
||||
return 'Building';
|
||||
case NetworkLocationKind.floor:
|
||||
return 'Floor';
|
||||
case NetworkLocationKind.room:
|
||||
return 'Room';
|
||||
case NetworkLocationKind.rack:
|
||||
return 'Rack';
|
||||
case NetworkLocationKind.wallJack:
|
||||
return 'Wall jack';
|
||||
case NetworkLocationKind.other:
|
||||
return 'Other';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final controller = ref.read(networkDevicesControllerProvider);
|
||||
final locationId = await _resolveLocationIdToSave();
|
||||
if (widget.deviceId == null) {
|
||||
await controller.createDevice(
|
||||
name: _nameCtrl.text.trim(),
|
||||
kind: _kind,
|
||||
role: _role,
|
||||
status: _status,
|
||||
vendor: _vendorCtrl.text.trim().isEmpty ? null : _vendorCtrl.text.trim(),
|
||||
model: _modelCtrl.text.trim().isEmpty ? null : _modelCtrl.text.trim(),
|
||||
serial: _serialCtrl.text.trim().isEmpty ? null : _serialCtrl.text.trim(),
|
||||
mgmtIp: _mgmtIpCtrl.text.trim().isEmpty ? null : _mgmtIpCtrl.text.trim(),
|
||||
mac: _macCtrl.text.trim().isEmpty ? null : _macCtrl.text.trim(),
|
||||
locationId: locationId,
|
||||
notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(),
|
||||
);
|
||||
} else {
|
||||
final existing =
|
||||
await ref.read(networkDeviceByIdProvider(widget.deviceId!).future);
|
||||
if (existing == null) return;
|
||||
final updated = existing.copyWith(
|
||||
name: _nameCtrl.text.trim(),
|
||||
kind: _kind,
|
||||
role: _role,
|
||||
status: _status,
|
||||
vendor: _vendorCtrl.text.trim().isEmpty ? null : _vendorCtrl.text.trim(),
|
||||
model: _modelCtrl.text.trim().isEmpty ? null : _modelCtrl.text.trim(),
|
||||
serial: _serialCtrl.text.trim().isEmpty ? null : _serialCtrl.text.trim(),
|
||||
mgmtIp: _mgmtIpCtrl.text.trim().isEmpty ? null : _mgmtIpCtrl.text.trim(),
|
||||
mac: _macCtrl.text.trim().isEmpty ? null : _macCtrl.text.trim(),
|
||||
locationId: locationId,
|
||||
notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(),
|
||||
);
|
||||
await controller.updateDevice(updated);
|
||||
}
|
||||
if (mounted) context.pop();
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addPort(String deviceId) async {
|
||||
await showPortEditDialog(
|
||||
context: context,
|
||||
ref: ref,
|
||||
deviceId: deviceId,
|
||||
);
|
||||
}
|
||||
|
||||
Icon _statusIcon(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
switch (_status) {
|
||||
case NetworkDeviceStatus.online:
|
||||
return const Icon(Icons.circle, size: 14, color: Color(0xFF388E3C));
|
||||
case NetworkDeviceStatus.offline:
|
||||
return Icon(Icons.circle, size: 14, color: cs.error);
|
||||
case NetworkDeviceStatus.warning:
|
||||
return const Icon(Icons.circle, size: 14, color: Color(0xFFF57C00));
|
||||
case NetworkDeviceStatus.unknown:
|
||||
return Icon(Icons.circle_outlined, size: 14, color: cs.onSurfaceVariant);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final existingAsync = widget.deviceId == null
|
||||
? const AsyncValue<NetworkDevice?>.data(null)
|
||||
: ref.watch(networkDeviceByIdProvider(widget.deviceId!));
|
||||
final sitesAsync = ref.watch(networkSitesProvider);
|
||||
final locationsAsync = ref.watch(networkLocationsProvider);
|
||||
|
||||
if (!_initialized && widget.deviceId == null && widget.initialSiteId != null) {
|
||||
_siteId = widget.initialSiteId;
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: Text(widget.deviceId == null ? 'New device' : 'Edit device'),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Save'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: existingAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => AppErrorView(error: e),
|
||||
data: (device) {
|
||||
if (device != null) {
|
||||
_prefillFromDevice(device, locationsAsync.valueOrNull ?? const []);
|
||||
}
|
||||
final locationsForSite = (locationsAsync.valueOrNull ?? const [])
|
||||
.where((l) => _siteId != null && l.siteId == _siteId)
|
||||
.toList();
|
||||
return ResponsiveBody(
|
||||
maxWidth: 720,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// ── Identity ────────────────────────────────────────────
|
||||
_SectionCard(
|
||||
icon: Icons.badge_outlined,
|
||||
title: 'Identity',
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameCtrl,
|
||||
autofocus: widget.deviceId == null,
|
||||
decoration: const InputDecoration(labelText: 'Name *'),
|
||||
validator: (v) => (v == null || v.trim().isEmpty)
|
||||
? 'Name is required'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_FieldRow(
|
||||
left: DropdownButtonFormField<NetworkDeviceKind>(
|
||||
initialValue: _kind,
|
||||
items: NetworkDeviceKind.values
|
||||
.map((k) => DropdownMenuItem(
|
||||
value: k,
|
||||
child: Text(k.label),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _kind = v ?? _kind),
|
||||
decoration: const InputDecoration(labelText: 'Type *'),
|
||||
),
|
||||
right: DropdownButtonFormField<NetworkDeviceRole?>(
|
||||
initialValue: _role,
|
||||
items: [
|
||||
const DropdownMenuItem<NetworkDeviceRole?>(
|
||||
value: null,
|
||||
child: Text('(none)'),
|
||||
),
|
||||
for (final r in NetworkDeviceRole.values)
|
||||
DropdownMenuItem(value: r, child: Text(r.label)),
|
||||
],
|
||||
onChanged: (v) => setState(() => _role = v),
|
||||
decoration: const InputDecoration(labelText: 'Role'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<NetworkDeviceStatus>(
|
||||
initialValue: _status,
|
||||
items: NetworkDeviceStatus.values
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s,
|
||||
child: Text(s.label),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(
|
||||
() => _status = v ?? NetworkDeviceStatus.unknown),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Status',
|
||||
prefixIcon: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: _statusIcon(context),
|
||||
),
|
||||
prefixIconConstraints:
|
||||
const BoxConstraints(minWidth: 0, minHeight: 0),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Location ─────────────────────────────────────────────
|
||||
_SectionCard(
|
||||
icon: Icons.location_on_outlined,
|
||||
title: 'Location',
|
||||
children: [
|
||||
DropdownButtonFormField<String?>(
|
||||
initialValue: _siteId,
|
||||
items: [
|
||||
const DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
child: Text('Unassigned'),
|
||||
),
|
||||
for (final s in sitesAsync.valueOrNull ?? [])
|
||||
DropdownMenuItem(value: s.id, child: Text(s.name)),
|
||||
],
|
||||
onChanged: (v) => setState(() {
|
||||
_siteId = v;
|
||||
_locationId = null;
|
||||
}),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Site',
|
||||
helperText:
|
||||
'Devices must be assigned to a site to appear on the topology canvas.',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String?>(
|
||||
initialValue: _locationId,
|
||||
items: [
|
||||
const DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
child: Text('(Default — auto-create "Main")'),
|
||||
),
|
||||
for (final l in locationsForSite)
|
||||
DropdownMenuItem(
|
||||
value: l.id,
|
||||
child: Text(
|
||||
'${l.name} · ${_locationKindLabel(l.kind)}',
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: _siteId == null
|
||||
? null
|
||||
: (v) => setState(() => _locationId = v),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Location',
|
||||
helperText: _siteId == null
|
||||
? 'Pick a site first.'
|
||||
: 'Building / floor / room / rack within the site.',
|
||||
enabled: _siteId != null,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Add new location to this site',
|
||||
onPressed:
|
||||
_siteId == null ? null : _showAddLocationDialog,
|
||||
icon: const Icon(Icons.add_location_alt_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Hardware Details ─────────────────────────────────────
|
||||
_SectionCard(
|
||||
icon: Icons.memory_outlined,
|
||||
title: 'Hardware Details',
|
||||
children: [
|
||||
_FieldRow(
|
||||
left: TextFormField(
|
||||
controller: _vendorCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Vendor',
|
||||
hintText: 'e.g. Ruijie, TP-Link',
|
||||
),
|
||||
),
|
||||
right: TextFormField(
|
||||
controller: _modelCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Model'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_FieldRow(
|
||||
left: TextFormField(
|
||||
controller: _serialCtrl,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Serial'),
|
||||
),
|
||||
right: TextFormField(
|
||||
controller: _mgmtIpCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Management IP'),
|
||||
keyboardType: TextInputType.text,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _macCtrl,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'MAC address'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _notesCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Notes'),
|
||||
maxLines: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// ── Ports (edit mode only) ────────────────────────────────
|
||||
if (widget.deviceId != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
_PortsSectionCard(
|
||||
deviceId: widget.deviceId!,
|
||||
onAddPort: () => _addPort(widget.deviceId!),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper widgets ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Outlined card with a section header (icon + title) and field children.
|
||||
class _SectionCard extends StatelessWidget {
|
||||
const _SectionCard({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
return Card.outlined(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: cs.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: tt.labelLarge?.copyWith(color: cs.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders two widgets side-by-side on wide screens (≥480dp), stacked on narrow.
|
||||
class _FieldRow extends StatelessWidget {
|
||||
const _FieldRow({
|
||||
required this.left,
|
||||
required this.right,
|
||||
});
|
||||
|
||||
final Widget left;
|
||||
final Widget right;
|
||||
static const double threshold = 480;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth >= threshold) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: left),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: right),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
left,
|
||||
const SizedBox(height: 12),
|
||||
right,
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ports section card with header and add-port button.
|
||||
class _PortsSectionCard extends StatelessWidget {
|
||||
const _PortsSectionCard({
|
||||
required this.deviceId,
|
||||
required this.onAddPort,
|
||||
});
|
||||
|
||||
final String deviceId;
|
||||
final VoidCallback onAddPort;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
return Card.outlined(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.cable_outlined, size: 16, color: cs.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Ports',
|
||||
style: tt.labelLarge?.copyWith(color: cs.primary),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton.tonal(
|
||||
onPressed: onAddPort,
|
||||
child: const Text('Add port'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_PortsList(deviceId: deviceId),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PortsList extends ConsumerWidget {
|
||||
const _PortsList({required this.deviceId});
|
||||
final String deviceId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final portsAsync = ref.watch(networkPortsByDeviceProvider(deviceId));
|
||||
return portsAsync.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (e, _) => Text('Error: $e'),
|
||||
data: (ports) {
|
||||
if (ports.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.cable_outlined,
|
||||
size: 32, color: cs.onSurfaceVariant),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'No ports configured yet.',
|
||||
style: TextStyle(color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (final p in ports)
|
||||
PortListTile(
|
||||
port: p,
|
||||
canEdit: true,
|
||||
onTap: (ctx) => showPortEditDialog(
|
||||
context: ctx,
|
||||
ref: ref,
|
||||
deviceId: deviceId,
|
||||
existing: p,
|
||||
),
|
||||
onDelete: () => ref
|
||||
.read(networkDevicesControllerProvider)
|
||||
.deletePort(p.id, deviceId: deviceId),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/network/network_device.dart';
|
||||
import '../../providers/network_map/network_devices_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../widgets/app_state_view.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import 'widgets/link_edit_dialog.dart';
|
||||
import 'widgets/port_edit_dialog.dart';
|
||||
import 'widgets/port_list_tile.dart';
|
||||
|
||||
class NetworkMapDeviceScreen extends ConsumerWidget {
|
||||
const NetworkMapDeviceScreen({super.key, required this.deviceId});
|
||||
|
||||
final String deviceId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final deviceAsync = ref.watch(networkDeviceByIdProvider(deviceId));
|
||||
final portsAsync = ref.watch(networkPortsByDeviceProvider(deviceId));
|
||||
final linksAsync = ref.watch(networkLinksProvider);
|
||||
final allPortsAsync = ref.watch(networkPortsProvider);
|
||||
final allDevicesAsync = ref.watch(networkDevicesProvider);
|
||||
final profileAsync = ref.watch(currentProfileProvider);
|
||||
final canEdit = profileAsync.maybeWhen(
|
||||
data: (p) => p?.role == 'admin' || p?.role == 'it_staff',
|
||||
orElse: () => false,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: deviceAsync.when(
|
||||
data: (d) => Text(d?.name ?? 'Device'),
|
||||
loading: () => const Text('Device'),
|
||||
error: (_, _) => const Text('Device'),
|
||||
),
|
||||
actions: [
|
||||
if (canEdit)
|
||||
IconButton(
|
||||
tooltip: 'Edit device',
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: () => context.go('/network-map/device/$deviceId/edit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: deviceAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => AppErrorView(
|
||||
error: e,
|
||||
onRetry: () => ref.invalidate(networkDeviceByIdProvider(deviceId)),
|
||||
),
|
||||
data: (device) {
|
||||
if (device == null) {
|
||||
return const AppEmptyView(
|
||||
icon: Icons.device_unknown_outlined,
|
||||
title: 'Device not found',
|
||||
);
|
||||
}
|
||||
final ports = portsAsync.valueOrNull ?? const [];
|
||||
final links = linksAsync.valueOrNull ?? const [];
|
||||
final allPorts = allPortsAsync.valueOrNull ?? const [];
|
||||
final allDevices = allDevicesAsync.valueOrNull ?? const [];
|
||||
|
||||
final portToDevice = <String, String>{
|
||||
for (final p in allPorts) p.id: p.deviceId,
|
||||
};
|
||||
final portLabel = <String, String>{
|
||||
for (final p in allPorts) p.id: p.portNumber,
|
||||
};
|
||||
final deviceById = <String, NetworkDevice>{
|
||||
for (final d in allDevices) d.id: d,
|
||||
};
|
||||
|
||||
// For each port of this device, find the link and the other end.
|
||||
String? otherDeviceName(String portId) {
|
||||
for (final link in links) {
|
||||
String? otherPort;
|
||||
if (link.portA == portId) otherPort = link.portB;
|
||||
if (link.portB == portId) otherPort = link.portA;
|
||||
if (otherPort == null) continue;
|
||||
final otherDevId = portToDevice[otherPort];
|
||||
if (otherDevId == null) return null;
|
||||
return deviceById[otherDevId]?.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? otherPortLabel(String portId) {
|
||||
for (final link in links) {
|
||||
if (link.portA == portId) return portLabel[link.portB];
|
||||
if (link.portB == portId) return portLabel[link.portA];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? linkIdForPort(String portId) {
|
||||
for (final link in links) {
|
||||
if (link.portA == portId || link.portB == portId) return link.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return ResponsiveBody(
|
||||
maxWidth: 720,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
children: [
|
||||
_MetaCard(device: device),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
'Ports (${ports.length})',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (ports.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text('No ports configured yet.'),
|
||||
)
|
||||
else
|
||||
for (final port in ports)
|
||||
PortListTile(
|
||||
port: port,
|
||||
linkedDeviceName: otherDeviceName(port.id),
|
||||
linkedPortLabel: otherPortLabel(port.id),
|
||||
canEdit: canEdit,
|
||||
onTap: canEdit
|
||||
? (ctx) => showPortEditDialog(
|
||||
context: ctx,
|
||||
ref: ref,
|
||||
deviceId: device.id,
|
||||
existing: port,
|
||||
)
|
||||
: null,
|
||||
onConnect: canEdit
|
||||
? (ctx) => showLinkEditDialog(
|
||||
context: ctx,
|
||||
ref: ref,
|
||||
portId: port.id,
|
||||
currentDeviceId: device.id,
|
||||
)
|
||||
: null,
|
||||
onDisconnect: canEdit
|
||||
? (_) {
|
||||
final lid = linkIdForPort(port.id);
|
||||
if (lid != null) {
|
||||
ref
|
||||
.read(networkDevicesControllerProvider)
|
||||
.deleteLink(lid);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
onDelete: canEdit
|
||||
? () => ref
|
||||
.read(networkDevicesControllerProvider)
|
||||
.deletePort(port.id)
|
||||
: null,
|
||||
),
|
||||
if (device.notes != null && device.notes!.trim().isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
'Notes',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(device.notes!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetaCard extends StatelessWidget {
|
||||
const _MetaCard({required this.device});
|
||||
final NetworkDevice device;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final rows = <(String, String)>[
|
||||
('Type', device.kind.label),
|
||||
if (device.role != null) ('Role', device.role!.label),
|
||||
if (device.vendor != null) ('Vendor', device.vendor!),
|
||||
if (device.model != null) ('Model', device.model!),
|
||||
if (device.serial != null) ('Serial', device.serial!),
|
||||
if (device.mgmtIp != null) ('Mgmt IP', device.mgmtIp!),
|
||||
if (device.mac != null) ('MAC', device.mac!),
|
||||
];
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(device.name, style: tt.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
for (final (key, value) in rows)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 90,
|
||||
child: Text(key,
|
||||
style: tt.labelMedium?.copyWith(color: cs.onSurfaceVariant)),
|
||||
),
|
||||
Expanded(child: Text(value, style: tt.bodyMedium)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/network/network_device.dart';
|
||||
import '../../models/network/network_link.dart';
|
||||
import '../../models/network/network_port.dart';
|
||||
import '../../providers/network_map/network_devices_provider.dart';
|
||||
import '../../providers/network_map/network_import_provider.dart';
|
||||
import '../../providers/network_map/network_sites_provider.dart';
|
||||
import '../../widgets/app_state_view.dart';
|
||||
import 'widgets/import_diff_view.dart';
|
||||
|
||||
class NetworkMapImportReviewScreen extends ConsumerWidget {
|
||||
const NetworkMapImportReviewScreen({super.key, required this.importId});
|
||||
|
||||
final String importId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final importAsync = ref.watch(networkImportByIdProvider(importId));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/network-map'),
|
||||
),
|
||||
title: const Text('Review extraction'),
|
||||
actions: [
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(networkImportControllerProvider)
|
||||
.discard(importId);
|
||||
if (context.mounted) context.go('/network-map');
|
||||
},
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: const Text('Discard'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: importAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => AppErrorView(error: e),
|
||||
data: (imp) {
|
||||
if (imp == null) {
|
||||
return const AppEmptyView(
|
||||
icon: Icons.upload_file_outlined,
|
||||
title: 'Import not found',
|
||||
);
|
||||
}
|
||||
final aiResult = imp.aiResult ?? const <String, dynamic>{};
|
||||
return ImportDiffView(
|
||||
aiResult: aiResult,
|
||||
onApply: ({
|
||||
required acceptedSites,
|
||||
required acceptedDevices,
|
||||
required acceptedPorts,
|
||||
required acceptedLinks,
|
||||
required acceptedVlans,
|
||||
}) async {
|
||||
await _applyExtraction(
|
||||
context: context,
|
||||
ref: ref,
|
||||
importId: importId,
|
||||
acceptedSites: acceptedSites,
|
||||
acceptedDevices: acceptedDevices,
|
||||
acceptedPorts: acceptedPorts,
|
||||
acceptedLinks: acceptedLinks,
|
||||
acceptedVlans: acceptedVlans,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _applyExtraction({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required String importId,
|
||||
required List<Map<String, dynamic>> acceptedSites,
|
||||
required List<Map<String, dynamic>> acceptedDevices,
|
||||
required List<Map<String, dynamic>> acceptedPorts,
|
||||
required List<Map<String, dynamic>> acceptedLinks,
|
||||
required List<Map<String, dynamic>> acceptedVlans,
|
||||
}) async {
|
||||
final devicesCtrl = ref.read(networkDevicesControllerProvider);
|
||||
final sitesCtrl = ref.read(networkSitesControllerProvider);
|
||||
|
||||
// Create accepted sites first, keyed by AI-extracted site name. If the AI
|
||||
// didn't extract any sites, we'll create a single "Imported" site so the
|
||||
// devices have somewhere to live and become visible on the canvas.
|
||||
final locationIdBySiteName = <String, String>{};
|
||||
for (final raw in acceptedSites) {
|
||||
final name = raw['name']?.toString();
|
||||
if (name == null || name.isEmpty) continue;
|
||||
try {
|
||||
final site = await sitesCtrl.createSite(
|
||||
name: name,
|
||||
address: raw['address']?.toString(),
|
||||
);
|
||||
final loc = await sitesCtrl.ensureDefaultLocation(site.id);
|
||||
locationIdBySiteName[name] = loc.id;
|
||||
} catch (_) {
|
||||
// Duplicate or RLS — skip; the unassigned bulk-move fallback will handle it.
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback site: if no sites were extracted/accepted, create one so all
|
||||
// imported devices land in one visible place (admins can rename/split later).
|
||||
String? fallbackLocationId;
|
||||
Future<String> getFallbackLocationId() async {
|
||||
if (fallbackLocationId != null) return fallbackLocationId!;
|
||||
final site = await sitesCtrl.createSite(name: 'Imported');
|
||||
final loc = await sitesCtrl.ensureDefaultLocation(site.id);
|
||||
fallbackLocationId = loc.id;
|
||||
return fallbackLocationId!;
|
||||
}
|
||||
|
||||
// Create devices, keyed by AI name → new device id.
|
||||
final deviceIdByName = <String, String>{};
|
||||
final createdDeviceIds = <String>[];
|
||||
for (final raw in acceptedDevices) {
|
||||
final name = raw['name']?.toString() ?? 'Unnamed';
|
||||
final kind = NetworkDeviceKind.fromWire(raw['kind']?.toString());
|
||||
// Resolve a location for this device: prefer the AI-extracted site, fall
|
||||
// back to a synthesized "Imported" site so devices are never orphaned.
|
||||
String? locationId;
|
||||
final aiSiteName = raw['site_name']?.toString();
|
||||
if (aiSiteName != null && locationIdBySiteName.containsKey(aiSiteName)) {
|
||||
locationId = locationIdBySiteName[aiSiteName];
|
||||
} else {
|
||||
try {
|
||||
locationId = await getFallbackLocationId();
|
||||
} catch (_) {
|
||||
// If site creation fails (RLS, dup), leave unassigned — user can
|
||||
// bulk-move from the overview screen.
|
||||
locationId = null;
|
||||
}
|
||||
}
|
||||
final created = await devicesCtrl.createDevice(
|
||||
name: name,
|
||||
kind: kind,
|
||||
vendor: raw['vendor']?.toString(),
|
||||
model: raw['model']?.toString(),
|
||||
mgmtIp: raw['mgmt_ip']?.toString(),
|
||||
locationId: locationId,
|
||||
importSource: NetworkImportSource.aiImport,
|
||||
);
|
||||
deviceIdByName[name] = created.id;
|
||||
createdDeviceIds.add(created.id);
|
||||
}
|
||||
|
||||
// Create ports for each accepted port (resolve device by name).
|
||||
final portIdByDeviceAndNumber = <String, String>{};
|
||||
for (final raw in acceptedPorts) {
|
||||
final deviceName = raw['device_name']?.toString();
|
||||
final portNumber = raw['port_number']?.toString();
|
||||
if (deviceName == null || portNumber == null) continue;
|
||||
final deviceId = deviceIdByName[deviceName];
|
||||
if (deviceId == null) continue;
|
||||
try {
|
||||
final port = await devicesCtrl.createPort(
|
||||
deviceId: deviceId,
|
||||
portNumber: portNumber,
|
||||
portKind: NetworkPortKind.fromWire(raw['port_kind']?.toString()),
|
||||
accessVlan: raw['access_vlan'] is int
|
||||
? raw['access_vlan'] as int
|
||||
: int.tryParse(raw['access_vlan']?.toString() ?? ''),
|
||||
isTrunk: raw['is_trunk'] == true,
|
||||
);
|
||||
portIdByDeviceAndNumber['$deviceId|$portNumber'] = port.id;
|
||||
} catch (_) {
|
||||
// Port might already exist due to UNIQUE constraint — skip silently.
|
||||
}
|
||||
}
|
||||
|
||||
// Create VLANs.
|
||||
for (final raw in acceptedVlans) {
|
||||
final vlanId = raw['vlan_id'];
|
||||
final name = raw['name']?.toString();
|
||||
if (vlanId is! int || name == null) continue;
|
||||
try {
|
||||
await devicesCtrl.createVlan(
|
||||
vlanId: vlanId,
|
||||
name: name,
|
||||
description: raw['description']?.toString(),
|
||||
);
|
||||
} catch (_) {
|
||||
// Duplicate VLAN id — skip.
|
||||
}
|
||||
}
|
||||
|
||||
// Create links: need to find/create both endpoint ports.
|
||||
final createdLinkIds = <String>[];
|
||||
for (final raw in acceptedLinks) {
|
||||
final deviceA = raw['device_a']?.toString();
|
||||
final deviceB = raw['device_b']?.toString();
|
||||
final portALabel = raw['port_a']?.toString() ?? 'unknown';
|
||||
final portBLabel = raw['port_b']?.toString() ?? 'unknown';
|
||||
if (deviceA == null || deviceB == null) continue;
|
||||
final devAId = deviceIdByName[deviceA];
|
||||
final devBId = deviceIdByName[deviceB];
|
||||
if (devAId == null || devBId == null) continue;
|
||||
|
||||
// Resolve or create endpoint ports.
|
||||
final aKey = '$devAId|$portALabel';
|
||||
final bKey = '$devBId|$portBLabel';
|
||||
String? portAId = portIdByDeviceAndNumber[aKey];
|
||||
String? portBId = portIdByDeviceAndNumber[bKey];
|
||||
try {
|
||||
if (portAId == null) {
|
||||
final p = await devicesCtrl.createPort(
|
||||
deviceId: devAId, portNumber: portALabel);
|
||||
portAId = p.id;
|
||||
portIdByDeviceAndNumber[aKey] = p.id;
|
||||
}
|
||||
if (portBId == null) {
|
||||
final p = await devicesCtrl.createPort(
|
||||
deviceId: devBId, portNumber: portBLabel);
|
||||
portBId = p.id;
|
||||
portIdByDeviceAndNumber[bKey] = p.id;
|
||||
}
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
final link = await devicesCtrl.createLink(
|
||||
portA: portAId,
|
||||
portB: portBId,
|
||||
linkKind: NetworkLinkKind.fromWire(raw['link_kind']?.toString()),
|
||||
);
|
||||
createdLinkIds.add(link.id);
|
||||
} catch (_) {
|
||||
// Link already exists or violates CHECK — skip.
|
||||
}
|
||||
}
|
||||
|
||||
await ref.read(networkImportControllerProvider).markApplied(
|
||||
importId: importId,
|
||||
deviceIds: createdDeviceIds,
|
||||
linkIds: createdLinkIds,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(
|
||||
'Applied ${createdDeviceIds.length} devices, '
|
||||
'${createdLinkIds.length} links.',
|
||||
),
|
||||
));
|
||||
context.go('/network-map');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/network/network_import.dart';
|
||||
import '../../providers/network_map/network_import_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
|
||||
class NetworkMapImportScreen extends ConsumerStatefulWidget {
|
||||
const NetworkMapImportScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<NetworkMapImportScreen> createState() =>
|
||||
_NetworkMapImportScreenState();
|
||||
}
|
||||
|
||||
class _NetworkMapImportScreenState extends ConsumerState<NetworkMapImportScreen> {
|
||||
bool _uploading = false;
|
||||
String? _statusMessage;
|
||||
|
||||
Future<void> _pickAndStart() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
withData: true,
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const [
|
||||
'pdf', 'png', 'jpg', 'jpeg', 'webp', 'vsdx', 'csv', 'xlsx', 'docx',
|
||||
],
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
final picked = result.files.first;
|
||||
final bytes = picked.bytes;
|
||||
if (bytes == null) {
|
||||
setState(() => _statusMessage = 'Could not read file bytes.');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_uploading = true;
|
||||
_statusMessage = 'Uploading ${picked.name}…';
|
||||
});
|
||||
|
||||
final profile = ref.read(currentProfileProvider).valueOrNull;
|
||||
if (profile == null) {
|
||||
setState(() {
|
||||
_uploading = false;
|
||||
_statusMessage = 'Not signed in.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final controller = ref.read(networkImportControllerProvider);
|
||||
final importId = await controller.uploadAndQueueExtraction(
|
||||
bytes: bytes,
|
||||
filename: picked.name,
|
||||
createdBy: profile.id,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_statusMessage = 'Extracting topology — this can take up to a minute…';
|
||||
});
|
||||
|
||||
final result = await controller.runExtraction(importId);
|
||||
if (!mounted) return;
|
||||
|
||||
if (result.status == NetworkImportStatus.review) {
|
||||
context.go('/network-map/import/$importId/review');
|
||||
} else if (result.status == NetworkImportStatus.failed) {
|
||||
setState(() {
|
||||
_statusMessage =
|
||||
result.errorMessage ?? 'Extraction failed. Starting from blank canvas.';
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_statusMessage = 'Unexpected status: ${result.status.wire}';
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _statusMessage = 'Error: $e');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _uploading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/network-map'),
|
||||
),
|
||||
title: const Text('Import network document'),
|
||||
),
|
||||
body: ResponsiveBody(
|
||||
maxWidth: 720,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.auto_awesome_outlined, color: cs.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'AI-assisted extraction',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Upload an existing network document — Visio (.vsdx), PDF, '
|
||||
'a photo of a whiteboard, or a spreadsheet — and we\'ll '
|
||||
'try to extract devices, ports, links, and VLANs for you '
|
||||
'to review. If extraction returns nothing useful, you '
|
||||
'can still start with a blank canvas.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Supported: PDF, PNG/JPG/WebP, VSDX, CSV, XLSX, DOCX. '
|
||||
'Max ~10 MB recommended.',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: _uploading ? null : _pickAndStart,
|
||||
icon: _uploading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.upload_file_outlined),
|
||||
label: Text(_uploading ? 'Working…' : 'Choose file'),
|
||||
),
|
||||
if (_statusMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(_statusMessage!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/network/network_device.dart';
|
||||
import '../../providers/network_map/network_devices_provider.dart';
|
||||
import '../../providers/network_map/network_sites_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../widgets/app_page_header.dart';
|
||||
import '../../widgets/app_state_view.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import 'widgets/device_node.dart';
|
||||
|
||||
class NetworkMapOverviewScreen extends ConsumerWidget {
|
||||
const NetworkMapOverviewScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sitesAsync = ref.watch(networkSitesProvider);
|
||||
final devicesAsync = ref.watch(networkDevicesProvider);
|
||||
final profileAsync = ref.watch(currentProfileProvider);
|
||||
final canEdit = profileAsync.maybeWhen(
|
||||
data: (p) => p?.role == 'admin' || p?.role == 'it_staff',
|
||||
orElse: () => false,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
floatingActionButton: canEdit
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: () => _showAddSiteDialog(context, ref),
|
||||
icon: const Icon(Icons.add_location_alt_outlined),
|
||||
label: const Text('Add site'),
|
||||
)
|
||||
: null,
|
||||
body: ResponsiveBody(
|
||||
maxWidth: double.infinity,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
AppPageHeader(
|
||||
title: 'Network Infrastructure',
|
||||
subtitle: 'Drillable map of switches, routers, APs, and links',
|
||||
actions: canEdit
|
||||
? [
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Import document',
|
||||
onPressed: () => context.go('/network-map/import'),
|
||||
icon: const Icon(Icons.upload_file_outlined),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'VLANs',
|
||||
onPressed: () => context.go('/network-map/vlans'),
|
||||
icon: const Icon(Icons.lan_outlined),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
_SummaryRow(
|
||||
siteCount: sitesAsync.valueOrNull?.length ?? 0,
|
||||
deviceCount: devicesAsync.valueOrNull?.length ?? 0,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (sitesAsync.isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(40),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (sitesAsync.hasError)
|
||||
AppErrorView(
|
||||
error: sitesAsync.error!,
|
||||
onRetry: () => ref.invalidate(networkSitesProvider),
|
||||
)
|
||||
else if ((sitesAsync.valueOrNull ?? []).isEmpty)
|
||||
AppEmptyView(
|
||||
icon: Icons.hub_outlined,
|
||||
title: 'No sites yet',
|
||||
subtitle: canEdit
|
||||
? 'Add your first site or import an existing network diagram to get started.'
|
||||
: 'No network sites have been recorded yet.',
|
||||
action: canEdit
|
||||
? FilledButton.icon(
|
||||
onPressed: () => _showAddSiteDialog(context, ref),
|
||||
icon: const Icon(Icons.add_location_alt_outlined),
|
||||
label: const Text('Add first site'),
|
||||
)
|
||||
: null,
|
||||
)
|
||||
else ...[
|
||||
for (final site in sitesAsync.valueOrNull!)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _SiteCard(
|
||||
name: site.name,
|
||||
address: site.address,
|
||||
deviceCount: (devicesAsync.valueOrNull ?? [])
|
||||
.where((d) => d.locationId != null)
|
||||
.length,
|
||||
onTap: () => context.go('/network-map/site/${site.id}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
_UnassignedSection(
|
||||
devices: (devicesAsync.valueOrNull ?? [])
|
||||
.where((d) => d.locationId == null)
|
||||
.toList(),
|
||||
canEdit: canEdit,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showAddSiteDialog(BuildContext context, WidgetRef ref) async {
|
||||
final nameCtrl = TextEditingController();
|
||||
final addressCtrl = TextEditingController();
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('New site'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: addressCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Address (optional)'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (result == true && nameCtrl.text.trim().isNotEmpty) {
|
||||
final profile = ref.read(currentProfileProvider).valueOrNull;
|
||||
await ref.read(networkSitesControllerProvider).createSite(
|
||||
name: nameCtrl.text.trim(),
|
||||
address: addressCtrl.text.trim().isEmpty ? null : addressCtrl.text.trim(),
|
||||
createdBy: profile?.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryRow extends StatelessWidget {
|
||||
const _SummaryRow({required this.siteCount, required this.deviceCount});
|
||||
final int siteCount;
|
||||
final int deviceCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: _SummaryCard(label: 'Sites', value: siteCount.toString(), icon: Icons.location_city_outlined)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _SummaryCard(label: 'Devices', value: deviceCount.toString(), icon: Icons.devices_outlined)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryCard extends StatelessWidget {
|
||||
const _SummaryCard({required this.label, required this.value, required this.icon});
|
||||
final String label;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: cs.primary),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant)),
|
||||
Text(value, style: tt.headlineSmall?.copyWith(fontWeight: FontWeight.w700)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UnassignedSection extends ConsumerWidget {
|
||||
const _UnassignedSection({required this.devices, required this.canEdit});
|
||||
|
||||
final List<NetworkDevice> devices;
|
||||
final bool canEdit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (devices.isEmpty) return const SizedBox.shrink();
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 24),
|
||||
child: Card(
|
||||
color: cs.tertiaryContainer.withValues(alpha: 0.3),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.help_outline, color: cs.tertiary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Unassigned devices (${devices.length})',
|
||||
style: tt.titleMedium,
|
||||
),
|
||||
),
|
||||
if (canEdit)
|
||||
TextButton.icon(
|
||||
onPressed: () => _showBulkAssignDialog(context, ref, devices),
|
||||
icon: const Icon(Icons.move_up),
|
||||
label: const Text('Assign all to site…'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'These devices have no site/location yet, so they don\'t show on '
|
||||
'any topology canvas. Tap one to assign it.',
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final d in devices)
|
||||
DeviceNode(
|
||||
device: d,
|
||||
onTap: () => context.go('/network-map/device/${d.id}/edit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showBulkAssignDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
List<NetworkDevice> devices,
|
||||
) async {
|
||||
final sites = ref.read(networkSitesProvider).valueOrNull ?? const [];
|
||||
if (sites.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Create a site first.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
String? picked = sites.first.id;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setLocal) => AlertDialog(
|
||||
title: Text('Assign ${devices.length} devices to a site'),
|
||||
content: DropdownButtonFormField<String>(
|
||||
initialValue: picked,
|
||||
items: [
|
||||
for (final s in sites)
|
||||
DropdownMenuItem(value: s.id, child: Text(s.name)),
|
||||
],
|
||||
onChanged: (v) => setLocal(() => picked = v),
|
||||
decoration: const InputDecoration(labelText: 'Target site'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Assign'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (ok != true || picked == null) return;
|
||||
|
||||
final sitesCtrl = ref.read(networkSitesControllerProvider);
|
||||
final devicesCtrl = ref.read(networkDevicesControllerProvider);
|
||||
final location = await sitesCtrl.ensureDefaultLocation(picked!);
|
||||
|
||||
for (final d in devices) {
|
||||
final updated = d.copyWith(locationId: location.id);
|
||||
await devicesCtrl.updateDevice(updated);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Moved ${devices.length} devices.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SiteCard extends StatelessWidget {
|
||||
const _SiteCard({
|
||||
required this.name,
|
||||
required this.address,
|
||||
required this.deviceCount,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String? address;
|
||||
final int deviceCount;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: cs.primaryContainer,
|
||||
child: Icon(Icons.location_city, color: cs.onPrimaryContainer),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(name, style: tt.titleMedium),
|
||||
if (address != null && address!.isNotEmpty)
|
||||
Text(address!, style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text('$deviceCount devices', style: tt.labelMedium?.copyWith(color: cs.onSurfaceVariant)),
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.chevron_right, color: cs.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/network/topology_graph.dart';
|
||||
import '../../providers/network_map/network_sites_provider.dart';
|
||||
import '../../providers/network_map/network_topology_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../widgets/app_state_view.dart';
|
||||
import 'widgets/topology_canvas.dart';
|
||||
import 'widgets/topology_legend.dart';
|
||||
import 'widgets/topology_minimap.dart';
|
||||
import 'widgets/zoom_controls.dart';
|
||||
|
||||
class NetworkMapSiteScreen extends ConsumerStatefulWidget {
|
||||
const NetworkMapSiteScreen({super.key, required this.siteId});
|
||||
|
||||
final String siteId;
|
||||
|
||||
@override
|
||||
ConsumerState<NetworkMapSiteScreen> createState() =>
|
||||
_NetworkMapSiteScreenState();
|
||||
}
|
||||
|
||||
class _NetworkMapSiteScreenState extends ConsumerState<NetworkMapSiteScreen> {
|
||||
late final TransformationController _transformController;
|
||||
Map<String, Offset> _nodePositions = const {};
|
||||
Size _canvasSize = const Size(600, 400);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_transformController = TransformationController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_transformController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onLayoutUpdated(Map<String, Offset> positions, Size canvasSize) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_nodePositions = positions;
|
||||
_canvasSize = canvasSize;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final topologyAsync = ref.watch(topologyForSiteProvider(widget.siteId));
|
||||
final viewMode = ref.watch(topologyViewModeProvider);
|
||||
final sitesAsync = ref.watch(networkSitesProvider);
|
||||
final profileAsync = ref.watch(currentProfileProvider);
|
||||
final canEdit = profileAsync.maybeWhen(
|
||||
data: (p) => p?.role == 'admin' || p?.role == 'it_staff',
|
||||
orElse: () => false,
|
||||
);
|
||||
|
||||
final siteName = sitesAsync.valueOrNull
|
||||
?.firstWhere(
|
||||
(s) => s.id == widget.siteId,
|
||||
orElse: () => sitesAsync.valueOrNull!.first,
|
||||
)
|
||||
.name;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/network-map'),
|
||||
),
|
||||
title: Text(siteName ?? 'Site'),
|
||||
actions: [
|
||||
SegmentedButton<TopologyViewMode>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: TopologyViewMode.physical,
|
||||
label: Text('Physical'),
|
||||
icon: Icon(Icons.business_outlined),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: TopologyViewMode.logical,
|
||||
label: Text('Logical'),
|
||||
icon: Icon(Icons.account_tree_outlined),
|
||||
),
|
||||
],
|
||||
selected: {viewMode},
|
||||
onSelectionChanged: (s) {
|
||||
ref.read(topologyViewModeProvider.notifier).state = s.first;
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (canEdit)
|
||||
IconButton(
|
||||
tooltip: 'Add device',
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
onPressed: () =>
|
||||
context.go('/network-map/site/${widget.siteId}/device/new'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: topologyAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => AppErrorView(
|
||||
error: e,
|
||||
onRetry: () => ref.invalidate(topologyForSiteProvider(widget.siteId)),
|
||||
),
|
||||
data: (graph) => _CanvasWithOverlays(
|
||||
graph: graph,
|
||||
viewMode: viewMode,
|
||||
siteId: widget.siteId,
|
||||
transformController: _transformController,
|
||||
nodePositions: _nodePositions,
|
||||
canvasSize: _canvasSize,
|
||||
onLayoutUpdated: _onLayoutUpdated,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Canvas + all overlay widgets (legend, zoom controls, minimap) in a Stack.
|
||||
class _CanvasWithOverlays extends StatelessWidget {
|
||||
const _CanvasWithOverlays({
|
||||
required this.graph,
|
||||
required this.viewMode,
|
||||
required this.siteId,
|
||||
required this.transformController,
|
||||
required this.nodePositions,
|
||||
required this.canvasSize,
|
||||
required this.onLayoutUpdated,
|
||||
});
|
||||
|
||||
final TopologyGraph graph;
|
||||
final TopologyViewMode viewMode;
|
||||
final String siteId;
|
||||
final TransformationController transformController;
|
||||
final Map<String, Offset> nodePositions;
|
||||
final Size canvasSize;
|
||||
final void Function(Map<String, Offset>, Size) onLayoutUpdated;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// ── Main topology canvas ─────────────────────────────────────
|
||||
Positioned.fill(
|
||||
child: TopologyCanvas(
|
||||
graph: graph,
|
||||
viewMode: viewMode,
|
||||
transformationController: transformController,
|
||||
onLayoutUpdated: onLayoutUpdated,
|
||||
onDeviceTap: (device) =>
|
||||
context.push('/network-map/device/${device.id}'),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Legend — bottom-left ─────────────────────────────────────
|
||||
Positioned(
|
||||
left: 16,
|
||||
bottom: 16,
|
||||
child: const TopologyLegend(),
|
||||
),
|
||||
|
||||
// ── Zoom controls + minimap — bottom-right ───────────────────
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// Minimap above zoom controls
|
||||
TopologyMinimap(
|
||||
graph: graph,
|
||||
nodePositions: nodePositions,
|
||||
canvasSize: canvasSize,
|
||||
controller: transformController,
|
||||
viewportSize: viewportSize,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ZoomControls(
|
||||
controller: transformController,
|
||||
canvasSize: canvasSize,
|
||||
viewportSize: viewportSize,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../providers/network_map/network_devices_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../widgets/app_page_header.dart';
|
||||
import '../../widgets/app_state_view.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
|
||||
class NetworkMapVlanScreen extends ConsumerWidget {
|
||||
const NetworkMapVlanScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final vlansAsync = ref.watch(networkVlansProvider);
|
||||
final profileAsync = ref.watch(currentProfileProvider);
|
||||
final canEdit = profileAsync.maybeWhen(
|
||||
data: (p) => p?.role == 'admin' || p?.role == 'it_staff',
|
||||
orElse: () => false,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/network-map'),
|
||||
),
|
||||
title: const Text('VLANs'),
|
||||
),
|
||||
floatingActionButton: canEdit
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: () => _showAddDialog(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New VLAN'),
|
||||
)
|
||||
: null,
|
||||
body: ResponsiveBody(
|
||||
maxWidth: 720,
|
||||
child: vlansAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => AppErrorView(error: e),
|
||||
data: (vlans) {
|
||||
if (vlans.isEmpty) {
|
||||
return const AppEmptyView(
|
||||
icon: Icons.lan_outlined,
|
||||
title: 'No VLANs defined',
|
||||
subtitle: 'Add a VLAN to start tagging access ports.',
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
const AppPageHeader(title: 'VLANs', subtitle: 'Network segmentation'),
|
||||
for (final v in vlans)
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _parseColor(v.color) ??
|
||||
Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Text(
|
||||
v.vlanId.toString(),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
title: Text(v.name),
|
||||
subtitle: v.description == null ? null : Text(v.description!),
|
||||
trailing: canEdit
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () => ref
|
||||
.read(networkDevicesControllerProvider)
|
||||
.deleteVlan(v.id),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color? _parseColor(String? hex) {
|
||||
if (hex == null) return null;
|
||||
var s = hex.replaceFirst('#', '');
|
||||
if (s.length == 6) s = 'FF$s';
|
||||
final n = int.tryParse(s, radix: 16);
|
||||
if (n == null) return null;
|
||||
return Color(n);
|
||||
}
|
||||
|
||||
Future<void> _showAddDialog(BuildContext context, WidgetRef ref) async {
|
||||
final idCtrl = TextEditingController();
|
||||
final nameCtrl = TextEditingController();
|
||||
final descCtrl = TextEditingController();
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('New VLAN'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: idCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'VLAN ID (1-4094)'),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: descCtrl,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Description (optional)'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final vlanId = int.tryParse(idCtrl.text.trim());
|
||||
final name = nameCtrl.text.trim();
|
||||
if (ok == true && vlanId != null && vlanId >= 1 && vlanId <= 4094 && name.isNotEmpty) {
|
||||
await ref.read(networkDevicesControllerProvider).createVlan(
|
||||
vlanId: vlanId,
|
||||
name: name,
|
||||
description: descCtrl.text.trim().isEmpty ? null : descCtrl.text.trim(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/network/network_device.dart';
|
||||
import 'network_device_icon.dart';
|
||||
|
||||
/// A styled node representing one network device on the topology canvas.
|
||||
///
|
||||
/// Accepts an explicit [nodeSize] so role-based sizing can be applied by the
|
||||
/// canvas without this widget needing to know the layout strategy.
|
||||
class DeviceNode extends StatelessWidget {
|
||||
const DeviceNode({
|
||||
super.key,
|
||||
required this.device,
|
||||
this.portCount,
|
||||
this.isSelected = false,
|
||||
this.isHighlighted = false,
|
||||
this.nodeSize = const Size(160, 110),
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final NetworkDevice device;
|
||||
final int? portCount;
|
||||
final bool isSelected;
|
||||
final bool isHighlighted;
|
||||
final Size nodeSize;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
Color _accentForRole(ColorScheme cs, NetworkDeviceRole? role) {
|
||||
switch (role) {
|
||||
case NetworkDeviceRole.core:
|
||||
return cs.error;
|
||||
case NetworkDeviceRole.distribution:
|
||||
return cs.tertiary;
|
||||
case NetworkDeviceRole.access:
|
||||
return cs.primary;
|
||||
case NetworkDeviceRole.edge:
|
||||
return cs.secondary;
|
||||
case NetworkDeviceRole.endpoint:
|
||||
case null:
|
||||
return cs.outline;
|
||||
}
|
||||
}
|
||||
|
||||
Color? _statusColor(ColorScheme cs, NetworkDeviceStatus status) {
|
||||
switch (status) {
|
||||
case NetworkDeviceStatus.online:
|
||||
return const Color(0xFF34A853); // Google green — universally understood
|
||||
case NetworkDeviceStatus.offline:
|
||||
return cs.error;
|
||||
case NetworkDeviceStatus.warning:
|
||||
return const Color(0xFFFBBC04); // Amber
|
||||
case NetworkDeviceStatus.unknown:
|
||||
return null; // No dot shown
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final accent = _accentForRole(cs, device.role);
|
||||
final statusColor = _statusColor(cs, device.status);
|
||||
|
||||
final bgColor = isSelected
|
||||
? cs.primaryContainer
|
||||
: isHighlighted
|
||||
? cs.secondaryContainer.withValues(alpha: 0.6)
|
||||
: cs.surfaceContainerHigh;
|
||||
final borderColor = isSelected
|
||||
? cs.primary
|
||||
: isHighlighted
|
||||
? cs.primary.withValues(alpha: 0.8)
|
||||
: accent.withValues(alpha: 0.6);
|
||||
final borderWidth = isSelected ? 2.0 : (isHighlighted ? 1.5 : 1.0);
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: borderColor, width: borderWidth),
|
||||
boxShadow: (isHighlighted || isSelected)
|
||||
? [
|
||||
BoxShadow(
|
||||
color: cs.primary.withValues(alpha: 0.18),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: nodeSize.width,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
NetworkDeviceIcon(
|
||||
kind: device.kind,
|
||||
color: accent,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
device.kind.label,
|
||||
style: tt.labelSmall?.copyWith(color: accent),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
device.name,
|
||||
style: tt.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (device.vendor != null || device.model != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
[
|
||||
if (device.vendor != null) device.vendor,
|
||||
if (device.model != null) device.model,
|
||||
].whereType<String>().join(' • '),
|
||||
style: tt.labelSmall?.copyWith(
|
||||
color: cs.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
if (portCount != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cable_outlined,
|
||||
size: 12,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$portCount ports',
|
||||
style: tt.labelSmall?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// Status dot — top-right, only shown when status is not unknown
|
||||
if (statusColor != null)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: bgColor,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Side-by-side review of an AI extraction result.
|
||||
///
|
||||
/// Left column: items the AI proposed (devices, ports, links, vlans), each
|
||||
/// togglable on/off with confidence tag. Right column: a summary panel that
|
||||
/// shows what will be applied. Apply button persists the toggled-on items.
|
||||
class ImportDiffView extends StatefulWidget {
|
||||
const ImportDiffView({
|
||||
super.key,
|
||||
required this.aiResult,
|
||||
required this.onApply,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> aiResult;
|
||||
final Future<void> Function({
|
||||
required List<Map<String, dynamic>> acceptedDevices,
|
||||
required List<Map<String, dynamic>> acceptedPorts,
|
||||
required List<Map<String, dynamic>> acceptedLinks,
|
||||
required List<Map<String, dynamic>> acceptedVlans,
|
||||
required List<Map<String, dynamic>> acceptedSites,
|
||||
}) onApply;
|
||||
|
||||
@override
|
||||
State<ImportDiffView> createState() => _ImportDiffViewState();
|
||||
}
|
||||
|
||||
class _ImportDiffViewState extends State<ImportDiffView> {
|
||||
late final Set<int> _enabledDevices;
|
||||
late final Set<int> _enabledPorts;
|
||||
late final Set<int> _enabledLinks;
|
||||
late final Set<int> _enabledVlans;
|
||||
late final Set<int> _enabledSites;
|
||||
bool _applying = false;
|
||||
|
||||
List<Map<String, dynamic>> _asList(dynamic value) {
|
||||
if (value is! List) return const [];
|
||||
return value
|
||||
.whereType<Map>()
|
||||
.map((e) => Map<String, dynamic>.from(e))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_enabledDevices = {
|
||||
for (var i = 0; i < _asList(widget.aiResult['devices']).length; i++) i,
|
||||
};
|
||||
_enabledPorts = {
|
||||
for (var i = 0; i < _asList(widget.aiResult['ports']).length; i++) i,
|
||||
};
|
||||
_enabledLinks = {
|
||||
for (var i = 0; i < _asList(widget.aiResult['links']).length; i++) i,
|
||||
};
|
||||
_enabledVlans = {
|
||||
for (var i = 0; i < _asList(widget.aiResult['vlans']).length; i++) i,
|
||||
};
|
||||
_enabledSites = {
|
||||
for (var i = 0; i < _asList(widget.aiResult['sites']).length; i++) i,
|
||||
};
|
||||
}
|
||||
|
||||
Color _confidenceColor(BuildContext context, String? c) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
switch (c) {
|
||||
case 'high':
|
||||
return cs.primary;
|
||||
case 'medium':
|
||||
return cs.tertiary;
|
||||
case 'low':
|
||||
return cs.error;
|
||||
default:
|
||||
return cs.outline;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _itemTile({
|
||||
required String primary,
|
||||
String? secondary,
|
||||
String? confidence,
|
||||
required bool enabled,
|
||||
required ValueChanged<bool?> onToggle,
|
||||
}) {
|
||||
return CheckboxListTile(
|
||||
dense: true,
|
||||
value: enabled,
|
||||
onChanged: onToggle,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
title: Text(primary),
|
||||
subtitle: secondary != null ? Text(secondary) : null,
|
||||
secondary: confidence == null
|
||||
? null
|
||||
: Chip(
|
||||
label: Text(confidence),
|
||||
backgroundColor:
|
||||
_confidenceColor(context, confidence).withValues(alpha: 0.15),
|
||||
labelStyle: TextStyle(color: _confidenceColor(context, confidence)),
|
||||
side: BorderSide.none,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onApplyPressed() async {
|
||||
setState(() => _applying = true);
|
||||
try {
|
||||
final allSites = _asList(widget.aiResult['sites']);
|
||||
final allDevices = _asList(widget.aiResult['devices']);
|
||||
final allPorts = _asList(widget.aiResult['ports']);
|
||||
final allLinks = _asList(widget.aiResult['links']);
|
||||
final allVlans = _asList(widget.aiResult['vlans']);
|
||||
|
||||
await widget.onApply(
|
||||
acceptedSites: [
|
||||
for (var i = 0; i < allSites.length; i++)
|
||||
if (_enabledSites.contains(i)) allSites[i],
|
||||
],
|
||||
acceptedDevices: [
|
||||
for (var i = 0; i < allDevices.length; i++)
|
||||
if (_enabledDevices.contains(i)) allDevices[i],
|
||||
],
|
||||
acceptedPorts: [
|
||||
for (var i = 0; i < allPorts.length; i++)
|
||||
if (_enabledPorts.contains(i)) allPorts[i],
|
||||
],
|
||||
acceptedLinks: [
|
||||
for (var i = 0; i < allLinks.length; i++)
|
||||
if (_enabledLinks.contains(i)) allLinks[i],
|
||||
],
|
||||
acceptedVlans: [
|
||||
for (var i = 0; i < allVlans.length; i++)
|
||||
if (_enabledVlans.contains(i)) allVlans[i],
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _applying = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sites = _asList(widget.aiResult['sites']);
|
||||
final devices = _asList(widget.aiResult['devices']);
|
||||
final ports = _asList(widget.aiResult['ports']);
|
||||
final links = _asList(widget.aiResult['links']);
|
||||
final vlans = _asList(widget.aiResult['vlans']);
|
||||
final notes = widget.aiResult['notes']?.toString() ?? '';
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
if (notes.isNotEmpty)
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.info_outline, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(notes)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (sites.isNotEmpty) ...[
|
||||
_sectionHeader('Sites (${sites.length})'),
|
||||
for (var i = 0; i < sites.length; i++)
|
||||
_itemTile(
|
||||
primary: sites[i]['name']?.toString() ?? '(unnamed)',
|
||||
secondary: sites[i]['address']?.toString(),
|
||||
enabled: _enabledSites.contains(i),
|
||||
onToggle: (v) => setState(() {
|
||||
if (v == true) {
|
||||
_enabledSites.add(i);
|
||||
} else {
|
||||
_enabledSites.remove(i);
|
||||
}
|
||||
}),
|
||||
),
|
||||
],
|
||||
if (devices.isNotEmpty) ...[
|
||||
_sectionHeader('Devices (${devices.length})'),
|
||||
for (var i = 0; i < devices.length; i++)
|
||||
_itemTile(
|
||||
primary: devices[i]['name']?.toString() ?? '(unnamed)',
|
||||
secondary: [
|
||||
devices[i]['kind']?.toString() ?? '',
|
||||
if (devices[i]['vendor'] != null) devices[i]['vendor'],
|
||||
if (devices[i]['model'] != null) devices[i]['model'],
|
||||
].whereType<String>().where((s) => s.isNotEmpty).join(' • '),
|
||||
confidence: devices[i]['confidence']?.toString(),
|
||||
enabled: _enabledDevices.contains(i),
|
||||
onToggle: (v) => setState(() {
|
||||
if (v == true) {
|
||||
_enabledDevices.add(i);
|
||||
} else {
|
||||
_enabledDevices.remove(i);
|
||||
}
|
||||
}),
|
||||
),
|
||||
],
|
||||
if (ports.isNotEmpty) ...[
|
||||
_sectionHeader('Ports (${ports.length})'),
|
||||
for (var i = 0; i < ports.length; i++)
|
||||
_itemTile(
|
||||
primary:
|
||||
'${ports[i]['device_name'] ?? '?'} · ${ports[i]['port_number'] ?? '?'}',
|
||||
secondary: ports[i]['port_kind']?.toString(),
|
||||
enabled: _enabledPorts.contains(i),
|
||||
onToggle: (v) => setState(() {
|
||||
if (v == true) {
|
||||
_enabledPorts.add(i);
|
||||
} else {
|
||||
_enabledPorts.remove(i);
|
||||
}
|
||||
}),
|
||||
),
|
||||
],
|
||||
if (links.isNotEmpty) ...[
|
||||
_sectionHeader('Links (${links.length})'),
|
||||
for (var i = 0; i < links.length; i++)
|
||||
_itemTile(
|
||||
primary:
|
||||
'${links[i]['device_a'] ?? '?'} ↔ ${links[i]['device_b'] ?? '?'}',
|
||||
secondary: [
|
||||
if (links[i]['port_a'] != null) 'A: ${links[i]['port_a']}',
|
||||
if (links[i]['port_b'] != null) 'B: ${links[i]['port_b']}',
|
||||
if (links[i]['link_kind'] != null) links[i]['link_kind'],
|
||||
].whereType<String>().join(' • '),
|
||||
confidence: links[i]['confidence']?.toString(),
|
||||
enabled: _enabledLinks.contains(i),
|
||||
onToggle: (v) => setState(() {
|
||||
if (v == true) {
|
||||
_enabledLinks.add(i);
|
||||
} else {
|
||||
_enabledLinks.remove(i);
|
||||
}
|
||||
}),
|
||||
),
|
||||
],
|
||||
if (vlans.isNotEmpty) ...[
|
||||
_sectionHeader('VLANs (${vlans.length})'),
|
||||
for (var i = 0; i < vlans.length; i++)
|
||||
_itemTile(
|
||||
primary:
|
||||
'VLAN ${vlans[i]['vlan_id'] ?? '?'} · ${vlans[i]['name'] ?? ''}',
|
||||
secondary: vlans[i]['description']?.toString(),
|
||||
enabled: _enabledVlans.contains(i),
|
||||
onToggle: (v) => setState(() {
|
||||
if (v == true) {
|
||||
_enabledVlans.add(i);
|
||||
} else {
|
||||
_enabledVlans.remove(i);
|
||||
}
|
||||
}),
|
||||
),
|
||||
],
|
||||
if (sites.isEmpty &&
|
||||
devices.isEmpty &&
|
||||
ports.isEmpty &&
|
||||
links.isEmpty &&
|
||||
vlans.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Extraction returned no structured items. You can still '
|
||||
'discard this import and start from a blank canvas.',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Selected: ${_enabledSites.length} sites, '
|
||||
'${_enabledDevices.length} devices, '
|
||||
'${_enabledLinks.length} links',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _applying ? null : _onApplyPressed,
|
||||
icon: _applying
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.check),
|
||||
label: Text(_applying ? 'Applying…' : 'Apply selected'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionHeader(String label) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 4),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/network/network_link.dart';
|
||||
import '../../../models/network/network_port.dart';
|
||||
import '../../../providers/network_map/network_devices_provider.dart';
|
||||
|
||||
/// Opens an M3 dialog to connect [portId] to a port on another device.
|
||||
///
|
||||
/// Returns true if a link was successfully created.
|
||||
Future<bool> showLinkEditDialog({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required String portId,
|
||||
required String currentDeviceId,
|
||||
}) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => _LinkEditDialog(
|
||||
portId: portId,
|
||||
currentDeviceId: currentDeviceId,
|
||||
),
|
||||
);
|
||||
return result == true;
|
||||
}
|
||||
|
||||
class _LinkEditDialog extends ConsumerStatefulWidget {
|
||||
const _LinkEditDialog({
|
||||
required this.portId,
|
||||
required this.currentDeviceId,
|
||||
});
|
||||
|
||||
final String portId;
|
||||
final String currentDeviceId;
|
||||
|
||||
@override
|
||||
ConsumerState<_LinkEditDialog> createState() => _LinkEditDialogState();
|
||||
}
|
||||
|
||||
class _LinkEditDialogState extends ConsumerState<_LinkEditDialog> {
|
||||
String? _selectedDeviceId;
|
||||
String? _selectedPortId;
|
||||
NetworkLinkKind? _linkKind;
|
||||
final _cableLabelCtrl = TextEditingController();
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cableLabelCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final portA = widget.portId;
|
||||
final portB = _selectedPortId;
|
||||
if (portB == null) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
ref.invalidate(networkLinksProvider);
|
||||
final controller = ref.read(networkDevicesControllerProvider);
|
||||
await controller.createLink(
|
||||
portA: portA,
|
||||
portB: portB,
|
||||
linkKind: _linkKind,
|
||||
cableLabel: _cableLabelCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: _cableLabelCtrl.text.trim(),
|
||||
);
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to create link: $e'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final devicesAsync = ref.watch(networkDevicesProvider);
|
||||
final portsAsync = _selectedDeviceId != null
|
||||
? ref.watch(networkPortsByDeviceProvider(_selectedDeviceId!))
|
||||
: const AsyncValue<List<NetworkPort>>.data([]);
|
||||
final allLinksAsync = ref.watch(networkLinksProvider);
|
||||
|
||||
final otherDevices = (devicesAsync.valueOrNull ?? const [])
|
||||
.where((d) => d.id != widget.currentDeviceId)
|
||||
.toList()
|
||||
..sort((a, b) => a.name.compareTo(b.name));
|
||||
|
||||
final allLinks = allLinksAsync.valueOrNull ?? const [];
|
||||
final usedPortIds = <String>{
|
||||
for (final link in allLinks) ...[link.portA, link.portB],
|
||||
};
|
||||
|
||||
final devicePorts = portsAsync.valueOrNull ?? const [];
|
||||
final availablePorts =
|
||||
devicePorts.where((p) => !usedPortIds.contains(p.id)).toList();
|
||||
|
||||
final canSave = _selectedPortId != null && !_saving;
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Connect port'),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _selectedDeviceId,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Remote device *'),
|
||||
items: [
|
||||
for (final d in otherDevices)
|
||||
DropdownMenuItem(value: d.id, child: Text(d.name)),
|
||||
],
|
||||
onChanged: (v) => setState(() {
|
||||
_selectedDeviceId = v;
|
||||
_selectedPortId = null;
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
key: ValueKey(_selectedDeviceId),
|
||||
initialValue: _selectedPortId,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Remote port *',
|
||||
helperText: _selectedDeviceId == null
|
||||
? 'Select a device first'
|
||||
: availablePorts.isEmpty
|
||||
? 'No available ports on this device'
|
||||
: null,
|
||||
),
|
||||
items: [
|
||||
for (final p in availablePorts)
|
||||
DropdownMenuItem(
|
||||
value: p.id,
|
||||
child: Text(p.portNumber),
|
||||
),
|
||||
],
|
||||
onChanged: _selectedDeviceId == null
|
||||
? null
|
||||
: (v) => setState(() => _selectedPortId = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<NetworkLinkKind?>(
|
||||
initialValue: _linkKind,
|
||||
decoration: const InputDecoration(labelText: 'Link type'),
|
||||
items: [
|
||||
const DropdownMenuItem<NetworkLinkKind?>(
|
||||
value: null,
|
||||
child: Text('(unknown)'),
|
||||
),
|
||||
for (final k in NetworkLinkKind.values)
|
||||
DropdownMenuItem(value: k, child: Text(k.label)),
|
||||
],
|
||||
onChanged: (v) => setState(() => _linkKind = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _cableLabelCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Cable label',
|
||||
helperText: 'e.g. "CAT6-01", "SMF-A3" (optional)',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _saving ? null : () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: canSave ? _save : null,
|
||||
child: Text(_saving ? 'Saving…' : 'Connect'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/network/network_device.dart';
|
||||
|
||||
/// Custom-painted network equipment icon for each [NetworkDeviceKind].
|
||||
///
|
||||
/// Draws simplified but recognizable symbols matching professional network
|
||||
/// topology diagram conventions (Cisco-style silhouettes).
|
||||
class NetworkDeviceIcon extends StatelessWidget {
|
||||
const NetworkDeviceIcon({
|
||||
super.key,
|
||||
required this.kind,
|
||||
required this.color,
|
||||
this.size = 20,
|
||||
});
|
||||
|
||||
final NetworkDeviceKind kind;
|
||||
final Color color;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CustomPaint(
|
||||
painter: _DeviceIconPainter(kind: kind, color: color),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeviceIconPainter extends CustomPainter {
|
||||
_DeviceIconPainter({required this.kind, required this.color});
|
||||
|
||||
final NetworkDeviceKind kind;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
switch (kind) {
|
||||
case NetworkDeviceKind.router:
|
||||
_drawRouter(canvas, size);
|
||||
case NetworkDeviceKind.switchDevice:
|
||||
_drawSwitch(canvas, size);
|
||||
case NetworkDeviceKind.ap:
|
||||
_drawAp(canvas, size);
|
||||
case NetworkDeviceKind.firewall:
|
||||
_drawFirewall(canvas, size);
|
||||
case NetworkDeviceKind.server:
|
||||
_drawServer(canvas, size);
|
||||
case NetworkDeviceKind.endpoint:
|
||||
_drawEndpoint(canvas, size);
|
||||
case NetworkDeviceKind.patchPanel:
|
||||
_drawPatchPanel(canvas, size);
|
||||
case NetworkDeviceKind.other:
|
||||
_drawOther(canvas, size);
|
||||
}
|
||||
}
|
||||
|
||||
Paint get _stroke => Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round
|
||||
..strokeWidth = 1.4;
|
||||
|
||||
Paint get _fill => Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
// ─── Router: cylinder body + 4 arrows ─────────────────────────────────────
|
||||
|
||||
void _drawRouter(Canvas canvas, Size s) {
|
||||
final cx = s.width / 2;
|
||||
final cy = s.height / 2;
|
||||
final r = s.width * 0.28;
|
||||
final p = _stroke;
|
||||
|
||||
// Circle body
|
||||
canvas.drawCircle(Offset(cx, cy), r, p);
|
||||
|
||||
// 4 directional arrows at N/S/E/W
|
||||
const arrowLen = 0.22;
|
||||
const arrowHead = 0.1;
|
||||
for (var i = 0; i < 4; i++) {
|
||||
final angle = i * math.pi / 2;
|
||||
final ax = cx + math.cos(angle) * (r + s.width * arrowLen);
|
||||
final ay = cy + math.sin(angle) * (r + s.width * arrowLen);
|
||||
final bx = cx + math.cos(angle) * (r + 1);
|
||||
final by = cy + math.sin(angle) * (r + 1);
|
||||
canvas.drawLine(Offset(bx, by), Offset(ax, ay), p);
|
||||
// Arrowhead
|
||||
final perp = angle + math.pi / 2;
|
||||
final hw = s.width * arrowHead;
|
||||
canvas.drawPath(
|
||||
Path()
|
||||
..moveTo(ax, ay)
|
||||
..lineTo(
|
||||
ax - math.cos(angle) * hw + math.cos(perp) * hw / 2,
|
||||
ay - math.sin(angle) * hw + math.sin(perp) * hw / 2,
|
||||
)
|
||||
..lineTo(
|
||||
ax - math.cos(angle) * hw - math.cos(perp) * hw / 2,
|
||||
ay - math.sin(angle) * hw - math.sin(perp) * hw / 2,
|
||||
)
|
||||
..close(),
|
||||
_fill,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Switch: rectangle + port lines ───────────────────────────────────────
|
||||
|
||||
void _drawSwitch(Canvas canvas, Size s) {
|
||||
final w = s.width * 0.86;
|
||||
final h = s.height * 0.42;
|
||||
final left = (s.width - w) / 2;
|
||||
final top = (s.height - h) / 2;
|
||||
final p = _stroke;
|
||||
|
||||
// Body
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(left, top, w, h),
|
||||
const Radius.circular(2),
|
||||
),
|
||||
p,
|
||||
);
|
||||
|
||||
// Port lines (8 evenly spaced)
|
||||
const ports = 8;
|
||||
final portSpacing = w / (ports + 1);
|
||||
final portH = h * 0.5;
|
||||
final portTop = top + h * 0.2;
|
||||
for (var i = 1; i <= ports; i++) {
|
||||
final px = left + portSpacing * i;
|
||||
canvas.drawLine(
|
||||
Offset(px, portTop),
|
||||
Offset(px, portTop + portH),
|
||||
p,
|
||||
);
|
||||
}
|
||||
// Two legs at bottom center
|
||||
final legX1 = s.width * 0.38;
|
||||
final legX2 = s.width * 0.62;
|
||||
final bodyBottom = top + h;
|
||||
canvas.drawLine(
|
||||
Offset(legX1, bodyBottom),
|
||||
Offset(legX1, s.height * 0.85),
|
||||
p,
|
||||
);
|
||||
canvas.drawLine(
|
||||
Offset(legX2, bodyBottom),
|
||||
Offset(legX2, s.height * 0.85),
|
||||
p,
|
||||
);
|
||||
canvas.drawLine(
|
||||
Offset(legX1, s.height * 0.85),
|
||||
Offset(legX2, s.height * 0.85),
|
||||
p,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── AP: semicircle waves + stem ──────────────────────────────────────────
|
||||
|
||||
void _drawAp(Canvas canvas, Size s) {
|
||||
final cx = s.width / 2;
|
||||
final base = s.height * 0.72;
|
||||
final p = _stroke;
|
||||
|
||||
// Stem
|
||||
canvas.drawLine(Offset(cx, base), Offset(cx, s.height * 0.88), p);
|
||||
canvas.drawLine(
|
||||
Offset(cx - s.width * 0.15, s.height * 0.88),
|
||||
Offset(cx + s.width * 0.15, s.height * 0.88),
|
||||
p,
|
||||
);
|
||||
|
||||
// 3 concentric arcs from narrow to wide
|
||||
for (var i = 1; i <= 3; i++) {
|
||||
final r = s.width * 0.13 * i;
|
||||
final rect = Rect.fromCircle(center: Offset(cx, base), radius: r);
|
||||
canvas.drawArc(rect, math.pi, -math.pi, false, p);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Firewall: brick-wall grid ─────────────────────────────────────────────
|
||||
|
||||
void _drawFirewall(Canvas canvas, Size s) {
|
||||
final w = s.width * 0.8;
|
||||
final h = s.height * 0.72;
|
||||
final left = (s.width - w) / 2;
|
||||
final top = (s.height - h) / 2;
|
||||
final p = _stroke;
|
||||
|
||||
// Outer rect
|
||||
canvas.drawRect(Rect.fromLTWH(left, top, w, h), p);
|
||||
|
||||
// 3 rows, 2 staggered bricks each
|
||||
final rowH = h / 3;
|
||||
for (var row = 0; row < 3; row++) {
|
||||
final rowTop = top + row * rowH;
|
||||
// horizontal mortar line
|
||||
if (row > 0) {
|
||||
canvas.drawLine(
|
||||
Offset(left, rowTop),
|
||||
Offset(left + w, rowTop),
|
||||
p,
|
||||
);
|
||||
}
|
||||
// vertical mortar — offset every other row by half a brick
|
||||
final offset = (row % 2 == 0) ? w / 2 : w / 4;
|
||||
canvas.drawLine(
|
||||
Offset(left + offset, rowTop),
|
||||
Offset(left + offset, rowTop + rowH),
|
||||
p,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Server: rack unit with LED dots ──────────────────────────────────────
|
||||
|
||||
void _drawServer(Canvas canvas, Size s) {
|
||||
final w = s.width * 0.72;
|
||||
final h = s.height * 0.78;
|
||||
final left = (s.width - w) / 2;
|
||||
final top = (s.height - h) / 2;
|
||||
final p = _stroke;
|
||||
final fp = _fill;
|
||||
|
||||
// Body
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(left, top, w, h),
|
||||
const Radius.circular(2),
|
||||
),
|
||||
p,
|
||||
);
|
||||
|
||||
// 3 horizontal rack-unit dividers + LED dots
|
||||
final unitH = h / 3;
|
||||
for (var i = 0; i < 3; i++) {
|
||||
final unitTop = top + i * unitH;
|
||||
if (i > 0) {
|
||||
canvas.drawLine(
|
||||
Offset(left, unitTop),
|
||||
Offset(left + w, unitTop),
|
||||
p,
|
||||
);
|
||||
}
|
||||
// Small LED dot on right side of each unit
|
||||
canvas.drawCircle(
|
||||
Offset(left + w - w * 0.15, unitTop + unitH / 2),
|
||||
s.width * 0.05,
|
||||
fp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Endpoint: monitor + stand ─────────────────────────────────────────────
|
||||
|
||||
void _drawEndpoint(Canvas canvas, Size s) {
|
||||
final monW = s.width * 0.76;
|
||||
final monH = s.height * 0.52;
|
||||
final left = (s.width - monW) / 2;
|
||||
final top = s.height * 0.08;
|
||||
final p = _stroke;
|
||||
|
||||
// Monitor bezel
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(left, top, monW, monH),
|
||||
const Radius.circular(2),
|
||||
),
|
||||
p,
|
||||
);
|
||||
|
||||
// Screen inner (inset 2dp)
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(left + 3, top + 3, monW - 6, monH - 6),
|
||||
const Radius.circular(1),
|
||||
),
|
||||
p,
|
||||
);
|
||||
|
||||
// Neck
|
||||
final neckX = s.width / 2;
|
||||
final neckTop = top + monH;
|
||||
canvas.drawLine(
|
||||
Offset(neckX, neckTop),
|
||||
Offset(neckX, neckTop + s.height * 0.18),
|
||||
p,
|
||||
);
|
||||
|
||||
// Base
|
||||
canvas.drawLine(
|
||||
Offset(s.width * 0.25, s.height * 0.88),
|
||||
Offset(s.width * 0.75, s.height * 0.88),
|
||||
p,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Patch panel: flat rect with 2 rows of port circles ──────────────────
|
||||
|
||||
void _drawPatchPanel(Canvas canvas, Size s) {
|
||||
final w = s.width * 0.9;
|
||||
final h = s.height * 0.44;
|
||||
final left = (s.width - w) / 2;
|
||||
final top = (s.height - h) / 2;
|
||||
final p = _stroke;
|
||||
|
||||
// Body
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(left, top, w, h),
|
||||
const Radius.circular(2),
|
||||
),
|
||||
p,
|
||||
);
|
||||
|
||||
// 2 rows × 5 port circles
|
||||
const cols = 5;
|
||||
const rows = 2;
|
||||
final colSpacing = w / (cols + 1);
|
||||
final rowSpacing = h / (rows + 1);
|
||||
final dotR = s.width * 0.04;
|
||||
for (var row = 1; row <= rows; row++) {
|
||||
for (var col = 1; col <= cols; col++) {
|
||||
canvas.drawCircle(
|
||||
Offset(left + colSpacing * col, top + rowSpacing * row),
|
||||
dotR,
|
||||
p,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Other: circle with question mark ─────────────────────────────────────
|
||||
|
||||
void _drawOther(Canvas canvas, Size s) {
|
||||
final cx = s.width / 2;
|
||||
final cy = s.height / 2;
|
||||
final r = s.width * 0.42;
|
||||
final p = _stroke;
|
||||
|
||||
canvas.drawCircle(Offset(cx, cy), r, p);
|
||||
|
||||
// "?" drawn as a small arc + dot
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: '?',
|
||||
style: TextStyle(
|
||||
fontSize: s.width * 0.52,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
tp.paint(
|
||||
canvas,
|
||||
Offset(cx - tp.width / 2, cy - tp.height / 2),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _DeviceIconPainter old) =>
|
||||
old.kind != kind || old.color != color;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/network/network_port.dart';
|
||||
import '../../../providers/network_map/network_devices_provider.dart';
|
||||
|
||||
/// Opens an M3 dialog for creating or editing a network port.
|
||||
///
|
||||
/// When [existing] is null, the dialog operates in "add" mode and creates a new
|
||||
/// port on [deviceId]. When [existing] is supplied, the dialog edits that port
|
||||
/// in place. Returns true if the user saved a change.
|
||||
Future<bool> showPortEditDialog({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required String deviceId,
|
||||
NetworkPort? existing,
|
||||
}) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => _PortEditDialog(deviceId: deviceId, existing: existing),
|
||||
);
|
||||
return result == true;
|
||||
}
|
||||
|
||||
class _PortEditDialog extends ConsumerStatefulWidget {
|
||||
const _PortEditDialog({required this.deviceId, this.existing});
|
||||
|
||||
final String deviceId;
|
||||
final NetworkPort? existing;
|
||||
|
||||
@override
|
||||
ConsumerState<_PortEditDialog> createState() => _PortEditDialogState();
|
||||
}
|
||||
|
||||
class _PortEditDialogState extends ConsumerState<_PortEditDialog> {
|
||||
final _numberCtrl = TextEditingController();
|
||||
final _speedCtrl = TextEditingController();
|
||||
final _vlanCtrl = TextEditingController();
|
||||
final _trunkVlansCtrl = TextEditingController();
|
||||
final _notesCtrl = TextEditingController();
|
||||
NetworkPortKind? _kind;
|
||||
bool _isTrunk = false;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final e = widget.existing;
|
||||
if (e != null) {
|
||||
_numberCtrl.text = e.portNumber;
|
||||
_kind = e.portKind;
|
||||
_speedCtrl.text = e.speedMbps?.toString() ?? '';
|
||||
_vlanCtrl.text = e.accessVlan?.toString() ?? '';
|
||||
_isTrunk = e.isTrunk;
|
||||
_trunkVlansCtrl.text = e.trunkVlans.join(', ');
|
||||
_notesCtrl.text = e.notes ?? '';
|
||||
} else {
|
||||
_kind = NetworkPortKind.rj45;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_numberCtrl.dispose();
|
||||
_speedCtrl.dispose();
|
||||
_vlanCtrl.dispose();
|
||||
_trunkVlansCtrl.dispose();
|
||||
_notesCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<int> _parseTrunkVlans(String input) {
|
||||
return input
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.map(int.tryParse)
|
||||
.whereType<int>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final number = _numberCtrl.text.trim();
|
||||
if (number.isEmpty) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final controller = ref.read(networkDevicesControllerProvider);
|
||||
final speed = _speedCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: int.tryParse(_speedCtrl.text.trim());
|
||||
final vlan = _vlanCtrl.text.trim().isEmpty || _isTrunk
|
||||
? null
|
||||
: int.tryParse(_vlanCtrl.text.trim());
|
||||
final trunkVlans = _isTrunk ? _parseTrunkVlans(_trunkVlansCtrl.text) : <int>[];
|
||||
final notes = _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim();
|
||||
|
||||
if (widget.existing == null) {
|
||||
await controller.createPort(
|
||||
deviceId: widget.deviceId,
|
||||
portNumber: number,
|
||||
portKind: _kind,
|
||||
speedMbps: speed,
|
||||
accessVlan: vlan,
|
||||
isTrunk: _isTrunk,
|
||||
trunkVlans: trunkVlans,
|
||||
notes: notes,
|
||||
);
|
||||
} else {
|
||||
await controller.updatePort(
|
||||
id: widget.existing!.id,
|
||||
deviceId: widget.deviceId,
|
||||
portNumber: number,
|
||||
portKind: _kind,
|
||||
speedMbps: speed,
|
||||
accessVlan: vlan,
|
||||
isTrunk: _isTrunk,
|
||||
trunkVlans: trunkVlans,
|
||||
notes: notes,
|
||||
);
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
return AlertDialog(
|
||||
title: Text(widget.existing == null ? 'Add port' : 'Edit port'),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 320, maxWidth: 480),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// ── Identity ─────────────────────────────────────────
|
||||
TextField(
|
||||
controller: _numberCtrl,
|
||||
autofocus: widget.existing == null,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Port number *',
|
||||
helperText: 'e.g. Gi0/1, eth0, port24',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<NetworkPortKind?>(
|
||||
initialValue: _kind,
|
||||
items: [
|
||||
const DropdownMenuItem<NetworkPortKind?>(
|
||||
value: null,
|
||||
child: Text('(unknown)'),
|
||||
),
|
||||
for (final k in NetworkPortKind.values)
|
||||
DropdownMenuItem(value: k, child: Text(k.label)),
|
||||
],
|
||||
onChanged: (v) => setState(() => _kind = v),
|
||||
decoration: const InputDecoration(labelText: 'Kind'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _speedCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Speed (Mbps)',
|
||||
helperText: 'e.g. 1000, 10000',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _vlanCtrl,
|
||||
enabled: !_isTrunk,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Access VLAN',
|
||||
helperText: _isTrunk ? 'Disabled for trunk' : '1–4094',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// ── Switching config ──────────────────────────────────
|
||||
const Divider(height: 28),
|
||||
SwitchListTile.adaptive(
|
||||
value: _isTrunk,
|
||||
onChanged: (v) => setState(() => _isTrunk = v),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Trunk port'),
|
||||
subtitle: Text('Carries multiple VLANs', style: tt.bodySmall),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
child: _isTrunk
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: TextField(
|
||||
controller: _trunkVlansCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Allowed VLANs',
|
||||
helperText: 'Comma-separated, e.g. 10, 20, 100',
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
// ── Notes ─────────────────────────────────────────────
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _notesCtrl,
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
decoration: const InputDecoration(labelText: 'Notes'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _saving ? null : () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(widget.existing == null ? 'Add' : 'Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/network/network_port.dart';
|
||||
|
||||
enum _PortAction { editPort, connect, disconnect, deletePort }
|
||||
|
||||
class PortListTile extends StatelessWidget {
|
||||
const PortListTile({
|
||||
super.key,
|
||||
required this.port,
|
||||
this.linkedDeviceName,
|
||||
this.linkedPortLabel,
|
||||
this.canEdit = false,
|
||||
this.onTap,
|
||||
this.onConnect,
|
||||
this.onDisconnect,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
final NetworkPort port;
|
||||
final String? linkedDeviceName;
|
||||
final String? linkedPortLabel;
|
||||
final bool canEdit;
|
||||
final void Function(BuildContext)? onTap;
|
||||
final void Function(BuildContext)? onConnect;
|
||||
final void Function(BuildContext)? onDisconnect;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
|
||||
final isLinked = linkedDeviceName != null;
|
||||
final subtitleParts = <String>[
|
||||
if (port.portKind != null) port.portKind!.label,
|
||||
if (port.speedMbps != null) '${port.speedMbps} Mbps',
|
||||
if (port.isTrunk) 'Trunk',
|
||||
if (!port.isTrunk && port.accessVlan != null) 'VLAN ${port.accessVlan}',
|
||||
];
|
||||
|
||||
return ListTile(
|
||||
onTap: onTap == null ? null : () => onTap!(context),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: isLinked
|
||||
? cs.primaryContainer
|
||||
: cs.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
isLinked ? Icons.cable : Icons.cable_outlined,
|
||||
color: isLinked ? cs.onPrimaryContainer : cs.onSurfaceVariant,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
title: Text(port.portNumber, style: tt.bodyMedium),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (subtitleParts.isNotEmpty)
|
||||
Text(
|
||||
subtitleParts.join(' • '),
|
||||
style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
if (isLinked)
|
||||
Text(
|
||||
'↔ $linkedDeviceName${linkedPortLabel != null ? " · $linkedPortLabel" : ""}',
|
||||
style: tt.bodySmall?.copyWith(color: cs.primary),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: canEdit
|
||||
? PopupMenuButton<_PortAction>(
|
||||
tooltip: 'Port actions',
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case _PortAction.editPort:
|
||||
onTap?.call(context);
|
||||
case _PortAction.connect:
|
||||
onConnect?.call(context);
|
||||
case _PortAction.disconnect:
|
||||
onDisconnect?.call(context);
|
||||
case _PortAction.deletePort:
|
||||
onDelete?.call();
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(
|
||||
value: _PortAction.editPort,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.edit_outlined),
|
||||
title: Text('Edit port'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
if (!isLinked)
|
||||
const PopupMenuItem(
|
||||
value: _PortAction.connect,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.add_link),
|
||||
title: Text('Connect to…'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
if (isLinked)
|
||||
const PopupMenuItem(
|
||||
value: _PortAction.disconnect,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.link_off),
|
||||
title: Text('Disconnect'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _PortAction.deletePort,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.delete_outline),
|
||||
title: Text('Delete port'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,245 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/network/network_device.dart';
|
||||
import '../../../models/network/network_link.dart';
|
||||
|
||||
/// Collapsible legend overlay explaining device role border colours and link
|
||||
/// type colours. Positioned at the bottom-left of the topology canvas view.
|
||||
class TopologyLegend extends StatefulWidget {
|
||||
const TopologyLegend({super.key});
|
||||
|
||||
@override
|
||||
State<TopologyLegend> createState() => _TopologyLegendState();
|
||||
}
|
||||
|
||||
class _TopologyLegendState extends State<TopologyLegend>
|
||||
with SingleTickerProviderStateMixin {
|
||||
bool _expanded = false;
|
||||
late final AnimationController _anim;
|
||||
late final Animation<double> _fade;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_anim = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
_fade = CurvedAnimation(parent: _anim, curve: Curves.easeOut);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_anim.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggle() {
|
||||
setState(() => _expanded = !_expanded);
|
||||
if (_expanded) {
|
||||
_anim.forward();
|
||||
} else {
|
||||
_anim.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
Color _roleColor(ColorScheme cs, NetworkDeviceRole role) {
|
||||
switch (role) {
|
||||
case NetworkDeviceRole.core:
|
||||
return cs.error;
|
||||
case NetworkDeviceRole.distribution:
|
||||
return cs.tertiary;
|
||||
case NetworkDeviceRole.access:
|
||||
return cs.primary;
|
||||
case NetworkDeviceRole.edge:
|
||||
return cs.secondary;
|
||||
case NetworkDeviceRole.endpoint:
|
||||
return cs.outline;
|
||||
}
|
||||
}
|
||||
|
||||
Color _linkColor(ColorScheme cs, NetworkLinkKind kind) {
|
||||
switch (kind) {
|
||||
case NetworkLinkKind.copper:
|
||||
return cs.primary;
|
||||
case NetworkLinkKind.fiber:
|
||||
return cs.tertiary;
|
||||
case NetworkLinkKind.wireless:
|
||||
return cs.secondary;
|
||||
case NetworkLinkKind.virtual:
|
||||
return cs.outline;
|
||||
case NetworkLinkKind.unknown:
|
||||
return cs.outline;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Expand/collapse toggle chip
|
||||
Material(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
elevation: 2,
|
||||
child: InkWell(
|
||||
onTap: _toggle,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.legend_toggle_outlined,
|
||||
size: 16,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Legend',
|
||||
style: tt.labelSmall?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
AnimatedRotation(
|
||||
turns: _expanded ? 0.5 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Icon(
|
||||
Icons.expand_more,
|
||||
size: 16,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Expandable panel
|
||||
FadeTransition(
|
||||
opacity: _fade,
|
||||
child: SizeTransition(
|
||||
sizeFactor: _fade,
|
||||
axisAlignment: -1,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Material(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
elevation: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
width: 200,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// ── Device Roles ─────────────────────────────
|
||||
Text(
|
||||
'Device Roles',
|
||||
style: tt.labelSmall?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
for (final role in NetworkDeviceRole.values)
|
||||
_LegendRow(
|
||||
color: _roleColor(cs, role),
|
||||
label: role.label,
|
||||
isLine: false,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
const SizedBox(height: 10),
|
||||
// ── Link Types ───────────────────────────────
|
||||
Text(
|
||||
'Link Types',
|
||||
style: tt.labelSmall?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
for (final kind in NetworkLinkKind.values)
|
||||
_LegendRow(
|
||||
color: _linkColor(cs, kind),
|
||||
label: kind.label,
|
||||
isLine: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LegendRow extends StatelessWidget {
|
||||
const _LegendRow({
|
||||
required this.color,
|
||||
required this.label,
|
||||
required this.isLine,
|
||||
});
|
||||
|
||||
final Color color;
|
||||
final String label;
|
||||
final bool isLine;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
if (isLine)
|
||||
// Coloured line segment for link types
|
||||
Container(
|
||||
width: 20,
|
||||
height: 3,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
)
|
||||
else
|
||||
// Coloured square for device roles (border indicator)
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: color, width: 2),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: tt.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/network/network_device.dart';
|
||||
import '../../../models/network/topology_graph.dart';
|
||||
|
||||
/// Scaled-down overview of the topology canvas with a viewport indicator.
|
||||
///
|
||||
/// Shows all device nodes as small colour-coded rectangles and all edges as
|
||||
/// thin lines. A semi-transparent white rectangle indicates the current
|
||||
/// viewport within the full canvas. Tapping on the minimap pans the main
|
||||
/// canvas to centre on that point.
|
||||
class TopologyMinimap extends StatefulWidget {
|
||||
const TopologyMinimap({
|
||||
super.key,
|
||||
required this.graph,
|
||||
required this.nodePositions,
|
||||
required this.canvasSize,
|
||||
required this.controller,
|
||||
required this.viewportSize,
|
||||
});
|
||||
|
||||
final TopologyGraph graph;
|
||||
|
||||
/// Current interpolated node positions (top-left corners) from the canvas.
|
||||
final Map<String, Offset> nodePositions;
|
||||
|
||||
final Size canvasSize;
|
||||
final TransformationController controller;
|
||||
final Size viewportSize;
|
||||
|
||||
static const Size _minimapSize = Size(160, 100);
|
||||
|
||||
@override
|
||||
State<TopologyMinimap> createState() => _TopologyMinimapState();
|
||||
}
|
||||
|
||||
class _TopologyMinimapState extends State<TopologyMinimap> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onTransformChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TopologyMinimap oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
oldWidget.controller.removeListener(_onTransformChanged);
|
||||
widget.controller.addListener(_onTransformChanged);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onTransformChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTransformChanged() => setState(() {});
|
||||
|
||||
double get _scaleX {
|
||||
if (widget.canvasSize.width == 0) return 1;
|
||||
return TopologyMinimap._minimapSize.width / widget.canvasSize.width;
|
||||
}
|
||||
|
||||
double get _scaleY {
|
||||
if (widget.canvasSize.height == 0) return 1;
|
||||
return TopologyMinimap._minimapSize.height / widget.canvasSize.height;
|
||||
}
|
||||
|
||||
double get _scale => _scaleX < _scaleY ? _scaleX : _scaleY;
|
||||
|
||||
Offset _toMinimap(Offset canvasPoint) {
|
||||
final s = _scale;
|
||||
final offsetX = (TopologyMinimap._minimapSize.width - widget.canvasSize.width * s) / 2;
|
||||
final offsetY = (TopologyMinimap._minimapSize.height - widget.canvasSize.height * s) / 2;
|
||||
return Offset(canvasPoint.dx * s + offsetX, canvasPoint.dy * s + offsetY);
|
||||
}
|
||||
|
||||
/// Convert a tap on the minimap to a canvas point, then pan the main canvas
|
||||
/// so that point appears at the viewport centre.
|
||||
void _onTap(Offset localPos) {
|
||||
final s = _scale;
|
||||
if (s == 0) return;
|
||||
final offsetX = (TopologyMinimap._minimapSize.width - widget.canvasSize.width * s) / 2;
|
||||
final offsetY = (TopologyMinimap._minimapSize.height - widget.canvasSize.height * s) / 2;
|
||||
final canvasX = (localPos.dx - offsetX) / s;
|
||||
final canvasY = (localPos.dy - offsetY) / s;
|
||||
|
||||
final currentScale = widget.controller.value.getMaxScaleOnAxis();
|
||||
final tx = widget.viewportSize.width / 2 - canvasX * currentScale;
|
||||
final ty = widget.viewportSize.height / 2 - canvasY * currentScale;
|
||||
final result = Matrix4.translationValues(tx, ty, 0);
|
||||
result.multiply(Matrix4.diagonal3Values(currentScale, currentScale, 1.0));
|
||||
widget.controller.value = result;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return GestureDetector(
|
||||
onTapDown: (d) => _onTap(d.localPosition),
|
||||
child: Material(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
elevation: 2,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: TopologyMinimap._minimapSize.width,
|
||||
height: TopologyMinimap._minimapSize.height,
|
||||
child: CustomPaint(
|
||||
painter: _MinimapPainter(
|
||||
graph: widget.graph,
|
||||
nodePositions: widget.nodePositions,
|
||||
canvasSize: widget.canvasSize,
|
||||
controller: widget.controller,
|
||||
viewportSize: widget.viewportSize,
|
||||
colorScheme: cs,
|
||||
scale: _scale,
|
||||
toMinimap: _toMinimap,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MinimapPainter extends CustomPainter {
|
||||
_MinimapPainter({
|
||||
required this.graph,
|
||||
required this.nodePositions,
|
||||
required this.canvasSize,
|
||||
required this.controller,
|
||||
required this.viewportSize,
|
||||
required this.colorScheme,
|
||||
required this.scale,
|
||||
required this.toMinimap,
|
||||
});
|
||||
|
||||
final TopologyGraph graph;
|
||||
final Map<String, Offset> nodePositions;
|
||||
final Size canvasSize;
|
||||
final TransformationController controller;
|
||||
final Size viewportSize;
|
||||
final ColorScheme colorScheme;
|
||||
final double scale;
|
||||
final Offset Function(Offset) toMinimap;
|
||||
|
||||
static const Size _nodeRectSize = Size(8, 5);
|
||||
|
||||
Color _roleColor(NetworkDeviceRole? role) {
|
||||
switch (role) {
|
||||
case NetworkDeviceRole.core:
|
||||
return colorScheme.error;
|
||||
case NetworkDeviceRole.distribution:
|
||||
return colorScheme.tertiary;
|
||||
case NetworkDeviceRole.access:
|
||||
return colorScheme.primary;
|
||||
case NetworkDeviceRole.edge:
|
||||
return colorScheme.secondary;
|
||||
case NetworkDeviceRole.endpoint:
|
||||
case null:
|
||||
return colorScheme.outline;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
// Edges
|
||||
final edgePaint = Paint()
|
||||
..color = colorScheme.outline.withValues(alpha: 0.4)
|
||||
..strokeWidth = 0.6
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
for (final edge in graph.edges) {
|
||||
final fromPos = nodePositions[edge.fromDeviceId];
|
||||
final toPos = nodePositions[edge.toDeviceId];
|
||||
if (fromPos == null || toPos == null) continue;
|
||||
|
||||
// Use node centres
|
||||
final from = toMinimap(fromPos + const Offset(80, 55));
|
||||
final to = toMinimap(toPos + const Offset(80, 55));
|
||||
canvas.drawLine(from, to, edgePaint);
|
||||
}
|
||||
|
||||
// Device nodes
|
||||
for (final node in graph.nodes) {
|
||||
final pos = nodePositions[node.device.id];
|
||||
if (pos == null) continue;
|
||||
final minimapPos = toMinimap(pos);
|
||||
final rect = Rect.fromLTWH(
|
||||
minimapPos.dx - _nodeRectSize.width / 2,
|
||||
minimapPos.dy - _nodeRectSize.height / 2,
|
||||
_nodeRectSize.width,
|
||||
_nodeRectSize.height,
|
||||
);
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(rect, const Radius.circular(1.5)),
|
||||
Paint()
|
||||
..color = _roleColor(node.device.role)
|
||||
..style = PaintingStyle.fill,
|
||||
);
|
||||
}
|
||||
|
||||
// Viewport rectangle
|
||||
final m = controller.value;
|
||||
final currentScale = m.getMaxScaleOnAxis();
|
||||
if (currentScale > 0) {
|
||||
// The translation is in the 3rd column of the matrix
|
||||
final tx = m.getTranslation().x;
|
||||
final ty = m.getTranslation().y;
|
||||
|
||||
// Viewport in canvas coordinates
|
||||
final vpLeft = -tx / currentScale;
|
||||
final vpTop = -ty / currentScale;
|
||||
final vpWidth = viewportSize.width / currentScale;
|
||||
final vpHeight = viewportSize.height / currentScale;
|
||||
|
||||
final tl = toMinimap(Offset(vpLeft, vpTop));
|
||||
final br = toMinimap(Offset(vpLeft + vpWidth, vpTop + vpHeight));
|
||||
final vpRect = Rect.fromPoints(tl, br);
|
||||
|
||||
// Fill
|
||||
canvas.drawRect(
|
||||
vpRect,
|
||||
Paint()
|
||||
..color = colorScheme.primary.withValues(alpha: 0.08)
|
||||
..style = PaintingStyle.fill,
|
||||
);
|
||||
// Border
|
||||
canvas.drawRect(
|
||||
vpRect,
|
||||
Paint()
|
||||
..color = colorScheme.primary.withValues(alpha: 0.6)
|
||||
..strokeWidth = 1.0
|
||||
..style = PaintingStyle.stroke,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _MinimapPainter old) => true;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Vertical +/−/fit-to-view zoom controls for the topology canvas.
|
||||
/// Requires a [TransformationController] shared with the [InteractiveViewer].
|
||||
class ZoomControls extends StatelessWidget {
|
||||
const ZoomControls({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.canvasSize,
|
||||
required this.viewportSize,
|
||||
});
|
||||
|
||||
final TransformationController controller;
|
||||
|
||||
/// Full canvas size (from [computeSugiyamaLayout] result).
|
||||
final Size canvasSize;
|
||||
|
||||
/// Viewport widget size (the screen area the canvas is displayed in).
|
||||
final Size viewportSize;
|
||||
|
||||
static const double _zoomIn = 1.25;
|
||||
static const double _zoomOut = 0.8;
|
||||
static const double _minScale = 0.2;
|
||||
static const double _maxScale = 2.5;
|
||||
|
||||
void _applyZoom(double factor) {
|
||||
final current = controller.value.getMaxScaleOnAxis();
|
||||
final next = (current * factor).clamp(_minScale, _maxScale);
|
||||
final scale = next / current;
|
||||
final cx = viewportSize.width / 2;
|
||||
final cy = viewportSize.height / 2;
|
||||
// Compose: T(cx,cy) * Scale(s) * T(-cx,-cy) * currentTransform
|
||||
final result = Matrix4.translationValues(cx, cy, 0);
|
||||
result.multiply(Matrix4.diagonal3Values(scale, scale, 1.0));
|
||||
result.multiply(Matrix4.translationValues(-cx, -cy, 0));
|
||||
result.multiply(controller.value);
|
||||
controller.value = result;
|
||||
}
|
||||
|
||||
void _fitToView() {
|
||||
if (canvasSize.isEmpty) return;
|
||||
final scaleX = viewportSize.width / canvasSize.width;
|
||||
final scaleY = viewportSize.height / canvasSize.height;
|
||||
final scale = (scaleX < scaleY ? scaleX : scaleY).clamp(_minScale, _maxScale);
|
||||
final tx = (viewportSize.width - canvasSize.width * scale) / 2;
|
||||
final ty = (viewportSize.height - canvasSize.height * scale) / 2;
|
||||
// Build: T(tx,ty,0) * Scale(s,s,1) — maps canvas (0,0) → (tx,ty) on screen
|
||||
final result = Matrix4.translationValues(tx, ty, 0);
|
||||
result.multiply(Matrix4.diagonal3Values(scale, scale, 1.0));
|
||||
controller.value = result;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Material(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ZoomButton(
|
||||
icon: Icons.add,
|
||||
tooltip: 'Zoom in',
|
||||
onTap: () => _applyZoom(_zoomIn),
|
||||
),
|
||||
_Divider(color: cs.outlineVariant.withValues(alpha: 0.4)),
|
||||
_ZoomButton(
|
||||
icon: Icons.remove,
|
||||
tooltip: 'Zoom out',
|
||||
onTap: () => _applyZoom(_zoomOut),
|
||||
),
|
||||
_Divider(color: cs.outlineVariant.withValues(alpha: 0.4)),
|
||||
_ZoomButton(
|
||||
icon: Icons.fit_screen_outlined,
|
||||
tooltip: 'Fit to view',
|
||||
onTap: _fitToView,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ZoomButton extends StatelessWidget {
|
||||
const _ZoomButton({
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Icon(icon, size: 18, color: cs.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Divider extends StatelessWidget {
|
||||
const _Divider({required this.color});
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Divider(height: 1, color: color),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import '../../models/notification_item.dart';
|
||||
import '../../services/notification_service.dart';
|
||||
|
||||
import '../../providers/notifications_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
@@ -37,8 +37,105 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
||||
Future<void> _checkChannel() async {
|
||||
final muted = await NotificationService.isHighPriorityChannelMuted();
|
||||
if (!mounted) return;
|
||||
if (muted) {
|
||||
setState(() => _showBanner = true);
|
||||
if (muted) setState(() => _showBanner = true);
|
||||
}
|
||||
|
||||
String _relativeTime(DateTime dt) {
|
||||
final diff = DateTime.now().difference(dt.toLocal());
|
||||
if (diff.inMinutes < 1) return 'Just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
if (diff.inDays == 1) return 'Yesterday';
|
||||
return '${diff.inDays}d ago';
|
||||
}
|
||||
|
||||
String _dateGroup(DateTime dt) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final yesterday = today.subtract(const Duration(days: 1));
|
||||
final local = dt.toLocal();
|
||||
final d = DateTime(local.year, local.month, local.day);
|
||||
if (d == today) return 'Today';
|
||||
if (d == yesterday) return 'Yesterday';
|
||||
return 'Earlier';
|
||||
}
|
||||
|
||||
Color _iconContainerColor(BuildContext context, String type) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return switch (type) {
|
||||
'mention' || 'announcement' || 'announcement_comment' =>
|
||||
cs.secondaryContainer,
|
||||
'assignment' || 'created' || 'isr_assigned' => cs.primaryContainer,
|
||||
_ => cs.tertiaryContainer,
|
||||
};
|
||||
}
|
||||
|
||||
Color _iconForegroundColor(BuildContext context, String type) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return switch (type) {
|
||||
'mention' || 'announcement' || 'announcement_comment' =>
|
||||
cs.onSecondaryContainer,
|
||||
'assignment' || 'created' || 'isr_assigned' => cs.onPrimaryContainer,
|
||||
_ => cs.onTertiaryContainer,
|
||||
};
|
||||
}
|
||||
|
||||
String _notificationTitle(String type, String actorName) {
|
||||
return switch (type) {
|
||||
'assignment' => '$actorName assigned you',
|
||||
'created' => '$actorName created a new item',
|
||||
'swap_request' => '$actorName requested a shift swap',
|
||||
'swap_update' => '$actorName updated a swap request',
|
||||
'announcement' => '$actorName posted an announcement',
|
||||
'announcement_comment' => '$actorName commented on an announcement',
|
||||
'it_job_reminder' => 'IT Job submission reminder',
|
||||
'isr_approved' => 'IT Service Request approved',
|
||||
'isr_status_changed' => 'IT Service Request status updated',
|
||||
'isr_assigned' => '$actorName assigned you an IT Service Request',
|
||||
_ => '$actorName mentioned you',
|
||||
};
|
||||
}
|
||||
|
||||
IconData _notificationIcon(String type) {
|
||||
return switch (type) {
|
||||
'assignment' => Icons.assignment_ind_outlined,
|
||||
'created' => Icons.campaign_outlined,
|
||||
'swap_request' => Icons.swap_horiz,
|
||||
'swap_update' => Icons.update,
|
||||
'announcement' => Icons.campaign,
|
||||
'announcement_comment' => Icons.comment_outlined,
|
||||
'it_job_reminder' => Icons.print,
|
||||
'isr_approved' => Icons.check_circle_outline,
|
||||
'isr_status_changed' => Icons.sync_alt_outlined,
|
||||
'isr_assigned' => Icons.engineering_outlined,
|
||||
_ => Icons.alternate_email,
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _handleTap(BuildContext context, NotificationItem item) async {
|
||||
final ticketId = item.ticketId;
|
||||
final taskId = item.taskId;
|
||||
final isrId = item.itServiceRequestId;
|
||||
|
||||
if (ticketId != null) {
|
||||
await ref
|
||||
.read(notificationsControllerProvider)
|
||||
.markReadForTicket(ticketId);
|
||||
} else if (taskId != null) {
|
||||
await ref.read(notificationsControllerProvider).markReadForTask(taskId);
|
||||
} else if (item.isUnread) {
|
||||
await ref.read(notificationsControllerProvider).markRead(item.id);
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
if (item.announcementId != null) {
|
||||
context.go('/announcements');
|
||||
} else if (taskId != null) {
|
||||
context.go('/tasks/$taskId');
|
||||
} else if (ticketId != null) {
|
||||
context.go('/tickets/$ticketId');
|
||||
} else if (isrId != null) {
|
||||
context.go('/it-service-requests/$isrId');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,23 +147,35 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
||||
final tasksAsync = ref.watch(tasksProvider);
|
||||
|
||||
final profileById = {
|
||||
for (final profile in profilesAsync.valueOrNull ?? [])
|
||||
profile.id: profile,
|
||||
for (final p in profilesAsync.valueOrNull ?? []) p.id: p,
|
||||
};
|
||||
final ticketById = {
|
||||
for (final ticket in ticketsAsync.valueOrNull ?? []) ticket.id: ticket,
|
||||
for (final t in ticketsAsync.valueOrNull ?? []) t.id: t,
|
||||
};
|
||||
final taskById = {
|
||||
for (final task in tasksAsync.valueOrNull ?? []) task.id: task,
|
||||
for (final t in tasksAsync.valueOrNull ?? []) t.id: t,
|
||||
};
|
||||
|
||||
final hasUnread =
|
||||
notificationsAsync.valueOrNull?.any((n) => n.isUnread) ?? false;
|
||||
|
||||
return ResponsiveBody(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const AppPageHeader(
|
||||
AppPageHeader(
|
||||
title: 'Notifications',
|
||||
subtitle: 'Updates and mentions across tasks and tickets',
|
||||
actions: hasUnread
|
||||
? [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.done_all_outlined),
|
||||
tooltip: 'Mark all as read',
|
||||
onPressed: () =>
|
||||
ref.read(notificationsControllerProvider).markAllRead(),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
if (_showBanner && !_dismissed)
|
||||
Padding(
|
||||
@@ -98,94 +207,65 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
||||
"You'll see updates here when something needs your attention.",
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final actorName = item.actorId == null
|
||||
? 'System'
|
||||
: (profileById[item.actorId]?.fullName ??
|
||||
|
||||
// Build a flat list interleaving section-header strings and items.
|
||||
// Parallel animIndexes tracks the per-item animation delay index
|
||||
// (-1 for headers which don't animate independently).
|
||||
final entries = <Object>[];
|
||||
final animIndexes = <int>[];
|
||||
int ni = 0;
|
||||
String? lastGroup;
|
||||
for (final item in items) {
|
||||
final group = _dateGroup(item.createdAt);
|
||||
if (group != lastGroup) {
|
||||
entries.add(group);
|
||||
animIndexes.add(-1);
|
||||
lastGroup = group;
|
||||
}
|
||||
entries.add(item);
|
||||
animIndexes.add(ni++);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(notificationsProvider),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
itemCount: entries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
if (entry is String) {
|
||||
return _SectionHeader(label: entry);
|
||||
}
|
||||
final item = entry as NotificationItem;
|
||||
final actorName = item.actorId == null
|
||||
? 'System'
|
||||
: (profileById[item.actorId]?.fullName ??
|
||||
item.actorId!);
|
||||
final ticketSubject = item.ticketId == null
|
||||
? 'Ticket'
|
||||
: (ticketById[item.ticketId]?.subject ??
|
||||
item.ticketId!);
|
||||
final taskTitle = item.taskId == null
|
||||
? 'Task'
|
||||
: (taskById[item.taskId]?.title ?? item.taskId!);
|
||||
final subtitle = item.announcementId != null
|
||||
? 'Announcement'
|
||||
: item.taskId != null
|
||||
? taskTitle
|
||||
: ticketSubject;
|
||||
|
||||
final title = _notificationTitle(item.type, actorName);
|
||||
final icon = _notificationIcon(item.type);
|
||||
|
||||
Future<void> handleTap() async {
|
||||
final ticketId = item.ticketId;
|
||||
final taskId = item.taskId;
|
||||
if (ticketId != null) {
|
||||
await ref
|
||||
.read(notificationsControllerProvider)
|
||||
.markReadForTicket(ticketId);
|
||||
} else if (taskId != null) {
|
||||
await ref
|
||||
.read(notificationsControllerProvider)
|
||||
.markReadForTask(taskId);
|
||||
} else if (item.isUnread) {
|
||||
await ref
|
||||
.read(notificationsControllerProvider)
|
||||
.markRead(item.id);
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
if (item.announcementId != null) {
|
||||
context.go('/announcements');
|
||||
} else if (taskId != null) {
|
||||
context.go('/tasks/$taskId');
|
||||
} else if (ticketId != null) {
|
||||
context.go('/tickets/$ticketId');
|
||||
}
|
||||
}
|
||||
|
||||
return M3FadeSlideIn(
|
||||
delay: Duration(
|
||||
milliseconds: index.clamp(0, 6) * 50,
|
||||
),
|
||||
child: M3PressScale(
|
||||
onTap: handleTap,
|
||||
child: Card(
|
||||
shape: AppSurfaces.of(context).compactShape,
|
||||
child: ListTile(
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(subtitle),
|
||||
const SizedBox(height: 4),
|
||||
if (item.ticketId != null)
|
||||
MonoText('Ticket ${item.ticketId}')
|
||||
else if (item.taskId != null)
|
||||
MonoText('Task ${item.taskId}'),
|
||||
],
|
||||
),
|
||||
trailing: item.isUnread
|
||||
? Icon(
|
||||
Icons.circle,
|
||||
size: 10,
|
||||
color:
|
||||
Theme.of(context).colorScheme.error,
|
||||
)
|
||||
: null,
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _NotificationCard(
|
||||
item: item,
|
||||
animDelay: Duration(
|
||||
milliseconds: animIndexes[index].clamp(0, 6) * 50,
|
||||
),
|
||||
title: _notificationTitle(item.type, actorName),
|
||||
icon: _notificationIcon(item.type),
|
||||
iconContainerColor:
|
||||
_iconContainerColor(context, item.type),
|
||||
iconForegroundColor:
|
||||
_iconForegroundColor(context, item.type),
|
||||
taskTitle: item.taskId == null
|
||||
? null
|
||||
: taskById[item.taskId]?.title,
|
||||
ticketSubject: item.ticketId == null
|
||||
? null
|
||||
: ticketById[item.ticketId]?.subject,
|
||||
relativeTime: _relativeTime(item.createdAt),
|
||||
onTap: () => _handleTap(context, item),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
@@ -199,48 +279,160 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _notificationTitle(String type, String actorName) {
|
||||
switch (type) {
|
||||
case 'assignment':
|
||||
return '$actorName assigned you';
|
||||
case 'created':
|
||||
return '$actorName created a new item';
|
||||
case 'swap_request':
|
||||
return '$actorName requested a shift swap';
|
||||
case 'swap_update':
|
||||
return '$actorName updated a swap request';
|
||||
case 'announcement':
|
||||
return '$actorName posted an announcement';
|
||||
case 'announcement_comment':
|
||||
return '$actorName commented on an announcement';
|
||||
case 'it_job_reminder':
|
||||
return 'IT Job submission reminder';
|
||||
case 'mention':
|
||||
default:
|
||||
return '$actorName mentioned you';
|
||||
}
|
||||
}
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.label});
|
||||
|
||||
IconData _notificationIcon(String type) {
|
||||
switch (type) {
|
||||
case 'assignment':
|
||||
return Icons.assignment_ind_outlined;
|
||||
case 'created':
|
||||
return Icons.campaign_outlined;
|
||||
case 'swap_request':
|
||||
return Icons.swap_horiz;
|
||||
case 'swap_update':
|
||||
return Icons.update;
|
||||
case 'announcement':
|
||||
return Icons.campaign;
|
||||
case 'announcement_comment':
|
||||
return Icons.comment_outlined;
|
||||
case 'it_job_reminder':
|
||||
return Icons.print;
|
||||
case 'mention':
|
||||
default:
|
||||
return Icons.alternate_email;
|
||||
}
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NotificationCard extends StatelessWidget {
|
||||
const _NotificationCard({
|
||||
required this.item,
|
||||
required this.animDelay,
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.iconContainerColor,
|
||||
required this.iconForegroundColor,
|
||||
required this.taskTitle,
|
||||
required this.ticketSubject,
|
||||
required this.relativeTime,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final NotificationItem item;
|
||||
final Duration animDelay;
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final Color iconContainerColor;
|
||||
final Color iconForegroundColor;
|
||||
final String? taskTitle;
|
||||
final String? ticketSubject;
|
||||
final String relativeTime;
|
||||
final VoidCallback onTap;
|
||||
|
||||
String get _subtitle {
|
||||
if (item.announcementId != null) return 'Announcement';
|
||||
if (item.taskId != null) return taskTitle ?? 'Task';
|
||||
if (item.itServiceRequestId != null) return 'IT Service Request';
|
||||
return ticketSubject ?? 'Ticket';
|
||||
}
|
||||
|
||||
// Shows a short monospace reference only when no human-readable label exists.
|
||||
String? get _refLabel {
|
||||
if (item.itServiceRequestId != null) {
|
||||
return 'ISR #${item.itServiceRequestId!.substring(0, 8)}';
|
||||
}
|
||||
if (item.ticketId != null && ticketSubject == null) {
|
||||
return 'Ticket #${item.ticketId!.substring(0, 8)}';
|
||||
}
|
||||
if (item.taskId != null && taskTitle == null) {
|
||||
return 'Task #${item.taskId!.substring(0, 8)}';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cardColor =
|
||||
item.isUnread ? cs.primaryContainer.withValues(alpha: 0.28) : null;
|
||||
|
||||
return M3FadeSlideIn(
|
||||
delay: animDelay,
|
||||
child: M3PressScale(
|
||||
onTap: onTap,
|
||||
child: Card(
|
||||
color: cardColor,
|
||||
shape: AppSurfaces.of(context).compactShape,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Tonal icon container keyed by notification category
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: iconContainerColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: 20, color: iconForegroundColor),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: tt.bodyMedium?.copyWith(
|
||||
fontWeight: item.isUnread
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_subtitle,
|
||||
style: tt.bodySmall
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (_refLabel != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
MonoText(_refLabel!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Timestamp + unread indicator
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
relativeTime,
|
||||
style:
|
||||
tt.labelSmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
if (item.isUnread) ...[
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:isolate';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
@@ -72,22 +71,19 @@ class ReportPdfExport {
|
||||
final generated = AppTime.formatDate(AppTime.now());
|
||||
final dateLabel = dateRange.label;
|
||||
|
||||
// ── Build the PDF on a background isolate so it doesn't freeze the UI ──
|
||||
return Isolate.run(() {
|
||||
return _buildPdfBytes(
|
||||
chartImages: chartImages,
|
||||
dateRangeText: dateRangeText,
|
||||
dateLabel: dateLabel,
|
||||
generated: generated,
|
||||
regularFontData: regularFontData.buffer.asUint8List(),
|
||||
boldFontData: boldFontData.buffer.asUint8List(),
|
||||
logoBytes: logoBytes,
|
||||
);
|
||||
});
|
||||
// ── Build the PDF on the main thread (dart:isolate is not supported on web) ──
|
||||
return _buildPdfBytes(
|
||||
chartImages: chartImages,
|
||||
dateRangeText: dateRangeText,
|
||||
dateLabel: dateLabel,
|
||||
generated: generated,
|
||||
regularFontData: regularFontData.buffer.asUint8List(),
|
||||
boldFontData: boldFontData.buffer.asUint8List(),
|
||||
logoBytes: logoBytes,
|
||||
);
|
||||
}
|
||||
|
||||
/// Pure function that assembles the PDF document from raw data.
|
||||
/// Designed to run inside [Isolate.run].
|
||||
static Future<Uint8List> _buildPdfBytes({
|
||||
required Map<String, Uint8List> chartImages,
|
||||
required String dateRangeText,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,9 +30,9 @@ import '../../utils/app_time.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
import '../../utils/subject_suggestions.dart';
|
||||
import '../../widgets/app_breakpoints.dart';
|
||||
import '../../widgets/office_picker.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/status_pill.dart';
|
||||
import '../../theme/app_surfaces.dart';
|
||||
import '../../widgets/sync_pending_badge.dart';
|
||||
import '../../widgets/task_assignment_section.dart';
|
||||
@@ -125,6 +125,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
bool _actionSaving = false;
|
||||
bool _actionSaved = false;
|
||||
bool _actionProcessing = false;
|
||||
bool _isUpdatingStatus = false;
|
||||
bool _pauseActionInFlight = false;
|
||||
Timer? _elapsedTicker;
|
||||
DateTime _elapsedNow = AppTime.now();
|
||||
@@ -458,62 +459,82 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
const _TaskPendingBanner(),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
// ── Hero zone ────────────────────────────────────────────────
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer
|
||||
.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
task.title.isNotEmpty
|
||||
? task.title
|
||||
: 'Task ${task.taskNumber ?? task.id}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
task.title.isNotEmpty
|
||||
? task.title
|
||||
: 'Task ${task.taskNumber ?? task.id}',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
SyncPendingBadge(
|
||||
isPending: isPending,
|
||||
compact: true,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Builder(
|
||||
builder: (ctx) {
|
||||
final profile = profileAsync.maybeWhen(
|
||||
data: (p) => p,
|
||||
orElse: () => null,
|
||||
);
|
||||
final canEdit =
|
||||
profile != null &&
|
||||
(profile.role == 'admin' ||
|
||||
profile.role == 'dispatcher' ||
|
||||
profile.role == 'it_staff' ||
|
||||
profile.id == task.creatorId);
|
||||
if (!canEdit) return const SizedBox.shrink();
|
||||
return IconButton(
|
||||
tooltip: 'Edit task',
|
||||
onPressed: () =>
|
||||
_showEditTaskDialog(ctx, ref, task),
|
||||
icon: const Icon(Icons.edit),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
SyncPendingBadge(isPending: isPending, compact: true),
|
||||
const SizedBox(width: 8),
|
||||
Builder(
|
||||
builder: (ctx) {
|
||||
final profile = profileAsync.maybeWhen(
|
||||
data: (p) => p,
|
||||
orElse: () => null,
|
||||
);
|
||||
final canEdit =
|
||||
profile != null &&
|
||||
(profile.role == 'admin' ||
|
||||
profile.role == 'dispatcher' ||
|
||||
profile.role == 'it_staff' ||
|
||||
profile.id == task.creatorId);
|
||||
if (!canEdit) return const SizedBox.shrink();
|
||||
return IconButton(
|
||||
tooltip: 'Edit task',
|
||||
onPressed: () =>
|
||||
_showEditTaskDialog(ctx, ref, task),
|
||||
icon: const Icon(Icons.edit),
|
||||
);
|
||||
},
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_createdByLabel(profilesAsync, task, ticket),
|
||||
style: Theme.of(context).textTheme.labelMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_createdByLabel(profilesAsync, task, ticket),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
),
|
||||
// ── Meta strip ───────────────────────────────────────────────
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_buildStatusChip(
|
||||
@@ -522,8 +543,13 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
canUpdateStatus,
|
||||
hasAssignedItStaff,
|
||||
),
|
||||
_MetaBadge(label: 'Office', value: officeName),
|
||||
_MetaBadge(
|
||||
icon: Icons.business_rounded,
|
||||
label: 'Office',
|
||||
value: officeName,
|
||||
),
|
||||
_MetaBadge(
|
||||
icon: Icons.tag_rounded,
|
||||
label: 'Task #',
|
||||
value: isPending && task.taskNumber == null
|
||||
? 'Pending'
|
||||
@@ -586,7 +612,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
);
|
||||
} catch (_) {}
|
||||
},
|
||||
icon: const Icon(Icons.print),
|
||||
icon: const Icon(Icons.print_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -622,7 +648,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
// Collapsible tabbed details: Assignees / Type & Category / Signatories
|
||||
ExpansionTile(
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
child: ExpansionTile(
|
||||
title: const Text('Details'),
|
||||
initiallyExpanded: isWide,
|
||||
childrenPadding: const EdgeInsets.symmetric(horizontal: 0),
|
||||
@@ -2647,6 +2677,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2683,8 +2715,29 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
labelColor: Theme.of(context).colorScheme.onSurface,
|
||||
indicatorColor: Theme.of(context).colorScheme.primary,
|
||||
tabs: const [
|
||||
Tab(text: 'Chat'),
|
||||
Tab(text: 'Activity'),
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline_rounded,
|
||||
size: 15,
|
||||
),
|
||||
SizedBox(width: 6),
|
||||
Text('Chat'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.timeline_rounded, size: 15),
|
||||
SizedBox(width: 6),
|
||||
Text('Activity'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -2789,62 +2842,86 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
).textTheme.labelMedium,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
decoration:
|
||||
const InputDecoration(
|
||||
hintText: 'Message...',
|
||||
),
|
||||
textInputAction:
|
||||
TextInputAction.send,
|
||||
enabled: canSendMessages,
|
||||
onChanged: (_) =>
|
||||
_handleComposerChanged(
|
||||
profilesAsync
|
||||
.valueOrNull ??
|
||||
[],
|
||||
ref.read(
|
||||
currentUserIdProvider,
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
borderRadius:
|
||||
BorderRadius.circular(28),
|
||||
),
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 4,
|
||||
top: 2,
|
||||
bottom: 2,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller:
|
||||
_messageController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Message...',
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding:
|
||||
const EdgeInsets
|
||||
.symmetric(
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
textInputAction:
|
||||
TextInputAction.send,
|
||||
enabled: canSendMessages,
|
||||
onChanged: (_) =>
|
||||
_handleComposerChanged(
|
||||
profilesAsync
|
||||
.valueOrNull ??
|
||||
[],
|
||||
ref.read(
|
||||
currentUserIdProvider,
|
||||
),
|
||||
canSendMessages,
|
||||
typingChannelId,
|
||||
),
|
||||
canSendMessages,
|
||||
typingChannelId,
|
||||
),
|
||||
onSubmitted: (_) =>
|
||||
_handleSendMessage(
|
||||
task,
|
||||
profilesAsync
|
||||
.valueOrNull ??
|
||||
[],
|
||||
ref.read(
|
||||
currentUserIdProvider,
|
||||
onSubmitted: (_) =>
|
||||
_handleSendMessage(
|
||||
task,
|
||||
profilesAsync
|
||||
.valueOrNull ??
|
||||
[],
|
||||
ref.read(
|
||||
currentUserIdProvider,
|
||||
),
|
||||
canSendMessages,
|
||||
typingChannelId,
|
||||
),
|
||||
canSendMessages,
|
||||
typingChannelId,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
IconButton(
|
||||
tooltip: 'Send',
|
||||
onPressed: canSendMessages
|
||||
? () => _handleSendMessage(
|
||||
task,
|
||||
profilesAsync
|
||||
.valueOrNull ??
|
||||
[],
|
||||
ref.read(
|
||||
currentUserIdProvider,
|
||||
),
|
||||
canSendMessages,
|
||||
typingChannelId,
|
||||
)
|
||||
: null,
|
||||
icon: const Icon(Icons.send),
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
tooltip: 'Send',
|
||||
onPressed: canSendMessages
|
||||
? () =>
|
||||
_handleSendMessage(
|
||||
task,
|
||||
profilesAsync
|
||||
.valueOrNull ??
|
||||
[],
|
||||
ref.read(
|
||||
currentUserIdProvider,
|
||||
),
|
||||
canSendMessages,
|
||||
typingChannelId,
|
||||
)
|
||||
: null,
|
||||
icon: const Icon(
|
||||
Icons.send_rounded,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -2969,7 +3046,36 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
List<Profile> profiles,
|
||||
) {
|
||||
if (messages.isEmpty) {
|
||||
return const Center(child: Text('No messages yet.'));
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline_rounded,
|
||||
size: 44,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withValues(alpha: 0.35),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'No messages yet',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Be the first to send a message',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final profileById = {for (final profile in profiles) profile.id: profile};
|
||||
final currentUserId = ref.read(currentUserIdProvider);
|
||||
@@ -3276,34 +3382,55 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
DateTime at, {
|
||||
IconData icon = Icons.circle,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final theme = Theme.of(context);
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 28,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
width: 32,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: Theme.of(context).textTheme.bodyMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$actor • ${AppTime.formatDate(at)} ${AppTime.formatTime(at)}',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: 14, color: cs.onPrimaryContainer),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: 1.5,
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
color: cs.outlineVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Text(title, style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'$actor • ${AppTime.formatDate(at)} ${AppTime.formatTime(at)}',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -3866,7 +3993,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
return AlertDialog(
|
||||
shape: dialogShape,
|
||||
title: const Text('Edit Task'),
|
||||
content: SingleChildScrollView(
|
||||
content: SizedBox(
|
||||
width: 600,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -4034,22 +4163,10 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
b.name.toLowerCase(),
|
||||
),
|
||||
);
|
||||
return DropdownButtonFormField<String?>(
|
||||
initialValue: selectedOffice,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Office',
|
||||
),
|
||||
items: [
|
||||
const DropdownMenuItem(
|
||||
value: null,
|
||||
child: Text('Unassigned'),
|
||||
),
|
||||
for (final o in officesSorted)
|
||||
DropdownMenuItem(
|
||||
value: o.id,
|
||||
child: Text(o.name),
|
||||
),
|
||||
],
|
||||
return OfficeSelectorField(
|
||||
offices: officesSorted,
|
||||
selectedOfficeId: selectedOffice,
|
||||
allowUnassigned: true,
|
||||
onChanged: saving
|
||||
? null
|
||||
: (v) => setDialogState(
|
||||
@@ -4068,6 +4185,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: saving
|
||||
@@ -4238,9 +4356,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
bool canUpdateStatus,
|
||||
bool hasAssignedItStaff,
|
||||
) {
|
||||
final chip = StatusPill(
|
||||
label: task.status.toUpperCase(),
|
||||
isEmphasized: task.status != 'queued',
|
||||
final chip = _TaskStatusChip(
|
||||
status: task.status,
|
||||
isLoading: _isUpdatingStatus,
|
||||
);
|
||||
|
||||
final isTerminal =
|
||||
@@ -4404,9 +4522,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
return;
|
||||
}
|
||||
|
||||
// Update DB only — Supabase realtime stream will emit the
|
||||
// updated task list, so explicit invalidation here causes a
|
||||
// visible loading/refresh and is unnecessary.
|
||||
if (mounted) setState(() => _isUpdatingStatus = true);
|
||||
try {
|
||||
await ref
|
||||
.read(tasksControllerProvider)
|
||||
@@ -4421,6 +4537,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
showErrorSnackBarGlobal(e.toString());
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isUpdatingStatus = false);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => statusOptions
|
||||
@@ -4684,30 +4802,106 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
// PDF preview/building moved to `task_pdf.dart`.
|
||||
}
|
||||
|
||||
class _MetaBadge extends StatelessWidget {
|
||||
const _MetaBadge({required this.label, required this.value, this.isMono});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final bool? isMono;
|
||||
class _TaskStatusChip extends StatelessWidget {
|
||||
const _TaskStatusChip({required this.status, this.isLoading = false});
|
||||
final String status;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final border = Theme.of(context).colorScheme.outlineVariant;
|
||||
final background = Theme.of(context).colorScheme.surfaceContainerLow;
|
||||
final textStyle = Theme.of(context).textTheme.labelSmall;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final (icon, bg, fg) = switch (status) {
|
||||
'queued' => (
|
||||
Icons.inbox_rounded,
|
||||
cs.secondaryContainer,
|
||||
cs.onSecondaryContainer,
|
||||
),
|
||||
'in_progress' => (
|
||||
Icons.play_arrow_rounded,
|
||||
cs.primaryContainer,
|
||||
cs.onPrimaryContainer,
|
||||
),
|
||||
'completed' => (
|
||||
Icons.check_circle_rounded,
|
||||
cs.tertiaryContainer,
|
||||
cs.onTertiaryContainer,
|
||||
),
|
||||
'cancelled' => (
|
||||
Icons.cancel_rounded,
|
||||
cs.errorContainer,
|
||||
cs.onErrorContainer,
|
||||
),
|
||||
_ => (
|
||||
Icons.help_outline_rounded,
|
||||
cs.surfaceContainerHighest,
|
||||
cs.onSurface,
|
||||
),
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: border),
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: textStyle),
|
||||
const SizedBox(width: 6),
|
||||
if (isLoading)
|
||||
SizedBox(
|
||||
width: 13,
|
||||
height: 13,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: fg),
|
||||
)
|
||||
else
|
||||
Icon(icon, size: 13, color: fg),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
status.toUpperCase().replaceAll('_', ' '),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: fg,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetaBadge extends StatelessWidget {
|
||||
const _MetaBadge({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.isMono,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final bool? isMono;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final textStyle = Theme.of(context).textTheme.labelSmall;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: cs.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 12, color: cs.onSurfaceVariant),
|
||||
const SizedBox(width: 5),
|
||||
],
|
||||
Text(label, style: textStyle?.copyWith(color: cs.onSurfaceVariant)),
|
||||
const SizedBox(width: 5),
|
||||
if (isMono == true)
|
||||
MonoText(value, style: textStyle)
|
||||
else
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/notification_item.dart';
|
||||
import '../../models/office.dart';
|
||||
import '../../widgets/office_picker.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/task.dart';
|
||||
import '../../models/task_assignment.dart';
|
||||
@@ -513,6 +514,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||
),
|
||||
TasQColumn<Task>(
|
||||
header: 'Status',
|
||||
hideOnMedium: true,
|
||||
cellBuilder: (context, task) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -532,6 +534,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||
TasQColumn<Task>(
|
||||
header: 'Timestamp',
|
||||
technical: true,
|
||||
hideOnMedium: true,
|
||||
cellBuilder: (context, task) =>
|
||||
Text(_formatTimestamp(task.createdAt)),
|
||||
),
|
||||
@@ -549,7 +552,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||
profileById,
|
||||
latestAssigneeByTaskId[task.id],
|
||||
);
|
||||
final subtitle = _buildSubtitle(officeName, task.status);
|
||||
// Status is shown in the trailing badge; subtitle shows
|
||||
// only the office name to avoid displaying it twice.
|
||||
final subtitle = officeName;
|
||||
final hasMention = _hasTaskMention(
|
||||
notificationsAsync,
|
||||
task,
|
||||
@@ -587,9 +592,24 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||
const SizedBox(height: 2),
|
||||
Text('Assigned: $assigned'),
|
||||
const SizedBox(height: 4),
|
||||
MonoText('ID ${task.taskNumber ?? task.id}'),
|
||||
const SizedBox(height: 2),
|
||||
Text(_formatTimestamp(task.createdAt)),
|
||||
// Metadata: ID · timestamp in a single dimmed row
|
||||
DefaultTextStyle.merge(
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
MonoText(
|
||||
task.taskNumber ?? task.id,
|
||||
),
|
||||
const Text(' · '),
|
||||
Text(_formatTimestamp(task.createdAt)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
@@ -961,28 +981,15 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||
b.name.toLowerCase(),
|
||||
),
|
||||
);
|
||||
selectedOfficeId ??= officesSorted.first.id;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: selectedOfficeId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Office',
|
||||
),
|
||||
items: officesSorted
|
||||
.map(
|
||||
(office) => DropdownMenuItem(
|
||||
value: office.id,
|
||||
child: Text(office.name),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
OfficeSelectorField(
|
||||
offices: officesSorted,
|
||||
selectedOfficeId: selectedOfficeId,
|
||||
onChanged: saving
|
||||
? null
|
||||
: (value) => setState(
|
||||
() => selectedOfficeId = value,
|
||||
),
|
||||
: (id) => setState(() => selectedOfficeId = id),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// optional request metadata inputs
|
||||
@@ -1206,10 +1213,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||
);
|
||||
}
|
||||
|
||||
String _buildSubtitle(String officeName, String status) {
|
||||
final statusLabel = status.toUpperCase();
|
||||
return '$officeName · $statusLabel';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
List<DropdownMenuItem<String?>> _staffOptions(List<Profile>? profiles) {
|
||||
|
||||
@@ -195,26 +195,91 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
||||
officeNamesList.sort(
|
||||
(a, b) => a.toLowerCase().compareTo(b.toLowerCase()),
|
||||
);
|
||||
final officeNames = officeNamesList.join(', ');
|
||||
final members = team.members(teamMembers);
|
||||
final memberNames = members
|
||||
.map((id) => profileById[id]?.fullName ?? id)
|
||||
.join(', ');
|
||||
return ListTile(
|
||||
title: Text(team.name),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Leader: ${leader?.fullName ?? team.leaderId}'),
|
||||
Text('Offices: $officeNames'),
|
||||
Text('Members: $memberNames'),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: actions,
|
||||
),
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
return M3PressScale(
|
||||
onTap: () => _showTeamDialog(context, team: team),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: cs.primaryContainer,
|
||||
child: Text(
|
||||
team.name.isNotEmpty
|
||||
? team.name[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(team.name, style: tt.titleSmall),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
leader?.fullName ?? team.leaderId,
|
||||
style: tt.bodySmall?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (officeNamesList.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
for (final office in officeNamesList)
|
||||
Chip(
|
||||
label: Text(
|
||||
office,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (memberNames.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
memberNames,
|
||||
style: tt.bodySmall?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: actions,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
rowActions: (team) => [
|
||||
|
||||
@@ -16,9 +16,9 @@ import '../../providers/tickets_provider.dart';
|
||||
import '../../providers/typing_provider.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
import '../../widgets/app_breakpoints.dart';
|
||||
import '../../widgets/office_picker.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/status_pill.dart';
|
||||
import '../../widgets/task_assignment_section.dart';
|
||||
import '../../widgets/typing_dots.dart';
|
||||
import '../../theme/app_surfaces.dart';
|
||||
@@ -38,6 +38,7 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
String? _mentionQuery;
|
||||
int? _mentionStart;
|
||||
List<Profile> _mentionResults = [];
|
||||
bool _isUpdatingStatus = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -99,66 +100,83 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
final detailsContent = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
// ── Hero zone ──────────────────────────────────────────────
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer
|
||||
.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
ticket.subject,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
ticket.subject,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
Builder(
|
||||
builder: (ctx) {
|
||||
final profile = currentProfileAsync.maybeWhen(
|
||||
data: (p) => p,
|
||||
orElse: () => null,
|
||||
);
|
||||
final canEdit =
|
||||
profile != null &&
|
||||
(profile.role == 'admin' ||
|
||||
profile.role == 'programmer' ||
|
||||
profile.role == 'dispatcher' ||
|
||||
profile.role == 'it_staff' ||
|
||||
profile.id == ticket.creatorId);
|
||||
if (!canEdit) return const SizedBox.shrink();
|
||||
return IconButton(
|
||||
tooltip: 'Edit ticket',
|
||||
onPressed: () =>
|
||||
_showEditTicketDialog(ctx, ref, ticket),
|
||||
icon: const Icon(Icons.edit),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Builder(
|
||||
builder: (ctx) {
|
||||
final profile = currentProfileAsync.maybeWhen(
|
||||
data: (p) => p,
|
||||
orElse: () => null,
|
||||
);
|
||||
final canEdit =
|
||||
profile != null &&
|
||||
(profile.role == 'admin' ||
|
||||
profile.role == 'programmer' ||
|
||||
profile.role == 'dispatcher' ||
|
||||
profile.role == 'it_staff' ||
|
||||
profile.id == ticket.creatorId);
|
||||
if (!canEdit) return const SizedBox.shrink();
|
||||
return IconButton(
|
||||
tooltip: 'Edit ticket',
|
||||
onPressed: () =>
|
||||
_showEditTicketDialog(ctx, ref, ticket),
|
||||
icon: const Icon(Icons.edit),
|
||||
);
|
||||
},
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_filedByLabel(profilesAsync, ticket),
|
||||
style: Theme.of(context).textTheme.labelMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_filedByLabel(profilesAsync, ticket),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
// ── Meta strip ─────────────────────────────────────────────
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_buildStatusChip(context, ref, ticket, canPromote),
|
||||
_MetaBadge(
|
||||
icon: Icons.business_rounded,
|
||||
label: 'Office',
|
||||
value: _officeLabel(officesAsync, ticket),
|
||||
),
|
||||
_MetaBadge(
|
||||
icon: Icons.confirmation_number_rounded,
|
||||
label: 'Ticket ID',
|
||||
value: ticket.id,
|
||||
isMono: true,
|
||||
@@ -167,37 +185,51 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// collapse the rest of the details so tall chat areas won't push off-screen
|
||||
ExpansionTile(
|
||||
key: const Key('ticket-details-expansion'),
|
||||
title: const Text('Details'),
|
||||
initiallyExpanded: true,
|
||||
childrenPadding: const EdgeInsets.only(top: 8),
|
||||
children: [
|
||||
Text(ticket.description),
|
||||
const SizedBox(height: 12),
|
||||
_buildTatRow(context, ticket, effectiveRespondedAt),
|
||||
if (taskForTicket != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TaskAssignmentSection(
|
||||
taskId: taskForTicket.id,
|
||||
canAssign: showAssign,
|
||||
),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
child: ExpansionTile(
|
||||
key: const Key('ticket-details-expansion'),
|
||||
title: const Text('Details'),
|
||||
initiallyExpanded: true,
|
||||
childrenPadding: const EdgeInsets.only(top: 8),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(ticket.description),
|
||||
const SizedBox(height: 12),
|
||||
_buildTatRow(context, ticket, effectiveRespondedAt),
|
||||
if (taskForTicket != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TaskAssignmentSection(
|
||||
taskId: taskForTicket.id,
|
||||
canAssign: showAssign,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: 'Open task',
|
||||
onPressed: () =>
|
||||
context.go('/tasks/${taskForTicket.id}'),
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: 'Open task',
|
||||
onPressed: () =>
|
||||
context.go('/tasks/${taskForTicket.id}'),
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -232,7 +264,46 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
child: messagesAsync.when(
|
||||
data: (messages) {
|
||||
if (messages.isEmpty) {
|
||||
return const Center(child: Text('No messages yet.'));
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline_rounded,
|
||||
size: 44,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant
|
||||
.withValues(alpha: 0.35),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'No messages yet',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Be the first to send a message',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final profileById = {
|
||||
for (final profile in profilesAsync.valueOrNull ?? [])
|
||||
@@ -391,47 +462,66 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Message...',
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 4,
|
||||
top: 2,
|
||||
bottom: 2,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Message...',
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
enabled: canSendMessages,
|
||||
textInputAction: TextInputAction.send,
|
||||
onChanged: canSendMessages
|
||||
? (_) => _handleComposerChanged(
|
||||
profilesAsync.valueOrNull ?? [],
|
||||
currentUserId,
|
||||
canSendMessages,
|
||||
)
|
||||
: null,
|
||||
onSubmitted: canSendMessages
|
||||
? (_) => _handleSendMessage(
|
||||
ref,
|
||||
profilesAsync.valueOrNull ?? [],
|
||||
currentUserId,
|
||||
canSendMessages,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
enabled: canSendMessages,
|
||||
textInputAction: TextInputAction.send,
|
||||
onChanged: canSendMessages
|
||||
? (_) => _handleComposerChanged(
|
||||
profilesAsync.valueOrNull ?? [],
|
||||
currentUserId,
|
||||
canSendMessages,
|
||||
)
|
||||
: null,
|
||||
onSubmitted: canSendMessages
|
||||
? (_) => _handleSendMessage(
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Send',
|
||||
onPressed: canSendMessages
|
||||
? () => _handleSendMessage(
|
||||
ref,
|
||||
profilesAsync.valueOrNull ?? [],
|
||||
currentUserId,
|
||||
canSendMessages,
|
||||
)
|
||||
: null,
|
||||
icon: const Icon(Icons.send_rounded),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
IconButton(
|
||||
tooltip: 'Send',
|
||||
onPressed: canSendMessages
|
||||
? () => _handleSendMessage(
|
||||
ref,
|
||||
profilesAsync.valueOrNull ?? [],
|
||||
currentUserId,
|
||||
canSendMessages,
|
||||
)
|
||||
: null,
|
||||
icon: const Icon(Icons.send),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -888,19 +978,172 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
final descCtrl = TextEditingController(text: ticket.description);
|
||||
String? selectedOffice = ticket.officeId;
|
||||
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
var saving = false;
|
||||
return StatefulBuilder(
|
||||
builder: (dialogBuilderContext, setDialogState) {
|
||||
return AlertDialog(
|
||||
shape: dialogShape,
|
||||
title: const Text('Edit Ticket'),
|
||||
content: SingleChildScrollView(
|
||||
final isWide =
|
||||
MediaQuery.sizeOf(context).width >= AppBreakpoints.tablet;
|
||||
|
||||
if (isWide) {
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
var saving = false;
|
||||
return StatefulBuilder(
|
||||
builder: (dialogBuilderContext, setDialogState) {
|
||||
return AlertDialog(
|
||||
shape: dialogShape,
|
||||
title: const Text('Edit Ticket'),
|
||||
content: SizedBox(
|
||||
width: 600,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: subjectCtrl,
|
||||
enabled: !saving,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Subject',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: descCtrl,
|
||||
enabled: !saving,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description',
|
||||
),
|
||||
maxLines: 4,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
officesAsync.when(
|
||||
data: (offices) {
|
||||
final officesSorted = List<Office>.from(offices)
|
||||
..sort(
|
||||
(a, b) => a.name.toLowerCase().compareTo(
|
||||
b.name.toLowerCase(),
|
||||
),
|
||||
);
|
||||
return OfficeSelectorField(
|
||||
offices: officesSorted,
|
||||
selectedOfficeId: selectedOffice,
|
||||
allowUnassigned: true,
|
||||
onChanged: saving
|
||||
? null
|
||||
: (v) =>
|
||||
setDialogState(() => selectedOffice = v),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (error, stack) => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () async {
|
||||
final subject = subjectCtrl.text.trim();
|
||||
final desc = descCtrl.text.trim();
|
||||
setDialogState(() => saving = true);
|
||||
try {
|
||||
await ref
|
||||
.read(ticketsControllerProvider)
|
||||
.updateTicket(
|
||||
ticketId: ticket.id,
|
||||
subject: subject.isEmpty ? null : subject,
|
||||
description:
|
||||
desc.isEmpty ? null : desc,
|
||||
officeId: selectedOffice,
|
||||
);
|
||||
ref.invalidate(ticketsProvider);
|
||||
ref.invalidate(ticketByIdProvider(ticket.id));
|
||||
if (!dialogContext.mounted ||
|
||||
!screenContext.mounted) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(dialogContext).pop();
|
||||
showSuccessSnackBar(
|
||||
screenContext,
|
||||
'Ticket updated',
|
||||
);
|
||||
} catch (e) {
|
||||
if (!screenContext.mounted) return;
|
||||
if (isOfflineSaveError(e)) {
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
showSuccessSnackBar(
|
||||
screenContext,
|
||||
'Ticket update saved offline — will sync when connected.',
|
||||
);
|
||||
} else {
|
||||
showErrorSnackBar(
|
||||
screenContext,
|
||||
'Failed to update ticket: $e',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (dialogContext.mounted) {
|
||||
setDialogState(() => saving = false);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: saving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await m3ShowBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (sheetContext) {
|
||||
var saving = false;
|
||||
return StatefulBuilder(
|
||||
builder: (ctx, setDialogState) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 24,
|
||||
bottom: MediaQuery.viewInsetsOf(ctx).bottom + 24,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Edit Ticket',
|
||||
style: Theme.of(ctx).textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed:
|
||||
saving ? null : () => Navigator.of(ctx).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: subjectCtrl,
|
||||
enabled: !saving,
|
||||
@@ -924,103 +1167,104 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
b.name.toLowerCase(),
|
||||
),
|
||||
);
|
||||
return DropdownButtonFormField<String?>(
|
||||
initialValue: selectedOffice,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Office',
|
||||
),
|
||||
items: [
|
||||
const DropdownMenuItem(
|
||||
value: null,
|
||||
child: Text('Unassigned'),
|
||||
),
|
||||
for (final o in officesSorted)
|
||||
DropdownMenuItem(
|
||||
value: o.id,
|
||||
child: Text(o.name),
|
||||
),
|
||||
],
|
||||
return OfficeSelectorField(
|
||||
offices: officesSorted,
|
||||
selectedOfficeId: selectedOffice,
|
||||
allowUnassigned: true,
|
||||
onChanged: saving
|
||||
? null
|
||||
: (v) => setDialogState(() => selectedOffice = v),
|
||||
: (v) =>
|
||||
setDialogState(() => selectedOffice = v),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (error, stack) => const SizedBox.shrink(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OverflowBar(
|
||||
alignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () async {
|
||||
final subject = subjectCtrl.text.trim();
|
||||
final desc = descCtrl.text.trim();
|
||||
setDialogState(() => saving = true);
|
||||
try {
|
||||
await ref
|
||||
.read(ticketsControllerProvider)
|
||||
.updateTicket(
|
||||
ticketId: ticket.id,
|
||||
subject: subject.isEmpty
|
||||
? null
|
||||
: subject,
|
||||
description: desc.isEmpty
|
||||
? null
|
||||
: desc,
|
||||
officeId: selectedOffice,
|
||||
);
|
||||
ref.invalidate(ticketsProvider);
|
||||
ref.invalidate(
|
||||
ticketByIdProvider(ticket.id),
|
||||
);
|
||||
if (!ctx.mounted ||
|
||||
!screenContext.mounted) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(ctx).pop();
|
||||
showSuccessSnackBar(
|
||||
screenContext,
|
||||
'Ticket updated',
|
||||
);
|
||||
} catch (e) {
|
||||
if (!screenContext.mounted) return;
|
||||
if (isOfflineSaveError(e)) {
|
||||
if (ctx.mounted) {
|
||||
Navigator.of(ctx).pop();
|
||||
}
|
||||
showSuccessSnackBar(
|
||||
screenContext,
|
||||
'Ticket update saved offline — will sync when connected.',
|
||||
);
|
||||
} else {
|
||||
showErrorSnackBar(
|
||||
screenContext,
|
||||
'Failed to update ticket: $e',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (ctx.mounted) {
|
||||
setDialogState(() => saving = false);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: saving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () async {
|
||||
final subject = subjectCtrl.text.trim();
|
||||
final desc = descCtrl.text.trim();
|
||||
setDialogState(() => saving = true);
|
||||
try {
|
||||
await ref
|
||||
.read(ticketsControllerProvider)
|
||||
.updateTicket(
|
||||
ticketId: ticket.id,
|
||||
subject: subject.isEmpty ? null : subject,
|
||||
description: desc.isEmpty ? null : desc,
|
||||
officeId: selectedOffice,
|
||||
);
|
||||
ref.invalidate(ticketsProvider);
|
||||
ref.invalidate(ticketByIdProvider(ticket.id));
|
||||
if (!dialogContext.mounted ||
|
||||
!screenContext.mounted) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(dialogContext).pop();
|
||||
showSuccessSnackBar(
|
||||
screenContext,
|
||||
'Ticket updated',
|
||||
);
|
||||
} catch (e) {
|
||||
if (!screenContext.mounted) return;
|
||||
if (isOfflineSaveError(e)) {
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
showSuccessSnackBar(
|
||||
screenContext,
|
||||
'Ticket update saved offline — will sync when connected.',
|
||||
);
|
||||
} else {
|
||||
showErrorSnackBar(
|
||||
screenContext,
|
||||
'Failed to update ticket: $e',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (dialogContext.mounted) {
|
||||
setDialogState(() => saving = false);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: saving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _timelineRow(String label, DateTime? value) {
|
||||
@@ -1049,9 +1293,9 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
bool canPromote,
|
||||
) {
|
||||
final isLocked = ticket.status == 'promoted' || ticket.status == 'closed';
|
||||
final chip = StatusPill(
|
||||
label: _statusLabel(ticket.status),
|
||||
isEmphasized: ticket.status != 'pending',
|
||||
final chip = _TicketStatusChip(
|
||||
status: ticket.status,
|
||||
isLoading: _isUpdatingStatus,
|
||||
);
|
||||
|
||||
if (isLocked) {
|
||||
@@ -1064,10 +1308,16 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
|
||||
return PopupMenuButton<String>(
|
||||
onSelected: (value) async {
|
||||
// Rely on the realtime stream to propagate the status change.
|
||||
await ref
|
||||
.read(ticketsControllerProvider)
|
||||
.updateTicketStatus(ticketId: ticket.id, status: value);
|
||||
if (mounted) setState(() => _isUpdatingStatus = true);
|
||||
try {
|
||||
await ref
|
||||
.read(ticketsControllerProvider)
|
||||
.updateTicketStatus(ticketId: ticket.id, status: value);
|
||||
} catch (e) {
|
||||
if (mounted) showErrorSnackBarGlobal(e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _isUpdatingStatus = false);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => availableStatuses
|
||||
.map(
|
||||
@@ -1081,9 +1331,6 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
String _statusLabel(String status) {
|
||||
return status.toUpperCase();
|
||||
}
|
||||
|
||||
String _statusMenuLabel(String status) {
|
||||
return switch (status) {
|
||||
@@ -1103,32 +1350,101 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _MetaBadge extends StatelessWidget {
|
||||
const _MetaBadge({required this.label, required this.value, this.isMono});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final bool? isMono;
|
||||
class _TicketStatusChip extends StatelessWidget {
|
||||
const _TicketStatusChip({required this.status, this.isLoading = false});
|
||||
final String status;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final border = Theme.of(context).colorScheme.outlineVariant;
|
||||
final background = Theme.of(context).colorScheme.surfaceContainerLow;
|
||||
final textStyle = Theme.of(context).textTheme.labelSmall;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final (icon, bg, fg) = switch (status) {
|
||||
'pending' => (
|
||||
Icons.hourglass_empty_rounded,
|
||||
cs.secondaryContainer,
|
||||
cs.onSecondaryContainer,
|
||||
),
|
||||
'promoted' => (
|
||||
Icons.trending_up_rounded,
|
||||
cs.primaryContainer,
|
||||
cs.onPrimaryContainer,
|
||||
),
|
||||
'closed' => (
|
||||
Icons.lock_rounded,
|
||||
cs.tertiaryContainer,
|
||||
cs.onTertiaryContainer,
|
||||
),
|
||||
_ => (
|
||||
Icons.help_outline_rounded,
|
||||
cs.surfaceContainerHighest,
|
||||
cs.onSurface,
|
||||
),
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSurfaces.of(context).compactCardRadius,
|
||||
),
|
||||
border: Border.all(color: border),
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: textStyle),
|
||||
const SizedBox(width: 6),
|
||||
if (isLoading)
|
||||
SizedBox(
|
||||
width: 13,
|
||||
height: 13,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: fg),
|
||||
)
|
||||
else
|
||||
Icon(icon, size: 13, color: fg),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
status.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: fg,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetaBadge extends StatelessWidget {
|
||||
const _MetaBadge({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.isMono,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final bool? isMono;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final textStyle = Theme.of(context).textTheme.labelSmall;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: cs.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 12, color: cs.onSurfaceVariant),
|
||||
const SizedBox(width: 5),
|
||||
],
|
||||
Text(label, style: textStyle?.copyWith(color: cs.onSurfaceVariant)),
|
||||
const SizedBox(width: 5),
|
||||
if (isMono == true)
|
||||
MonoText(value, style: textStyle)
|
||||
else
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:tasq/utils/app_time.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/office.dart';
|
||||
import '../../widgets/office_picker.dart';
|
||||
import '../../models/notification_item.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/ticket.dart';
|
||||
@@ -440,7 +441,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||
) async {
|
||||
final subjectController = TextEditingController();
|
||||
final descriptionController = TextEditingController();
|
||||
Office? selectedOffice;
|
||||
String? selectedOfficeId;
|
||||
|
||||
final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
|
||||
var saving = false;
|
||||
@@ -471,15 +472,10 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||
if (offices.isEmpty) return const Text('No offices assigned.');
|
||||
final officesSorted = List<Office>.from(offices)
|
||||
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
selectedOffice ??= officesSorted.first;
|
||||
return DropdownButtonFormField<Office>(
|
||||
key: ValueKey(selectedOffice?.id),
|
||||
initialValue: selectedOffice,
|
||||
items: officesSorted
|
||||
.map((o) => DropdownMenuItem(value: o, child: Text(o.name)))
|
||||
.toList(),
|
||||
onChanged: saving ? null : (v) => setState(() => selectedOffice = v),
|
||||
decoration: const InputDecoration(labelText: 'Office'),
|
||||
return OfficeSelectorField(
|
||||
offices: officesSorted,
|
||||
selectedOfficeId: selectedOfficeId,
|
||||
onChanged: saving ? null : (id) => setState(() => selectedOfficeId = id),
|
||||
);
|
||||
},
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
@@ -503,7 +499,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||
: () async {
|
||||
final subject = subjectController.text.trim();
|
||||
final description = descriptionController.text.trim();
|
||||
if (subject.isEmpty || description.isEmpty || selectedOffice == null) {
|
||||
if (subject.isEmpty || description.isEmpty || selectedOfficeId == null) {
|
||||
showWarningSnackBar(closeCtx, 'Fill out all fields.');
|
||||
return;
|
||||
}
|
||||
@@ -514,7 +510,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||
.createTicket(
|
||||
subject: subject,
|
||||
description: description,
|
||||
officeId: selectedOffice!.id,
|
||||
officeId: selectedOfficeId!,
|
||||
);
|
||||
if (pendingTicket != null) {
|
||||
final current = ref.read(offlinePendingTicketsProvider);
|
||||
|
||||
@@ -808,7 +808,10 @@ class _ScheduleGeneratorPanelState
|
||||
|
||||
final isRamadan = ref.watch(isRamadanActiveProvider);
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -827,11 +830,9 @@ class _ScheduleGeneratorPanelState
|
||||
Chip(
|
||||
label: const Text('Ramadan'),
|
||||
avatar: const Icon(Icons.nights_stay, size: 16),
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.tertiaryContainer,
|
||||
backgroundColor: colorScheme.tertiaryContainer,
|
||||
labelStyle: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -843,6 +844,14 @@ class _ScheduleGeneratorPanelState
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isGenerating) ...[
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
_dateField(
|
||||
context,
|
||||
@@ -861,15 +870,19 @@ class _ScheduleGeneratorPanelState
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _isGenerating ? null : _handleGenerate,
|
||||
child: _isGenerating
|
||||
icon: _isGenerating
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('Generate preview'),
|
||||
: const Icon(Icons.auto_awesome, size: 18),
|
||||
label: const Text('Generate preview'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -891,22 +904,25 @@ class _ScheduleGeneratorPanelState
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _isSaving ? null : _addDraft,
|
||||
icon: const Icon(Icons.add),
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('Add shift'),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton.icon(
|
||||
onPressed: _isSaving ? null : _commitDraft,
|
||||
icon: const Icon(Icons.check_circle),
|
||||
label: _isSaving
|
||||
icon: _isSaving
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('Commit schedule'),
|
||||
: const Icon(Icons.check_circle_outline, size: 18),
|
||||
label: const Text('Commit schedule'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -924,9 +940,13 @@ class _ScheduleGeneratorPanelState
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
onTap: onTap,
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(labelText: label),
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18),
|
||||
),
|
||||
child: Text(value == null ? 'Select date' : AppTime.formatDate(value)),
|
||||
),
|
||||
);
|
||||
@@ -1013,17 +1033,25 @@ class _ScheduleGeneratorPanelState
|
||||
Widget _buildWarningPanel(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
color: colorScheme.errorContainer.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSurfaces.of(context).compactCardRadius,
|
||||
),
|
||||
border: Border.all(
|
||||
color: colorScheme.error.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.warning_amber, color: colorScheme.onTertiaryContainer),
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 18,
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -1031,17 +1059,20 @@ class _ScheduleGeneratorPanelState
|
||||
children: [
|
||||
Text(
|
||||
'Uncovered shifts',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const SizedBox(height: 4),
|
||||
for (final warning in _warnings)
|
||||
Text(
|
||||
warning,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
warning,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1090,6 +1121,14 @@ class _ScheduleGeneratorPanelState
|
||||
: id,
|
||||
)
|
||||
.toList();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final shiftInitial =
|
||||
_shiftLabel(draft.shiftType, rotationConfig).isNotEmpty
|
||||
? _shiftLabel(
|
||||
draft.shiftType,
|
||||
rotationConfig,
|
||||
)[0].toUpperCase()
|
||||
: '?';
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
@@ -1097,6 +1136,24 @@ class _ScheduleGeneratorPanelState
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
margin: const EdgeInsets.only(right: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
shiftInitial,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -1106,9 +1163,9 @@ class _ScheduleGeneratorPanelState
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${AppTime.formatDate(draft.startTime)} · ${AppTime.formatTime(draft.startTime)} - ${AppTime.formatTime(draft.endTime)}',
|
||||
'${AppTime.formatDate(draft.startTime)} · ${AppTime.formatTime(draft.startTime)} – ${AppTime.formatTime(draft.endTime)}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
@@ -1117,12 +1174,16 @@ class _ScheduleGeneratorPanelState
|
||||
IconButton(
|
||||
tooltip: 'Edit',
|
||||
onPressed: () => _editDraft(draft),
|
||||
icon: const Icon(Icons.edit),
|
||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Delete',
|
||||
onPressed: () => _deleteDraft(draft),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
icon: Icon(
|
||||
Icons.delete_outline,
|
||||
size: 18,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1180,10 +1241,16 @@ class _ScheduleGeneratorPanelState
|
||||
return;
|
||||
}
|
||||
|
||||
final rotationConfig = ref.read(rotationConfigProvider).valueOrNull;
|
||||
final shiftTypeConfigs =
|
||||
rotationConfig?.shiftTypes ?? RotationConfig().shiftTypes;
|
||||
|
||||
final start = existing?.startTime ?? _startDate ?? AppTime.now();
|
||||
var selectedDate = DateTime(start.year, start.month, start.day);
|
||||
var selectedUserId = existing?.userId ?? staff.first.id;
|
||||
var selectedShift = existing?.shiftType ?? 'am';
|
||||
var selectedShift =
|
||||
existing?.shiftType ??
|
||||
(shiftTypeConfigs.isNotEmpty ? shiftTypeConfigs.first.id : 'am');
|
||||
var startTime = TimeOfDay.fromDateTime(existing?.startTime ?? start);
|
||||
var endTime = TimeOfDay.fromDateTime(
|
||||
existing?.endTime ?? start.add(const Duration(hours: 8)),
|
||||
@@ -1194,6 +1261,17 @@ class _ScheduleGeneratorPanelState
|
||||
builder: (dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
final startMinutes = startTime.hour * 60 + startTime.minute;
|
||||
final endMinutes = endTime.hour * 60 + endTime.minute;
|
||||
final durationMinutes = endMinutes > startMinutes
|
||||
? endMinutes - startMinutes
|
||||
: (endMinutes + 1440) - startMinutes;
|
||||
final durationHours = durationMinutes ~/ 60;
|
||||
final durationRem = durationMinutes % 60;
|
||||
final durationLabel = durationRem == 0
|
||||
? '${durationHours}h'
|
||||
: '${durationHours}h ${durationRem}m';
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(existing == null ? 'Add shift' : 'Edit shift'),
|
||||
content: SingleChildScrollView(
|
||||
@@ -1217,22 +1295,20 @@ class _ScheduleGeneratorPanelState
|
||||
if (value == null) return;
|
||||
setDialogState(() => selectedUserId = value);
|
||||
},
|
||||
decoration: const InputDecoration(labelText: 'Assignee'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Assignee',
|
||||
filled: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: selectedShift,
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'am', child: Text('AM Duty')),
|
||||
DropdownMenuItem(
|
||||
value: 'normal',
|
||||
child: Text('Normal Duty'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'on_call',
|
||||
child: Text('On Call'),
|
||||
),
|
||||
DropdownMenuItem(value: 'pm', child: Text('PM Duty')),
|
||||
items: [
|
||||
for (final s in shiftTypeConfigs)
|
||||
DropdownMenuItem(
|
||||
value: s.id,
|
||||
child: Text(s.label),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
@@ -1240,6 +1316,7 @@ class _ScheduleGeneratorPanelState
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Shift type',
|
||||
filled: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -1260,30 +1337,56 @@ class _ScheduleGeneratorPanelState
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_dialogTimeField(
|
||||
label: 'Start time',
|
||||
value: startTime,
|
||||
onTap: () async {
|
||||
final picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: startTime,
|
||||
);
|
||||
if (picked == null) return;
|
||||
setDialogState(() => startTime = picked);
|
||||
},
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _dialogTimeField(
|
||||
label: 'Start time',
|
||||
value: startTime,
|
||||
onTap: () async {
|
||||
final picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: startTime,
|
||||
);
|
||||
if (picked == null) return;
|
||||
setDialogState(() => startTime = picked);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _dialogTimeField(
|
||||
label: 'End time',
|
||||
value: endTime,
|
||||
onTap: () async {
|
||||
final picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: endTime,
|
||||
);
|
||||
if (picked == null) return;
|
||||
setDialogState(() => endTime = picked);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_dialogTimeField(
|
||||
label: 'End time',
|
||||
value: endTime,
|
||||
onTap: () async {
|
||||
final picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: endTime,
|
||||
);
|
||||
if (picked == null) return;
|
||||
setDialogState(() => endTime = picked);
|
||||
},
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Chip(
|
||||
avatar: const Icon(Icons.schedule, size: 16),
|
||||
label: Text('Duration: $durationLabel'),
|
||||
visualDensity: VisualDensity.compact,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.secondaryContainer,
|
||||
labelStyle: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSecondaryContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1295,7 +1398,7 @@ class _ScheduleGeneratorPanelState
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final startDateTime = DateTime(
|
||||
var startDateTime = DateTime(
|
||||
selectedDate.year,
|
||||
selectedDate.month,
|
||||
selectedDate.day,
|
||||
@@ -1312,6 +1415,8 @@ class _ScheduleGeneratorPanelState
|
||||
if (!endDateTime.isAfter(startDateTime)) {
|
||||
endDateTime = endDateTime.add(const Duration(days: 1));
|
||||
}
|
||||
startDateTime = AppTime.toAppTime(startDateTime);
|
||||
endDateTime = AppTime.toAppTime(endDateTime);
|
||||
|
||||
final draft =
|
||||
existing ??
|
||||
@@ -1361,9 +1466,14 @@ class _ScheduleGeneratorPanelState
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
onTap: onTap,
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(labelText: label),
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
filled: true,
|
||||
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18),
|
||||
),
|
||||
child: Text(value),
|
||||
),
|
||||
);
|
||||
@@ -1375,9 +1485,14 @@ class _ScheduleGeneratorPanelState
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
onTap: onTap,
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(labelText: label),
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
filled: true,
|
||||
suffixIcon: const Icon(Icons.schedule, size: 18),
|
||||
),
|
||||
child: Text(value.format(context)),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -76,6 +76,21 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
|
||||
}
|
||||
}
|
||||
|
||||
String _bannerLabel(String type) => switch (type) {
|
||||
'mention' => 'New mention',
|
||||
'assignment' => 'New assignment',
|
||||
'created' => 'New item created',
|
||||
'announcement' => 'New announcement',
|
||||
'announcement_comment' => 'New announcement comment',
|
||||
'swap_request' => 'New shift swap request',
|
||||
'swap_update' => 'Shift swap updated',
|
||||
'it_job_reminder' => 'IT Job reminder',
|
||||
'isr_approved' => 'IT Service Request approved',
|
||||
'isr_status_changed' => 'IT Service Request updated',
|
||||
'isr_assigned' => 'New IT Service Request assigned',
|
||||
_ => 'New notification',
|
||||
};
|
||||
|
||||
void _showBanner(String type, NotificationItem item) {
|
||||
// Use a post-frame callback so that the ScaffoldMessenger from
|
||||
// MaterialApp is available in the element tree.
|
||||
@@ -84,7 +99,7 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
|
||||
try {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('New $type received!'),
|
||||
content: Text(_bannerLabel(type)),
|
||||
action: SnackBarAction(
|
||||
label: 'View',
|
||||
onPressed: () =>
|
||||
|
||||
@@ -89,6 +89,10 @@ class AppTheme {
|
||||
scrolledUnderElevation: 2,
|
||||
surfaceTintColor: cs.surfaceTint,
|
||||
centerTitle: false,
|
||||
toolbarHeight: 64,
|
||||
titleSpacing: 16.0,
|
||||
iconTheme: IconThemeData(color: cs.onSurfaceVariant, size: 24),
|
||||
actionsIconTheme: IconThemeData(color: cs.onSurfaceVariant, size: 24),
|
||||
titleTextStyle: textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:safe_device/safe_device.dart';
|
||||
|
||||
/// Returns true if the device appears compromised for attendance purposes:
|
||||
/// rooted (Android) / jailbroken (iOS), or developer mode enabled.
|
||||
/// Returns false on any error so a detection failure never blocks a valid user.
|
||||
Future<bool> isDeviceCompromised() async {
|
||||
if (!Platform.isAndroid && !Platform.isIOS) return false;
|
||||
try {
|
||||
final isJailBroken = await SafeDevice.isJailBroken;
|
||||
final isDeveloperMode = await SafeDevice.isDevelopmentModeEnable;
|
||||
return isJailBroken || isDeveloperMode;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import '../models/task_activity_log.dart';
|
||||
|
||||
/// Finds the canonical execution start time for a task.
|
||||
///
|
||||
/// [logs] is expected in DESCENDING order (newest first), matching the
|
||||
/// `order('created_at', ascending: false)` used by Supabase queries.
|
||||
/// The reversed iteration therefore walks chronologically forward and stops
|
||||
/// at the earliest 'started' event.
|
||||
DateTime? resolveTaskExecutionStart(
|
||||
List<TaskActivityLog> logs,
|
||||
DateTime? fallbackStartedAt,
|
||||
) {
|
||||
DateTime? started;
|
||||
for (final entry in logs.reversed) {
|
||||
if (entry.actionType == 'started') {
|
||||
started = entry.createdAt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return started ?? fallbackStartedAt;
|
||||
}
|
||||
|
||||
/// Computes the effective worked duration for a task, subtracting all
|
||||
/// paused intervals between [fallbackStartedAt] (or the first 'started'
|
||||
/// log event) and [endAt].
|
||||
///
|
||||
/// [logs] must be in DESCENDING order (newest first) — the same order
|
||||
/// returned by `task_activity_logs` queries with `ascending: false`.
|
||||
Duration computeEffectiveTaskDuration({
|
||||
required DateTime? fallbackStartedAt,
|
||||
required DateTime endAt,
|
||||
required List<TaskActivityLog> logs,
|
||||
}) {
|
||||
final start = resolveTaskExecutionStart(logs, fallbackStartedAt);
|
||||
if (start == null || !endAt.isAfter(start)) return Duration.zero;
|
||||
|
||||
Duration pausedTotal = Duration.zero;
|
||||
DateTime? pausedSince;
|
||||
|
||||
// logs.reversed → ascending (chronological) order within [start, endAt]
|
||||
final events = logs.reversed.where((e) {
|
||||
if (e.createdAt.isBefore(start)) return false;
|
||||
if (e.createdAt.isAfter(endAt)) return false;
|
||||
return e.actionType == 'paused' || e.actionType == 'resumed';
|
||||
});
|
||||
|
||||
for (final event in events) {
|
||||
if (event.actionType == 'paused') {
|
||||
pausedSince ??= event.createdAt;
|
||||
} else if (event.actionType == 'resumed' && pausedSince != null) {
|
||||
if (event.createdAt.isAfter(pausedSince)) {
|
||||
pausedTotal += event.createdAt.difference(pausedSince);
|
||||
}
|
||||
pausedSince = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Task is still paused at endAt — count the open pause interval.
|
||||
if (pausedSince != null && endAt.isAfter(pausedSince)) {
|
||||
pausedTotal += endAt.difference(pausedSince);
|
||||
}
|
||||
|
||||
final total = endAt.difference(start) - pausedTotal;
|
||||
return total.isNegative ? Duration.zero : total;
|
||||
}
|
||||
+374
-131
@@ -10,6 +10,7 @@ import '../providers/notifications_provider.dart';
|
||||
import '../providers/profile_provider.dart';
|
||||
import 'announcement_banner.dart';
|
||||
import 'app_breakpoints.dart';
|
||||
import 'offline_readiness_sheet.dart';
|
||||
import 'profile_avatar.dart';
|
||||
import 'pass_slip_countdown_banner.dart';
|
||||
import 'shift_countdown_banner.dart';
|
||||
@@ -57,69 +58,29 @@ class AppScaffold extends ConsumerWidget {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Row(
|
||||
children: [
|
||||
Image.asset('assets/tasq_ico.png', width: 28, height: 28),
|
||||
const SizedBox(width: 8),
|
||||
Text('TasQ'),
|
||||
],
|
||||
title: _AppBarTitle(
|
||||
location: location,
|
||||
showRail: showRail,
|
||||
isExtended: isExtended,
|
||||
),
|
||||
actions: [
|
||||
if (isStandard)
|
||||
PopupMenuButton<int>(
|
||||
tooltip: 'Account',
|
||||
onSelected: (value) {
|
||||
if (value == 0) {
|
||||
context.go('/profile');
|
||||
} else if (value == 1) {
|
||||
ref.read(authControllerProvider).signOut();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(value: 0, child: Text('My profile')),
|
||||
PopupMenuItem(value: 1, child: Text('Sign out')),
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ProfileAvatar(
|
||||
fullName: displayName,
|
||||
avatarUrl: avatarUrl,
|
||||
radius: 16,
|
||||
heroTag: 'profile-avatar',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(displayName),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.expand_more),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Profile',
|
||||
onPressed: () => context.go('/profile'),
|
||||
icon: ProfileAvatar(
|
||||
fullName: displayName,
|
||||
avatarUrl: avatarUrl,
|
||||
radius: 16,
|
||||
heroTag: 'profile-avatar',
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Sign out',
|
||||
onPressed: () => ref.read(authControllerProvider).signOut(),
|
||||
icon: const Icon(Icons.logout),
|
||||
),
|
||||
],
|
||||
),
|
||||
_ProfileMenuAnchor(
|
||||
isStandard: isStandard,
|
||||
displayName: displayName,
|
||||
avatarUrl: avatarUrl,
|
||||
onProfile: () => context.go('/profile'),
|
||||
onSignOut: () => ref.read(authControllerProvider).signOut(),
|
||||
),
|
||||
const _NotificationBell(),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1),
|
||||
child: Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: showRail
|
||||
? null
|
||||
@@ -165,7 +126,7 @@ class AppScaffold extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class AppNavigationRail extends StatelessWidget {
|
||||
class AppNavigationRail extends StatefulWidget {
|
||||
const AppNavigationRail({
|
||||
super.key,
|
||||
required this.items,
|
||||
@@ -181,60 +142,153 @@ class AppNavigationRail extends StatelessWidget {
|
||||
final String displayName;
|
||||
final VoidCallback onLogout;
|
||||
|
||||
@override
|
||||
State<AppNavigationRail> createState() => _AppNavigationRailState();
|
||||
}
|
||||
|
||||
class _AppNavigationRailState extends State<AppNavigationRail> {
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentIndex = _currentIndex(location, items);
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final currentIndex = _currentIndex(widget.location, widget.items);
|
||||
|
||||
// M3 Expressive: tonal surface container instead of a hard border divider.
|
||||
// Custom scrollable layout replaces NavigationRail so items are reachable
|
||||
// on small viewport heights (web, small laptop screens).
|
||||
return Container(
|
||||
decoration: BoxDecoration(color: cs.surfaceContainerLow),
|
||||
child: NavigationRail(
|
||||
backgroundColor: Colors.transparent,
|
||||
extended: extended,
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: (value) {
|
||||
items[value].onTap(context, onLogout: onLogout);
|
||||
},
|
||||
leading: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: extended ? 12 : 8,
|
||||
horizontal: extended ? 16 : 0,
|
||||
),
|
||||
child: Center(
|
||||
child: Image.asset(
|
||||
'assets/tasq_ico.png',
|
||||
width: extended ? 48 : 40,
|
||||
height: extended ? 48 : 40,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: widget.extended ? 12 : 8,
|
||||
horizontal: widget.extended ? 16 : 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
trailing: Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: IconButton(
|
||||
tooltip: 'Sign out',
|
||||
onPressed: onLogout,
|
||||
icon: const Icon(Icons.logout),
|
||||
child: Center(
|
||||
child: Image.asset(
|
||||
'assets/tasq_ico.png',
|
||||
width: widget.extended ? 48 : 40,
|
||||
height: widget.extended ? 48 : 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
destinations: [
|
||||
for (final item in items)
|
||||
NavigationRailDestination(
|
||||
icon: Icon(item.icon),
|
||||
selectedIcon: Icon(item.selectedIcon ?? item.icon),
|
||||
label: Text(item.label),
|
||||
Expanded(
|
||||
child: Scrollbar(
|
||||
controller: _scrollController,
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < widget.items.length; i++)
|
||||
_NavRailItem(
|
||||
item: widget.items[i],
|
||||
selected: i == currentIndex,
|
||||
extended: widget.extended,
|
||||
onTap: () => widget.items[i]
|
||||
.onTap(context, onLogout: widget.onLogout),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: IconButton(
|
||||
tooltip: 'Sign out',
|
||||
onPressed: widget.onLogout,
|
||||
icon: const Icon(Icons.logout),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavRailItem extends StatelessWidget {
|
||||
const _NavRailItem({
|
||||
required this.item,
|
||||
required this.selected,
|
||||
required this.extended,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final NavItem item;
|
||||
final bool selected;
|
||||
final bool extended;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
final icon = Icon(
|
||||
selected ? (item.selectedIcon ?? item.icon) : item.icon,
|
||||
size: 24,
|
||||
color: selected ? cs.onSecondaryContainer : cs.onSurfaceVariant,
|
||||
);
|
||||
|
||||
final indicator = AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 56,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? cs.secondaryContainer : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Center(child: icon),
|
||||
);
|
||||
|
||||
final Widget content = extended
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
indicator,
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.label,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: selected
|
||||
? cs.onSecondaryContainer
|
||||
: cs.onSurfaceVariant,
|
||||
fontWeight: selected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Center(child: indicator);
|
||||
|
||||
final tile = InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
width: double.infinity,
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
|
||||
if (extended) return tile;
|
||||
return Tooltip(message: item.label, child: tile);
|
||||
}
|
||||
}
|
||||
|
||||
class AppBottomNav extends StatelessWidget {
|
||||
const AppBottomNav({
|
||||
super.key,
|
||||
@@ -278,43 +332,35 @@ class _NotificationBell extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final unreadCount = ref.watch(unreadNotificationsCountProvider);
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return IconButton(
|
||||
tooltip: 'Notifications',
|
||||
onPressed: () => context.go('/notifications'),
|
||||
icon: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
const Icon(Icons.notifications_outlined),
|
||||
Positioned(
|
||||
right: -3,
|
||||
top: -3,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
switchInCurve: Curves.easeOutBack,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, animation) => ScaleTransition(
|
||||
scale: animation,
|
||||
child: child,
|
||||
),
|
||||
child: unreadCount > 0
|
||||
? Container(
|
||||
key: const ValueKey('badge'),
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.error,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: cs.surface,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('no-badge')),
|
||||
final String? badgeLabel = unreadCount == 0
|
||||
? null
|
||||
: unreadCount > 99
|
||||
? '99+'
|
||||
: unreadCount.toString();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: IconButton(
|
||||
key: notificationBellKey,
|
||||
tooltip: 'Notifications',
|
||||
onPressed: () => context.go('/notifications'),
|
||||
icon: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
switchInCurve: Curves.easeOutBack,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, animation) =>
|
||||
ScaleTransition(scale: animation, child: child),
|
||||
child: Badge(
|
||||
key: ValueKey(badgeLabel),
|
||||
isLabelVisible: unreadCount > 0,
|
||||
label: badgeLabel != null ? Text(badgeLabel) : null,
|
||||
child: Icon(
|
||||
unreadCount > 0
|
||||
? Icons.notifications
|
||||
: Icons.notifications_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -416,6 +462,13 @@ List<NavSection> _buildSections(String role) {
|
||||
icon: Icons.miscellaneous_services_outlined,
|
||||
selectedIcon: Icons.miscellaneous_services,
|
||||
),
|
||||
if (!isStandard)
|
||||
NavItem(
|
||||
label: 'Network Map',
|
||||
route: '/network-map',
|
||||
icon: Icons.hub_outlined,
|
||||
selectedIcon: Icons.hub,
|
||||
),
|
||||
NavItem(
|
||||
label: 'Announcement',
|
||||
route: '/announcements',
|
||||
@@ -600,6 +653,7 @@ Future<void> _showOverflowSheet(
|
||||
List<NavItem> items,
|
||||
VoidCallback onLogout,
|
||||
) async {
|
||||
final parentContext = context;
|
||||
await m3ShowBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
@@ -616,6 +670,18 @@ Future<void> _showOverflowSheet(
|
||||
item.onTap(context, onLogout: onLogout);
|
||||
},
|
||||
),
|
||||
if (!kIsWeb) ...[
|
||||
const Divider(height: 16),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.offline_bolt_rounded),
|
||||
title: const Text('Offline Readiness'),
|
||||
subtitle: const Text('Cache status & pending sync'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
showOfflineReadinessSheet(parentContext);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -635,3 +701,180 @@ int _currentIndex(String location, List<NavItem> items) {
|
||||
final overflowIndex = items.indexWhere((item) => item.isOverflow);
|
||||
return overflowIndex == -1 ? 0 : overflowIndex;
|
||||
}
|
||||
|
||||
String _routeToTitle(String location) {
|
||||
final path = location.split('?').first;
|
||||
return switch (path) {
|
||||
'/dashboard' => 'Dashboard',
|
||||
'/attendance' => 'Attendance',
|
||||
'/tickets' => 'Tickets',
|
||||
'/tasks' => 'Tasks',
|
||||
'/it-service-requests' => 'IT Service Requests',
|
||||
'/network-map' => 'Network Map',
|
||||
'/notifications' => 'Notifications',
|
||||
'/whereabouts' => 'Whereabouts',
|
||||
'/workforce' => 'Workforce',
|
||||
'/reports' => 'Reports',
|
||||
'/announcements' => 'Announcements',
|
||||
'/profile' => 'My Profile',
|
||||
'/settings/users' => 'Users',
|
||||
'/settings/offices' => 'Offices',
|
||||
'/settings/teams' => 'IT Teams',
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Adaptive AppBar title ────────────────────────────────────────────────────
|
||||
|
||||
class _AppBarTitle extends StatelessWidget {
|
||||
const _AppBarTitle({
|
||||
required this.location,
|
||||
required this.showRail,
|
||||
required this.isExtended,
|
||||
});
|
||||
|
||||
final String location;
|
||||
final bool showRail;
|
||||
final bool isExtended;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final pageTitle = _routeToTitle(location);
|
||||
|
||||
// Shared page-name suffix: divider line + title text
|
||||
List<Widget> pageSuffix = pageTitle.isEmpty
|
||||
? []
|
||||
: [
|
||||
const SizedBox(width: 12),
|
||||
Container(width: 1, height: 28, color: cs.outlineVariant),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
pageTitle,
|
||||
style: tt.titleMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
// Mobile: compact logo + brand name, no page title
|
||||
if (!showRail) {
|
||||
return Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Image.asset('assets/tasq_ico.png', width: 28, height: 28),
|
||||
const SizedBox(width: 8),
|
||||
const Text('TasQ'),
|
||||
]);
|
||||
}
|
||||
|
||||
// Desktop (extended rail already shows brand): logo + page name only
|
||||
if (isExtended) {
|
||||
return Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Image.asset('assets/tasq_ico.png', width: 28, height: 28),
|
||||
...pageSuffix,
|
||||
]);
|
||||
}
|
||||
|
||||
// Tablet (collapsed rail): logo + brand + page name
|
||||
return Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Image.asset('assets/tasq_ico.png', width: 28, height: 28),
|
||||
const SizedBox(width: 8),
|
||||
const Text('TasQ'),
|
||||
...pageSuffix,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── M3 profile menu anchor ───────────────────────────────────────────────────
|
||||
|
||||
class _ProfileMenuAnchor extends StatelessWidget {
|
||||
const _ProfileMenuAnchor({
|
||||
required this.isStandard,
|
||||
required this.displayName,
|
||||
required this.avatarUrl,
|
||||
required this.onProfile,
|
||||
required this.onSignOut,
|
||||
});
|
||||
|
||||
final bool isStandard;
|
||||
final String displayName;
|
||||
final String? avatarUrl;
|
||||
final VoidCallback onProfile;
|
||||
final VoidCallback onSignOut;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
|
||||
return MenuAnchor(
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(Icons.person_outline),
|
||||
onPressed: onProfile,
|
||||
child: const Text('My Profile'),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(Icons.logout),
|
||||
onPressed: onSignOut,
|
||||
child: const Text('Sign out'),
|
||||
),
|
||||
],
|
||||
builder: (context, controller, _) {
|
||||
void toggle() =>
|
||||
controller.isOpen ? controller.close() : controller.open();
|
||||
|
||||
if (isStandard) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: InkWell(
|
||||
onTap: toggle,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
ProfileAvatar(
|
||||
fullName: displayName,
|
||||
avatarUrl: avatarUrl,
|
||||
radius: 14,
|
||||
heroTag: 'profile-avatar',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
displayName,
|
||||
style: tt.labelLarge?.copyWith(
|
||||
color: cs.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.expand_more, size: 18, color: cs.onSecondaryContainer),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Admin / non-standard: circular avatar icon button
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: IconButton(
|
||||
tooltip: 'Account',
|
||||
onPressed: toggle,
|
||||
icon: ProfileAvatar(
|
||||
fullName: displayName,
|
||||
avatarUrl: avatarUrl,
|
||||
radius: 16,
|
||||
heroTag: 'profile-avatar',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/office.dart';
|
||||
import '../theme/m3_motion.dart';
|
||||
import 'app_breakpoints.dart';
|
||||
|
||||
class _PickResult {
|
||||
const _PickResult(this.officeId);
|
||||
final String? officeId;
|
||||
}
|
||||
|
||||
/// A tappable form field that opens a searchable office picker.
|
||||
///
|
||||
/// Replaces [DropdownButtonFormField] when the office list is large.
|
||||
class OfficeSelectorField extends StatelessWidget {
|
||||
const OfficeSelectorField({
|
||||
super.key,
|
||||
required this.offices,
|
||||
required this.selectedOfficeId,
|
||||
required this.onChanged,
|
||||
this.allowUnassigned = false,
|
||||
this.labelText = 'Office',
|
||||
});
|
||||
|
||||
final List<Office> offices;
|
||||
final String? selectedOfficeId;
|
||||
/// Null disables the field (shows dimmed, no tap).
|
||||
final ValueChanged<String?>? onChanged;
|
||||
/// When true, an "Unassigned" tile appears at the top of the picker.
|
||||
final bool allowUnassigned;
|
||||
final String labelText;
|
||||
|
||||
String _displayText() {
|
||||
if (selectedOfficeId == null) {
|
||||
return allowUnassigned ? 'Unassigned' : 'Select office…';
|
||||
}
|
||||
try {
|
||||
return offices.firstWhere((o) => o.id == selectedOfficeId).name;
|
||||
} catch (_) {
|
||||
return 'Select office…';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openPicker(BuildContext context) async {
|
||||
final sorted = List<Office>.from(offices)
|
||||
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
final result = await _showOfficePicker(
|
||||
context,
|
||||
offices: sorted,
|
||||
initialOfficeId: selectedOfficeId,
|
||||
allowUnassigned: allowUnassigned,
|
||||
);
|
||||
if (result != null) onChanged?.call(result.officeId);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isEnabled = onChanged != null;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return InkWell(
|
||||
onTap: isEnabled ? () => _openPicker(context) : null,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: labelText,
|
||||
enabled: isEnabled,
|
||||
suffixIcon: Icon(
|
||||
Icons.search_rounded,
|
||||
color: isEnabled
|
||||
? cs.onSurfaceVariant
|
||||
: cs.onSurface.withValues(alpha: 0.38),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_displayText(),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isEnabled
|
||||
? cs.onSurface
|
||||
: cs.onSurface.withValues(alpha: 0.38),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<_PickResult?> _showOfficePicker(
|
||||
BuildContext context, {
|
||||
required List<Office> offices,
|
||||
String? initialOfficeId,
|
||||
bool allowUnassigned = false,
|
||||
}) {
|
||||
final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
|
||||
|
||||
List<Widget> buildItems(
|
||||
String? selected,
|
||||
String search,
|
||||
void Function(String?) onPick,
|
||||
ColorScheme cs,
|
||||
) {
|
||||
final filtered = search.isEmpty
|
||||
? offices
|
||||
: offices
|
||||
.where((o) => o.name.toLowerCase().contains(search.toLowerCase()))
|
||||
.toList();
|
||||
return [
|
||||
if (allowUnassigned)
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: Icon(Icons.block_rounded, color: cs.onSurfaceVariant),
|
||||
title: const Text('Unassigned'),
|
||||
selected: selected == null,
|
||||
trailing: selected == null
|
||||
? Icon(Icons.check_rounded, size: 18, color: cs.primary)
|
||||
: null,
|
||||
onTap: () => onPick(null),
|
||||
),
|
||||
for (final office in filtered)
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: Icon(Icons.business_rounded, color: cs.onSurfaceVariant),
|
||||
title: Text(office.name),
|
||||
selected: selected == office.id,
|
||||
trailing: selected == office.id
|
||||
? Icon(Icons.check_rounded, size: 18, color: cs.primary)
|
||||
: null,
|
||||
onTap: () => onPick(office.id),
|
||||
),
|
||||
if (filtered.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'No offices match your search.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: cs.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return m3ShowBottomSheet<_PickResult>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (sheetCtx) {
|
||||
String search = '';
|
||||
String? selected = initialOfficeId;
|
||||
return StatefulBuilder(
|
||||
builder: (ctx, setState) {
|
||||
final cs = Theme.of(ctx).colorScheme;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 8,
|
||||
bottom: MediaQuery.viewInsetsOf(ctx).bottom + 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Select Office',
|
||||
style: Theme.of(ctx).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search office…',
|
||||
prefixIcon: Icon(Icons.search_rounded),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (v) => setState(() => search = v),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SizedBox(
|
||||
height: 320,
|
||||
child: ListView(
|
||||
children: buildItems(
|
||||
selected,
|
||||
search,
|
||||
(id) => Navigator.of(ctx).pop(_PickResult(id)),
|
||||
cs,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return m3ShowDialog<_PickResult>(
|
||||
context: context,
|
||||
builder: (dialogCtx) {
|
||||
String search = '';
|
||||
String? selected = initialOfficeId;
|
||||
return StatefulBuilder(
|
||||
builder: (ctx, setState) {
|
||||
final cs = Theme.of(ctx).colorScheme;
|
||||
return AlertDialog(
|
||||
title: const Text('Select Office'),
|
||||
contentPadding: const EdgeInsets.fromLTRB(0, 12, 0, 0),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search office…',
|
||||
prefixIcon: Icon(Icons.search_rounded),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (v) => setState(() => search = v),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 280,
|
||||
width: double.maxFinite,
|
||||
child: ListView(
|
||||
children: buildItems(
|
||||
selected,
|
||||
search,
|
||||
(id) => Navigator.of(ctx).pop(_PickResult(id)),
|
||||
cs,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
+118
-84
@@ -2,8 +2,10 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../brick/cache_warmer.dart';
|
||||
import '../providers/connectivity_provider.dart';
|
||||
import '../providers/sync_queue_provider.dart';
|
||||
import 'offline_readiness_sheet.dart';
|
||||
|
||||
/// Wraps [child] with a polished M3 status banner driven by connectivity + sync state.
|
||||
///
|
||||
@@ -23,57 +25,78 @@ class OfflineBanner extends ConsumerWidget {
|
||||
final pendingCount = ref.watch(pendingSyncCountProvider);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
Widget? banner;
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: CacheWarmer.warmingNotifier,
|
||||
builder: (context, isWarming, _) {
|
||||
Widget? banner;
|
||||
|
||||
if (!isOnline) {
|
||||
final label = pendingCount > 0
|
||||
? 'Offline — $pendingCount item${pendingCount == 1 ? '' : 's'} queued'
|
||||
: kIsWeb ? 'No internet connection' : 'No internet — changes saved locally';
|
||||
banner = _BannerStrip(
|
||||
key: const ValueKey('offline'),
|
||||
backgroundColor: scheme.errorContainer,
|
||||
foregroundColor: scheme.onErrorContainer,
|
||||
icon: Icon(Icons.wifi_off_rounded, color: scheme.onErrorContainer, size: 18),
|
||||
label: label,
|
||||
showProgress: false,
|
||||
);
|
||||
} else if (pendingCount > 0) {
|
||||
final label = 'Syncing $pendingCount item${pendingCount == 1 ? '' : 's'}…';
|
||||
banner = _BannerStrip(
|
||||
key: const ValueKey('syncing'),
|
||||
backgroundColor: scheme.tertiaryContainer,
|
||||
foregroundColor: scheme.onTertiaryContainer,
|
||||
icon: _SpinningIcon(
|
||||
icon: Icons.sync_rounded,
|
||||
color: scheme.onTertiaryContainer,
|
||||
size: 18,
|
||||
),
|
||||
label: label,
|
||||
showProgress: true,
|
||||
);
|
||||
}
|
||||
if (!isOnline) {
|
||||
final label = pendingCount > 0
|
||||
? 'Offline — $pendingCount item${pendingCount == 1 ? '' : 's'} queued'
|
||||
: kIsWeb ? 'No internet connection' : 'No internet — changes saved locally';
|
||||
banner = _BannerStrip(
|
||||
key: const ValueKey('offline'),
|
||||
backgroundColor: scheme.errorContainer,
|
||||
foregroundColor: scheme.onErrorContainer,
|
||||
icon: Icon(Icons.wifi_off_rounded, color: scheme.onErrorContainer, size: 18),
|
||||
label: label,
|
||||
showProgress: false,
|
||||
onTap: () => showOfflineReadinessSheet(context),
|
||||
);
|
||||
} else if (isWarming) {
|
||||
banner = _BannerStrip(
|
||||
key: const ValueKey('warming'),
|
||||
backgroundColor: scheme.secondaryContainer,
|
||||
foregroundColor: scheme.onSecondaryContainer,
|
||||
icon: _SpinningIcon(
|
||||
icon: Icons.cloud_sync_rounded,
|
||||
color: scheme.onSecondaryContainer,
|
||||
size: 18,
|
||||
),
|
||||
label: 'Updating offline cache…',
|
||||
showProgress: true,
|
||||
onTap: () => showOfflineReadinessSheet(context),
|
||||
);
|
||||
} else if (pendingCount > 0) {
|
||||
final label = 'Syncing $pendingCount item${pendingCount == 1 ? '' : 's'}…';
|
||||
banner = _BannerStrip(
|
||||
key: const ValueKey('syncing'),
|
||||
backgroundColor: scheme.tertiaryContainer,
|
||||
foregroundColor: scheme.onTertiaryContainer,
|
||||
icon: _SpinningIcon(
|
||||
icon: Icons.sync_rounded,
|
||||
color: scheme.onTertiaryContainer,
|
||||
size: 18,
|
||||
),
|
||||
label: label,
|
||||
showProgress: true,
|
||||
onTap: () => showOfflineReadinessSheet(context),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
transitionBuilder: (child, animation) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, -1),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
)),
|
||||
child: FadeTransition(opacity: animation, child: child),
|
||||
);
|
||||
},
|
||||
child: banner ?? const SizedBox.shrink(key: ValueKey('none')),
|
||||
),
|
||||
Expanded(child: child),
|
||||
],
|
||||
return Column(
|
||||
children: [
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
transitionBuilder: (child, animation) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, -1),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
)),
|
||||
child: FadeTransition(opacity: animation, child: child),
|
||||
);
|
||||
},
|
||||
child: banner ?? const SizedBox.shrink(key: ValueKey('none')),
|
||||
),
|
||||
Expanded(child: child),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -84,6 +107,7 @@ class _BannerStrip extends StatelessWidget {
|
||||
final Widget icon;
|
||||
final String label;
|
||||
final bool showProgress;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _BannerStrip({
|
||||
super.key,
|
||||
@@ -92,50 +116,60 @@ class _BannerStrip extends StatelessWidget {
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.showProgress,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const radius = BorderRadius.vertical(bottom: Radius.circular(12));
|
||||
return Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 8, 16, showProgress ? 6 : 8),
|
||||
child: Row(
|
||||
children: [
|
||||
icon,
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: foregroundColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
borderRadius: radius,
|
||||
child: InkWell(
|
||||
borderRadius: radius,
|
||||
onTap: onTap,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 8, onTap != null ? 12 : 16, showProgress ? 6 : 8),
|
||||
child: Row(
|
||||
children: [
|
||||
icon,
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: foregroundColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onTap != null)
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 16,
|
||||
color: foregroundColor.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showProgress)
|
||||
ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 2,
|
||||
backgroundColor: backgroundColor,
|
||||
valueColor: AlwaysStoppedAnimation(
|
||||
foregroundColor.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showProgress)
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
bottom: Radius.circular(12),
|
||||
),
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 2,
|
||||
backgroundColor: backgroundColor,
|
||||
valueColor: AlwaysStoppedAnimation(
|
||||
foregroundColor.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../brick/cache_warmer.dart';
|
||||
import '../providers/connectivity_provider.dart';
|
||||
import '../providers/offline_readiness_provider.dart';
|
||||
import '../providers/supabase_provider.dart';
|
||||
import '../providers/sync_queue_provider.dart';
|
||||
|
||||
void showOfflineReadinessSheet(BuildContext context) {
|
||||
final container = ProviderScope.containerOf(context);
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (_) => UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const _OfflineReadinessSheet(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _OfflineReadinessSheet extends ConsumerStatefulWidget {
|
||||
const _OfflineReadinessSheet();
|
||||
|
||||
@override
|
||||
ConsumerState<_OfflineReadinessSheet> createState() => _OfflineReadinessSheetState();
|
||||
}
|
||||
|
||||
class _OfflineReadinessSheetState extends ConsumerState<_OfflineReadinessSheet> {
|
||||
bool _refreshing = false;
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (_refreshing) return;
|
||||
setState(() => _refreshing = true);
|
||||
try {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
await CacheWarmer.warmAll(client);
|
||||
ref.invalidate(offlineReadinessProvider);
|
||||
} finally {
|
||||
if (mounted) setState(() => _refreshing = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final readiness = ref.watch(offlineReadinessProvider);
|
||||
final isOnline = ref.watch(isOnlineProvider);
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.92,
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
return Column(
|
||||
children: [
|
||||
// Handle bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.onSurfaceVariant.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.offline_bolt_rounded, color: scheme.primary, size: 22),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Offline Readiness',
|
||||
style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const Spacer(),
|
||||
if (isOnline)
|
||||
_refreshing
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: scheme.primary),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
tooltip: 'Refresh cache',
|
||||
onPressed: _refresh,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// Body
|
||||
Expanded(
|
||||
child: readiness.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Text('Unable to load status', style: TextStyle(color: scheme.error)),
|
||||
),
|
||||
data: (state) => _ReadinessBody(
|
||||
state: state,
|
||||
isOnline: isOnline,
|
||||
scrollController: scrollController,
|
||||
onRefresh: _refresh,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReadinessBody extends ConsumerWidget {
|
||||
final OfflineReadinessState state;
|
||||
final bool isOnline;
|
||||
final ScrollController scrollController;
|
||||
final VoidCallback onRefresh;
|
||||
|
||||
const _ReadinessBody({
|
||||
required this.state,
|
||||
required this.isOnline,
|
||||
required this.scrollController,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final breakdown = ref.watch(pendingSyncBreakdownProvider);
|
||||
|
||||
return ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
|
||||
children: [
|
||||
// Overall status chip
|
||||
_StatusChip(isReady: state.isReady, isOnline: isOnline),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Cached data section
|
||||
Text(
|
||||
'Cached Data',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_CacheGrid(counts: state.cacheCounts),
|
||||
|
||||
// Pending sync section — shown whenever any module has pending items
|
||||
if (breakdown.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Pending Sync',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_PendingList(breakdown: breakdown, isOnline: isOnline),
|
||||
],
|
||||
|
||||
// Prompt to go online if not ready and not warmed
|
||||
if (!state.hasWarmedOnce && !isOnline) ...[
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.errorContainer.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline_rounded, color: scheme.error, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Connect to the internet at least once to download offline data.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: scheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (!isOnline && state.hasWarmedOnce) ...[
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: null,
|
||||
icon: const Icon(Icons.cloud_off_rounded, size: 18),
|
||||
label: const Text('Connect to refresh cache'),
|
||||
),
|
||||
] else if (isOnline) ...[
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onRefresh,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('Refresh Cache Now'),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
final bool isReady;
|
||||
final bool isOnline;
|
||||
|
||||
const _StatusChip({required this.isReady, required this.isOnline});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final ready = isReady;
|
||||
final bg = ready ? scheme.primaryContainer : scheme.errorContainer;
|
||||
final fg = ready ? scheme.onPrimaryContainer : scheme.onErrorContainer;
|
||||
final icon = ready ? Icons.check_circle_rounded : Icons.warning_amber_rounded;
|
||||
final label = ready ? 'Ready for offline use' : 'Cache incomplete — go online to set up';
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Container(
|
||||
key: ValueKey(ready),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(12)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: fg, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(label, style: TextStyle(color: fg, fontWeight: FontWeight.w500)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheGrid extends StatefulWidget {
|
||||
final Map<String, int> counts;
|
||||
|
||||
const _CacheGrid({required this.counts});
|
||||
|
||||
@override
|
||||
State<_CacheGrid> createState() => _CacheGridState();
|
||||
}
|
||||
|
||||
class _CacheGridState extends State<_CacheGrid> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
static const _groups = [
|
||||
('Tasks', 'tasks', Icons.task_alt_rounded),
|
||||
('Tickets', 'tickets', Icons.confirmation_number_rounded),
|
||||
('Announcements', 'announcements', Icons.campaign_rounded),
|
||||
('Attendance', 'attendance_logs', Icons.fingerprint_rounded),
|
||||
('Schedules', 'duty_schedules', Icons.calendar_month_rounded),
|
||||
('Pass Slips', 'pass_slips', Icons.badge_rounded),
|
||||
('Leaves', 'leave_of_absence', Icons.beach_access_rounded),
|
||||
('Profiles', 'profiles', Icons.people_rounded),
|
||||
('Teams', 'teams', Icons.groups_rounded),
|
||||
('IT Requests', 'it_service_requests', Icons.computer_rounded),
|
||||
('Notifications', 'notifications', Icons.notifications_rounded),
|
||||
('Offices', 'offices', Icons.business_rounded),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 800),
|
||||
vsync: this,
|
||||
)..forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisExtent: 100,
|
||||
),
|
||||
itemCount: _groups.length,
|
||||
itemBuilder: (context, i) {
|
||||
final (label, key, icon) = _groups[i];
|
||||
final count = widget.counts[key] ?? -1;
|
||||
final cached = count >= 0;
|
||||
|
||||
final start = i * 0.055;
|
||||
final end = (start + 0.45).clamp(0.0, 1.0);
|
||||
final curve = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Interval(start, end, curve: Curves.easeOutCubic),
|
||||
);
|
||||
final fadeAnim = Tween<double>(begin: 0.0, end: 1.0).animate(curve);
|
||||
final slideAnim = Tween<Offset>(
|
||||
begin: const Offset(0, 0.25),
|
||||
end: Offset.zero,
|
||||
).animate(curve);
|
||||
|
||||
return FadeTransition(
|
||||
opacity: fadeAnim,
|
||||
child: SlideTransition(
|
||||
position: slideAnim,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
child: _CacheTile(
|
||||
key: ValueKey('$key-$count'),
|
||||
label: label,
|
||||
icon: icon,
|
||||
count: count,
|
||||
cached: cached,
|
||||
scheme: scheme,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheTile extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final int count;
|
||||
final bool cached;
|
||||
final ColorScheme scheme;
|
||||
|
||||
const _CacheTile({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.count,
|
||||
required this.cached,
|
||||
required this.scheme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bg = cached ? scheme.primaryContainer.withValues(alpha: 0.5) : scheme.surfaceContainerHighest;
|
||||
final fg = cached ? scheme.onPrimaryContainer : scheme.onSurfaceVariant;
|
||||
return Container(
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(14)),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: fg, size: 24),
|
||||
const SizedBox(height: 6),
|
||||
SizedBox(
|
||||
height: 30,
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: fg, fontSize: 11, fontWeight: FontWeight.w500),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
cached ? '$count items' : 'Not cached',
|
||||
style: TextStyle(color: fg.withValues(alpha: 0.7), fontSize: 10),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingList extends StatelessWidget {
|
||||
final Map<String, int> breakdown;
|
||||
final bool isOnline;
|
||||
|
||||
const _PendingList({required this.breakdown, required this.isOnline});
|
||||
|
||||
static const _icons = <String, IconData>{
|
||||
'tasks': Icons.task_alt_rounded,
|
||||
'tickets': Icons.confirmation_number_rounded,
|
||||
'messages': Icons.chat_bubble_rounded,
|
||||
'assignments': Icons.assignment_ind_rounded,
|
||||
'activity_logs': Icons.history_rounded,
|
||||
'check_ins': Icons.login_rounded,
|
||||
'attendance': Icons.fingerprint_rounded,
|
||||
'leaves': Icons.beach_access_rounded,
|
||||
'pass_slips': Icons.badge_rounded,
|
||||
'announcements': Icons.campaign_rounded,
|
||||
'it_requests': Icons.computer_rounded,
|
||||
'notification_reads': Icons.notifications_rounded,
|
||||
'profile_updates': Icons.person_rounded,
|
||||
'office_changes': Icons.business_rounded,
|
||||
};
|
||||
|
||||
static String _label(String key, int count) {
|
||||
final n = count.toString();
|
||||
final s = count == 1 ? '' : 's';
|
||||
switch (key) {
|
||||
case 'tasks': return '$n task$s';
|
||||
case 'tickets': return '$n ticket$s';
|
||||
case 'messages': return '$n message$s';
|
||||
case 'assignments': return '$n assignment$s';
|
||||
case 'activity_logs': return '$n activity log$s';
|
||||
case 'check_ins': return '$n check-in$s';
|
||||
case 'attendance': return '$n attendance update$s';
|
||||
case 'leaves': return '$n leave$s';
|
||||
case 'pass_slips': return '$n pass slip$s';
|
||||
case 'announcements': return '$n announcement$s';
|
||||
case 'it_requests': return '$n IT request$s';
|
||||
case 'notification_reads': return '$n notification read$s';
|
||||
case 'profile_updates': return '$n profile update$s';
|
||||
case 'office_changes': return '$n office change$s';
|
||||
default: return '$n $key';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final syncing = isOnline && breakdown.isNotEmpty;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
for (int i = 0; i < breakdown.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 6),
|
||||
_PendingTile(
|
||||
icon: _icons[breakdown.keys.elementAt(i)] ?? Icons.sync_rounded,
|
||||
label: _label(breakdown.keys.elementAt(i), breakdown.values.elementAt(i)),
|
||||
syncing: syncing,
|
||||
scheme: scheme,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool syncing;
|
||||
final ColorScheme scheme;
|
||||
|
||||
const _PendingTile({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.syncing,
|
||||
required this.scheme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bg = syncing ? scheme.tertiaryContainer : scheme.surfaceContainerHighest;
|
||||
final fg = syncing ? scheme.onTertiaryContainer : scheme.onSurfaceVariant;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(10)),
|
||||
child: Row(
|
||||
children: [
|
||||
syncing ? _SpinningIcon(icon: icon, color: fg) : Icon(icon, color: fg, size: 18),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(label, style: TextStyle(color: fg, fontWeight: FontWeight.w500)),
|
||||
),
|
||||
if (syncing)
|
||||
Text('Syncing…', style: TextStyle(color: fg.withValues(alpha: 0.7), fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SpinningIcon extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _SpinningIcon({required this.icon, required this.color});
|
||||
|
||||
@override
|
||||
State<_SpinningIcon> createState() => _SpinningIconState();
|
||||
}
|
||||
|
||||
class _SpinningIconState extends State<_SpinningIcon>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(vsync: this, duration: const Duration(seconds: 2))..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RotationTransition(
|
||||
turns: _ctrl,
|
||||
child: Icon(widget.icon, color: widget.color, size: 18),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ class TasQColumn<T> {
|
||||
required this.header,
|
||||
required this.cellBuilder,
|
||||
this.technical = false,
|
||||
this.hideOnMedium = false,
|
||||
});
|
||||
|
||||
/// The column header text.
|
||||
@@ -26,6 +27,11 @@ class TasQColumn<T> {
|
||||
|
||||
/// If true, applies monospace text style to the cell content.
|
||||
final bool technical;
|
||||
|
||||
/// If true, this column is hidden on medium-width screens (< 1200px).
|
||||
/// Use this for lower-priority columns (e.g. Timestamp, Status) that
|
||||
/// would otherwise force a horizontal scrollbar on tablet viewports.
|
||||
final bool hideOnMedium;
|
||||
}
|
||||
|
||||
/// Builds a mobile tile for [TasQAdaptiveList].
|
||||
@@ -351,6 +357,13 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||
}
|
||||
final contentWidth = constraints.maxWidth * contentFactor;
|
||||
|
||||
// On medium screens (< 1200px) hide columns that are marked hideOnMedium,
|
||||
// preventing the horizontal scrollbar from appearing on tablet viewports.
|
||||
final isMediumScreen = constraints.maxWidth < 1200;
|
||||
final visibleColumns = isMediumScreen
|
||||
? columns.where((c) => !c.hideOnMedium).toList()
|
||||
: columns;
|
||||
|
||||
// Table width: mirrors PaginatedDataTable's own internal layout so the
|
||||
// horizontal scrollbar appears exactly when columns would otherwise be
|
||||
// squeezed below their minimum comfortable width.
|
||||
@@ -362,10 +375,10 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||
const double hMarginTotal = 16.0 * 2;
|
||||
const double colSpacing = 20.0;
|
||||
final actionsColumnCount = rowActions == null ? 0 : 1;
|
||||
final totalCols = columns.length + actionsColumnCount;
|
||||
final totalCols = visibleColumns.length + actionsColumnCount;
|
||||
final minColumnsWidth =
|
||||
hMarginTotal +
|
||||
(columns.length * colMinW) +
|
||||
(visibleColumns.length * colMinW) +
|
||||
(actionsColumnCount * actMinW) +
|
||||
(math.max(0, totalCols - 1) * colSpacing);
|
||||
final tableWidth = math.max(contentWidth, minColumnsWidth);
|
||||
@@ -388,7 +401,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||
|
||||
return _DesktopTableView<T>(
|
||||
items: items,
|
||||
columns: columns,
|
||||
columns: visibleColumns,
|
||||
rowActions: rowActions,
|
||||
onRowTap: onRowTap,
|
||||
maxRowsPerPage: rowsPerPage,
|
||||
@@ -472,6 +485,11 @@ class _DesktopTableViewState<T> extends State<_DesktopTableView<T>> {
|
||||
const double colHeaderH = 56.0;
|
||||
const double footerH = 56.0;
|
||||
const double defaultRowH = 48.0;
|
||||
// Comfortable height used when the table is sparse (fewer items than the
|
||||
// viewport can hold). Prevents 2 rows from each stretching to ~290px.
|
||||
const double comfortableRowH = 52.0;
|
||||
// Cap applied even when the table is full, so rows never become unwieldy.
|
||||
const double maxRowH = 72.0;
|
||||
const double cardPad = 8.0;
|
||||
final double headerH = widget.tableHeader != null ? 64.0 : 0.0;
|
||||
|
||||
@@ -488,9 +506,18 @@ class _DesktopTableViewState<T> extends State<_DesktopTableView<T>> {
|
||||
math.min(widget.maxRowsPerPage, itemCount),
|
||||
);
|
||||
|
||||
// Expand each row to fill the leftover space so the table reaches
|
||||
// exactly the bottom of the Expanded widget — no empty space.
|
||||
final rowHeight = math.max(defaultRowH, availableForRows / rows);
|
||||
// Only expand rows to fill leftover space when the table is "full"
|
||||
// (items reach the viewport limit). Sparse tables use a comfortable fixed
|
||||
// height instead — dividing 580px by 2 items would yield ~290px each.
|
||||
//
|
||||
// For full tables we must use exactFit (availableForRows / rows) so that
|
||||
// rows × rowH == availableForRows exactly. Applying comfortableRowH as a
|
||||
// lower-bound here caused overflow: e.g. 9 rows × 52px = 468px when only
|
||||
// 444px was available → "BOTTOM OVERFLOWED BY 16 PIXELS".
|
||||
final isTableFull = rows >= maxFitting;
|
||||
final rowHeight = isTableFull
|
||||
? math.min(maxRowH, availableForRows / rows)
|
||||
: comfortableRowH;
|
||||
return (rows, rowHeight);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import geolocator_apple
|
||||
import package_info_plus
|
||||
import printing
|
||||
import quill_native_bridge_macos
|
||||
import share_plus
|
||||
import shared_preferences_foundation
|
||||
import sqflite_darwin
|
||||
import url_launcher_macos
|
||||
@@ -36,6 +37,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
|
||||
QuillNativeBridgePlugin.register(with: registry.registrar(forPlugin: "QuillNativeBridgePlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
|
||||
+34
-10
@@ -373,10 +373,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
version: "1.4.0"
|
||||
charcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -802,10 +802,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_keyboard_visibility
|
||||
sha256: "4983655c26ab5b959252ee204c2fffa4afeb4413cd030455194ec0caa3b8e7cb"
|
||||
sha256: "98664be7be0e3ffca00de50f7f6a287ab62c763fc8c762e0a21584584a3ff4f8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.4.1"
|
||||
version: "6.0.0"
|
||||
flutter_keyboard_visibility_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1337,18 +1337,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.18"
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1789,6 +1789,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
safe_device:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: safe_device
|
||||
sha256: f6404b240191e83bcf814abddeb350158cdc69a6cc749aff73f199090b08e402
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.10"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.1.4"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_platform_interface
|
||||
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.2"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -2014,10 +2038,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.9"
|
||||
version: "0.7.7"
|
||||
timezone:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -19,6 +19,7 @@ dependencies:
|
||||
google_fonts: ^8.0.2
|
||||
audioplayers: ^6.1.0
|
||||
geolocator: ^13.0.1
|
||||
safe_device: ^1.1.4
|
||||
timezone: ^0.10.1
|
||||
flutter_map: ^8.2.2
|
||||
latlong2: ^0.9.0
|
||||
@@ -52,6 +53,7 @@ dependencies:
|
||||
flutter_image_compress: ^2.4.0
|
||||
dio: ^5.1.2
|
||||
package_info_plus: ^9.0.0
|
||||
share_plus: ^10.0.0
|
||||
pub_semver: ^2.1.1
|
||||
ota_update: ^7.1.0
|
||||
brick_offline_first: ^4.0.0
|
||||
@@ -76,6 +78,9 @@ flutter:
|
||||
- assets/
|
||||
- assets/fonts/
|
||||
|
||||
dependency_overrides:
|
||||
flutter_keyboard_visibility: ^6.0.0
|
||||
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: true
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
Postgres + pg_cron scheduled shift reminders
|
||||
|
||||
1) Run migrations
|
||||
- Apply `supabase/migrations/20260318_add_scheduled_notifications.sql` (and other pending migrations) to your Supabase DB.
|
||||
|
||||
2) Enable pg_cron (requires Supabase project admin)
|
||||
- If your Supabase tier supports it, enable the `pg_cron` extension.
|
||||
- Example (run as a privileged role):
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||
SELECT cron.schedule('shift_reminders_every_min', '*/1 * * * *', $$SELECT public.enqueue_due_shift_notifications();$$);
|
||||
|
||||
3) Deploy processor Edge Function
|
||||
- Add `supabase/functions/process_scheduled_notifications/` to your Supabase functions and deploy with the following required env vars:
|
||||
- `SUPABASE_URL`
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
- `SEND_FCM_URL` (the HTTP URL for your existing `send_fcm` function, e.g., https://<project>.functions.supabase.co/send_fcm)
|
||||
- Optional: `PROCESSOR_BATCH_SIZE` (default 50)
|
||||
|
||||
- You can deploy via `supabase functions deploy process_scheduled_notifications` or using your CI.
|
||||
|
||||
4) Scheduling / triggering processor
|
||||
- Option A (recommended): Use `pg_cron` to only enqueue rows, and configure a small interval GitHub Actions or Cloud Scheduler to call the Edge Function endpoint (POST) every minute. This keeps sending out of the DB.
|
||||
- Option B: Use `pg_cron` to call the Edge Function HTTP endpoint directly if `pg_http` is available (not recommended unless approved).
|
||||
|
||||
5) Verification
|
||||
- Insert a test `duty_schedules` record 15 minutes ahead and run `SELECT public.enqueue_due_shift_notifications();` manually — confirm `scheduled_notifications` row appears.
|
||||
- Call the Edge Function (`supabase functions invoke process_scheduled_notifications --project <id>`) or POST to its URL — confirm `send_fcm` receives payload and `scheduled_notifications` row becomes processed.
|
||||
|
||||
6) Monitoring
|
||||
- Watch `cron.job_run_details` for cron runs and `scheduled_notifications` rows with `retry_count` > 3.
|
||||
- Inspect `notification_pushes` table to ensure deduplication works.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tasks": {
|
||||
"start": "deno run --allow-net --allow-env --allow-read index.ts"
|
||||
},
|
||||
"imports": {}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
// extract_topology — Supabase Edge Function
|
||||
// Reads a network-imports file from storage, sends it to Gemini with a strict
|
||||
// JSON-schema prompt, and writes the parsed draft back to network_imports.
|
||||
//
|
||||
// Auth: caller must be admin or it_staff. Mirrors teams_crud auth pattern.
|
||||
// Gemini model selection: 2.5 Flash by default; 2.5 Pro for PDFs >5 pages.
|
||||
//
|
||||
// Env vars required:
|
||||
// SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, GEMINI_API_KEY
|
||||
|
||||
import { serve } from "https://deno.land/std@0.203.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const CORS_HEADERS: Record<string, string> = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers":
|
||||
"Content-Type, Authorization, apikey, x-client-info, x-requested-with, Origin, Accept",
|
||||
"Access-Control-Max-Age": "3600",
|
||||
};
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
|
||||
// Strict response schema we send to Gemini. Items get confidence scores so the
|
||||
// review UI can rank "definitely a switch" above "might be a switch".
|
||||
const RESPONSE_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
sites: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
address: { type: "string" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
kind: {
|
||||
type: "string",
|
||||
enum: ["router", "switch", "ap", "firewall", "server", "endpoint", "patch_panel", "other"],
|
||||
},
|
||||
vendor: { type: "string" },
|
||||
model: { type: "string" },
|
||||
mgmt_ip: { type: "string" },
|
||||
site_name: { type: "string" },
|
||||
location_label: { type: "string" },
|
||||
confidence: { type: "string", enum: ["high", "medium", "low"] },
|
||||
},
|
||||
required: ["name", "kind"],
|
||||
},
|
||||
},
|
||||
ports: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
device_name: { type: "string" },
|
||||
port_number: { type: "string" },
|
||||
port_kind: {
|
||||
type: "string",
|
||||
enum: ["rj45", "sfp", "sfp_plus", "console", "power", "wireless", "virtual", "other"],
|
||||
},
|
||||
access_vlan: { type: "integer" },
|
||||
is_trunk: { type: "boolean" },
|
||||
},
|
||||
required: ["device_name", "port_number"],
|
||||
},
|
||||
},
|
||||
links: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
device_a: { type: "string" },
|
||||
port_a: { type: "string" },
|
||||
device_b: { type: "string" },
|
||||
port_b: { type: "string" },
|
||||
link_kind: {
|
||||
type: "string",
|
||||
enum: ["copper", "fiber", "wireless", "virtual", "unknown"],
|
||||
},
|
||||
confidence: { type: "string", enum: ["high", "medium", "low"] },
|
||||
},
|
||||
required: ["device_a", "device_b"],
|
||||
},
|
||||
},
|
||||
vlans: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
vlan_id: { type: "integer" },
|
||||
name: { type: "string" },
|
||||
description: { type: "string" },
|
||||
},
|
||||
required: ["vlan_id", "name"],
|
||||
},
|
||||
},
|
||||
notes: { type: "string" },
|
||||
},
|
||||
};
|
||||
|
||||
const EXTRACTION_PROMPT = `You are a network-documentation reader. Look at the attached file (which may be a network diagram in PDF, an image of a diagram, a photo of a whiteboard, a Visio export, or a spreadsheet/notes) and extract every network device, port, link, VLAN, and site you can identify.
|
||||
|
||||
Rules:
|
||||
- Output ONLY structured JSON conforming to the provided schema. No prose.
|
||||
- For devices, classify kind precisely (switch / router / ap / etc.). If you genuinely cannot tell, use "other".
|
||||
- For ports and links, give the exact labels as they appear ("Gi0/1", "Port 24", "uplink", etc.) — do not normalize.
|
||||
- For each device and each link, attach a "confidence": "high" if the document clearly states it, "medium" if it's reasonably implied, "low" if it's a guess.
|
||||
- If you cannot extract anything useful, return all arrays as empty and put a brief explanation in "notes".
|
||||
- Do not invent items. Better to return less with high confidence than more with low.
|
||||
- VLANs: only include if a VLAN ID number is visible.
|
||||
- Sites: only include if a site/building name is clearly indicated.`;
|
||||
|
||||
interface ImportRow {
|
||||
id: string;
|
||||
storage_path: string;
|
||||
file_kind: string;
|
||||
status: string;
|
||||
created_by: string;
|
||||
}
|
||||
|
||||
function pickGeminiModel(fileKind: string, byteLength: number): string {
|
||||
// Cheap default; escalate to Pro for big PDFs where layout reasoning matters.
|
||||
if (fileKind === "pdf" && byteLength > 2 * 1024 * 1024) {
|
||||
return "gemini-2.5-pro";
|
||||
}
|
||||
return "gemini-2.5-flash";
|
||||
}
|
||||
|
||||
function mimeForFileKind(fileKind: string): string {
|
||||
switch (fileKind) {
|
||||
case "pdf":
|
||||
return "application/pdf";
|
||||
case "image":
|
||||
// Default to PNG; Gemini accepts most image MIMEs and will sniff if needed.
|
||||
return "image/png";
|
||||
case "vsdx":
|
||||
return "application/vnd.ms-visio.drawing";
|
||||
case "csv":
|
||||
return "text/csv";
|
||||
case "xlsx":
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
case "docx":
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
// Deno has no btoa for binary; build base64 in chunks to avoid stack overflow.
|
||||
let binary = "";
|
||||
const chunkSize = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const slice = bytes.subarray(i, i + chunkSize);
|
||||
binary += String.fromCharCode.apply(null, Array.from(slice));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||
}
|
||||
if (req.method !== "POST") {
|
||||
return jsonResponse({ error: "Method not allowed" }, 405);
|
||||
}
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? "";
|
||||
const anonKey = Deno.env.get("SUPABASE_ANON_KEY") ?? "";
|
||||
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
||||
const geminiKey = Deno.env.get("GEMINI_API_KEY") ?? "";
|
||||
if (!supabaseUrl || !anonKey || !serviceKey) {
|
||||
return jsonResponse({ error: "Missing Supabase env config" }, 500);
|
||||
}
|
||||
if (!geminiKey) {
|
||||
return jsonResponse({ error: "GEMINI_API_KEY not configured" }, 500);
|
||||
}
|
||||
|
||||
// Auth caller
|
||||
const authHeader = req.headers.get("Authorization") ?? "";
|
||||
const token = authHeader.replace("Bearer ", "").trim();
|
||||
if (!token) return jsonResponse({ error: "Missing access token" }, 401);
|
||||
|
||||
const authClient = createClient(supabaseUrl, anonKey, {
|
||||
global: { headers: { Authorization: `Bearer ${token}` } },
|
||||
});
|
||||
const { data: authData, error: authError } = await authClient.auth.getUser();
|
||||
if (authError || !authData?.user) return jsonResponse({ error: "Unauthorized" }, 401);
|
||||
|
||||
const adminClient = createClient(supabaseUrl, serviceKey);
|
||||
|
||||
// Caller must be admin or it_staff
|
||||
const { data: profile, error: profileError } = await adminClient
|
||||
.from("profiles")
|
||||
.select("role")
|
||||
.eq("id", authData.user.id)
|
||||
.maybeSingle();
|
||||
const role = (profile?.role ?? "").toString().toLowerCase();
|
||||
if (profileError || (role !== "admin" && role !== "it_staff")) {
|
||||
return jsonResponse({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
// Body: { import_id: string }
|
||||
let body: { import_id?: string } = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch (_e) {
|
||||
return jsonResponse({ error: "Invalid JSON body" }, 400);
|
||||
}
|
||||
const importId = body.import_id;
|
||||
if (!importId || typeof importId !== "string") {
|
||||
return jsonResponse({ error: "Missing import_id" }, 400);
|
||||
}
|
||||
|
||||
// Load the import row
|
||||
const { data: imp, error: impErr } = await adminClient
|
||||
.from("network_imports")
|
||||
.select("id, storage_path, file_kind, status, created_by")
|
||||
.eq("id", importId)
|
||||
.maybeSingle<ImportRow>();
|
||||
if (impErr || !imp) {
|
||||
return jsonResponse({ error: "Import not found" }, 404);
|
||||
}
|
||||
if (imp.status === "applied" || imp.status === "discarded") {
|
||||
return jsonResponse({ error: `Import already ${imp.status}` }, 409);
|
||||
}
|
||||
|
||||
// Mark extracting
|
||||
await adminClient
|
||||
.from("network_imports")
|
||||
.update({ status: "extracting", error_message: null })
|
||||
.eq("id", importId);
|
||||
|
||||
try {
|
||||
// Download from storage
|
||||
const { data: fileData, error: dlErr } = await adminClient
|
||||
.storage
|
||||
.from("network-imports")
|
||||
.download(imp.storage_path);
|
||||
if (dlErr || !fileData) {
|
||||
throw new Error(dlErr?.message ?? "Failed to download import file");
|
||||
}
|
||||
|
||||
const arrayBuf = await fileData.arrayBuffer();
|
||||
const bytes = new Uint8Array(arrayBuf);
|
||||
const base64 = bytesToBase64(bytes);
|
||||
const mime = mimeForFileKind(imp.file_kind);
|
||||
const model = pickGeminiModel(imp.file_kind, bytes.length);
|
||||
|
||||
// Call Gemini
|
||||
const geminiUrl =
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${geminiKey}`;
|
||||
const geminiBody = {
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ text: EXTRACTION_PROMPT },
|
||||
{ inlineData: { mimeType: mime, data: base64 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: RESPONSE_SCHEMA,
|
||||
temperature: 0.1,
|
||||
},
|
||||
};
|
||||
|
||||
const geminiRes = await fetch(geminiUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(geminiBody),
|
||||
});
|
||||
|
||||
if (!geminiRes.ok) {
|
||||
const errBody = await geminiRes.text();
|
||||
throw new Error(`Gemini ${geminiRes.status}: ${errBody.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
const geminiJson = await geminiRes.json();
|
||||
const textOut: string =
|
||||
geminiJson?.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(textOut);
|
||||
} catch (_e) {
|
||||
throw new Error("Gemini returned non-JSON output");
|
||||
}
|
||||
|
||||
// Minimum-quality gate: must have at least one device, or status=failed.
|
||||
const deviceCount = Array.isArray((parsed as { devices?: unknown[] })?.devices)
|
||||
? (parsed as { devices: unknown[] }).devices.length
|
||||
: 0;
|
||||
|
||||
if (deviceCount === 0) {
|
||||
await adminClient
|
||||
.from("network_imports")
|
||||
.update({
|
||||
status: "failed",
|
||||
ai_result: parsed as Record<string, unknown>,
|
||||
error_message: "No devices extracted from document. Start from blank canvas.",
|
||||
})
|
||||
.eq("id", importId);
|
||||
return jsonResponse(
|
||||
{ status: "failed", reason: "no_devices", ai_result: parsed },
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
// Store + mark for review
|
||||
await adminClient
|
||||
.from("network_imports")
|
||||
.update({
|
||||
status: "review",
|
||||
ai_result: parsed as Record<string, unknown>,
|
||||
error_message: null,
|
||||
})
|
||||
.eq("id", importId);
|
||||
|
||||
return jsonResponse({ status: "review", ai_result: parsed });
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message ?? String(err);
|
||||
await adminClient
|
||||
.from("network_imports")
|
||||
.update({ status: "failed", error_message: msg })
|
||||
.eq("id", importId);
|
||||
return jsonResponse({ error: msg }, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
-- Add location quality metadata columns to attendance_logs.
|
||||
-- These are populated by the extended RPCs and used for admin audit of
|
||||
-- suspicious check-ins (mock location, low accuracy).
|
||||
|
||||
ALTER TABLE attendance_logs
|
||||
ADD COLUMN IF NOT EXISTS check_in_accuracy double precision,
|
||||
ADD COLUMN IF NOT EXISTS check_in_is_mocked boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS check_out_accuracy double precision,
|
||||
ADD COLUMN IF NOT EXISTS check_out_is_mocked boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- Extend attendance_check_in to accept and store location metadata.
|
||||
-- New params have defaults so existing callers without them continue to work.
|
||||
CREATE OR REPLACE FUNCTION public.attendance_check_in(
|
||||
p_duty_id uuid,
|
||||
p_lat double precision,
|
||||
p_lng double precision,
|
||||
p_accuracy double precision DEFAULT NULL,
|
||||
p_is_mocked boolean DEFAULT false
|
||||
) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_schedule duty_schedules%ROWTYPE;
|
||||
v_log_id uuid;
|
||||
v_now timestamptz := now();
|
||||
v_status text;
|
||||
BEGIN
|
||||
SELECT * INTO v_schedule FROM duty_schedules WHERE id = p_duty_id;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Duty schedule not found';
|
||||
END IF;
|
||||
IF v_schedule.user_id != auth.uid() THEN
|
||||
RAISE EXCEPTION 'Not your duty schedule';
|
||||
END IF;
|
||||
|
||||
IF v_now < (v_schedule.start_time - interval '2 hours') THEN
|
||||
RAISE EXCEPTION 'Too early to check in (2-hour window)';
|
||||
END IF;
|
||||
IF v_now > v_schedule.end_time THEN
|
||||
RAISE EXCEPTION 'Duty has already ended';
|
||||
END IF;
|
||||
|
||||
IF v_now <= v_schedule.start_time THEN
|
||||
v_status := 'arrival';
|
||||
ELSE
|
||||
v_status := 'late';
|
||||
END IF;
|
||||
|
||||
INSERT INTO attendance_logs (
|
||||
user_id, duty_schedule_id, check_in_at, check_in_lat, check_in_lng,
|
||||
check_in_accuracy, check_in_is_mocked
|
||||
)
|
||||
VALUES (
|
||||
auth.uid(), p_duty_id, v_now, p_lat, p_lng,
|
||||
p_accuracy, p_is_mocked
|
||||
)
|
||||
RETURNING id INTO v_log_id;
|
||||
|
||||
UPDATE duty_schedules
|
||||
SET check_in_at = COALESCE(check_in_at, v_now),
|
||||
check_in_location = ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography,
|
||||
status = CASE
|
||||
WHEN check_in_at IS NULL THEN v_status::duty_status
|
||||
ELSE status
|
||||
END
|
||||
WHERE id = p_duty_id;
|
||||
|
||||
RETURN v_log_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Extend attendance_check_out to accept and store location metadata.
|
||||
CREATE OR REPLACE FUNCTION public.attendance_check_out(
|
||||
p_attendance_id uuid,
|
||||
p_lat double precision,
|
||||
p_lng double precision,
|
||||
p_justification text DEFAULT NULL,
|
||||
p_accuracy double precision DEFAULT NULL,
|
||||
p_is_mocked boolean DEFAULT false
|
||||
) RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_log attendance_logs%ROWTYPE;
|
||||
BEGIN
|
||||
SELECT * INTO v_log FROM attendance_logs WHERE id = p_attendance_id;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Attendance log not found';
|
||||
END IF;
|
||||
IF v_log.user_id != auth.uid() THEN
|
||||
RAISE EXCEPTION 'Not your attendance log';
|
||||
END IF;
|
||||
IF v_log.check_out_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Already checked out';
|
||||
END IF;
|
||||
|
||||
UPDATE attendance_logs
|
||||
SET check_out_at = now(),
|
||||
check_out_lat = p_lat,
|
||||
check_out_lng = p_lng,
|
||||
justification = COALESCE(p_justification, justification),
|
||||
check_out_accuracy = p_accuracy,
|
||||
check_out_is_mocked = p_is_mocked
|
||||
WHERE id = p_attendance_id;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,241 @@
|
||||
-- Restore server-side geofence validation in attendance_check_in and
|
||||
-- attendance_check_out, with support for both polygon and circle geofences
|
||||
-- and the IT service request outside-premise override.
|
||||
--
|
||||
-- Context: geofence validation was added in 20260316120000 (polygon only)
|
||||
-- then stripped in 20260317100000 because circle geofences weren't handled,
|
||||
-- causing false-rejections for offices configured with a radius. This migration
|
||||
-- restores it with full polygon + circle support using PostGIS geography.
|
||||
--
|
||||
-- Security: without server-side validation anyone with a valid session can
|
||||
-- call the RPC directly with arbitrary coordinates and bypass the geofence.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.attendance_check_in(
|
||||
p_duty_id uuid,
|
||||
p_lat double precision,
|
||||
p_lng double precision,
|
||||
p_accuracy double precision DEFAULT NULL,
|
||||
p_is_mocked boolean DEFAULT false
|
||||
) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_schedule duty_schedules%ROWTYPE;
|
||||
v_log_id uuid;
|
||||
v_now timestamptz := now();
|
||||
v_status text;
|
||||
v_geofence jsonb;
|
||||
v_in_premise boolean := true;
|
||||
v_override boolean := false;
|
||||
-- polygon ray-casting
|
||||
v_polygon jsonb;
|
||||
v_point_count int;
|
||||
v_i int;
|
||||
v_j int;
|
||||
v_xi double precision;
|
||||
v_yi double precision;
|
||||
v_xj double precision;
|
||||
v_yj double precision;
|
||||
v_inside boolean := false;
|
||||
-- circle
|
||||
v_center_lat double precision;
|
||||
v_center_lng double precision;
|
||||
v_radius_m double precision;
|
||||
v_dist_m double precision;
|
||||
BEGIN
|
||||
SELECT * INTO v_schedule FROM duty_schedules WHERE id = p_duty_id;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Duty schedule not found';
|
||||
END IF;
|
||||
IF v_schedule.user_id != auth.uid() THEN
|
||||
RAISE EXCEPTION 'Not your duty schedule';
|
||||
END IF;
|
||||
|
||||
-- Geofence validation
|
||||
SELECT value INTO v_geofence FROM app_settings WHERE key = 'geofence';
|
||||
IF v_geofence IS NOT NULL THEN
|
||||
v_polygon := COALESCE(v_geofence->'polygon', v_geofence->'points');
|
||||
|
||||
IF v_polygon IS NOT NULL AND jsonb_array_length(v_polygon) > 2 THEN
|
||||
-- Polygon: ray-casting point-in-polygon
|
||||
v_point_count := jsonb_array_length(v_polygon);
|
||||
v_j := v_point_count - 1;
|
||||
FOR v_i IN 0..(v_point_count - 1) LOOP
|
||||
v_xi := (v_polygon->v_i->>'lng')::double precision;
|
||||
v_yi := (v_polygon->v_i->>'lat')::double precision;
|
||||
v_xj := (v_polygon->v_j->>'lng')::double precision;
|
||||
v_yj := (v_polygon->v_j->>'lat')::double precision;
|
||||
IF ((v_yi > p_lat) != (v_yj > p_lat)) AND
|
||||
(p_lng < (v_xj - v_xi) * (p_lat - v_yi) / (v_yj - v_yi) + v_xi) THEN
|
||||
v_inside := NOT v_inside;
|
||||
END IF;
|
||||
v_j := v_i;
|
||||
END LOOP;
|
||||
v_in_premise := v_inside;
|
||||
|
||||
ELSIF (v_geofence->>'lat') IS NOT NULL
|
||||
AND (v_geofence->>'lng') IS NOT NULL
|
||||
AND (v_geofence->>'radius_m') IS NOT NULL THEN
|
||||
-- Circle: PostGIS ST_Distance on geography gives metres directly
|
||||
v_center_lat := (v_geofence->>'lat')::double precision;
|
||||
v_center_lng := (v_geofence->>'lng')::double precision;
|
||||
v_radius_m := (v_geofence->>'radius_m')::double precision;
|
||||
SELECT ST_Distance(
|
||||
ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography,
|
||||
ST_SetSRID(ST_MakePoint(v_center_lng, v_center_lat), 4326)::geography
|
||||
) INTO v_dist_m;
|
||||
v_in_premise := (v_dist_m <= v_radius_m);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- IT service request outside-premise override
|
||||
IF NOT v_in_premise THEN
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM it_service_request_assignments a
|
||||
JOIN it_service_requests r ON r.id = a.request_id
|
||||
WHERE a.user_id = auth.uid()
|
||||
AND r.outside_premise_allowed
|
||||
AND r.status IN ('scheduled', 'in_progress_dry_run', 'in_progress')
|
||||
) INTO v_override;
|
||||
IF NOT v_override THEN
|
||||
RAISE EXCEPTION 'Outside geofence';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Time window check
|
||||
IF v_now < (v_schedule.start_time - interval '2 hours') THEN
|
||||
RAISE EXCEPTION 'Too early to check in (2-hour window)';
|
||||
END IF;
|
||||
IF v_now > v_schedule.end_time THEN
|
||||
RAISE EXCEPTION 'Duty has already ended';
|
||||
END IF;
|
||||
|
||||
IF v_now <= v_schedule.start_time THEN
|
||||
v_status := 'arrival';
|
||||
ELSE
|
||||
v_status := 'late';
|
||||
END IF;
|
||||
|
||||
INSERT INTO attendance_logs (
|
||||
user_id, duty_schedule_id, check_in_at, check_in_lat, check_in_lng,
|
||||
check_in_accuracy, check_in_is_mocked
|
||||
)
|
||||
VALUES (
|
||||
auth.uid(), p_duty_id, v_now, p_lat, p_lng,
|
||||
p_accuracy, p_is_mocked
|
||||
)
|
||||
RETURNING id INTO v_log_id;
|
||||
|
||||
UPDATE duty_schedules
|
||||
SET check_in_at = COALESCE(check_in_at, v_now),
|
||||
check_in_location = ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography,
|
||||
status = CASE
|
||||
WHEN check_in_at IS NULL THEN v_status::duty_status
|
||||
ELSE status
|
||||
END
|
||||
WHERE id = p_duty_id;
|
||||
|
||||
RETURN v_log_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.attendance_check_out(
|
||||
p_attendance_id uuid,
|
||||
p_lat double precision,
|
||||
p_lng double precision,
|
||||
p_justification text DEFAULT NULL,
|
||||
p_accuracy double precision DEFAULT NULL,
|
||||
p_is_mocked boolean DEFAULT false
|
||||
) RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_log attendance_logs%ROWTYPE;
|
||||
v_geofence jsonb;
|
||||
v_in_premise boolean := true;
|
||||
v_override boolean := false;
|
||||
v_polygon jsonb;
|
||||
v_point_count int;
|
||||
v_i int;
|
||||
v_j int;
|
||||
v_xi double precision;
|
||||
v_yi double precision;
|
||||
v_xj double precision;
|
||||
v_yj double precision;
|
||||
v_inside boolean := false;
|
||||
v_center_lat double precision;
|
||||
v_center_lng double precision;
|
||||
v_radius_m double precision;
|
||||
v_dist_m double precision;
|
||||
BEGIN
|
||||
SELECT * INTO v_log FROM attendance_logs WHERE id = p_attendance_id;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Attendance log not found';
|
||||
END IF;
|
||||
IF v_log.user_id != auth.uid() THEN
|
||||
RAISE EXCEPTION 'Not your attendance log';
|
||||
END IF;
|
||||
IF v_log.check_out_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Already checked out';
|
||||
END IF;
|
||||
|
||||
-- Geofence validation on checkout (same logic as check-in)
|
||||
SELECT value INTO v_geofence FROM app_settings WHERE key = 'geofence';
|
||||
IF v_geofence IS NOT NULL THEN
|
||||
v_polygon := COALESCE(v_geofence->'polygon', v_geofence->'points');
|
||||
|
||||
IF v_polygon IS NOT NULL AND jsonb_array_length(v_polygon) > 2 THEN
|
||||
v_point_count := jsonb_array_length(v_polygon);
|
||||
v_j := v_point_count - 1;
|
||||
FOR v_i IN 0..(v_point_count - 1) LOOP
|
||||
v_xi := (v_polygon->v_i->>'lng')::double precision;
|
||||
v_yi := (v_polygon->v_i->>'lat')::double precision;
|
||||
v_xj := (v_polygon->v_j->>'lng')::double precision;
|
||||
v_yj := (v_polygon->v_j->>'lat')::double precision;
|
||||
IF ((v_yi > p_lat) != (v_yj > p_lat)) AND
|
||||
(p_lng < (v_xj - v_xi) * (p_lat - v_yi) / (v_yj - v_yi) + v_xi) THEN
|
||||
v_inside := NOT v_inside;
|
||||
END IF;
|
||||
v_j := v_i;
|
||||
END LOOP;
|
||||
v_in_premise := v_inside;
|
||||
|
||||
ELSIF (v_geofence->>'lat') IS NOT NULL
|
||||
AND (v_geofence->>'lng') IS NOT NULL
|
||||
AND (v_geofence->>'radius_m') IS NOT NULL THEN
|
||||
v_center_lat := (v_geofence->>'lat')::double precision;
|
||||
v_center_lng := (v_geofence->>'lng')::double precision;
|
||||
v_radius_m := (v_geofence->>'radius_m')::double precision;
|
||||
SELECT ST_Distance(
|
||||
ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography,
|
||||
ST_SetSRID(ST_MakePoint(v_center_lng, v_center_lat), 4326)::geography
|
||||
) INTO v_dist_m;
|
||||
v_in_premise := (v_dist_m <= v_radius_m);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF NOT v_in_premise THEN
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM it_service_request_assignments a
|
||||
JOIN it_service_requests r ON r.id = a.request_id
|
||||
WHERE a.user_id = auth.uid()
|
||||
AND r.outside_premise_allowed
|
||||
AND r.status IN ('scheduled', 'in_progress_dry_run', 'in_progress')
|
||||
) INTO v_override;
|
||||
IF NOT v_override THEN
|
||||
RAISE EXCEPTION 'Outside geofence';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
UPDATE attendance_logs
|
||||
SET check_out_at = now(),
|
||||
check_out_lat = p_lat,
|
||||
check_out_lng = p_lng,
|
||||
justification = COALESCE(p_justification, justification),
|
||||
check_out_accuracy = p_accuracy,
|
||||
check_out_is_mocked = p_is_mocked
|
||||
WHERE id = p_attendance_id;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,329 @@
|
||||
-- Network Infrastructure Map — V1 schema.
|
||||
-- Adds 7 tables (sites, locations, devices, ports, links, vlans, imports)
|
||||
-- plus a private Storage bucket for uploaded source documents.
|
||||
-- RLS: read for admin/it_staff/dispatcher/programmer; write for admin/it_staff only.
|
||||
-- See plan: C:\Users\marcr\.claude\plans\i-m-thinking-of-nested-lagoon.md
|
||||
|
||||
-- =============================================================================
|
||||
-- TABLES
|
||||
-- =============================================================================
|
||||
|
||||
-- Sites: top-level physical locations (campus, building treated standalone)
|
||||
CREATE TABLE IF NOT EXISTS public.network_sites (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
address text,
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by uuid REFERENCES public.profiles(id)
|
||||
);
|
||||
|
||||
-- Locations: nested physical hierarchy within a site
|
||||
CREATE TABLE IF NOT EXISTS public.network_locations (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id uuid NOT NULL REFERENCES public.network_sites(id) ON DELETE CASCADE,
|
||||
parent_id uuid REFERENCES public.network_locations(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
kind text NOT NULL CHECK (kind IN ('building','floor','room','rack','wall_jack','other')),
|
||||
position_label text,
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_network_locations_site ON public.network_locations(site_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_network_locations_parent ON public.network_locations(parent_id);
|
||||
|
||||
-- Devices: switches, routers, APs, endpoints, patch panels
|
||||
CREATE TABLE IF NOT EXISTS public.network_devices (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
kind text NOT NULL CHECK (kind IN
|
||||
('router','switch','ap','firewall','server','endpoint','patch_panel','other')),
|
||||
role text CHECK (role IN ('core','distribution','access','edge','endpoint')),
|
||||
vendor text,
|
||||
model text,
|
||||
serial text,
|
||||
mgmt_ip inet,
|
||||
mac macaddr,
|
||||
location_id uuid REFERENCES public.network_locations(id) ON DELETE SET NULL,
|
||||
import_source text NOT NULL DEFAULT 'manual'
|
||||
CHECK (import_source IN ('manual','ai_import','agent')),
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_network_devices_location ON public.network_devices(location_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_network_devices_role ON public.network_devices(role);
|
||||
CREATE INDEX IF NOT EXISTS idx_network_devices_kind ON public.network_devices(kind);
|
||||
|
||||
-- Ports: physical/logical ports on a device
|
||||
CREATE TABLE IF NOT EXISTS public.network_ports (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
device_id uuid NOT NULL REFERENCES public.network_devices(id) ON DELETE CASCADE,
|
||||
port_number text NOT NULL,
|
||||
port_kind text CHECK (port_kind IN ('rj45','sfp','sfp_plus','console','power','wireless','virtual','other')),
|
||||
speed_mbps integer,
|
||||
access_vlan integer,
|
||||
is_trunk boolean NOT NULL DEFAULT false,
|
||||
trunk_vlans integer[],
|
||||
notes text,
|
||||
UNIQUE (device_id, port_number)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_network_ports_device ON public.network_ports(device_id);
|
||||
|
||||
-- Links: port-to-port edges. Canonical ordering (port_a < port_b) prevents A↔B/B↔A dupes.
|
||||
CREATE TABLE IF NOT EXISTS public.network_links (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
port_a uuid NOT NULL REFERENCES public.network_ports(id) ON DELETE CASCADE,
|
||||
port_b uuid NOT NULL REFERENCES public.network_ports(id) ON DELETE CASCADE,
|
||||
link_kind text CHECK (link_kind IN ('copper','fiber','wireless','virtual','unknown')),
|
||||
cable_label text,
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (port_a < port_b),
|
||||
UNIQUE (port_a, port_b)
|
||||
);
|
||||
|
||||
-- VLANs (org-scoped, named)
|
||||
CREATE TABLE IF NOT EXISTS public.network_vlans (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
vlan_id integer NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
color text
|
||||
);
|
||||
|
||||
-- Import sessions: each upload + extraction state + raw AI result for audit/replay
|
||||
CREATE TABLE IF NOT EXISTS public.network_imports (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
storage_path text NOT NULL,
|
||||
file_kind text NOT NULL
|
||||
CHECK (file_kind IN ('pdf','image','vsdx','csv','xlsx','docx','other')),
|
||||
status text NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','extracting','review','applied','discarded','failed')),
|
||||
ai_result jsonb,
|
||||
error_message text,
|
||||
applied_device_ids uuid[],
|
||||
applied_link_ids uuid[],
|
||||
applied_at timestamptz,
|
||||
created_by uuid NOT NULL REFERENCES public.profiles(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_network_imports_status ON public.network_imports(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_network_imports_created_by ON public.network_imports(created_by);
|
||||
|
||||
-- =============================================================================
|
||||
-- updated_at TRIGGERS
|
||||
-- =============================================================================
|
||||
-- Reuses common pattern from prior Tasq migrations.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.network_map_set_updated_at()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_network_sites_updated_at ON public.network_sites;
|
||||
CREATE TRIGGER trg_network_sites_updated_at
|
||||
BEFORE UPDATE ON public.network_sites
|
||||
FOR EACH ROW EXECUTE FUNCTION public.network_map_set_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_network_devices_updated_at ON public.network_devices;
|
||||
CREATE TRIGGER trg_network_devices_updated_at
|
||||
BEFORE UPDATE ON public.network_devices
|
||||
FOR EACH ROW EXECUTE FUNCTION public.network_map_set_updated_at();
|
||||
|
||||
-- =============================================================================
|
||||
-- ROW LEVEL SECURITY
|
||||
-- =============================================================================
|
||||
-- Pattern matches supabase/migrations/20260223090000_services_read_only_for_standard_it_dispatcher.sql
|
||||
-- READ: admin + it_staff + dispatcher + programmer
|
||||
-- WRITE: admin + it_staff
|
||||
|
||||
ALTER TABLE public.network_sites ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.network_locations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.network_devices ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.network_ports ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.network_links ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.network_vlans ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.network_imports ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- ----- network_sites -----
|
||||
DROP POLICY IF EXISTS "Network sites: select" ON public.network_sites;
|
||||
CREATE POLICY "Network sites: select" ON public.network_sites
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid()
|
||||
AND p.role IN ('admin','it_staff','dispatcher','programmer'))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Network sites: write" ON public.network_sites;
|
||||
CREATE POLICY "Network sites: write" ON public.network_sites
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
) WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
);
|
||||
|
||||
-- ----- network_locations -----
|
||||
DROP POLICY IF EXISTS "Network locations: select" ON public.network_locations;
|
||||
CREATE POLICY "Network locations: select" ON public.network_locations
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid()
|
||||
AND p.role IN ('admin','it_staff','dispatcher','programmer'))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Network locations: write" ON public.network_locations;
|
||||
CREATE POLICY "Network locations: write" ON public.network_locations
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
) WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
);
|
||||
|
||||
-- ----- network_devices -----
|
||||
DROP POLICY IF EXISTS "Network devices: select" ON public.network_devices;
|
||||
CREATE POLICY "Network devices: select" ON public.network_devices
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid()
|
||||
AND p.role IN ('admin','it_staff','dispatcher','programmer'))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Network devices: write" ON public.network_devices;
|
||||
CREATE POLICY "Network devices: write" ON public.network_devices
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
) WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
);
|
||||
|
||||
-- ----- network_ports -----
|
||||
DROP POLICY IF EXISTS "Network ports: select" ON public.network_ports;
|
||||
CREATE POLICY "Network ports: select" ON public.network_ports
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid()
|
||||
AND p.role IN ('admin','it_staff','dispatcher','programmer'))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Network ports: write" ON public.network_ports;
|
||||
CREATE POLICY "Network ports: write" ON public.network_ports
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
) WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
);
|
||||
|
||||
-- ----- network_links -----
|
||||
DROP POLICY IF EXISTS "Network links: select" ON public.network_links;
|
||||
CREATE POLICY "Network links: select" ON public.network_links
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid()
|
||||
AND p.role IN ('admin','it_staff','dispatcher','programmer'))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Network links: write" ON public.network_links;
|
||||
CREATE POLICY "Network links: write" ON public.network_links
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
) WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
);
|
||||
|
||||
-- ----- network_vlans -----
|
||||
DROP POLICY IF EXISTS "Network vlans: select" ON public.network_vlans;
|
||||
CREATE POLICY "Network vlans: select" ON public.network_vlans
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid()
|
||||
AND p.role IN ('admin','it_staff','dispatcher','programmer'))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Network vlans: write" ON public.network_vlans;
|
||||
CREATE POLICY "Network vlans: write" ON public.network_vlans
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
) WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
);
|
||||
|
||||
-- ----- network_imports -----
|
||||
DROP POLICY IF EXISTS "Network imports: select" ON public.network_imports;
|
||||
CREATE POLICY "Network imports: select" ON public.network_imports
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid()
|
||||
AND p.role IN ('admin','it_staff','dispatcher','programmer'))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Network imports: write" ON public.network_imports;
|
||||
CREATE POLICY "Network imports: write" ON public.network_imports
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
) WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- STORAGE BUCKET (private) for uploaded source documents
|
||||
-- =============================================================================
|
||||
-- The Edge Function `extract_topology` uses the service role to read from this
|
||||
-- bucket. Authenticated admin/it_staff clients upload via signed URLs or with
|
||||
-- their RLS-bound JWT.
|
||||
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('network-imports', 'network-imports', false)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Storage policies: only admin/it_staff can upload; same group + dispatcher/programmer can read.
|
||||
DROP POLICY IF EXISTS "Network imports bucket: select" ON storage.objects;
|
||||
CREATE POLICY "Network imports bucket: select" ON storage.objects
|
||||
FOR SELECT USING (
|
||||
bucket_id = 'network-imports'
|
||||
AND EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid()
|
||||
AND p.role IN ('admin','it_staff','dispatcher','programmer'))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Network imports bucket: write" ON storage.objects;
|
||||
CREATE POLICY "Network imports bucket: write" ON storage.objects
|
||||
FOR INSERT WITH CHECK (
|
||||
bucket_id = 'network-imports'
|
||||
AND EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Network imports bucket: update" ON storage.objects;
|
||||
CREATE POLICY "Network imports bucket: update" ON storage.objects
|
||||
FOR UPDATE USING (
|
||||
bucket_id = 'network-imports'
|
||||
AND EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Network imports bucket: delete" ON storage.objects;
|
||||
CREATE POLICY "Network imports bucket: delete" ON storage.objects
|
||||
FOR DELETE USING (
|
||||
bucket_id = 'network-imports'
|
||||
AND EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Fix: insert_it_service_request_with_number did not accept p_id, causing the
|
||||
-- Flutter createRequest RPC call to fail because rpcParams always includes p_id
|
||||
-- for offline-replay idempotency. Add p_id as an optional param; if supplied the
|
||||
-- row uses that UUID (offline sync), otherwise a new one is generated (normal flow).
|
||||
|
||||
CREATE OR REPLACE FUNCTION insert_it_service_request_with_number(
|
||||
p_event_name text,
|
||||
p_services text[],
|
||||
p_creator_id uuid,
|
||||
p_id uuid DEFAULT NULL,
|
||||
p_office_id uuid DEFAULT NULL,
|
||||
p_requested_by text DEFAULT NULL,
|
||||
p_requested_by_user_id uuid DEFAULT NULL,
|
||||
p_status text DEFAULT 'draft'
|
||||
)
|
||||
RETURNS TABLE(id uuid, request_number text) AS $$
|
||||
DECLARE
|
||||
v_seq int;
|
||||
v_id uuid;
|
||||
v_number text;
|
||||
BEGIN
|
||||
SELECT COALESCE(MAX(
|
||||
CAST(NULLIF(regexp_replace(r.request_number, '^ISR-\d{4}-', ''), '') AS int)
|
||||
), 0) + 1
|
||||
INTO v_seq
|
||||
FROM it_service_requests r
|
||||
WHERE r.request_number LIKE 'ISR-' || EXTRACT(YEAR FROM now())::text || '-%';
|
||||
|
||||
v_number := 'ISR-' || EXTRACT(YEAR FROM now())::text || '-' || LPAD(v_seq::text, 4, '0');
|
||||
v_id := COALESCE(p_id, gen_random_uuid());
|
||||
|
||||
INSERT INTO it_service_requests (
|
||||
id, request_number, event_name, services,
|
||||
creator_id, office_id, requested_by, requested_by_user_id, status
|
||||
)
|
||||
VALUES (
|
||||
v_id, v_number, p_event_name, p_services,
|
||||
p_creator_id, p_office_id, p_requested_by, p_requested_by_user_id, p_status
|
||||
);
|
||||
|
||||
RETURN QUERY SELECT v_id, v_number;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -0,0 +1,61 @@
|
||||
-- Fix overtime_check_in to accept p_accuracy and p_is_mocked.
|
||||
-- Migration 20260504000000 added these to attendance_check_in/out but missed
|
||||
-- overtime_check_in, causing a PostgREST "no candidate" error during overtime
|
||||
-- check-in when Flutter passes these two params.
|
||||
--
|
||||
-- The attendance_logs columns check_in_accuracy and check_in_is_mocked already
|
||||
-- exist (added in 20260504000000), so only the function signature needs updating.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.overtime_check_in(
|
||||
p_lat double precision,
|
||||
p_lng double precision,
|
||||
p_justification text DEFAULT NULL,
|
||||
p_accuracy double precision DEFAULT NULL,
|
||||
p_is_mocked boolean DEFAULT false
|
||||
) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_uid uuid := auth.uid();
|
||||
v_now timestamptz := now();
|
||||
v_schedule_id uuid;
|
||||
v_log_id uuid;
|
||||
v_end_time timestamptz;
|
||||
BEGIN
|
||||
IF v_uid IS NULL THEN
|
||||
RAISE EXCEPTION 'Not authenticated';
|
||||
END IF;
|
||||
|
||||
v_end_time := v_now + interval '8 hours';
|
||||
|
||||
INSERT INTO duty_schedules (
|
||||
user_id, shift_type, start_time, end_time,
|
||||
status, check_in_at, check_in_location
|
||||
)
|
||||
VALUES (
|
||||
v_uid, 'overtime', v_now, v_end_time,
|
||||
'arrival'::duty_status, v_now,
|
||||
ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography
|
||||
)
|
||||
RETURNING id INTO v_schedule_id;
|
||||
|
||||
INSERT INTO attendance_logs (
|
||||
user_id, duty_schedule_id, check_in_at,
|
||||
check_in_lat, check_in_lng,
|
||||
justification, check_in_accuracy, check_in_is_mocked
|
||||
)
|
||||
VALUES (
|
||||
v_uid, v_schedule_id, v_now,
|
||||
p_lat, p_lng,
|
||||
p_justification, p_accuracy, p_is_mocked
|
||||
)
|
||||
RETURNING id INTO v_log_id;
|
||||
|
||||
RETURN v_log_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Drop the old 3-param attendance_check_in overload (from 20260307090000).
|
||||
-- The 5-param version (with p_accuracy, p_is_mocked) is the only correct one.
|
||||
-- Mirrors the cleanup done for attendance_check_out in 20260309090000.
|
||||
DROP FUNCTION IF EXISTS public.attendance_check_in(uuid, double precision, double precision);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Add status column to network_devices for online/offline/warning indicators.
|
||||
ALTER TABLE network_devices
|
||||
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'unknown'
|
||||
CHECK (status IN ('online', 'offline', 'warning', 'unknown'));
|
||||
@@ -73,6 +73,9 @@ class FakeNotificationsController implements NotificationsController {
|
||||
@override
|
||||
Future<void> markReadForTask(String taskId) async {}
|
||||
|
||||
@override
|
||||
Future<void> markAllRead() async {}
|
||||
|
||||
@override
|
||||
Future<void> registerFcmToken(String token) async {}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include <geolocator_windows/geolocator_windows.h>
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
#include <printing/printing_plugin.h>
|
||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
@@ -30,6 +31,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
PrintingPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PrintingPlugin"));
|
||||
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
geolocator_windows
|
||||
permission_handler_windows
|
||||
printing
|
||||
share_plus
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user