Compare commits

...

11 Commits

Author SHA1 Message Date
redz1029 7f38086237 Migration fixes 2026-05-21 06:29:37 +08:00
redz1029 5d0a66bb52 Initial Commit : Network Map 2026-05-21 06:29:14 +08:00
redz1029 82141dd61c Improved attendance screen navigations on mobile 2026-05-18 23:53:08 +08:00
redz1029 425fe8e683 Weekly, Monthly and Custom Range Worklog 2026-05-16 05:10:04 +08:00
redz1029 4b9b0c7a20 Fixed report rendering in web 2026-05-14 22:08:15 +08:00
redz1029 e9d1af867a Notification Screen and tasq_adaptive_list enhancements 2026-05-11 11:34:34 +08:00
redz1029 30b301765b * Improved Task and Ticket Detail Screen
* Made Office assignment searchable in Task and Ticket Creation and Edit Screen
2026-05-10 17:54:03 +08:00
redz1029 4d0d4d5ab3 Attendance DTR and UI Improvement for Offline Rediness 2026-05-09 17:32:07 +08:00
redz1029 ccedf6e5f0 Worklog, anti geo spoofing and UI Polish 2026-05-05 05:51:19 +08:00
redz1029 d068887354 Clean up phantom files 2026-05-03 15:00:35 +08:00
redz1029 858520bd8d Offline rediness 2026-05-03 14:50:14 +08:00
73 changed files with 14380 additions and 945 deletions
+2 -2
View File
@@ -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)"
}
]
}
+2 -1
View File
@@ -1,4 +1,5 @@
{
"deno.enable": true,
"git.ignoreLimitWarning": true
"git.ignoreLimitWarning": true,
"cmake.ignoreCMakeListsMissing": true
}
View File
+91 -113
View File
@@ -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,
};
}
}
+274
View File
@@ -0,0 +1,274 @@
import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';
import 'package:brick_supabase/brick_supabase.dart';
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 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.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?),
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,
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,
'notes': notes,
};
NetworkDevice copyWith({
String? name,
NetworkDeviceKind? kind,
NetworkDeviceRole? role,
String? vendor,
String? model,
String? serial,
String? mgmtIp,
String? mac,
String? locationId,
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,
notes: notes ?? this.notes,
createdAt: createdAt,
updatedAt: updatedAt,
);
}
}
+175
View File
@@ -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;
}
}
}
+112
View File
@@ -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,
};
}
}
+93
View File
@@ -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,
};
}
+148
View File
@@ -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,
};
}
+52
View File
@@ -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,
};
}
+40
View File
@@ -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,
};
}
+143
View File
@@ -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,
);
}
}
+74 -5
View File
@@ -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?;
}
+19 -4
View File
@@ -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');
+1 -1
View File
@@ -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,246 @@
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,
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,
'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 (560s) — 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);
+21
View File
@@ -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,
);
});
+70
View File
@@ -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;
});
+64 -4
View File
@@ -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 [];
}
}
},
);
+10 -3
View File
@@ -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);
-1
View File
@@ -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);
+1
View File
@@ -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);
+90
View File
@@ -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
+604
View File
@@ -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;
}
}
}
+321
View File
@@ -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,465 @@
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;
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;
// Resolve the device's location → its site for the pickers.
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,
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,
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,
);
}
@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);
// For a new device under a specific site (from the /site/:id/device/new
// route), pre-fill the site picker so the user can save without picking.
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: [
TextButton(
onPressed: _saving ? null : _save,
child: _saving ? const Text('Saving…') : 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: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(labelText: 'Name *'),
validator: (v) => (v == null || v.trim().isEmpty)
? 'Name is required'
: null,
),
const SizedBox(height: 12),
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 *'),
),
const SizedBox(height: 12),
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: 'Logical role'),
),
const SizedBox(height: 12),
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;
// Reset location when site changes old location wouldn't
// belong to the new site.
_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.'
: 'Specific 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: 12),
TextFormField(
controller: _vendorCtrl,
decoration: const InputDecoration(
labelText: 'Vendor (e.g. Ruijie, TP-Link)',
),
),
const SizedBox(height: 12),
TextFormField(
controller: _modelCtrl,
decoration: const InputDecoration(labelText: 'Model'),
),
const SizedBox(height: 12),
TextFormField(
controller: _serialCtrl,
decoration: const InputDecoration(labelText: 'Serial'),
),
const SizedBox(height: 12),
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: 3,
),
if (widget.deviceId != null) ...[
const SizedBox(height: 24),
Row(
children: [
Text('Ports',
style: Theme.of(context).textTheme.titleMedium),
const Spacer(),
TextButton.icon(
onPressed: () => _addPort(widget.deviceId!),
icon: const Icon(Icons.add),
label: const Text('Add port'),
),
],
),
_PortsList(deviceId: widget.deviceId!),
],
],
),
),
),
);
},
),
);
}
}
class _PortsList extends ConsumerWidget {
const _PortsList({required this.deviceId});
final String deviceId;
@override
Widget build(BuildContext context, WidgetRef ref) {
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 const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text('No ports yet.'),
);
}
return Column(
children: [
for (final p in ports)
PortListTile(
port: p,
canEdit: true,
onTap: () => showPortEditDialog(
context: context,
ref: ref,
deviceId: deviceId,
existing: p,
),
onDelete: () => ref
.read(networkDevicesControllerProvider)
.deletePort(p.id, deviceId: deviceId),
),
],
);
},
);
}
}
@@ -0,0 +1,203 @@
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/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;
}
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
? () => showPortEditDialog(
context: context,
ref: ref,
deviceId: device.id,
existing: port,
)
: 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,86 @@
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';
class NetworkMapSiteScreen extends ConsumerWidget {
const NetworkMapSiteScreen({super.key, required this.siteId});
final String siteId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final topologyAsync = ref.watch(topologyForSiteProvider(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 == 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/$siteId/device/new'),
),
],
),
body: topologyAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => AppErrorView(
error: e,
onRetry: () => ref.invalidate(topologyForSiteProvider(siteId)),
),
data: (graph) => TopologyCanvas(
graph: graph,
viewMode: viewMode,
onDeviceTap: (device) =>
context.go('/network-map/device/${device.id}'),
),
),
);
}
}
@@ -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,174 @@
import 'package:flutter/material.dart';
import '../../../models/network/network_device.dart';
/// A styled node representing one network device on the topology canvas.
///
/// Designed to feel solid at ~120-160dp wide so it remains legible at the
/// "good for 200 nodes" zoom level called out in the V1 spec.
///
/// The node has three visual states beyond its default rest:
/// - [isSelected]: stronger primary-tinted background + 2dp primary border.
/// - [isHighlighted]: softer secondary tint + primary outline, used when an
/// edge touching this device is hovered on the canvas.
/// - [isSelected] takes precedence over [isHighlighted].
class DeviceNode extends StatelessWidget {
const DeviceNode({
super.key,
required this.device,
this.portCount,
this.isSelected = false,
this.isHighlighted = false,
this.onTap,
});
final NetworkDevice device;
final int? portCount;
final bool isSelected;
final bool isHighlighted;
final VoidCallback? onTap;
IconData _iconForKind(NetworkDeviceKind kind) {
switch (kind) {
case NetworkDeviceKind.router:
return Icons.router_outlined;
case NetworkDeviceKind.switchDevice:
return Icons.hub_outlined;
case NetworkDeviceKind.ap:
return Icons.wifi_outlined;
case NetworkDeviceKind.firewall:
return Icons.security_outlined;
case NetworkDeviceKind.server:
return Icons.dns_outlined;
case NetworkDeviceKind.endpoint:
return Icons.devices_outlined;
case NetworkDeviceKind.patchPanel:
return Icons.view_module_outlined;
case NetworkDeviceKind.other:
return Icons.device_unknown_outlined;
}
}
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;
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
final accent = _accentForRole(cs, device.role);
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: Container(
width: 160,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(_iconForKind(device.kind), size: 18, color: accent),
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,
),
),
],
),
],
],
),
),
),
),
);
}
}
@@ -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,236 @@
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(maxWidth: 420),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _numberCtrl,
autofocus: widget.existing == null,
decoration: const InputDecoration(
labelText: 'Port number *',
helperText: 'Exactly as labeled on the device (e.g. Gi0/1, eth0, 24)',
),
),
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(
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 — trunk port'
: 'Single VLAN ID (14094)',
),
),
),
],
),
const SizedBox(height: 12),
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,
),
),
if (_isTrunk) ...[
const SizedBox(height: 4),
TextField(
controller: _trunkVlansCtrl,
decoration: const InputDecoration(
labelText: 'Allowed VLANs (comma-separated)',
helperText: 'e.g. 10, 20, 100-105',
),
),
],
const SizedBox(height: 12),
TextField(
controller: _notesCtrl,
maxLines: 2,
decoration: const InputDecoration(labelText: 'Notes'),
),
],
),
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _saving ? null : _save,
child: Text(_saving ? 'Saving…' : 'Save'),
),
],
);
}
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import '../../../models/network/network_port.dart';
class PortListTile extends StatelessWidget {
const PortListTile({
super.key,
required this.port,
this.linkedDeviceName,
this.linkedPortLabel,
this.canEdit = false,
this.onTap,
this.onDelete,
});
final NetworkPort port;
final String? linkedDeviceName;
final String? linkedPortLabel;
final bool canEdit;
final VoidCallback? onTap;
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,
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
? IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Delete port',
onPressed: onDelete,
)
: null,
);
}
}
@@ -0,0 +1,823 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../models/network/network_device.dart';
import '../../../models/network/topology_graph.dart';
import '../layout/sugiyama_layout.dart';
import 'device_node.dart';
/// Pan + zoom topology canvas with Sugiyama-layered layout, M3-styled edges,
/// line-crossing jumps, port labels, multi-layer Bézier routing, and animated
/// transitions between view modes.
///
/// Layout pipeline:
/// 1. On graph/viewMode change, [computeSugiyamaLayout] produces node
/// positions and edge waypoints. Positions are saved as a "target."
/// 2. An [AnimationController] interpolates from the previous positions to
/// the target over 600ms with [Curves.easeInOutCubicEmphasized].
/// 3. Each frame during the transition, edge geometries and line-jump
/// crossing points are recomputed from the interpolated positions.
/// O(E²) per frame is acceptable for 500 edges.
///
/// Rendering:
/// * Edges with no waypoints (single-layer span) draw as straight lines.
/// * Edges with waypoints (multi-layer span) draw as sequential quadratic
/// Bézier curves passing through each waypoint. The virtual nodes
/// informing those waypoints are never drawn as visible widgets.
/// * Line-jump arcs at crossings apply only to straight segments.
///
/// Performance:
/// * Hover state on two [ValueNotifier]s; the painter is wrapped in a
/// [RepaintBoundary] so hover only repaints the painter, not the widget
/// tree.
/// * Pan/zoom is pure Matrix4 transform via [InteractiveViewer]; no rebuild.
class TopologyCanvas extends StatefulWidget {
const TopologyCanvas({
super.key,
required this.graph,
required this.viewMode,
this.selectedDeviceId,
this.onDeviceTap,
});
final TopologyGraph graph;
final TopologyViewMode viewMode;
final String? selectedDeviceId;
final void Function(NetworkDevice device)? onDeviceTap;
@override
State<TopologyCanvas> createState() => _TopologyCanvasState();
}
class _TopologyCanvasState extends State<TopologyCanvas>
with SingleTickerProviderStateMixin {
// Hover state
final ValueNotifier<String?> _hoveredEdgeId = ValueNotifier<String?>(null);
final ValueNotifier<String?> _hoveredDeviceId = ValueNotifier<String?>(null);
// Animation
late AnimationController _layoutAnim;
// Layout snapshots (for interpolation)
Map<String, Offset> _prevPositions = const {};
Map<String, Offset> _targetPositions = const {};
Map<String, List<Offset>> _prevWaypoints = const {};
Map<String, List<Offset>> _targetWaypoints = const {};
Size _canvasSize = const Size(600, 400);
Map<String, List<String>> _deviceToEdges = const {};
// Constants
static const Size _nodeSize = Size(160, 110);
static const double _hitTolerance = 8;
static const double _jumpRadius = 6;
@override
void initState() {
super.initState();
_layoutAnim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
_runLayout(animate: false);
}
@override
void didUpdateWidget(covariant TopologyCanvas oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.graph, widget.graph) ||
oldWidget.viewMode != widget.viewMode) {
_runLayout(animate: true);
}
}
@override
void dispose() {
_hoveredEdgeId.dispose();
_hoveredDeviceId.dispose();
_layoutAnim.dispose();
super.dispose();
}
// Layout orchestration
void _runLayout({required bool animate}) {
// Snapshot current interpolated positions BEFORE recomputing target.
final newPrev = animate
? Map<String, Offset>.from(_currentPositions())
: <String, Offset>{};
final newPrevWaypoints = animate
? Map<String, List<Offset>>.from(_currentWaypoints())
: <String, List<Offset>>{};
final input = _buildSugiyamaInput();
final result = computeSugiyamaLayout(input);
setState(() {
_prevPositions = newPrev.isEmpty ? result.nodePositions : newPrev;
_targetPositions = result.nodePositions;
_prevWaypoints =
newPrevWaypoints.isEmpty ? result.edgeWaypoints : newPrevWaypoints;
_targetWaypoints = result.edgeWaypoints;
_canvasSize = result.canvasSize;
_deviceToEdges = _computeDeviceEdgeMap();
});
if (animate) {
_layoutAnim.value = 0;
_layoutAnim.forward();
} else {
_layoutAnim.value = 1.0;
}
}
SugiyamaInput _buildSugiyamaInput() {
final nodeIds = widget.graph.nodes.map((n) => n.device.id).toList();
final edges = widget.graph.edges
.map((e) => SugiyamaEdge(
id: e.link.id,
from: e.fromDeviceId,
to: e.toDeviceId,
))
.toList();
Map<String, int>? preassigned;
if (widget.viewMode == TopologyViewMode.logical) {
preassigned = {};
for (final node in widget.graph.nodes) {
final role = node.device.role;
if (role != null) {
preassigned[node.device.id] = _roleToLayer(role);
}
}
}
// Physical view: no preassignment; the algorithm derives layers from
// graph structure via longest-path. We could supply location depth as a
// hint here in a future iteration.
return SugiyamaInput(
nodeIds: nodeIds,
edges: edges,
preassignedLayers: preassigned,
// Sizes default for now; future: pass per-device width if cards become
// variable-width.
);
}
int _roleToLayer(NetworkDeviceRole role) {
switch (role) {
case NetworkDeviceRole.core:
return 0;
case NetworkDeviceRole.distribution:
return 1;
case NetworkDeviceRole.access:
return 2;
case NetworkDeviceRole.edge:
return 3;
case NetworkDeviceRole.endpoint:
return 4;
}
}
// Interpolation
/// Node positions at the current animation `t`. Linearly interpolates between
/// previous and target positions. Nodes that newly appeared use target as
/// both endpoints (they pop in place).
Map<String, Offset> _currentPositions() {
final t = _layoutAnim.value;
if (t >= 1.0) return _targetPositions;
if (t <= 0.0) return _prevPositions.isEmpty ? _targetPositions : _prevPositions;
final out = <String, Offset>{};
for (final entry in _targetPositions.entries) {
final target = entry.value;
final prev = _prevPositions[entry.key] ?? target;
out[entry.key] = Offset.lerp(prev, target, t)!;
}
return out;
}
/// Edge waypoints at the current animation `t`. Lerps element-wise when the
/// previous and target waypoint lists have the same length; otherwise
/// snaps to target (the rare case of an edge going from N-layer to M-layer).
Map<String, List<Offset>> _currentWaypoints() {
final t = _layoutAnim.value;
if (t >= 1.0) return _targetWaypoints;
if (t <= 0.0) return _prevWaypoints.isEmpty ? _targetWaypoints : _prevWaypoints;
final out = <String, List<Offset>>{};
for (final entry in _targetWaypoints.entries) {
final target = entry.value;
final prev = _prevWaypoints[entry.key];
if (prev == null || prev.length != target.length) {
out[entry.key] = target;
continue;
}
out[entry.key] = [
for (var i = 0; i < target.length; i++)
Offset.lerp(prev[i], target[i], t)!,
];
}
return out;
}
Map<String, List<String>> _computeDeviceEdgeMap() {
final map = <String, List<String>>{};
for (final edge in widget.graph.edges) {
map.putIfAbsent(edge.fromDeviceId, () => []).add(edge.link.id);
map.putIfAbsent(edge.toDeviceId, () => []).add(edge.link.id);
}
return map;
}
// Edge geometry
List<_EdgeGeometry> _buildEdgeGeometries(
Map<String, Offset> positions,
Map<String, List<Offset>> waypoints,
) {
final out = <_EdgeGeometry>[];
for (final edge in widget.graph.edges) {
final fromTopLeft = positions[edge.fromDeviceId];
final toTopLeft = positions[edge.toDeviceId];
if (fromTopLeft == null || toTopLeft == null) continue;
final fromCenter = Offset(
fromTopLeft.dx + _nodeSize.width / 2,
fromTopLeft.dy + _nodeSize.height / 2,
);
final toCenter = Offset(
toTopLeft.dx + _nodeSize.width / 2,
toTopLeft.dy + _nodeSize.height / 2,
);
// For edges with waypoints (multi-layer), exit points face the first/last
// waypoint rather than the other endpoint, so the curve enters/exits the
// card cleanly.
final wps = waypoints[edge.link.id] ?? const <Offset>[];
final firstAimAt = wps.isNotEmpty ? wps.first : toCenter;
final lastAimAt = wps.isNotEmpty ? wps.last : fromCenter;
final fromSide = _sideFacing(fromCenter, firstAimAt);
final toSide = _sideFacing(toCenter, lastAimAt);
final fromExit = _sideMidpoint(fromTopLeft, fromSide);
final toExit = _sideMidpoint(toTopLeft, toSide);
out.add(_EdgeGeometry(
edgeId: edge.link.id,
fromDeviceId: edge.fromDeviceId,
toDeviceId: edge.toDeviceId,
fromPortLabel: edge.fromPortLabel,
toPortLabel: edge.toPortLabel,
fromExit: fromExit,
toExit: toExit,
fromSide: fromSide,
toSide: toSide,
waypoints: wps,
));
}
return out;
}
_CardSide _sideFacing(Offset from, Offset to) {
final dx = to.dx - from.dx;
final dy = to.dy - from.dy;
final aspectRatio = _nodeSize.width / _nodeSize.height;
if (dx.abs() * aspectRatio > dy.abs()) {
return dx >= 0 ? _CardSide.right : _CardSide.left;
}
return dy >= 0 ? _CardSide.bottom : _CardSide.top;
}
Offset _sideMidpoint(Offset topLeft, _CardSide side) {
switch (side) {
case _CardSide.left:
return Offset(topLeft.dx, topLeft.dy + _nodeSize.height / 2);
case _CardSide.right:
return Offset(
topLeft.dx + _nodeSize.width, topLeft.dy + _nodeSize.height / 2);
case _CardSide.top:
return Offset(topLeft.dx + _nodeSize.width / 2, topLeft.dy);
case _CardSide.bottom:
return Offset(
topLeft.dx + _nodeSize.width / 2, topLeft.dy + _nodeSize.height);
}
}
/// Line-jump arcs at crossings between *straight* edges. Curved (waypoint-
/// bearing) edges don't participate — they already route around obstacles
/// by going through their virtual-node waypoints.
void _computeLineJumps(List<_EdgeGeometry> geos) {
for (var i = 0; i < geos.length; i++) {
geos[i].jumpPoints.clear();
}
for (var i = 0; i < geos.length; i++) {
final a = geos[i];
if (a.waypoints.isNotEmpty) continue;
for (var j = i + 1; j < geos.length; j++) {
final b = geos[j];
if (b.waypoints.isNotEmpty) continue;
if (_sharesDevice(a, b)) continue;
final hit = _segmentIntersection(a.fromExit, a.toExit, b.fromExit, b.toExit);
if (hit != null) b.jumpPoints.add(hit);
}
}
for (final geo in geos) {
if (geo.jumpPoints.isEmpty) continue;
geo.jumpPoints.sort((p1, p2) {
final d1 = (p1 - geo.fromExit).distanceSquared;
final d2 = (p2 - geo.fromExit).distanceSquared;
return d1.compareTo(d2);
});
}
}
bool _sharesDevice(_EdgeGeometry a, _EdgeGeometry b) {
return a.fromDeviceId == b.fromDeviceId ||
a.fromDeviceId == b.toDeviceId ||
a.toDeviceId == b.fromDeviceId ||
a.toDeviceId == b.toDeviceId;
}
Offset? _segmentIntersection(Offset p1, Offset p2, Offset p3, Offset p4) {
final s1x = p2.dx - p1.dx;
final s1y = p2.dy - p1.dy;
final s2x = p4.dx - p3.dx;
final s2y = p4.dy - p3.dy;
final denom = -s2x * s1y + s1x * s2y;
if (denom.abs() < 1e-6) return null;
final s = (-s1y * (p1.dx - p3.dx) + s1x * (p1.dy - p3.dy)) / denom;
final t = (s2x * (p1.dy - p3.dy) - s2y * (p1.dx - p3.dx)) / denom;
if (s >= 0 && s <= 1 && t >= 0 && t <= 1) {
return Offset(p1.dx + t * s1x, p1.dy + t * s1y);
}
return null;
}
String? _hitTestEdge(Offset localPos, List<_EdgeGeometry> geos) {
String? bestId;
double bestDist = _hitTolerance;
for (final geo in geos) {
// For curved edges, sample the Bézier path and use the nearest sample.
// For straight edges, distance to segment.
final d = geo.waypoints.isEmpty
? _distanceToSegment(localPos, geo.fromExit, geo.toExit)
: _distanceToCurve(localPos, geo);
if (d < bestDist) {
bestDist = d;
bestId = geo.edgeId;
}
}
return bestId;
}
double _distanceToSegment(Offset p, Offset a, Offset b) {
final ax = a.dx, ay = a.dy, bx = b.dx, by = b.dy;
final dx = bx - ax;
final dy = by - ay;
final lenSq = dx * dx + dy * dy;
if (lenSq < 1e-6) return (p - a).distance;
var t = ((p.dx - ax) * dx + (p.dy - ay) * dy) / lenSq;
t = t.clamp(0.0, 1.0);
return (p - Offset(ax + t * dx, ay + t * dy)).distance;
}
/// Approximate distance to a Bézier curve by sampling segments between the
/// path control points (fromExit, waypoints..., toExit) and taking the min
/// segment distance. Coarse but cheap.
double _distanceToCurve(Offset p, _EdgeGeometry geo) {
final pts = <Offset>[geo.fromExit, ...geo.waypoints, geo.toExit];
var best = double.infinity;
for (var i = 0; i < pts.length - 1; i++) {
final d = _distanceToSegment(p, pts[i], pts[i + 1]);
if (d < best) best = d;
}
return best;
}
// Build
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
if (widget.graph.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.hub_outlined, size: 56, color: cs.onSurfaceVariant),
const SizedBox(height: 12),
Text(
'No devices in this view yet',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'Import a document or add devices manually.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: cs.onSurfaceVariant,
),
),
],
),
),
);
}
return InteractiveViewer(
constrained: false,
minScale: 0.2,
maxScale: 2.5,
boundaryMargin: const EdgeInsets.all(200),
child: AnimatedBuilder(
animation: _layoutAnim,
builder: (context, _) {
final positions = _currentPositions();
final waypoints = _currentWaypoints();
final edgeGeos = _buildEdgeGeometries(positions, waypoints);
_computeLineJumps(edgeGeos);
return SizedBox(
width: _canvasSize.width,
height: _canvasSize.height,
child: MouseRegion(
onHover: (event) {
final hit = _hitTestEdge(event.localPosition, edgeGeos);
if (hit != _hoveredEdgeId.value) {
_hoveredEdgeId.value = hit;
}
},
onExit: (_) => _hoveredEdgeId.value = null,
child: Stack(
children: [
// Painter repaints on hover via inner AnimatedBuilder.
Positioned.fill(
child: RepaintBoundary(
child: AnimatedBuilder(
animation: _hoveredEdgeId,
builder: (_, _) {
return CustomPaint(
painter: _CanvasPainter(
edgeGeos: edgeGeos,
jumpRadius: _jumpRadius,
hoveredEdgeId: _hoveredEdgeId.value,
theme: _CanvasTheme.from(Theme.of(context)),
),
);
},
),
),
),
// Devices on top, hover-aware.
for (final node in widget.graph.nodes)
if (positions[node.device.id] != null)
Positioned(
left: positions[node.device.id]!.dx,
top: positions[node.device.id]!.dy,
child: _HoverAwareDevice(
node: node,
selectedId: widget.selectedDeviceId,
hoveredEdgeId: _hoveredEdgeId,
hoveredDeviceId: _hoveredDeviceId,
connectedEdgeIds:
_deviceToEdges[node.device.id] ?? const [],
onTap: widget.onDeviceTap,
),
),
],
),
),
);
},
),
);
}
}
enum _CardSide { left, right, top, bottom }
class _EdgeGeometry {
_EdgeGeometry({
required this.edgeId,
required this.fromDeviceId,
required this.toDeviceId,
required this.fromPortLabel,
required this.toPortLabel,
required this.fromExit,
required this.toExit,
required this.fromSide,
required this.toSide,
required this.waypoints,
});
final String edgeId;
final String fromDeviceId;
final String toDeviceId;
final String fromPortLabel;
final String toPortLabel;
final Offset fromExit;
final Offset toExit;
final _CardSide fromSide;
final _CardSide toSide;
/// Intermediate points the edge passes through. Empty for single-layer
/// edges (drawn as straight lines). Non-empty for multi-layer edges (drawn
/// as sequential quadratic Béziers through these points).
final List<Offset> waypoints;
/// Line-jump points along the *main* segment. Only populated for straight
/// edges (curved/waypoint edges skip line jumps).
final List<Offset> jumpPoints = [];
}
// Theme snapshot
class _CanvasTheme {
const _CanvasTheme({
required this.defaultEdgeColor,
required this.hoverEdgeColor,
required this.chipBg,
required this.chipHoverBg,
required this.chipBorder,
required this.chipHoverBorder,
required this.chipText,
required this.chipHoverText,
});
factory _CanvasTheme.from(ThemeData theme) {
final cs = theme.colorScheme;
return _CanvasTheme(
defaultEdgeColor: cs.outline.withValues(alpha: 0.75),
hoverEdgeColor: cs.primary,
chipBg: cs.surfaceContainerHigh.withValues(alpha: 0.95),
chipHoverBg: cs.primaryContainer,
chipBorder: cs.outlineVariant.withValues(alpha: 0.7),
chipHoverBorder: cs.primary,
chipText: cs.onSurfaceVariant,
chipHoverText: cs.onPrimaryContainer,
);
}
final Color defaultEdgeColor;
final Color hoverEdgeColor;
final Color chipBg;
final Color chipHoverBg;
final Color chipBorder;
final Color chipHoverBorder;
final Color chipText;
final Color chipHoverText;
}
// Painter
class _CanvasPainter extends CustomPainter {
_CanvasPainter({
required this.edgeGeos,
required this.jumpRadius,
required this.hoveredEdgeId,
required this.theme,
});
final List<_EdgeGeometry> edgeGeos;
final double jumpRadius;
final String? hoveredEdgeId;
final _CanvasTheme theme;
@override
void paint(Canvas canvas, Size size) {
for (final geo in edgeGeos) {
if (geo.edgeId == hoveredEdgeId) continue;
_drawEdge(canvas, geo, hovered: false);
}
for (final geo in edgeGeos) {
if (geo.edgeId != hoveredEdgeId) continue;
_drawEdge(canvas, geo, hovered: true);
}
for (final geo in edgeGeos) {
if (geo.edgeId == hoveredEdgeId) continue;
_drawChips(canvas, geo, hovered: false);
}
for (final geo in edgeGeos) {
if (geo.edgeId != hoveredEdgeId) continue;
_drawChips(canvas, geo, hovered: true);
}
}
void _drawEdge(Canvas canvas, _EdgeGeometry geo, {required bool hovered}) {
final color = hovered ? theme.hoverEdgeColor : theme.defaultEdgeColor;
final strokeWidth = hovered ? 2.5 : 1.4;
final paint = Paint()
..color = color
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke;
if (geo.waypoints.isNotEmpty) {
_drawCurvedEdge(canvas, geo, paint);
} else {
_drawStraightEdge(canvas, geo, paint);
}
// Endpoint plug dots.
final dotPaint = Paint()..color = color..style = PaintingStyle.fill;
final radius = hovered ? 4.0 : 3.0;
canvas.drawCircle(geo.fromExit, radius, dotPaint);
canvas.drawCircle(geo.toExit, radius, dotPaint);
}
void _drawStraightEdge(Canvas canvas, _EdgeGeometry geo, Paint paint) {
final dx = geo.toExit.dx - geo.fromExit.dx;
final dy = geo.toExit.dy - geo.fromExit.dy;
final len = math.sqrt(dx * dx + dy * dy);
if (len < 1) return;
final ux = dx / len;
final uy = dy / len;
final path = Path()..moveTo(geo.fromExit.dx, geo.fromExit.dy);
for (final jump in geo.jumpPoints) {
final beforeX = jump.dx - ux * jumpRadius;
final beforeY = jump.dy - uy * jumpRadius;
final afterX = jump.dx + ux * jumpRadius;
final afterY = jump.dy + uy * jumpRadius;
path.lineTo(beforeX, beforeY);
path.arcToPoint(
Offset(afterX, afterY),
radius: Radius.circular(jumpRadius),
clockwise: false,
);
}
path.lineTo(geo.toExit.dx, geo.toExit.dy);
canvas.drawPath(path, paint);
}
/// Render an edge through its waypoints as sequential quadratic Bézier
/// curves. The control point for each segment is the waypoint itself,
/// with each segment's anchor being the midpoint between consecutive
/// waypoints. This produces a smooth C1-continuous curve passing AT each
/// waypoint, mimicking what the eye expects from "a cable bending through
/// multiple connection points."
void _drawCurvedEdge(Canvas canvas, _EdgeGeometry geo, Paint paint) {
final pts = <Offset>[geo.fromExit, ...geo.waypoints, geo.toExit];
final path = Path()..moveTo(pts.first.dx, pts.first.dy);
if (pts.length == 2) {
path.lineTo(pts.last.dx, pts.last.dy);
canvas.drawPath(path, paint);
return;
}
// For each intermediate waypoint, draw a quadratic Bézier from the
// previous-anchor to the next-anchor, with this waypoint as the control.
// First anchor: midpoint between pts[0] and pts[1]. Last anchor: midpoint
// between pts[n-2] and pts[n-1]. This produces a smooth curve passing
// through every waypoint.
var anchor = Offset(
(pts[0].dx + pts[1].dx) / 2,
(pts[0].dy + pts[1].dy) / 2,
);
path.lineTo(anchor.dx, anchor.dy);
for (var i = 1; i < pts.length - 1; i++) {
final nextAnchor = Offset(
(pts[i].dx + pts[i + 1].dx) / 2,
(pts[i].dy + pts[i + 1].dy) / 2,
);
path.quadraticBezierTo(pts[i].dx, pts[i].dy, nextAnchor.dx, nextAnchor.dy);
anchor = nextAnchor;
}
path.lineTo(pts.last.dx, pts.last.dy);
canvas.drawPath(path, paint);
}
void _drawChips(Canvas canvas, _EdgeGeometry geo, {required bool hovered}) {
_drawChip(
canvas,
anchor: geo.fromExit,
side: geo.fromSide,
label: geo.fromPortLabel,
hovered: hovered,
);
_drawChip(
canvas,
anchor: geo.toExit,
side: geo.toSide,
label: geo.toPortLabel,
hovered: hovered,
);
}
void _drawChip(
Canvas canvas, {
required Offset anchor,
required _CardSide side,
required String label,
required bool hovered,
}) {
if (label.isEmpty) return;
final tp = TextPainter(
text: TextSpan(
text: label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: hovered ? theme.chipHoverText : theme.chipText,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
textDirection: TextDirection.ltr,
maxLines: 1,
ellipsis: '',
)..layout(maxWidth: 100);
const padH = 6.0;
const padV = 2.0;
final chipW = tp.width + padH * 2;
final chipH = tp.height + padV * 2;
const gap = 6.0;
Offset chipTopLeft;
switch (side) {
case _CardSide.left:
chipTopLeft = Offset(anchor.dx - gap - chipW, anchor.dy - chipH / 2);
break;
case _CardSide.right:
chipTopLeft = Offset(anchor.dx + gap, anchor.dy - chipH / 2);
break;
case _CardSide.top:
chipTopLeft = Offset(anchor.dx - chipW / 2, anchor.dy - gap - chipH);
break;
case _CardSide.bottom:
chipTopLeft = Offset(anchor.dx - chipW / 2, anchor.dy + gap);
break;
}
final rect = Rect.fromLTWH(chipTopLeft.dx, chipTopLeft.dy, chipW, chipH);
final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(6));
final bgPaint = Paint()
..color = hovered ? theme.chipHoverBg : theme.chipBg
..style = PaintingStyle.fill;
canvas.drawRRect(rrect, bgPaint);
final borderPaint = Paint()
..color = hovered ? theme.chipHoverBorder : theme.chipBorder
..style = PaintingStyle.stroke
..strokeWidth = hovered ? 1.2 : 0.7;
canvas.drawRRect(rrect, borderPaint);
tp.paint(canvas, Offset(chipTopLeft.dx + padH, chipTopLeft.dy + padV));
}
@override
bool shouldRepaint(covariant _CanvasPainter old) {
return old.edgeGeos != edgeGeos ||
old.hoveredEdgeId != hoveredEdgeId ||
old.theme.defaultEdgeColor != theme.defaultEdgeColor;
}
}
// Hover-aware device
class _HoverAwareDevice extends StatelessWidget {
const _HoverAwareDevice({
required this.node,
required this.selectedId,
required this.hoveredEdgeId,
required this.hoveredDeviceId,
required this.connectedEdgeIds,
required this.onTap,
});
final TopologyNode node;
final String? selectedId;
final ValueNotifier<String?> hoveredEdgeId;
final ValueNotifier<String?> hoveredDeviceId;
final List<String> connectedEdgeIds;
final void Function(NetworkDevice device)? onTap;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => hoveredDeviceId.value = node.device.id,
onExit: (_) {
if (hoveredDeviceId.value == node.device.id) {
hoveredDeviceId.value = null;
}
},
child: AnimatedBuilder(
animation: Listenable.merge([hoveredEdgeId, hoveredDeviceId]),
builder: (_, _) {
final isOwnHover = hoveredDeviceId.value == node.device.id;
final isEdgeEndpoint = hoveredEdgeId.value != null &&
connectedEdgeIds.contains(hoveredEdgeId.value);
return DeviceNode(
device: node.device,
portCount: node.ports.length,
isSelected: selectedId == node.device.id,
isHighlighted: isOwnHover || isEdgeEndpoint,
onTap: () => onTap?.call(node.device),
);
},
),
);
}
}
@@ -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(
// 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: items.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 12),
itemCount: entries.length,
itemBuilder: (context, index) {
final item = items[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});
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,
),
),
);
}
}
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;
}
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 -5
View File
@@ -1,4 +1,3 @@
import 'dart:isolate';
import 'dart:typed_data';
import 'dart:ui' as ui;
@@ -72,8 +71,7 @@ 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(() {
// Build the PDF on the main thread (dart:isolate is not supported on web)
return _buildPdfBytes(
chartImages: chartImages,
dateRangeText: dateRangeText,
@@ -83,11 +81,9 @@ class ReportPdfExport {
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,
+273 -79
View File
@@ -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,22 +459,38 @@ 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(
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
task.title.isNotEmpty
? task.title
: 'Task ${task.taskNumber ?? task.id}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(fontWeight: FontWeight.w700),
),
),
SyncPendingBadge(isPending: isPending, compact: true),
const SizedBox(width: 8),
SyncPendingBadge(
isPending: isPending,
compact: true,
),
const SizedBox(width: 4),
Builder(
builder: (ctx) {
final profile = profileAsync.maybeWhen(
@@ -497,23 +514,27 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
),
],
),
),
const SizedBox(height: 6),
Align(
alignment: Alignment.center,
child: Text(
Text(
_createdByLabel(profilesAsync, task, ticket),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelMedium,
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
],
),
),
// 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,14 +2842,35 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
).textTheme.labelMedium,
),
),
Row(
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:
const InputDecoration(
controller:
_messageController,
decoration: InputDecoration(
hintText: 'Message...',
border: InputBorder.none,
isDense: true,
contentPadding:
const EdgeInsets
.symmetric(
vertical: 10,
),
),
textInputAction:
TextInputAction.send,
@@ -2826,11 +2900,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
),
),
),
const SizedBox(width: 12),
IconButton(
tooltip: 'Send',
onPressed: canSendMessages
? () => _handleSendMessage(
? () =>
_handleSendMessage(
task,
profilesAsync
.valueOrNull ??
@@ -2842,10 +2916,13 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
typingChannelId,
)
: null,
icon: const Icon(Icons.send),
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: 32,
child: Column(
children: [
Container(
width: 28,
child: Center(
child: Icon(
icon,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
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: [
Text(title, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 4),
Text(title, style: theme.textTheme.bodyMedium),
const SizedBox(height: 3),
Text(
'$actor${AppTime.formatDate(at)} ${AppTime.formatTime(at)}',
style: Theme.of(context).textTheme.labelSmall,
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
+28 -24
View File
@@ -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,11 +592,26 @@ 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),
// 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(
mainAxisSize: MainAxisSize.min,
children: [
@@ -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) {
+74 -9
View File
@@ -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(
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: [
Text('Leader: ${leader?.fullName ?? team.leaderId}'),
Text('Offices: $officeNames'),
Text('Members: $memberNames'),
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,
),
],
),
trailing: Row(
],
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,
),
onTap: () => _showTeamDialog(context, team: team),
],
),
),
),
);
},
rowActions: (team) => [
+385 -69
View File
@@ -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,20 +100,31 @@ 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(
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
ticket.subject,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(fontWeight: FontWeight.w700),
),
),
),
const SizedBox(width: 8),
Builder(
builder: (ctx) {
final profile = currentProfileAsync.maybeWhen(
@@ -137,28 +149,34 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
),
],
),
),
const SizedBox(height: 6),
Align(
alignment: Alignment.center,
child: Text(
Text(
_filedByLabel(profilesAsync, ticket),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
),
// 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,11 +185,20 @@ 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(
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),
@@ -199,6 +226,11 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
],
],
),
),
],
),
),
),
],
);
@@ -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,13 +462,32 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
style: Theme.of(context).textTheme.labelMedium,
),
),
Row(
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: const InputDecoration(
decoration: InputDecoration(
hintText: 'Message...',
border: InputBorder.none,
isDense: true,
contentPadding:
const EdgeInsets.symmetric(
vertical: 10,
),
),
enabled: canSendMessages,
textInputAction: TextInputAction.send,
@@ -418,7 +508,6 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
: null,
),
),
const SizedBox(width: 12),
IconButton(
tooltip: 'Send',
onPressed: canSendMessages
@@ -429,10 +518,11 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
canSendMessages,
)
: null,
icon: const Icon(Icons.send),
icon: const Icon(Icons.send_rounded),
),
],
),
),
],
),
),
@@ -888,6 +978,10 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
final descCtrl = TextEditingController(text: ticket.description);
String? selectedOffice = ticket.officeId;
final isWide =
MediaQuery.sizeOf(context).width >= AppBreakpoints.tablet;
if (isWide) {
await m3ShowDialog<void>(
context: context,
builder: (dialogContext) {
@@ -897,14 +991,18 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
return AlertDialog(
shape: dialogShape,
title: const Text('Edit Ticket'),
content: SingleChildScrollView(
content: SizedBox(
width: 600,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: subjectCtrl,
enabled: !saving,
decoration: const InputDecoration(labelText: 'Subject'),
decoration: const InputDecoration(
labelText: 'Subject',
),
),
const SizedBox(height: 8),
TextField(
@@ -924,25 +1022,14 @@ 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(),
@@ -951,6 +1038,7 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
],
),
),
),
actions: [
TextButton(
onPressed: saving
@@ -971,7 +1059,8 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
.updateTicket(
ticketId: ticket.id,
subject: subject.isEmpty ? null : subject,
description: desc.isEmpty ? null : desc,
description:
desc.isEmpty ? null : desc,
officeId: selectedOffice,
);
ref.invalidate(ticketsProvider);
@@ -1021,6 +1110,161 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
);
},
);
} 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,
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(),
),
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'),
),
],
),
],
),
);
},
);
},
);
}
}
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.
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;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(
AppSurfaces.of(context).compactCardRadius,
final cs = Theme.of(context).colorScheme;
final (icon, bg, fg) = switch (status) {
'pending' => (
Icons.hourglass_empty_rounded,
cs.secondaryContainer,
cs.onSecondaryContainer,
),
border: Border.all(color: border),
'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: 10, vertical: 5),
decoration: BoxDecoration(
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
+8 -12
View File
@@ -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);
+16 -1
View File
@@ -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: () =>
+4
View File
@@ -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,
+17
View File
@@ -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;
}
}
+65
View File
@@ -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;
}
+236 -86
View File
@@ -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,
_ProfileMenuAnchor(
isStandard: isStandard,
displayName: 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),
),
],
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
@@ -278,44 +239,36 @@ class _NotificationBell extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final unreadCount = ref.watch(unreadNotificationsCountProvider);
final cs = Theme.of(context).colorScheme;
return IconButton(
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: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.notifications_outlined),
Positioned(
right: -3,
top: -3,
child: AnimatedSwitcher(
icon: 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,
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,
),
),
)
: const SizedBox.shrink(key: ValueKey('no-badge')),
),
),
],
),
);
}
}
@@ -416,6 +369,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 +560,7 @@ Future<void> _showOverflowSheet(
List<NavItem> items,
VoidCallback onLogout,
) async {
final parentContext = context;
await m3ShowBottomSheet<void>(
context: context,
builder: (context) {
@@ -616,6 +577,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 +608,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',
),
),
);
},
);
}
}
+260
View File
@@ -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'),
),
],
);
},
);
},
);
}
+39 -5
View File
@@ -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,6 +25,9 @@ class OfflineBanner extends ConsumerWidget {
final pendingCount = ref.watch(pendingSyncCountProvider);
final scheme = Theme.of(context).colorScheme;
return ValueListenableBuilder<bool>(
valueListenable: CacheWarmer.warmingNotifier,
builder: (context, isWarming, _) {
Widget? banner;
if (!isOnline) {
@@ -36,6 +41,21 @@ class OfflineBanner extends ConsumerWidget {
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'}';
@@ -50,6 +70,7 @@ class OfflineBanner extends ConsumerWidget {
),
label: label,
showProgress: true,
onTap: () => showOfflineReadinessSheet(context),
);
}
@@ -75,6 +96,8 @@ class OfflineBanner extends ConsumerWidget {
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,20 +116,25 @@ 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)),
borderRadius: radius,
child: InkWell(
borderRadius: radius,
onTap: onTap,
child: SafeArea(
bottom: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, showProgress ? 6 : 8),
padding: EdgeInsets.fromLTRB(16, 8, onTap != null ? 12 : 16, showProgress ? 6 : 8),
child: Row(
children: [
icon,
@@ -119,14 +148,18 @@ class _BannerStrip extends StatelessWidget {
),
),
),
if (onTap != null)
Icon(
Icons.chevron_right_rounded,
size: 16,
color: foregroundColor.withValues(alpha: 0.6),
),
],
),
),
if (showProgress)
ClipRRect(
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(12),
),
borderRadius: radius,
child: LinearProgressIndicator(
minHeight: 2,
backgroundColor: backgroundColor,
@@ -138,6 +171,7 @@ class _BannerStrip extends StatelessWidget {
],
),
),
),
);
}
}
+537
View File
@@ -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),
);
}
}
+33 -6
View File
@@ -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"))
+24
View File
@@ -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:
+2
View File
@@ -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
+31
View File
@@ -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'))
);
+3
View File
@@ -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"));
}
+1
View File
@@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
geolocator_windows
permission_handler_windows
printing
share_plus
url_launcher_windows
)