26 Commits

Author SHA1 Message Date
redz1029 9f764150a2 UI Phase 2 (L0): shared layout primitives + chrome standardization
Extract four shared layout widgets from the best existing implementations and
adopt them across list/overview screens, deleting duplicated inline code.
Layout/consistency only — no behavior change.

New primitives (lib/widgets/):
- app_section_header.dart  — AppSectionHeader (azure accent bar + uppercase
  telemetry label; promoted from the dashboard's _sectionTitle).
- app_metric_card.dart     — AppMetricCard (uppercase label + mono value on a
  flat hairline surface; unifies KPI/stat tiles).
- app_status_summary.dart  — AppStatusSummaryRow/Item/Chip (dot + count + label,
  selectable; semantic AppStatusColors).
- app_filter_bar.dart      — AppFilterBar (responsive filter-row chrome).

Adopted / de-duplicated:
- Dashboard: _MetricCard now wraps AppMetricCard; section titles use
  AppSectionHeader (removed local _sectionTitle).
- Tickets / Tasks / ISR: three separate summary-chip implementations
  (_StatusSummaryCard x2, _SummaryChip) collapsed into AppStatusSummaryRow.
- Network Map overview: Sites/Devices tiles -> AppMetricCard (removed local
  _SummaryCard).
- Workforce: "Duty Schedules" header -> AppSectionHeader with trailing chip.

Verified: flutter analyze lib clean; flutter build web green; visual QA (tickets
+ ISR summary chips) in dark.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-26 15:42:26 +08:00
redz1029 57377b1b21 UI: Cyber/IT design overhaul with semantic status tokens (Material 3)
Re-theme TasQ with a refined enterprise "Cyber/IT" identity on Material 3.
Functionality is unchanged — only color/shape tokens and a few state widgets.

Foundation:
- Hand-tuned light + dark ColorSchemes (deep-slate surfaces, azure/teal/violet
  accents), tightened 12-16dp shape language, flat hairline-outlined cards.
- New AppStatusColors theme extension (success/warning/danger/info/neutral,
  per-brightness) replacing scattered raw Colors.green/orange/red/grey so status
  color is consistent and legible in dark mode.
- New TechChip mono primitive; StatusPill.kind; M3Card hairline variants;
  tightened AppSurfaces radii.

Per-screen:
- Tokenized status colors + radii across dashboard, tickets, tasks, IT service
  requests, attendance, whereabouts, network map, reports charts, auth, and
  shared/admin screens. Added _ticketStatusKind/_taskStatusKind/_isrStatusKind
  mappers so status carries meaning consistently.
- Fixed white pie-slice borders for dark mode; dropped ad-hoc dialog radius
  overrides in favor of the theme default. Categorical palettes (leave-type
  icons, team color picker, distribution charts) intentionally preserved.

Verified: flutter analyze lib clean; flutter build web green; visual QA in
light and dark (dashboard, tickets, attendance).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-26 15:13:47 +08:00
redz1029 3cb980b629 QA hardening: security RLS fixes, Flutter 3.47.5 upgrade, UI/validation fixes
Security — enforce write authorization server-side (was UI/RPC-only):
- it_service_requests RLS: block cross-office read/edit + self-approve (QA-015)
- pass_slips RLS: owner can complete but not self-approve (QA-046)
- swap_requests RLS: scope select/update to participants + admin (QA-047)
- storage: tighten it_service_attachments + task_attachments write/delete (QA-027)
- admin_user_management edge function: allow programmers to manage users (QA-016)

Fixes:
- workforce generator "uncovered shifts" false alarms (QA-043/044)
- network-map VLAN + New-location dialog validation, disabled-until-valid (QA-048)
- de-flake time-of-day-dependent dashboard metrics test (QA-045)

Toolchain:
- upgrade to Flutter 3.47.5 / Dart 3.13.4; font_awesome_flutter 11.0.0,
  flutter_quill 11.6.0, pdfrx 2.6.5; clear resulting deprecations (QA-002)

analyze clean; 139 tests pass; web build succeeds. Report + evidence in docs/qa/.

Note: also carries the in-progress Brick model cleanup already present in the
working tree. QA-001 (AI keys public in the build) is deferred by owner decision.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-26 11:52:16 +08:00
redz1029 e49b52949c Fixed Edit Port Dialog on Network Map 2026-06-06 18:30:58 +08:00
redz1029 65a42039ee Enhanced Leave and Pass Slip UI/UX 2026-06-06 18:29:56 +08:00
redz1029 d813ee45a2 Network map enhancements and fixes 2026-06-05 16:42:56 +08:00
redz1029 e2ddc9a3ae Enhanced UI of IT Job Checklist 2026-06-05 10:48:53 +08:00
redz1029 5e20d14f23 Fixed attendance 2026-06-05 10:34:26 +08:00
redz1029 5d818e0d4f Enhanced APK Update Screen 2026-06-05 09:15:48 +08:00
redz1029 a91c049353 Scrollable Navigation Rail 2026-06-05 09:15:22 +08:00
redz1029 c31187ef7c Added keyboard visibility dependency 2026-06-05 07:11:18 +08:00
redz1029 1768ed7b04 Fixed custom shift type not appearing in dropdown.
Fixed inconsitent time reflection on edit.
2026-06-05 07:10:38 +08:00
redz1029 ce0be25136 Fixed IT Service Request Saving 2026-06-04 11:28:55 +08:00
redz1029 7475dbca41 Package updates 2026-06-04 07:43:46 +08:00
redz1029 f39bc2cc06 Fixed overtime checkin not showing 2026-06-04 07:43:23 +08:00
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
188 changed files with 23637 additions and 5528 deletions
+2 -2
View File
@@ -107,8 +107,8 @@
"type": "command", "type": "command",
"shell": "powershell", "shell": "powershell",
"async": true, "async": true,
"statusMessage": "Updating architecture graph...", "statusMessage": "Cleaning up and updating 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)" "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, "deno.enable": true,
"git.ignoreLimitWarning": true "git.ignoreLimitWarning": true,
"cmake.ignoreCMakeListsMissing": true
} }
View File
+7
View File
@@ -6,3 +6,10 @@ analyzer:
- lib/brick/*.g.dart - lib/brick/*.g.dart
- lib/brick/**/*.g.dart - lib/brick/**/*.g.dart
- lib/brick/db/*.dart - lib/brick/db/*.dart
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
+177
View File
@@ -0,0 +1,177 @@
# TasQ QA Report
Status: **In progress (loop round 3).**
- Round 1 covered everything reachable without logging in.
- Round 2 covered the **standard** role end-to-end at 1440×900 and 390×844.
- Round 3: logged in as **admin** (via `ADMIN_USERNAME`/`ADMIN_PASSWORD` in `.env`), then used User Management to set one **dispatcher**, **it_staff** and **programmer** account each to the shared test password. All five roles now have working logins and saved sessions. Verified role landing, route gating, network-map write gating, several earlier fixes, and dark mode.
**Round-3 confirmations (browser, live):**
- QA-004 landing routes for **all five roles**: admin/dispatcher/programmer → `/dashboard`, it_staff → `/tasks`, standard → `/tickets`.
- QA-005 deep links no longer bounce authorized roles; QA-006 gating is consistent (dispatcher/it_staff blocked from `/settings/*`, programmer allowed).
- QA-032 (dates instead of "4490h ago") and QA-035 (staff names, not initials) on the Dashboard pulse.
- Network-map write gating: dispatcher read-only (no "Add site"), admin has it.
- QA-016 reproduced live: as programmer, User Management fires a **403 per row** and shows every email "Unknown".
- Dark mode clean on Dashboard, User Management, Reports.
Last updated: 2026-09-25
## 1. Environment
| Item | Value |
|---|---|
| Flutter / Dart | **3.47.5** / 3.13.4 on the host at `~/flutter` (upgraded from 3.41.9 per QA-002; the two blocking packages were bumped so the project now builds on current stable). |
| Browser driver | agent-browser 0.27.0 with system Chrome 152, driving the app through Flutter's semantics tree |
| App under test | `flutter run -d web-server` in debug mode at `127.0.0.1:8787` |
| Backend | Hosted test Supabase project `pwbxgsuskvqwwaejxutj` (from `.env`). Email confirmation is off. |
| Test account | A self-registered `standard` user, "QA Standard User" (office: Accounting). All QA data is prefixed `[QA-standard]`. |
## 2. Static baseline
| Check | Before | Now |
|---|---|---|
| `flutter analyze` | 0 issues | 0 issues |
| `flutter test` | 126 pass / **8 fail** / 1 skip. The same 8 fail on clean `HEAD`. | **139 pass / 0 fail** / 1 skip |
Side effects on the working tree:
- `flutter pub get` refreshed `pubspec.lock` (drops `dio`) and added build and platform folders to `analysis_options.yaml`.
- An accidental `dart format` run was reverted by a verified 3-way merge. The one exception is `lib/screens/tasks/task_detail_screen.dart`, which stays formatted. Its behaviour is identical; the approved plan called for formatting it anyway.
## 3. Coverage
| Area | Desktop | Mobile | Dark | Roles checked |
|---|---|---|---|---|
| Auth: login and sign-up | ✅ | ✅ | ✅ | signed out |
| Shell: rail, bottom nav, More sheet, gated URLs | ✅ | ✅ | – | standard |
| Dashboard | ✅ | ✅ | – | standard |
| Tickets: list, filters, create, detail, chat, status menu | ✅ | ✅ | – | standard |
| Tasks: list, tabs, table columns | ✅ | – | – | standard |
| IT service requests: list, create dialog | ✅ | ✅ | – | standard. Create is blocked on the backend (QA-026). |
| Announcements, Workforce, Notifications, Profile | ✅ | ✅ | – | standard |
| Server security probes (RLS) | ✅ | | | standard token |
| Attendance (geofence detection verified; check-in RPC needs a real device — see below) | ✅ partial | – | – | admin |
| Whereabouts, Settings | ✅ | – | dark ok | admin/all |
| Reports: RPC data + all 14 widgets render, Export PDF generates (print stub caught, no hang) | ✅ | | | admin |
| Network Map: overview/sites/devices, **VLAN validation (QA-048)**, write RLS admin/it_staff-only (verified secure) | ✅ | | | admin |
| Cross-role: task create → assign → notify → transition | ✅ | | | admin+it_staff |
| Workforce: schedule generation (preview → warnings → commit → render → conflict guard → delete) | ✅ | | | admin |
| Announcements: create + role targeting + banner + notification fan-out + **cross-role RLS visibility** + delete (cascade) | ✅ | | | admin + standard |
| Approval-chain RLS audit: leave (secure), pass-slip (**QA-046 fixed+verified**), swap (**QA-047 fixed+verified**) | ✅ audit + live probes | | | inspection + REST (admin/it_staff tokens) |
| Live approval **workflows** (file → approve → notify in-browser), PDF export | ⛔ not yet | | | |
## 4. Issues
Severity:
- **P0:** build, crash, data loss or security
- **P1:** feature broken
- **P2:** UX, visual or accessibility defect
- **P3:** polish
"Verified" means checked in the browser; "unit/smoke" means checked by the test suite.
| ID | Sev | Area | Issue | Root cause | Status |
|---|---|---|---|---|---|
| QA-001 | P0 | Security | Anyone can download the whole `.env` from the web build at `/assets/.env`, and it ships inside the APK. It contains `GEMINI_API_KEY`, `DEEPSEEK_API_KEY` and the commented production credentials. | `.env` is bundled as a Flutter asset; `ai_service.dart` reads the keys on the client | **Accepted / deferred** (owner decision 2026-09-26: leave the Gemini/DeepSeek keys as-is, no rotation). ⚠️ **Noted for a future security pass:** these keys remain publicly readable in every web build and APK. When revisited, the fix is an `ai_proxy` edge function that holds the keys as server secrets so they stop shipping to clients (and rotate them then). No code changed. |
| QA-015 | **P0** | Security | Any signed-in user could read every office's IT service requests, **edit any of them, approve their own**, and assign or unassign staff through the REST API. | `USING (true)` policies in `20260308090000_add_it_service_requests.sql` | **Verified fixed.** standard reads 0 other-office ISRs (was 2); cross-office edit/self-approve → rows=0; **own** self-approve → 403 "Only admins can approve" (trigger); admin approve → 200; creator can still edit own pending request. Migration `20260924090000_isr_rls_hardening.sql`. |
| QA-039 | P1 | Backend | The QA-015 SELECT tightening broke **standard-user ISR creation**: `insert_it_service_request_with_number` computes `MAX(request_number)` under the caller's RLS, so a non-privileged creator (who can't see others' rows) regenerated `ISR-2026-0001` and hit the unique constraint (23505). Privileged roles (it_staff verified) create fine. | The function isn't `SECURITY DEFINER` | **Verified fixed.** Standard create now succeeds (`ISR-2026-0005`, forced `pending_approval`, creator = the user). Function is `SECURITY DEFINER`, creator pinned, status clamped. |
| QA-040 | P2 | Backend | Migration `20260604090000` added an 8-arg `insert_it_service_request_with_number` via `CREATE OR REPLACE` without dropping the original 7-arg one, leaving two overloads (PGRST203). | Overload not dropped | **Verified fixed** (single function; standard create resolves cleanly) |
| QA-041 | P3 | Tasks | On the task detail page the **Details** panel (assignees, signatories, attachments, type) is collapsed by default, and on wide screens Chat occupies the prominent right pane — so the assignment control is two clicks deep. | Default-collapsed section | Open (minor UX) |
| QA-042 | P2 | Attendance | A Dart console error `Too many positional arguments. Expected: 1 Actual: 2` fires when the Check-In geofence status refreshes (seen with and without a stubbed location). No visible impact, but it's an uncaught error worth a look. | Unknown (no stack trace captured); likely a geolocation/position callback arity mismatch | Open — needs investigation on a device |
| QA-043 | P2 | Workforce | Schedule Generator "Uncovered shifts" panel ignored already-committed schedules. After committing a schedule, regenerating the same range produced an empty preview (the weekly-hours cap correctly blocks new drafts) **and** falsely flagged *every* required shift as uncovered — directly contradicting the Schedule tab. This also made it impossible to use the generator to fill a genuine gap after a partial commit. | `_buildWarnings` computed the day's "covered" set only from freshly generated drafts, never from existing `dutySchedulesProvider` rows | **Verified fixed** (browser + debug trace). `_buildWarnings` now also counts committed schedules for each day. `workforce_screen.dart` |
| QA-044 | P2 | Workforce | Every Saturday and Sunday in a preview permanently reported "missing On Call", even in the first generation, although a weekend on-call shift *was* generated and committed. | The coverage check requires a generic `on_call` each day, but the generator emits the weekend variants `on_call_saturday` / `on_call_sunday`, which never matched. `_normalizeShiftType` (intended to fold variants) was an identity no-op and is load-bearing for template/generation, so it couldn't be changed globally | **Verified fixed** (debug trace: Sat/Sun `required={on_call,normal}` now satisfied by `available={normal,on_call}`). Added a coverage-only `_coverageShiftType` that folds the weekend variants to `on_call`; generation/template code untouched. `workforce_screen.dart` |
| QA-045 | P3 | Tests | `dashboard_metrics_provider_test.dart` → "rejected leave should not affect schedule status" was time-of-day dependent: it hard-coded an 08:00–16:00 shift and asserted the status isn't "off", so it passed only when the suite ran during the day and failed (correctly reading "Off duty") at night. Production status logic is correct. | Fixture used a fixed daytime window instead of anchoring the shift around `now` | **Fixed.** Shift now spans `now-1h`…`now+4h` (matching the leave-window style already in the same test); suite is green regardless of clock. `test/dashboard_metrics_provider_test.dart` |
| QA-046 | **P1** | Security | **Pass-slip self-approval bypass.** The `pass_slips_update` policy has a `USING` of `user_id = auth.uid() OR admin/dispatcher` and **no `WITH CHECK`**, so Postgres reuses `USING` as the check — the slip **owner can PATCH their own row to `status='approved'`** (plus `approved_by`/`approved_at`) via REST, self-approving their own duty excusal. Approval is only gated in the UI. Same class as QA-015. Confirmed by policy inspection (not live-probed: `pass_slips` has no `DELETE` policy and `duty_schedule_id` is RESTRICT-referenced, so a probe would leave un-removable test data). | Missing `WITH CHECK` in `20260306090200_pass_slips.sql:38` | **Verified fixed** (migration applied + live REST probes with an it_staff owner token): owner self-approve (pending→approved) → **0 rows**; owner approved→rejected → **42501 RLS violation**; admin approve → 200; owner approved→completed → 200. `supabase/migrations/20260926090000_pass_slip_rls_hardening.sql`. Probe left two un-deletable rows (`pass_slips` has no DELETE policy, `duty_schedule_id` is RESTRICT): one **completed** `[QA]` slip + one **2027-02-01** duty schedule — benign, removable only via SQL. |
| QA-048 | P2 | Network map | Two dialogs accepted the Create click unconditionally (`Navigator.pop(ctx, true)`) and validated only *after* the dialog closed, with no `else` — so invalid input silently closed the dialog and created nothing, with **no feedback**: (1) **New VLAN** — out-of-range ID (0, 4095), non-numeric, or empty name; (2) **New location** (device edit) — empty name. | Validation ran post-close; no guard on the button | **Fixed** (both). Create is now disabled until input is valid (same pattern as QA-029). VLAN verified in-browser (empty/0/4095/"abc" → disabled, 20 → enabled); New location fix is identical (analyze clean). `network_map_vlan_screen.dart`, `network_map_device_edit_screen.dart`. |
| QA-047 | **P1** | Security | **Shift-swap authorization bypass (confirmed live).** The RPCs (`respond_shift_swap`) correctly guard accept/reject by identity, but the `swap_requests` **table** has no scoped RLS, so the RPCs are bypassable via direct PostgREST: (a) a requester PATCHed their **own** swap straight to `status='accepted'`, forging the recipient's acceptance (200, 1 row); (b) a **non-participant** it_staff PATCHed **someone else's** swap (200, 1 row); (c) SELECT is unscoped too — any it_staff reads **all 60** swaps, incl. 48 they're not in. | `swap_requests` base-table policies (out-of-repo) allow any authenticated user to read/update any row | **Verified fixed** (migration applied + re-probed via MCP-confirmed policies and it_staff REST token): non-participant SELECT now `0` (was 48); requester direct status write → **0 rows**; non-participant direct write → **0 rows**; legitimate `request_shift_swap` create still succeeds. `supabase/migrations/20260926100000_swap_requests_rls_hardening.sql`. |
| QA-027 | P1 | Security | The `it_service_attachments` storage bucket is public, and any signed-in user can delete any attachment | Storage `DELETE` policy was `bucket_id = 'it_service_attachments'` for all authenticated users | **Fixed + applied** (per your decision: keep bucket public, tighten delete). DELETE now requires `owner = auth.uid()` OR an ISR-privileged role (admin/programmer/dispatcher/it_staff). Reads/uploads unchanged. `supabase/migrations/20260926110000_isr_attachment_delete_hardening.sql`, applied to the test backend. **Related (also fixed + applied):** the separate `task_attachments` bucket had INSERT/UPDATE/DELETE open to the `public` role (unauthenticated users could upload/overwrite/delete). Writes are now `authenticated`-only, with UPDATE/DELETE scoped to uploader-or-privileged; reads unchanged. `supabase/migrations/20260926120000_task_attachment_write_hardening.sql`. |
| QA-026 | P1 | Backend | Creating an IT service request failed with `PGRST202`; `network_devices.status` was missing (`42703`). | Test backend was missing migrations `20260604090000`, `20260605100000`, `20260605110000` | **Migrations applied.** it_staff ISR create now works and lands on the detail page. Standard create still blocked by QA-039 until the re-apply. |
| QA-002 | P1 | Build | Doesn't compile on Flutter ≥ 3.44 | `font_awesome_flutter` 10.12 (extends now-`final` `IconData`) and `flutter_quill` 11.5.0 (missing `TextInputClient.onFocusReceived`) | **Fixed + verified** (per your decision: upgrade). Moved to **Flutter 3.47.5 / Dart 3.13.4**; `font_awesome_flutter` → 11.0.0, `flutter_quill` → 11.6.0, and `pdfrx` → 2.6.5 / `pdfrx_engine` → 0.6.1 (0.3.9 failed native compile under Dart 3.13 — would have broken the APK). `pubspec` Dart floor `^3.10.7` → `^3.12.0`. Cleared the 4 resulting deprecations (`axisAlignment`, `onReorder`×2, `dismissPdfiumWasmWarnings`). **Result: `flutter analyze` 0 issues, 139 tests pass, `flutter build web` succeeds, app runs (icons + Quill render, no console errors).** |
| QA-003 | P1 | Build (Android) | The uncommitted diff dropped the `flutter_keyboard_visibility: ^6.0.0` override | `pubspec.yaml` | **Fixed** |
| QA-004 | P1 | Auth | Every login landed on `/tickets` instead of the role's home route | `login_screen.dart` forced `context.go('/tickets')` | **Verified** across all 5 roles |
| QA-005 | P1 | Routing | Opening a gated route directly bounced even an admin while the profile loaded | `app_router.dart` treated a still-loading role as "no access" | **Verified** |
| QA-006 | P1 | Shell | Dispatchers saw Settings items and standard users saw Reports, both of which the router blocks | Nav rules didn't match the router | **Verified** (standard, dispatcher, it_staff, programmer) |
| QA-007 | P1 | Notifications | In-app notification banners never appeared. Two latent bugs were behind that: the wrong item, and false banners on startup and resume. | `NotificationBridge` used a messenger with no Scaffold, and `.last` on a newest-first list | **Fixed + pipeline verified live.** admin assigns task → `notifications` row (201) → it_staff sees "ihomp assigned you … Just now" + 99+ bell → tap routes to the task → it_staff transitions it to in_progress (204). Banner-selection logic is unit-tested. |
| QA-008 | P1 | Data | Lists came back reversed across the app: pickers Z→A, ports and VLANs descending, comments and chat ordered differently online and offline | postgrest `.order()` defaults to descending | **Fixed.** Verified (`order=name.asc`) |
| QA-009 | P1 | Tasks | The Tasks table hid Status on almost every desktop width | The list-width check was measured against a screen breakpoint | **Fixed.** Verified at 1440 (`screenshots/QA-009-…`) |
| QA-028 | P1 | Tickets | Standard users could file a ticket for **any** office. It then vanished from their own list, because they only see their own offices' tickets. | The create dialog used the all-offices provider | **Fixed.** Verified: only the user's office is offered |
| QA-033 | P1 | Performance | Every client re-downloaded the **whole** `duty_schedules` table, and `swap_requests`, every 3 s on every page: about 40 requests a minute per user | A 3 s "safety-net" `Timer.periodic` in `workforce_provider.dart` | **Fixed.** Now 60 s; realtime remains the primary path |
| QA-025 | P2 | UX | Error messages showed raw exception text, e.g. `AuthApiException(message: …, statusCode: 400…)`. This affected 64 places. | Callers put `'$e'` straight into the message | **Fixed** once in `snackbar.dart` (`humanizeErrorText`, with a unit test). Verified |
| QA-029 | P2 | IT service requests, Workforce | "New Request" closed on an empty name and discarded what the user had entered. The "Add holiday" dialog silently did nothing on an empty name. | Validation ran after the dialog closed | **Fixed.** The button stays disabled until valid. Verified |
| QA-030 | P2 | Tickets | The table showed 36-character UUIDs, which pushed Status off-screen at 1440 | Raw `ticket.id` | **Fixed.** Now an 8-character ID with the full ID in a tooltip. Verified |
| QA-031 | P2 | Web | The mobile-only "Never miss an update" dialog, whose "Open settings" does nothing on web, appeared on web | No platform check | **Fixed.** Skipped on web |
| QA-032 | P2 | Dashboard | Stale times read "Last seen outside · 4490h ago" | Relative times were only ever shown in hours | **Verified** (shows dates); `AppTime.relative()` added, 2 dup helpers removed |
| QA-034 | P2 | Shell | The rail highlighted Dashboard while on Profile and Notifications | The index lookup fell back to 0 | **Fixed.** Verified |
| QA-035 | P2 | Dashboard (mobile) | IT Staff Pulse showed initials-only avatars, with names only in a long-press tooltip | A mobile-specific branch | **Verified.** Names shown, ellipsized |
| QA-010 | P2 | Web | Every page load logged "Could not navigate to initial route" | A wrapper `MaterialApp` on web | **Fixed.** Verified |
| QA-011 | P2 | Attendance | `Platform.isAndroid` threw on web | No `kIsWeb` guard | **Fixed** |
| QA-012 | P2 | Detail pages | The "Assigned IT Staff" header overflowed by 13 px | Fixed-width text with a `Spacer` | **Fixed** (smoke test) |
| QA-013 | P2 | Accessibility | Password show/hide buttons had no label | No tooltip | **Fixed.** Verified |
| QA-014 | P2 | Tests | 8 tests were failing before this work, and 1 passed by accident | Stale expectations, and a smoke-test helper whose screen size didn't reach `MediaQuery` | **Fixed** (139/139) |
| QA-016 | P1 | Settings | Programmers can open User Management, but every row 403s and emails show "Unknown" | The edge function allows admin only | **Fixed + verified + deployed** (per your decision: programmers may manage users). `admin_user_management/index.ts` now allows `admin` or `programmer`; deployed to the test backend (v12). Verified: a programmer token's `list_users` returns 200 (was 403). |
| QA-017 | P2 | Detail pages | Detail routes have no app-bar title | `_routeToTitle` | Open. No double app bar on ticket detail; network map not yet checked |
| QA-018 | P2 | Startup | Up to about 40 s of blank screen on a slow network | Startup timeouts add up | Open |
| QA-019 | P2 | Accessibility | No `Semantics` or `semanticLabel` anywhere. The ticket detail back button has no label. | App-wide | Open |
| QA-020 | P3 | Web | Warning about the viewport `<meta>` on every load | `web/index.html` | Open |
| QA-021 | P3 | Auth | Validation errors stay visible after the field is fixed | `autovalidateMode` | Open |
| QA-022 | P3 | Data | Duplicate office names ("dddd", "dawd" ×3) | No unique constraint | Open |
| QA-023 | P3 | Web | The connectivity check sends a `HEAD` every 5 s | `connectivity_provider.dart` | Open |
| QA-024 | P3 | Auth | No "forgot password" flow | Not built | Reported only |
| QA-036 | P3 | Copy | The Workforce empty state tells everyone to "Use the Generator tab", which non-admins don't have. The nav says "Announcement" but the page says "Announcements". The mobile label "IT Service Requests" wraps. The Pulse columns (Tickets, Tasks, Events) don't say they count "closed today". | Copy | Open |
| QA-037 | P3 | Privacy | Standard users can see IT staff location status ("Last seen outside…") on the Dashboard, even though Whereabouts is blocked for them | The dashboard is identical for all roles | Open. Product decision |
| QA-038 | P3 | Data | Two separate accounts share the display name "Paola Ross Liberato" (`paolaliberato92@gmail.com` = standard, `pao@mail.com` = programmer). Duplicate display names make User Management and pickers ambiguous. | No name uniqueness; likely a real re-registration | Open. Test-backend data, but the UI should disambiguate (show email) |
## 5. Server-side probes (standard user token, test backend)
| Table | Rows the standard user can read | Verdict |
|---|---|---|
| tickets | 8, all from the user's office | ✅ scoped |
| tasks | 48, all from the user's office | ✅ scoped |
| it_service_requests | 2, **both from other offices** | ❌ QA-015 |
| profiles | 21 (the full directory) | Acceptable |
| user_offices | 1 (own) | ✅ |
| it_service_requests PATCH on another office's row | **HTTP 200, 1 row** | ❌ QA-015 |
## 6. Blockers and decisions needed
Admin login is now resolved (round 3). All five role sessions exist. Remaining:
All prior decisions have been resolved by the owner (2026-09-26):
- ~~QA-015/039/040~~ — fixed + verified (ISR RLS).
- ~~QA-016~~ — programmers may manage users → fixed, deployed, verified.
- ~~QA-027~~ — keep buckets public, tighten deletes → done (it_service + task_attachments).
- ~~QA-002~~ — upgrade → done (Flutter 3.47.5).
- **QA-001** — leave the AI keys, **noted for a future security enhancement** (server-side `ai_proxy`). This is the one remaining known exposure, deliberately deferred.
## 6b. Harness limits (need a real device / manual)
- **Attendance check-in / overtime RPC:** headless Chrome doesn't grant geolocation, and the in-handler `Geolocator.getCurrentPosition` doesn't resolve with a stubbed position, so the check-in RPC can't be driven here. Geofence *detection* is verified (screen flips to "Within geofence" with a stubbed location). The check-in, face verification, and PDF print-dialog flows need on-device testing.
- **QA-046/047 probe residue — cleaned up.** The pass-slip and swap probes left rows that REST couldn't delete (`pass_slips`/`swap_requests` have no DELETE policy; the schedule was RESTRICT-referenced). Once the Supabase MCP reconnected these were removed with service access: `pass_slips 55046efa…`, `swap_requests 514c97ed…`/`befed0d0…`, `duty_schedules 5fe10a17…` — all confirmed gone (0 rows). The only remaining trace is a cosmetic `updated_at` bump on real long-rejected swap `ebb4b9e5…` from the non-destructive PROBE C (status unchanged).
- Minor observation: neither `pass_slips` nor `swap_requests` has a DELETE policy, so erroneous rows can't be removed through the app — likely intentional (immutable records), noted for awareness.
## 7. Files changed by QA
`lib/`
- `main.dart`
- `routing/app_router.dart`
- `services/notification_bridge.dart`
- `utils/`: `app_time.dart`, `device_security.dart`, `snackbar.dart`
- `widgets/`: `app_shell.dart`, `tasq_adaptive_list.dart`, `task_assignment_section.dart`
- `screens/`
- `auth/login_screen.dart`, `auth/signup_screen.dart`
- `dashboard/dashboard_screen.dart`
- `tickets/tickets_list_screen.dart`
- `it_service_requests/it_service_requests_list_screen.dart`
- `workforce/rotation_settings_dialog.dart`, `workforce/workforce_screen.dart` (QA-043/044: coverage warnings)
- `announcements/announcements_screen.dart`, `announcements/announcement_comments_section.dart`
- `providers/`
- `chat`, `profile`, `announcements`, `workforce`, `tickets`, `services`, `user_offices` and `teams` providers
- `network_map/{network_devices,network_sites}_provider.dart`
- `network_map/network_map_vlan_screen.dart`, `network_map/network_map_device_edit_screen.dart` (QA-048: dialog validation)
QA-002 upgrade also touched: `pubspec.yaml`/`pubspec.lock` (Flutter 3.47.5 + font_awesome 11 + flutter_quill 11.6 + pdfrx 2.6.5), `lib/main.dart` (dropped deprecated pdfrx flag), `lib/screens/network_map/widgets/topology_legend.dart` (`axisAlignment`→`alignment`), `lib/screens/workforce/rotation_settings_dialog.dart` (`onReorder`→`onReorderItem`), `lib/providers/stream_recovery.dart` (lint suppression).
Everything else:
- `pubspec.yaml`
- `supabase/migrations/20260924090000_isr_rls_hardening.sql` (new)
- `supabase/migrations/20260926090000_pass_slip_rls_hardening.sql` (new — QA-046)
- `supabase/migrations/20260926100000_swap_requests_rls_hardening.sql` (new — QA-047)
- tests: `notification_bridge_test.dart` (new), `snackbar_humanize_test.dart` (new), `layout_smoke_test.dart`, `offline_sync_test.dart`, `dashboard_metrics_provider_test.dart` (QA-045: de-flaked)
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+1 -1
View File
@@ -6,7 +6,7 @@ import 'package:flutter_quill/flutter_quill.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'routing/app_router.dart'; import 'routing/app_router.dart';
import 'models/profile.dart'; import 'models/profile.model.dart';
import 'providers/connectivity_provider.dart'; import 'providers/connectivity_provider.dart';
import 'providers/profile_provider.dart'; import 'providers/profile_provider.dart';
import 'services/background_location_service.dart'; import 'services/background_location_service.dart';
+91 -113
View File
@@ -1,7 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; 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:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
@@ -45,6 +45,9 @@ class CacheWarmer {
static const _kTeamMembersKey = 'cache_team_members_json'; static const _kTeamMembersKey = 'cache_team_members_json';
static const _kUserOfficesKey = 'cache_user_offices_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. /// Foreground timer handle so [start]/[stop] is idempotent.
static Timer? _refreshTimer; static Timer? _refreshTimer;
@@ -52,6 +55,9 @@ class CacheWarmer {
static bool _hasWarmedOnce = false; static bool _hasWarmedOnce = false;
static bool get hasWarmedOnce => _hasWarmedOnce; 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. /// Single-flight guard to prevent overlapping warms.
static Future<void>? _inflight; static Future<void>? _inflight;
@@ -62,9 +68,21 @@ class CacheWarmer {
final existing = _inflight; final existing = _inflight;
if (existing != null) return existing; if (existing != null) return existing;
warmingNotifier.value = true;
final future = _runWarmAll(client); final future = _runWarmAll(client);
_inflight = future; _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 { static Future<void> _runWarmAll(SupabaseClient client) async {
@@ -121,24 +139,28 @@ class CacheWarmer {
for (final row in rows) { for (final row in rows) {
await _localUpsert<Office>(Office.fromMap(row)); await _localUpsert<Office>(Office.fromMap(row));
} }
await _writeCount('offices', rows.length);
}), }),
_safe('services', () async { _safe('services', () async {
final rows = await client.from('services').select(); final rows = await client.from('services').select();
for (final row in rows) { for (final row in rows) {
await _localUpsert<Service>(Service.fromMap(row)); await _localUpsert<Service>(Service.fromMap(row));
} }
await _writeCount('services', rows.length);
}), }),
_safe('profiles', () async { _safe('profiles', () async {
final rows = await client.from('profiles').select(); final rows = await client.from('profiles').select();
for (final row in rows) { for (final row in rows) {
await _localUpsert<Profile>(Profile.fromMap(row)); await _localUpsert<Profile>(Profile.fromMap(row));
} }
await _writeCount('profiles', rows.length);
}), }),
_safe('teams', () async { _safe('teams', () async {
final rows = await client.from('teams').select(); final rows = await client.from('teams').select();
for (final row in rows) { for (final row in rows) {
await _localUpsert<Team>(Team.fromMap(row)); await _localUpsert<Team>(Team.fromMap(row));
} }
await _writeCount('teams', rows.length);
}), }),
// Association tables have composite PKs, so we cache them as JSON in // Association tables have composite PKs, so we cache them as JSON in
// SharedPreferences instead of Brick. The list is small and read-only. // 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 rows = await client.from('team_members').select();
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kTeamMembersKey, jsonEncode(rows)); await prefs.setString(_kTeamMembersKey, jsonEncode(rows));
await _writeCount('team_members', rows.length);
}), }),
_safe('user_offices', () async { _safe('user_offices', () async {
final rows = await client.from('user_offices').select(); final rows = await client.from('user_offices').select();
@@ -189,147 +212,77 @@ class CacheWarmer {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
static Future<void> _warmTasks(SupabaseClient client, String sinceIso) async { static Future<void> _warmTasks(SupabaseClient client, String sinceIso) async {
final rows = await client final rows = await client.from('tasks').select().gte('created_at', sinceIso);
.from('tasks') for (final row in rows) { await _localUpsert<Task>(Task.fromMap(row)); }
.select() await _writeCount('tasks', rows.length);
.gte('created_at', sinceIso);
for (final row in rows) {
await _localUpsert<Task>(Task.fromMap(row));
}
} }
static Future<void> _warmTickets( static Future<void> _warmTickets(SupabaseClient client, String sinceIso) async {
SupabaseClient client, final rows = await client.from('tickets').select().gte('created_at', sinceIso);
String sinceIso, for (final row in rows) { await _localUpsert<Ticket>(Ticket.fromMap(row)); }
) async { await _writeCount('tickets', rows.length);
final rows = await client
.from('tickets')
.select()
.gte('created_at', sinceIso);
for (final row in rows) {
await _localUpsert<Ticket>(Ticket.fromMap(row));
}
} }
static Future<void> _warmItServiceRequests( static Future<void> _warmItServiceRequests(SupabaseClient client, String sinceIso) async {
SupabaseClient client, final rows = await client.from('it_service_requests').select().gte('created_at', sinceIso);
String sinceIso, for (final row in rows) { await _localUpsert<ItServiceRequest>(ItServiceRequest.fromMap(row)); }
) async { await _writeCount('it_service_requests', rows.length);
final rows = await client
.from('it_service_requests')
.select()
.gte('created_at', sinceIso);
for (final row in rows) {
await _localUpsert<ItServiceRequest>(ItServiceRequest.fromMap(row));
}
} }
static Future<void> _warmAnnouncements( static Future<void> _warmAnnouncements(SupabaseClient client, String sinceIso) async {
SupabaseClient client, final rows = await client.from('announcements').select().gte('created_at', sinceIso);
String sinceIso, for (final row in rows) { await _localUpsert<Announcement>(Announcement.fromMap(row)); }
) async { await _writeCount('announcements', rows.length);
final rows = await client // Also pull comments so detail screens have them offline.
.from('announcements')
.select()
.gte('created_at', sinceIso);
for (final row in rows) {
await _localUpsert<Announcement>(Announcement.fromMap(row));
}
// Also pull comments for the windowed announcements so detail screens have
// them offline.
try { try {
final commentRows = await client final commentRows = await client
.from('announcement_comments') .from('announcement_comments')
.select() .select()
.gte('created_at', sinceIso); .gte('created_at', sinceIso);
for (final row in commentRows) { for (final row in commentRows) {
await _localUpsert<AnnouncementComment>( await _localUpsert<AnnouncementComment>(AnnouncementComment.fromMap(row));
AnnouncementComment.fromMap(row),
);
} }
} catch (e) { } catch (e) {
debugPrint('[CacheWarmer] announcement_comments failed: $e'); debugPrint('[CacheWarmer] announcement_comments failed: $e');
} }
} }
static Future<void> _warmAttendance( static Future<void> _warmAttendance(SupabaseClient client, String sinceIso) async {
SupabaseClient client, final rows = await client.from('attendance_logs').select().gte('check_in_at', sinceIso);
String sinceIso, for (final row in rows) { await _localUpsert<AttendanceLog>(AttendanceLog.fromMap(row)); }
) async { await _writeCount('attendance_logs', rows.length);
final rows = await client
.from('attendance_logs')
.select()
.gte('check_in_at', sinceIso);
for (final row in rows) {
await _localUpsert<AttendanceLog>(AttendanceLog.fromMap(row));
}
} }
static Future<void> _warmPassSlips( static Future<void> _warmPassSlips(SupabaseClient client, String sinceIso) async {
SupabaseClient client, final rows = await client.from('pass_slips').select().gte('created_at', sinceIso);
String sinceIso, for (final row in rows) { await _localUpsert<PassSlip>(PassSlip.fromMap(row)); }
) async { await _writeCount('pass_slips', rows.length);
final rows = await client
.from('pass_slips')
.select()
.gte('created_at', sinceIso);
for (final row in rows) {
await _localUpsert<PassSlip>(PassSlip.fromMap(row));
}
} }
static Future<void> _warmLeaves( static Future<void> _warmLeaves(SupabaseClient client, String sinceIso) async {
SupabaseClient client, final rows = await client.from('leave_of_absence').select().gte('created_at', sinceIso);
String sinceIso, for (final row in rows) { await _localUpsert<LeaveOfAbsence>(LeaveOfAbsence.fromMap(row)); }
) async { await _writeCount('leave_of_absence', rows.length);
final rows = await client
.from('leave_of_absence')
.select()
.gte('created_at', sinceIso);
for (final row in rows) {
await _localUpsert<LeaveOfAbsence>(LeaveOfAbsence.fromMap(row));
}
} }
static Future<void> _warmDutySchedules( static Future<void> _warmDutySchedules(SupabaseClient client, String sinceIso) async {
SupabaseClient client,
String sinceIso,
) async {
// Duty schedules are upcoming-focused; pull a wider window so the user // Duty schedules are upcoming-focused; pull a wider window so the user
// can see today + the next several weeks while offline. // can see today + the next several weeks while offline.
final rows = await client final rows = await client.from('duty_schedules').select().gte('start_time', sinceIso);
.from('duty_schedules') for (final row in rows) { await _localUpsert<DutySchedule>(DutySchedule.fromMap(row)); }
.select() await _writeCount('duty_schedules', rows.length);
.gte('start_time', sinceIso);
for (final row in rows) {
await _localUpsert<DutySchedule>(DutySchedule.fromMap(row));
}
} }
static Future<void> _warmSwapRequests( static Future<void> _warmSwapRequests(SupabaseClient client, String sinceIso) async {
SupabaseClient client, final rows = await client.from('swap_requests').select().gte('created_at', sinceIso);
String sinceIso, for (final row in rows) { await _localUpsert<SwapRequest>(SwapRequest.fromMap(row)); }
) async { await _writeCount('swap_requests', rows.length);
final rows = await client
.from('swap_requests')
.select()
.gte('created_at', sinceIso);
for (final row in rows) {
await _localUpsert<SwapRequest>(SwapRequest.fromMap(row));
}
} }
static Future<void> _warmNotifications( static Future<void> _warmNotifications(SupabaseClient client, String sinceIso) async {
SupabaseClient client, final rows = await client.from('notifications').select().gte('created_at', sinceIso);
String sinceIso, for (final row in rows) { await _localUpsert<NotificationItem>(NotificationItem.fromMap(row)); }
) async { await _writeCount('notifications', rows.length);
final rows = await client
.from('notifications')
.select()
.gte('created_at', sinceIso);
for (final row in rows) {
await _localUpsert<NotificationItem>(NotificationItem.fromMap(row));
}
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -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,
};
}
} }
+27 -19
View File
@@ -249,8 +249,7 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// The flag optionally hides annoying WASM warnings in your Chrome dev console pdfrxFlutterInitialize();
pdfrxFlutterInitialize(dismissPdfiumWasmWarnings: true);
// initialize Firebase before anything that uses messaging // initialize Firebase before anything that uses messaging
try { try {
@@ -268,6 +267,21 @@ Future<void> main() async {
debugPrint('dotenv load failed or timed out: $e'); debugPrint('dotenv load failed or timed out: $e');
} }
// Read VAPID_KEY once at startup. trim() handles trailing whitespace / BOM
// artifacts that the .env parser may leave, which would cause isEmpty to
// return true even when the key is present with a non-empty value.
final vapidKey = kIsWeb ? (dotenv.env['VAPID_KEY']?.trim() ?? '') : '';
if (kIsWeb) {
if (vapidKey.isEmpty) {
debugPrint(
'Web FCM: VAPID_KEY not set in .env — web push notifications disabled. '
'Add VAPID_KEY=<key> from Firebase Console → Project Settings → Cloud Messaging.',
);
} else {
debugPrint('Web FCM: VAPID_KEY loaded (${vapidKey.length} chars).');
}
}
AppTime.initialize(location: 'Asia/Manila'); AppTime.initialize(location: 'Asia/Manila');
final supabaseUrl = dotenv.env['SUPABASE_URL'] ?? ''; final supabaseUrl = dotenv.env['SUPABASE_URL'] ?? '';
@@ -336,17 +350,9 @@ Future<void> main() async {
final event = data.event; final event = data.event;
// Web: register FCM token for iOS 16.4+ PWA push support. // Web: register FCM token for iOS 16.4+ PWA push support.
// Requires the VAPID key from Firebase Console → Project Settings → // vapidKey was read once at startup from dotenv — see boot sequence above.
// Cloud Messaging → Web Push certificates → Key pair.
// Add VAPID_KEY=<your_key> to your .env file.
if (kIsWeb) { if (kIsWeb) {
final vapidKey = dotenv.env['VAPID_KEY'] ?? ''; if (vapidKey.isEmpty) return; // already warned at startup
if (vapidKey.isEmpty) {
debugPrint(
'Web FCM: VAPID_KEY not set in .env — skipping token registration.',
);
return;
}
if (event == AuthChangeEvent.signedIn) { if (event == AuthChangeEvent.signedIn) {
try { try {
final token = await FirebaseMessaging.instance.getToken( final token = await FirebaseMessaging.instance.getToken(
@@ -761,6 +767,10 @@ class _UpdateCheckWrapperState extends State<UpdateCheckWrapper> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Web has no update check. Skip the wrapper MaterialApp there: its plain
// Navigator gets the browser URL as its initial route and logs "Could not
// navigate to initial route" on every deep link.
if (kIsWeb) return const NotificationBridge(child: TasqApp());
return MaterialApp( return MaterialApp(
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: AppTheme.light(), theme: AppTheme.light(),
@@ -784,14 +794,12 @@ class _UpdateCheckWrapperState extends State<UpdateCheckWrapper> {
), ),
); );
}, },
child: kIsWeb child: _done
? const NotificationBridge(child: TasqApp()) ? const NotificationBridge(child: TasqApp())
: (_done : UpdateCheckingScreen(
? const NotificationBridge(child: TasqApp()) checkForUpdates: _checkForUpdates,
: UpdateCheckingScreen( onCompleted: _handleUpdateComplete,
checkForUpdates: _checkForUpdates, ),
onCompleted: _handleUpdateComplete,
)),
), ),
); );
} }
-1
View File
@@ -1 +0,0 @@
export 'announcement.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'announcement_comment.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'attendance_log.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'chat_message.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'duty_schedule.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'it_service_request.model.dart';
@@ -1 +0,0 @@
export 'it_service_request_action.model.dart';
@@ -1 +0,0 @@
export 'it_service_request_activity_log.model.dart';
@@ -1 +0,0 @@
export 'it_service_request_assignment.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'leave_of_absence.model.dart';
+327
View File
@@ -0,0 +1,327 @@
import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';
import 'package:brick_supabase/brick_supabase.dart';
enum NetworkDeviceStatus {
online,
offline,
warning,
unknown;
String get wire {
switch (this) {
case NetworkDeviceStatus.online:
return 'online';
case NetworkDeviceStatus.offline:
return 'offline';
case NetworkDeviceStatus.warning:
return 'warning';
case NetworkDeviceStatus.unknown:
return 'unknown';
}
}
String get label {
switch (this) {
case NetworkDeviceStatus.online:
return 'Online';
case NetworkDeviceStatus.offline:
return 'Offline';
case NetworkDeviceStatus.warning:
return 'Warning';
case NetworkDeviceStatus.unknown:
return 'Unknown';
}
}
static NetworkDeviceStatus fromWire(String? value) {
switch (value) {
case 'online':
return NetworkDeviceStatus.online;
case 'offline':
return NetworkDeviceStatus.offline;
case 'warning':
return NetworkDeviceStatus.warning;
default:
return NetworkDeviceStatus.unknown;
}
}
}
enum NetworkDeviceKind {
router,
switchDevice,
ap,
firewall,
server,
endpoint,
patchPanel,
other;
String get wire {
switch (this) {
case NetworkDeviceKind.router:
return 'router';
case NetworkDeviceKind.switchDevice:
return 'switch';
case NetworkDeviceKind.ap:
return 'ap';
case NetworkDeviceKind.firewall:
return 'firewall';
case NetworkDeviceKind.server:
return 'server';
case NetworkDeviceKind.endpoint:
return 'endpoint';
case NetworkDeviceKind.patchPanel:
return 'patch_panel';
case NetworkDeviceKind.other:
return 'other';
}
}
String get label {
switch (this) {
case NetworkDeviceKind.router:
return 'Router';
case NetworkDeviceKind.switchDevice:
return 'Switch';
case NetworkDeviceKind.ap:
return 'Access Point';
case NetworkDeviceKind.firewall:
return 'Firewall';
case NetworkDeviceKind.server:
return 'Server';
case NetworkDeviceKind.endpoint:
return 'Endpoint';
case NetworkDeviceKind.patchPanel:
return 'Patch Panel';
case NetworkDeviceKind.other:
return 'Other';
}
}
static NetworkDeviceKind fromWire(String? value) {
switch (value) {
case 'router':
return NetworkDeviceKind.router;
case 'switch':
return NetworkDeviceKind.switchDevice;
case 'ap':
return NetworkDeviceKind.ap;
case 'firewall':
return NetworkDeviceKind.firewall;
case 'server':
return NetworkDeviceKind.server;
case 'endpoint':
return NetworkDeviceKind.endpoint;
case 'patch_panel':
return NetworkDeviceKind.patchPanel;
default:
return NetworkDeviceKind.other;
}
}
}
enum NetworkDeviceRole {
core,
distribution,
access,
edge,
endpoint;
String get wire {
switch (this) {
case NetworkDeviceRole.core:
return 'core';
case NetworkDeviceRole.distribution:
return 'distribution';
case NetworkDeviceRole.access:
return 'access';
case NetworkDeviceRole.edge:
return 'edge';
case NetworkDeviceRole.endpoint:
return 'endpoint';
}
}
String get label {
switch (this) {
case NetworkDeviceRole.core:
return 'Core';
case NetworkDeviceRole.distribution:
return 'Distribution';
case NetworkDeviceRole.access:
return 'Access';
case NetworkDeviceRole.edge:
return 'Edge';
case NetworkDeviceRole.endpoint:
return 'Endpoint';
}
}
static NetworkDeviceRole? fromWire(String? value) {
switch (value) {
case 'core':
return NetworkDeviceRole.core;
case 'distribution':
return NetworkDeviceRole.distribution;
case 'access':
return NetworkDeviceRole.access;
case 'edge':
return NetworkDeviceRole.edge;
case 'endpoint':
return NetworkDeviceRole.endpoint;
default:
return null;
}
}
}
enum NetworkImportSource {
manual,
aiImport,
agent;
String get wire {
switch (this) {
case NetworkImportSource.manual:
return 'manual';
case NetworkImportSource.aiImport:
return 'ai_import';
case NetworkImportSource.agent:
return 'agent';
}
}
static NetworkImportSource fromWire(String? value) {
switch (value) {
case 'ai_import':
return NetworkImportSource.aiImport;
case 'agent':
return NetworkImportSource.agent;
default:
return NetworkImportSource.manual;
}
}
}
@ConnectOfflineFirstWithSupabase(
supabaseConfig: SupabaseSerializable(tableName: 'network_devices'),
)
class NetworkDevice extends OfflineFirstWithSupabaseModel {
final String id;
final String name;
final NetworkDeviceKind kind;
final NetworkDeviceRole? role;
final String? vendor;
final String? model;
final String? serial;
final String? mgmtIp;
final String? mac;
final String? locationId;
final NetworkImportSource importSource;
final NetworkDeviceStatus status;
final String? notes;
final DateTime createdAt;
final DateTime updatedAt;
NetworkDevice({
required this.id,
required this.name,
required this.kind,
this.role,
this.vendor,
this.model,
this.serial,
this.mgmtIp,
this.mac,
this.locationId,
this.importSource = NetworkImportSource.manual,
this.status = NetworkDeviceStatus.unknown,
this.notes,
required this.createdAt,
required this.updatedAt,
});
factory NetworkDevice.fromMap(Map<String, dynamic> map) {
return NetworkDevice(
id: map['id'] as String,
name: (map['name'] as String?) ?? '',
kind: NetworkDeviceKind.fromWire(map['kind'] as String?),
role: NetworkDeviceRole.fromWire(map['role'] as String?),
vendor: map['vendor'] as String?,
model: map['model'] as String?,
serial: map['serial'] as String?,
mgmtIp: map['mgmt_ip']?.toString(),
mac: map['mac']?.toString(),
locationId: map['location_id'] as String?,
importSource: NetworkImportSource.fromWire(map['import_source'] as String?),
status: NetworkDeviceStatus.fromWire(map['status'] as String?),
notes: map['notes'] as String?,
createdAt:
DateTime.tryParse(map['created_at']?.toString() ?? '') ?? DateTime.now(),
updatedAt:
DateTime.tryParse(map['updated_at']?.toString() ?? '') ?? DateTime.now(),
);
}
Map<String, dynamic> toInsertMap() => {
'name': name,
'kind': kind.wire,
if (role != null) 'role': role!.wire,
if (vendor != null) 'vendor': vendor,
if (model != null) 'model': model,
if (serial != null) 'serial': serial,
if (mgmtIp != null) 'mgmt_ip': mgmtIp,
if (mac != null) 'mac': mac,
if (locationId != null) 'location_id': locationId,
'import_source': importSource.wire,
'status': status.wire,
if (notes != null) 'notes': notes,
};
Map<String, dynamic> toUpdateMap() => {
'name': name,
'kind': kind.wire,
'role': role?.wire,
'vendor': vendor,
'model': model,
'serial': serial,
'mgmt_ip': mgmtIp,
'mac': mac,
'location_id': locationId,
'status': status.wire,
'notes': notes,
};
NetworkDevice copyWith({
String? name,
NetworkDeviceKind? kind,
NetworkDeviceRole? role,
String? vendor,
String? model,
String? serial,
String? mgmtIp,
String? mac,
String? locationId,
NetworkDeviceStatus? status,
String? notes,
}) {
return NetworkDevice(
id: id,
name: name ?? this.name,
kind: kind ?? this.kind,
role: role ?? this.role,
vendor: vendor ?? this.vendor,
model: model ?? this.model,
serial: serial ?? this.serial,
mgmtIp: mgmtIp ?? this.mgmtIp,
mac: mac ?? this.mac,
locationId: locationId ?? this.locationId,
importSource: importSource,
status: status ?? this.status,
notes: notes ?? this.notes,
createdAt: createdAt,
updatedAt: updatedAt,
);
}
}
+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,
);
}
}
-1
View File
@@ -1 +0,0 @@
export 'notification_item.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'office.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'pass_slip.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'profile.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'service.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'swap_request.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'task.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'task_activity_log.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'team.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'ticket.model.dart';
+33 -31
View File
@@ -3,8 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.dart'; import '../brick/cache_helpers.dart';
import '../models/announcement.dart'; import '../models/announcement.model.dart';
import '../models/announcement_comment.dart'; import '../models/announcement_comment.model.dart';
import '../utils/app_time.dart'; import '../utils/app_time.dart';
import '../utils/snackbar.dart' show isOfflineSaveError; import '../utils/snackbar.dart' show isOfflineSaveError;
import 'connectivity_provider.dart'; import 'connectivity_provider.dart';
@@ -273,35 +273,37 @@ final announcementCommentsProvider =
..sort((a, b) => a.createdAt.compareTo(b.createdAt)); ..sort((a, b) => a.createdAt.compareTo(b.createdAt));
} }
final wrapper = StreamRecoveryWrapper<AnnouncementComment>( final wrapper = StreamRecoveryWrapper<AnnouncementComment>(
stream: client stream: client
.from('announcement_comments') .from('announcement_comments')
.stream(primaryKey: ['id']) .stream(primaryKey: ['id'])
.eq('announcement_id', announcementId) .eq('announcement_id', announcementId)
.order('created_at'), .order('created_at', ascending: true),
onPollData: () async { onPollData: () async {
final data = await client final data = await client
.from('announcement_comments') .from('announcement_comments')
.select() .select()
.eq('announcement_id', announcementId) .eq('announcement_id', announcementId)
.order('created_at'); .order('created_at', ascending: true);
return data.map(AnnouncementComment.fromMap).toList(); return data.map(AnnouncementComment.fromMap).toList();
}, },
fromMap: AnnouncementComment.fromMap, fromMap: AnnouncementComment.fromMap,
channelName: 'announcement_comments_$announcementId', channelName: 'announcement_comments_$announcementId',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, onStatusChanged: ref
onOfflineData: () async { .read(realtimeControllerProvider)
final all = await cachedListFromBrick<AnnouncementComment>(); .handleChannelStatus,
final filtered = all onOfflineData: () async {
.where((c) => c.announcementId == announcementId) final all = await cachedListFromBrick<AnnouncementComment>();
.toList() final filtered =
..sort((a, b) => a.createdAt.compareTo(b.createdAt)); all.where((c) => c.announcementId == announcementId).toList()
return applyPending(filtered); ..sort((a, b) => a.createdAt.compareTo(b.createdAt));
}, return applyPending(filtered);
onCacheMirror: (rows) => mirrorBatchToBrick<AnnouncementComment>( },
rows, onCacheMirror: (rows) => mirrorBatchToBrick<AnnouncementComment>(
tag: 'announcement_comments'), rows,
); tag: 'announcement_comments',
),
);
ref.onDispose(wrapper.dispose); ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => applyPending(result.data)); return wrapper.stream.map((result) => applyPending(result.data));
+75 -6
View File
@@ -8,7 +8,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.dart'; import '../brick/cache_helpers.dart';
import '../models/attendance_log.dart'; import '../models/attendance_log.model.dart';
import '../utils/app_time.dart'; import '../utils/app_time.dart';
import '../utils/snackbar.dart' show isOfflineSaveError; import '../utils/snackbar.dart' show isOfflineSaveError;
import 'connectivity_provider.dart'; import 'connectivity_provider.dart';
@@ -175,19 +175,57 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
for (final params in pendingRaw) { for (final params in pendingRaw) {
final localId = params['localId'] as String; final localId = params['localId'] as String;
try { try {
await client.rpc( final serverId = await client.rpc(
'attendance_check_in', 'attendance_check_in',
params: { params: {
'p_duty_id': params['p_duty_id'], 'p_duty_id': params['p_duty_id'],
'p_lat': params['p_lat'], 'p_lat': params['p_lat'],
'p_lng': params['p_lng'], 'p_lng': params['p_lng'],
'p_accuracy': params['p_accuracy'],
'p_is_mocked': params['p_is_mocked'],
}, },
); ) as String?;
syncedLocalIds.add(localId); 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) { } catch (e) {
final msg = e.toString(); final msg = e.toString();
if (msg.contains('23505') || msg.contains('duplicate key')) { if (msg.contains('23505') || msg.contains('duplicate key')) {
syncedLocalIds.add(localId); 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 { } else {
debugPrint( debugPrint(
'[attendanceLogsProvider] failed to sync check-in localId=$localId: $e', '[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_lat': fields['check_out_lat'],
'p_lng': fields['check_out_lng'], 'p_lng': fields['check_out_lng'],
'p_justification': fields['check_out_justification'], 'p_justification': fields['check_out_justification'],
'p_accuracy': fields['check_out_accuracy'],
'p_is_mocked': fields['check_out_is_mocked'],
}, },
); );
syncedIds.add(attendanceId); syncedIds.add(attendanceId);
@@ -354,12 +394,20 @@ class AttendanceController {
required String dutyScheduleId, required String dutyScheduleId,
required double lat, required double lat,
required double lng, required double lng,
double accuracy = 0,
bool isMocked = false,
String shiftType = 'normal', String shiftType = 'normal',
}) async { }) async {
try { try {
final data = await _client.rpc( final data = await _client.rpc(
'attendance_check_in', '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?; return data as String?;
} catch (e) { } catch (e) {
@@ -382,7 +430,14 @@ class AttendanceController {
_queuePendingCheckIn( _queuePendingCheckIn(
log, 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'); debugPrint('[AttendanceController] checkIn queued offline: $id');
return id; return id;
@@ -395,6 +450,8 @@ class AttendanceController {
required double lat, required double lat,
required double lng, required double lng,
String? justification, String? justification,
double accuracy = 0,
bool isMocked = false,
}) async { }) async {
try { try {
await _client.rpc( await _client.rpc(
@@ -404,6 +461,8 @@ class AttendanceController {
'p_lat': lat, 'p_lat': lat,
'p_lng': lng, 'p_lng': lng,
'p_justification': justification, 'p_justification': justification,
'p_accuracy': accuracy,
'p_is_mocked': isMocked,
}, },
); );
} catch (e) { } catch (e) {
@@ -415,6 +474,8 @@ class AttendanceController {
'check_out_lat': lat, 'check_out_lat': lat,
'check_out_lng': lng, 'check_out_lng': lng,
'check_out_justification': justification, 'check_out_justification': justification,
'check_out_accuracy': accuracy,
'check_out_is_mocked': isMocked,
}); });
debugPrint( debugPrint(
'[AttendanceController] checkOut queued offline: $attendanceId', '[AttendanceController] checkOut queued offline: $attendanceId',
@@ -427,10 +488,18 @@ class AttendanceController {
required double lat, required double lat,
required double lng, required double lng,
String? justification, String? justification,
double accuracy = 0,
bool isMocked = false,
}) async { }) async {
final data = await _client.rpc( final data = await _client.rpc(
'overtime_check_in', '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?; return data as String?;
} }
+26 -12
View File
@@ -4,7 +4,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.dart'; import '../brick/cache_helpers.dart';
import '../models/chat_message.dart'; import '../models/chat_message.model.dart';
import '../utils/app_time.dart'; import '../utils/app_time.dart';
import '../utils/snackbar.dart' show isOfflineSaveError; import '../utils/snackbar.dart' show isOfflineSaveError;
import 'connectivity_provider.dart'; import 'connectivity_provider.dart';
@@ -42,21 +42,22 @@ final chatMessagesProvider = StreamProvider.family<List<ChatMessage>, String>((
byId.putIfAbsent(p.id, () => p); byId.putIfAbsent(p.id, () => p);
} }
return byId.values.toList() return byId.values.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt)); ..sort((a, b) => b.createdAt.compareTo(a.createdAt));
} }
// Newest-first everywhere: the swap chat renders with ListView(reverse: true).
final wrapper = StreamRecoveryWrapper<ChatMessage>( final wrapper = StreamRecoveryWrapper<ChatMessage>(
stream: client stream: client
.from('chat_messages') .from('chat_messages')
.stream(primaryKey: ['id']) .stream(primaryKey: ['id'])
.eq('thread_id', threadId) .eq('thread_id', threadId)
.order('created_at'), .order('created_at', ascending: false),
onPollData: () async { onPollData: () async {
final data = await client final data = await client
.from('chat_messages') .from('chat_messages')
.select() .select()
.eq('thread_id', threadId) .eq('thread_id', threadId)
.order('created_at'); .order('created_at', ascending: false);
return data.map(ChatMessage.fromMap).toList(); return data.map(ChatMessage.fromMap).toList();
}, },
fromMap: ChatMessage.fromMap, fromMap: ChatMessage.fromMap,
@@ -64,10 +65,8 @@ final chatMessagesProvider = StreamProvider.family<List<ChatMessage>, String>((
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async { onOfflineData: () async {
final all = await cachedListFromBrick<ChatMessage>(); final all = await cachedListFromBrick<ChatMessage>();
final filtered = all final filtered = all.where((m) => m.threadId == threadId).toList()
.where((m) => m.threadId == threadId) ..sort((a, b) => b.createdAt.compareTo(a.createdAt));
.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
return applyPending(filtered); return applyPending(filtered);
}, },
onCacheMirror: (rows) => onCacheMirror: (rows) =>
@@ -163,6 +162,21 @@ class ChatController {
} catch (e) { } catch (e) {
if (!isOfflineSaveError(e)) rethrow; 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( final message = ChatMessage(
id: id, id: id,
threadId: threadId, threadId: threadId,
@@ -171,13 +185,13 @@ class ChatController {
createdAt: AppTime.now(), createdAt: AppTime.now(),
); );
final ref = _ref; final ref2 = _ref;
if (ref != null) { if (ref2 != null) {
final current = Map<String, List<ChatMessage>>.from( final current = Map<String, List<ChatMessage>>.from(
ref.read(offlinePendingChatMessagesProvider), ref2.read(offlinePendingChatMessagesProvider),
); );
current[threadId] = [...(current[threadId] ?? []), message]; current[threadId] = [...(current[threadId] ?? []), message];
ref.read(offlinePendingChatMessagesProvider.notifier).state = ref2.read(offlinePendingChatMessagesProvider.notifier).state =
Map.unmodifiable(current); Map.unmodifiable(current);
} }
mirrorBatchToBrick<ChatMessage>([message], tag: 'offline_chat'); mirrorBatchToBrick<ChatMessage>([message], tag: 'offline_chat');
+1 -1
View File
@@ -64,7 +64,7 @@ class ConnectivityMonitor {
} }
try { try {
final res = await http final res = await http
.head(Uri.parse('https://tasq.crmc.ph')) .head(Uri.parse('https://tasq.supabase.crmc.ph'))
.timeout(const Duration(seconds: 5)); .timeout(const Duration(seconds: 5));
return res.statusCode < 500; return res.statusCode < 500;
} catch (_) { } catch (_) {
@@ -9,10 +9,10 @@ import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.dart'; import '../brick/cache_helpers.dart';
import '../models/it_service_request.dart'; import '../models/it_service_request.model.dart';
import '../models/it_service_request_assignment.dart'; import '../models/it_service_request_assignment.model.dart';
import '../models/it_service_request_activity_log.dart'; import '../models/it_service_request_activity_log.model.dart';
import '../models/it_service_request_action.dart'; import '../models/it_service_request_action.model.dart';
import '../utils/app_time.dart'; import '../utils/app_time.dart';
import '../utils/snackbar.dart' show isOfflineSaveError; import '../utils/snackbar.dart' show isOfflineSaveError;
import 'connectivity_provider.dart'; import 'connectivity_provider.dart';
+1 -1
View File
@@ -4,7 +4,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.dart'; import '../brick/cache_helpers.dart';
import '../models/leave_of_absence.dart'; import '../models/leave_of_absence.model.dart';
import '../utils/app_time.dart'; import '../utils/app_time.dart';
import '../utils/snackbar.dart' show isOfflineSaveError; import '../utils/snackbar.dart' show isOfflineSaveError;
import 'connectivity_provider.dart'; import 'connectivity_provider.dart';
@@ -0,0 +1,248 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/network/network_device.dart';
import '../../models/network/network_link.dart';
import '../../models/network/network_port.dart';
import '../../models/network/network_vlan.dart';
import '../supabase_provider.dart';
final networkDevicesProvider = FutureProvider<List<NetworkDevice>>((ref) async {
final client = ref.watch(supabaseClientProvider);
final rows = await client.from('network_devices').select().order('name', ascending: true);
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', ascending: true);
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', ascending: true);
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', ascending: true);
return (rows as List)
.map((r) => NetworkPort.fromMap(r as Map<String, dynamic>))
.toList();
});
class NetworkDevicesController {
NetworkDevicesController(this.ref);
final Ref ref;
Future<NetworkDevice> createDevice({
required String name,
required NetworkDeviceKind kind,
NetworkDeviceRole? role,
String? vendor,
String? model,
String? serial,
String? mgmtIp,
String? mac,
String? locationId,
NetworkImportSource importSource = NetworkImportSource.manual,
NetworkDeviceStatus status = NetworkDeviceStatus.unknown,
String? notes,
}) async {
final client = ref.read(supabaseClientProvider);
final inserted = await client
.from('network_devices')
.insert({
'name': name,
'kind': kind.wire,
'role': ?role?.wire,
'vendor': ?vendor,
'model': ?model,
'serial': ?serial,
'mgmt_ip': ?mgmtIp,
'mac': ?mac,
'location_id': ?locationId,
'import_source': importSource.wire,
'status': status.wire,
'notes': ?notes,
})
.select()
.single();
ref.invalidate(networkDevicesProvider);
return NetworkDevice.fromMap(inserted);
}
Future<void> updateDevice(NetworkDevice device) async {
final client = ref.read(supabaseClientProvider);
await client
.from('network_devices')
.update(device.toUpdateMap())
.eq('id', device.id);
ref.invalidate(networkDevicesProvider);
ref.invalidate(networkDeviceByIdProvider(device.id));
}
Future<void> deleteDevice(String id) async {
final client = ref.read(supabaseClientProvider);
await client.from('network_devices').delete().eq('id', id);
ref.invalidate(networkDevicesProvider);
ref.invalidate(networkPortsProvider);
ref.invalidate(networkLinksProvider);
}
Future<NetworkPort> createPort({
required String deviceId,
required String portNumber,
NetworkPortKind? portKind,
int? speedMbps,
int? accessVlan,
bool isTrunk = false,
List<int> trunkVlans = const [],
String? notes,
}) async {
final client = ref.read(supabaseClientProvider);
final inserted = await client
.from('network_ports')
.insert({
'device_id': deviceId,
'port_number': portNumber,
'port_kind': ?portKind?.wire,
'speed_mbps': ?speedMbps,
'access_vlan': ?accessVlan,
'is_trunk': isTrunk,
if (trunkVlans.isNotEmpty) 'trunk_vlans': trunkVlans,
'notes': ?notes,
})
.select()
.single();
ref.invalidate(networkPortsProvider);
ref.invalidate(networkPortsByDeviceProvider(deviceId));
return NetworkPort.fromMap(inserted);
}
Future<void> deletePort(String id, {String? deviceId}) async {
final client = ref.read(supabaseClientProvider);
await client.from('network_ports').delete().eq('id', id);
ref.invalidate(networkPortsProvider);
ref.invalidate(networkLinksProvider);
if (deviceId != null) {
ref.invalidate(networkPortsByDeviceProvider(deviceId));
}
}
Future<void> updatePort({
required String id,
required String deviceId,
required String portNumber,
NetworkPortKind? portKind,
int? speedMbps,
int? accessVlan,
bool isTrunk = false,
List<int> trunkVlans = const [],
String? notes,
}) async {
final client = ref.read(supabaseClientProvider);
await client.from('network_ports').update({
'port_number': portNumber,
'port_kind': portKind?.wire,
'speed_mbps': speedMbps,
'access_vlan': accessVlan,
'is_trunk': isTrunk,
'trunk_vlans': trunkVlans.isEmpty ? null : trunkVlans,
'notes': notes,
}).eq('id', id);
ref.invalidate(networkPortsProvider);
ref.invalidate(networkPortsByDeviceProvider(deviceId));
}
Future<NetworkLink> createLink({
required String portA,
required String portB,
NetworkLinkKind? linkKind,
String? cableLabel,
String? notes,
}) async {
final client = ref.read(supabaseClientProvider);
final inserted = await client
.from('network_links')
.insert(NetworkLink.insertMapFor(
portA: portA,
portB: portB,
linkKind: linkKind,
cableLabel: cableLabel,
notes: notes,
))
.select()
.single();
ref.invalidate(networkLinksProvider);
return NetworkLink.fromMap(inserted);
}
Future<void> deleteLink(String id) async {
final client = ref.read(supabaseClientProvider);
await client.from('network_links').delete().eq('id', id);
ref.invalidate(networkLinksProvider);
}
Future<NetworkVlan> createVlan({
required int vlanId,
required String name,
String? description,
String? color,
}) async {
final client = ref.read(supabaseClientProvider);
final inserted = await client
.from('network_vlans')
.insert({
'vlan_id': vlanId,
'name': name,
'description': ?description,
'color': ?color,
})
.select()
.single();
ref.invalidate(networkVlansProvider);
return NetworkVlan.fromMap(inserted);
}
Future<void> deleteVlan(String id) async {
final client = ref.read(supabaseClientProvider);
await client.from('network_vlans').delete().eq('id', id);
ref.invalidate(networkVlansProvider);
}
}
final networkDevicesControllerProvider =
Provider<NetworkDevicesController>((ref) => NetworkDevicesController(ref));
@@ -0,0 +1,155 @@
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../models/network/network_import.dart';
import '../supabase_provider.dart';
final networkImportsProvider = FutureProvider<List<NetworkImport>>((ref) async {
final client = ref.watch(supabaseClientProvider);
final rows = await client
.from('network_imports')
.select()
.order('created_at', ascending: false);
return (rows as List)
.map((r) => NetworkImport.fromMap(r as Map<String, dynamic>))
.toList();
});
final networkImportByIdProvider =
FutureProvider.family<NetworkImport?, String>((ref, id) async {
final client = ref.watch(supabaseClientProvider);
final row = await client
.from('network_imports')
.select()
.eq('id', id)
.maybeSingle();
if (row == null) return null;
return NetworkImport.fromMap(row);
});
class NetworkImportResult {
NetworkImportResult({required this.status, this.aiResult, this.errorMessage});
final NetworkImportStatus status;
final Map<String, dynamic>? aiResult;
final String? errorMessage;
}
class NetworkImportController {
NetworkImportController(this.ref);
final Ref ref;
/// Upload a source document, create a network_imports row, and trigger the
/// extract_topology Edge Function. Returns the import_id immediately so the
/// UI can navigate to the extraction-progress screen.
Future<String> uploadAndQueueExtraction({
required Uint8List bytes,
required String filename,
required String createdBy,
}) async {
final client = ref.read(supabaseClientProvider);
final fileKind = NetworkImportFileKind.fromExtension(filename);
final sanitized = filename
.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_')
.substring(0, filename.length > 80 ? 80 : filename.length);
final timestamp = DateTime.now().millisecondsSinceEpoch;
final storagePath = '$createdBy/${timestamp}_$sanitized';
await client.storage.from('network-imports').uploadBinary(
storagePath,
bytes,
fileOptions: const FileOptions(upsert: false),
);
final inserted = await client
.from('network_imports')
.insert({
'storage_path': storagePath,
'file_kind': fileKind.wire,
'status': NetworkImportStatus.pending.wire,
'created_by': createdBy,
})
.select()
.single();
ref.invalidate(networkImportsProvider);
return inserted['id'] as String;
}
/// Invoke the extract_topology Edge Function and return the parsed result.
///
/// Caller is expected to already have polled/awaited the import being in
/// pending status. Long-running (5–60s) — UI should show progress.
Future<NetworkImportResult> runExtraction(String importId) async {
final client = ref.read(supabaseClientProvider);
final response = await client.functions.invoke(
'extract_topology',
body: {'import_id': importId},
);
final data = response.data;
if (data is! Map) {
return NetworkImportResult(
status: NetworkImportStatus.failed,
errorMessage: 'Unexpected response from extract_topology',
);
}
final asMap = Map<String, dynamic>.from(data);
if (asMap['error'] != null) {
ref.invalidate(networkImportByIdProvider(importId));
ref.invalidate(networkImportsProvider);
return NetworkImportResult(
status: NetworkImportStatus.failed,
errorMessage: asMap['error'].toString(),
);
}
final statusStr = asMap['status']?.toString() ?? 'failed';
final aiResult = asMap['ai_result'] is Map
? Map<String, dynamic>.from(asMap['ai_result'] as Map)
: null;
ref.invalidate(networkImportByIdProvider(importId));
ref.invalidate(networkImportsProvider);
return NetworkImportResult(
status: NetworkImportStatus.fromWire(statusStr),
aiResult: aiResult,
);
}
Future<void> discard(String importId) async {
final client = ref.read(supabaseClientProvider);
await client
.from('network_imports')
.update({'status': NetworkImportStatus.discarded.wire})
.eq('id', importId);
ref.invalidate(networkImportsProvider);
ref.invalidate(networkImportByIdProvider(importId));
}
Future<void> markApplied({
required String importId,
required List<String> deviceIds,
required List<String> linkIds,
}) async {
final client = ref.read(supabaseClientProvider);
await client
.from('network_imports')
.update({
'status': NetworkImportStatus.applied.wire,
'applied_device_ids': deviceIds,
'applied_link_ids': linkIds,
'applied_at': DateTime.now().toUtc().toIso8601String(),
})
.eq('id', importId);
ref.invalidate(networkImportsProvider);
ref.invalidate(networkImportByIdProvider(importId));
}
}
final networkImportControllerProvider =
Provider<NetworkImportController>((ref) => NetworkImportController(ref));
@@ -0,0 +1,104 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/network/network_site.dart';
import '../../models/network/network_location.dart';
import '../supabase_provider.dart';
final networkSitesProvider = FutureProvider<List<NetworkSite>>((ref) async {
final client = ref.watch(supabaseClientProvider);
final rows = await client.from('network_sites').select().order('name', ascending: true);
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', ascending: true);
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);
+22 -1
View File
@@ -4,7 +4,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import '../utils/device_id.dart'; import '../utils/device_id.dart';
import '../brick/cache_helpers.dart'; import '../brick/cache_helpers.dart';
import '../models/notification_item.dart'; import '../models/notification_item.model.dart';
import '../utils/app_time.dart'; import '../utils/app_time.dart';
import '../utils/snackbar.dart' show isOfflineSaveError; import '../utils/snackbar.dart' show isOfflineSaveError;
import 'connectivity_provider.dart'; import 'connectivity_provider.dart';
@@ -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 { Future<void> registerFcmToken(String token) async {
final userId = _client.auth.currentUser?.id; final userId = _client.auth.currentUser?.id;
if (userId == null) return; if (userId == null) return;
@@ -419,6 +434,12 @@ class NotificationsController {
final current = List<Map<String, dynamic>>.from( final current = List<Map<String, dynamic>>.from(
ref.read(offlinePendingBatchNotificationReadsProvider), 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({ current.add({
'filter_type': filterType, 'filter_type': filterType,
'filter_id': filterId, '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,
);
});
+1 -1
View File
@@ -4,7 +4,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.dart'; import '../brick/cache_helpers.dart';
import '../models/pass_slip.dart'; import '../models/pass_slip.model.dart';
import '../utils/app_time.dart'; import '../utils/app_time.dart';
import '../utils/snackbar.dart' show isOfflineSaveError; import '../utils/snackbar.dart' show isOfflineSaveError;
import 'connectivity_provider.dart'; import 'connectivity_provider.dart';
+6 -3
View File
@@ -9,7 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import '../brick/cache_helpers.dart'; import '../brick/cache_helpers.dart';
import '../models/profile.dart'; import '../models/profile.model.dart';
import '../utils/snackbar.dart' show isOfflineSaveError; import '../utils/snackbar.dart' show isOfflineSaveError;
import 'auth_provider.dart'; import 'auth_provider.dart';
import 'connectivity_provider.dart'; import 'connectivity_provider.dart';
@@ -165,9 +165,12 @@ final profilesProvider = StreamProvider<List<Profile>>((ref) {
stream: client stream: client
.from('profiles') .from('profiles')
.stream(primaryKey: ['id']) .stream(primaryKey: ['id'])
.order('full_name'), .order('full_name', ascending: true),
onPollData: () async { onPollData: () async {
final data = await client.from('profiles').select().order('full_name'); final data = await client
.from('profiles')
.select()
.order('full_name', ascending: true);
return data.map(Profile.fromMap).toList(); return data.map(Profile.fromMap).toList();
}, },
fromMap: Profile.fromMap, fromMap: Profile.fromMap,
+13 -4
View File
@@ -1,7 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../brick/cache_helpers.dart'; import '../brick/cache_helpers.dart';
import '../models/service.dart'; import '../models/service.model.dart';
import 'supabase_provider.dart'; import 'supabase_provider.dart';
import 'stream_recovery.dart'; import 'stream_recovery.dart';
import 'realtime_controller.dart'; import 'realtime_controller.dart';
@@ -10,9 +10,15 @@ final servicesProvider = StreamProvider<List<Service>>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<Service>( final wrapper = StreamRecoveryWrapper<Service>(
stream: client.from('services').stream(primaryKey: ['id']).order('name'), stream: client
.from('services')
.stream(primaryKey: ['id'])
.order('name', ascending: true),
onPollData: () async { onPollData: () async {
final data = await client.from('services').select().order('name'); final data = await client
.from('services')
.select()
.order('name', ascending: true);
return data.map(Service.fromMap).toList(); return data.map(Service.fromMap).toList();
}, },
fromMap: Service.fromMap, fromMap: Service.fromMap,
@@ -33,7 +39,10 @@ final servicesProvider = StreamProvider<List<Service>>((ref) {
final servicesOnceProvider = FutureProvider<List<Service>>((ref) async { final servicesOnceProvider = FutureProvider<List<Service>>((ref) async {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
final rows = await client.from('services').select().order('name'); final rows = await client
.from('services')
.select()
.order('name', ascending: true);
return (rows as List<dynamic>) return (rows as List<dynamic>)
.map((r) => Service.fromMap(r as Map<String, dynamic>)) .map((r) => Service.fromMap(r as Map<String, dynamic>))
.toList(); .toList();
+4
View File
@@ -1,3 +1,7 @@
// The constructor deliberately exposes public named params (onPollData, …) that
// initialize private fields (_onPollData, …); the initializing-formal "fix"
// would rename the params to private and break all call sites.
// ignore_for_file: prefer_initializing_formals
import 'dart:async'; import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
+70
View File
@@ -105,3 +105,73 @@ final pendingSyncCountProvider = Provider<int>((ref) {
officeOps + officeOps +
chatMessages; 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;
});
+66 -6
View File
@@ -9,8 +9,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import '../brick/cache_helpers.dart'; import '../brick/cache_helpers.dart';
import '../brick/repository.dart'; import '../brick/repository.dart';
import '../models/task.dart'; import '../models/task.model.dart';
import '../models/task_activity_log.dart'; import '../models/task_activity_log.model.dart';
import 'connectivity_provider.dart'; import 'connectivity_provider.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
@@ -328,9 +328,10 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
onOfflineData: () async { onOfflineData: () async {
if (!AppRepository.isConfigured) return []; if (!AppRepository.isConfigured) return [];
try { try {
return await AppRepository.instance.get<Task>( final all = await AppRepository.instance.get<Task>(
policy: OfflineFirstGetPolicy.localOnly, policy: OfflineFirstGetPolicy.localOnly,
); );
return {for (final t in all) t.id: t}.values.toList();
} catch (_) { } catch (_) {
return []; return [];
} }
@@ -363,6 +364,7 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
'[tasksProvider] reconnected — syncing ${pending.length} pending task(s)', '[tasksProvider] reconnected — syncing ${pending.length} pending task(s)',
); );
} }
final failedTaskIds = <String>{};
for (final task in pending) { for (final task in pending) {
try { try {
final syncPayload = <String, dynamic>{ final syncPayload = <String, dynamic>{
@@ -393,18 +395,26 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
} catch (e) { } catch (e) {
final msg = e.toString(); final msg = e.toString();
if (msg.contains('23505') || msg.contains('duplicate key')) { if (msg.contains('23505') || msg.contains('duplicate key')) {
// Task already in DB (Brick may have synced it first) — safe to // Task already in DB — treat as success so it's cleared from pending.
// leave cleanup to the realtime stream listener in tasks_list_screen.
debugPrint( debugPrint(
'[tasksProvider] pending task already in DB id=${task.id}', '[tasksProvider] pending task already in DB id=${task.id}',
); );
} else { } else {
failedTaskIds.add(task.id);
debugPrint( debugPrint(
'[tasksProvider] failed to sync pending task id=${task.id}: $e', '[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) ────────── // ── 2. Sync offline field edits for EXISTING tasks (PATCH) ──────────
// pendingUpdates already had pending-task entries removed in step 1. // 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. // When offline, seed from Brick local SQLite cache instead of network.
if (!ref.read(isOnlineProvider) && AppRepository.isConfigured) { if (!ref.read(isOnlineProvider) && AppRepository.isConfigured) {
try { try {
final cached = await AppRepository.instance.get<Task>( final all = await AppRepository.instance.get<Task>(
policy: OfflineFirstGetPolicy.localOnly, 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'); debugPrint('[tasksProvider] offline seed: ${cached.length} tasks from local cache');
final payload = _buildTaskPayload( final payload = _buildTaskPayload(
tasks: cached, tasks: cached,
@@ -2788,3 +2799,52 @@ class TaskAssignmentsController {
ref.read(offlinePendingAssignmentsProvider.notifier).state = current; 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 [];
}
}
},
);
+19 -6
View File
@@ -1,24 +1,33 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'supabase_provider.dart'; import '../brick/cache_helpers.dart';
import 'stream_recovery.dart'; import '../brick/cache_warmer.dart';
import 'realtime_controller.dart'; import '../models/team.model.dart';
import '../models/team.dart';
import '../models/team_member.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. /// Real-time stream of teams with automatic recovery and graceful degradation.
final teamsProvider = StreamProvider<List<Team>>((ref) { final teamsProvider = StreamProvider<List<Team>>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<Team>( final wrapper = StreamRecoveryWrapper<Team>(
stream: client.from('teams').stream(primaryKey: ['id']).order('name'), stream: client
.from('teams')
.stream(primaryKey: ['id'])
.order('name', ascending: true),
onPollData: () async { onPollData: () async {
final data = await client.from('teams').select().order('name'); final data = await client
.from('teams')
.select()
.order('name', ascending: true);
return data.map(Team.fromMap).toList(); return data.map(Team.fromMap).toList();
}, },
fromMap: Team.fromMap, fromMap: Team.fromMap,
channelName: 'teams', channelName: 'teams',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async => cachedListFromBrick<Team>(),
); );
ref.onDispose(wrapper.dispose); ref.onDispose(wrapper.dispose);
@@ -40,6 +49,10 @@ final teamMembersProvider = StreamProvider<List<TeamMember>>((ref) {
fromMap: TeamMember.fromMap, fromMap: TeamMember.fromMap,
channelName: 'team_members', channelName: 'team_members',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final rows = await CacheWarmer.readCachedTeamMembers();
return rows.map(TeamMember.fromMap).toList();
},
); );
ref.onDispose(wrapper.dispose); ref.onDispose(wrapper.dispose);
+14 -6
View File
@@ -10,8 +10,8 @@ import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.dart'; import '../brick/cache_helpers.dart';
import '../brick/repository.dart'; import '../brick/repository.dart';
import '../utils/snackbar.dart' show isOfflineSaveError; import '../utils/snackbar.dart' show isOfflineSaveError;
import '../models/office.dart'; import '../models/office.model.dart';
import '../models/ticket.dart'; import '../models/ticket.model.dart';
import '../models/ticket_message.dart'; import '../models/ticket_message.dart';
import 'profile_provider.dart'; import 'profile_provider.dart';
import 'supabase_provider.dart'; import 'supabase_provider.dart';
@@ -30,7 +30,6 @@ final officesProvider = StreamProvider<List<Office>>((ref) {
final pendingOffices = ref.watch(offlinePendingOfficesProvider); final pendingOffices = ref.watch(offlinePendingOfficesProvider);
List<Office> applyPending(List<Office> rows) { List<Office> applyPending(List<Office> rows) {
if (pendingOffices.isEmpty) return rows;
final byId = {for (final r in rows) r.id: r}; final byId = {for (final r in rows) r.id: r};
for (final p in pendingOffices) { for (final p in pendingOffices) {
byId.putIfAbsent(p.id, () => p); byId.putIfAbsent(p.id, () => p);
@@ -40,9 +39,15 @@ final officesProvider = StreamProvider<List<Office>>((ref) {
} }
final wrapper = StreamRecoveryWrapper<Office>( final wrapper = StreamRecoveryWrapper<Office>(
stream: client.from('offices').stream(primaryKey: ['id']).order('name'), stream: client
.from('offices')
.stream(primaryKey: ['id'])
.order('name', ascending: true),
onPollData: () async { onPollData: () async {
final data = await client.from('offices').select().order('name'); final data = await client
.from('offices')
.select()
.order('name', ascending: true);
return data.map(Office.fromMap).toList(); return data.map(Office.fromMap).toList();
}, },
fromMap: Office.fromMap, fromMap: Office.fromMap,
@@ -95,7 +100,10 @@ final officesProvider = StreamProvider<List<Office>>((ref) {
final officesOnceProvider = FutureProvider<List<Office>>((ref) async { final officesOnceProvider = FutureProvider<List<Office>>((ref) async {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
final rows = await client.from('offices').select().order('name'); final rows = await client
.from('offices')
.select()
.order('name', ascending: true);
return (rows as List<dynamic>) return (rows as List<dynamic>)
.map((row) => Office.fromMap(row as Map<String, dynamic>)) .map((row) => Office.fromMap(row as Map<String, dynamic>))
.toList(); .toList();
+2 -2
View File
@@ -52,9 +52,9 @@ final userOfficesProvider = StreamProvider<List<UserOffice>>((ref) {
stream: client stream: client
.from('user_offices') .from('user_offices')
.stream(primaryKey: ['user_id', 'office_id']) .stream(primaryKey: ['user_id', 'office_id'])
.order('created_at'), .order('created_at', ascending: true),
onPollData: () async { onPollData: () async {
final data = await client.from('user_offices').select().order('created_at'); final data = await client.from('user_offices').select().order('created_at', ascending: true);
return data.map(UserOffice.fromMap).toList(); return data.map(UserOffice.fromMap).toList();
}, },
fromMap: UserOffice.fromMap, fromMap: UserOffice.fromMap,
+1
View File
@@ -25,6 +25,7 @@ final livePositionsProvider = StreamProvider<List<LivePosition>>((ref) {
fromMap: LivePosition.fromMap, fromMap: LivePosition.fromMap,
channelName: 'live_positions', channelName: 'live_positions',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async => const [],
); );
ref.onDispose(wrapper.dispose); ref.onDispose(wrapper.dispose);
+15 -11
View File
@@ -7,8 +7,8 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import '../brick/cache_helpers.dart'; import '../brick/cache_helpers.dart';
import '../models/app_settings.dart'; import '../models/app_settings.dart';
import '../models/duty_schedule.dart'; import '../models/duty_schedule.model.dart';
import '../models/swap_request.dart'; import '../models/swap_request.model.dart';
import 'profile_provider.dart'; import 'profile_provider.dart';
import 'supabase_provider.dart'; import 'supabase_provider.dart';
import 'stream_recovery.dart'; import 'stream_recovery.dart';
@@ -65,12 +65,12 @@ final dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) {
stream: client stream: client
.from('duty_schedules') .from('duty_schedules')
.stream(primaryKey: ['id']) .stream(primaryKey: ['id'])
.order('start_time'), .order('start_time', ascending: true),
onPollData: () async { onPollData: () async {
final data = await client final data = await client
.from('duty_schedules') .from('duty_schedules')
.select() .select()
.order('start_time'); .order('start_time', ascending: true);
return data.map(DutySchedule.fromMap).toList(); return data.map(DutySchedule.fromMap).toList();
}, },
fromMap: DutySchedule.fromMap, fromMap: DutySchedule.fromMap,
@@ -89,13 +89,16 @@ final dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) {
// Immediate poll so any changes that happened while this provider was // Immediate poll so any changes that happened while this provider was
// not alive (e.g. a swap was accepted on another device) are reflected // not alive (e.g. a swap was accepted on another device) are reflected
// right away — before the 3-second periodic timer fires. // right away — before the periodic timer fires.
wrapper.pollNow(); wrapper.pollNow();
// Periodic safety-net: keep polling every 3 s so that ownership changes // Periodic safety-net: keep polling so that ownership changes
// (swap accepted → user_id updated on duty_schedules) are always picked // (swap accepted → user_id updated on duty_schedules) are always picked
// up even if Supabase Realtime misses the event. // up even if Supabase Realtime misses the event.
final dutyRefreshTimer = Timer.periodic(const Duration(seconds: 3), (_) { // ponytail: was 3 s, i.e. every client re-downloaded the whole table ~20x a
// minute. Realtime is the primary path; 60 s bounds how stale a missed swap
// can get. Tighten only if swaps need faster convergence.
final dutyRefreshTimer = Timer.periodic(const Duration(seconds: 60), (_) {
wrapper.pollNow(); wrapper.pollNow();
}); });
ref.onDispose(dutyRefreshTimer.cancel); ref.onDispose(dutyRefreshTimer.cancel);
@@ -135,7 +138,7 @@ final dutySchedulesForUserProvider =
.eq('user_id', userId) .eq('user_id', userId)
/* exclude past schedules by ensuring the shift has not ended */ /* exclude past schedules by ensuring the shift has not ended */
.gte('end_time', nowIso) .gte('end_time', nowIso)
.order('start_time') .order('start_time', ascending: true)
as List<dynamic>; as List<dynamic>;
return rows return rows
.map((r) => DutySchedule.fromMap(r as Map<String, dynamic>)) .map((r) => DutySchedule.fromMap(r as Map<String, dynamic>))
@@ -201,13 +204,14 @@ final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) {
// Immediate poll: fetch fresh data right away so any status changes that // Immediate poll: fetch fresh data right away so any status changes that
// happened while this provider was not alive are reflected instantly, // happened while this provider was not alive are reflected instantly,
// before the 3-second periodic timer fires for the first time. // before the periodic timer fires for the first time.
wrapper.pollNow(); wrapper.pollNow();
// Periodic safety-net: keep polling every 3 s to catch any status changes // Periodic safety-net: keep polling to catch any status changes
// that Supabase Realtime may have missed (e.g. when the swap_requests table // that Supabase Realtime may have missed (e.g. when the swap_requests table
// is not yet in the supabase_realtime publication). // is not yet in the supabase_realtime publication).
final refreshTimer = Timer.periodic(const Duration(seconds: 3), (_) { // ponytail: was 3 s (see dutySchedulesProvider); 60 s safety net.
final refreshTimer = Timer.periodic(const Duration(seconds: 60), (_) {
wrapper.pollNow(); wrapper.pollNow();
}); });
ref.onDispose(refreshTimer.cancel); ref.onDispose(refreshTimer.cancel);
+112 -81
View File
@@ -30,10 +30,20 @@ import '../widgets/app_shell.dart';
import '../screens/teams/teams_screen.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_requests_list_screen.dart';
import '../screens/it_service_requests/it_service_request_detail_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 '../theme/m3_motion.dart';
import '../utils/navigation.dart'; import '../utils/navigation.dart';
Page<dynamic> Function(BuildContext, GoRouterState) _page(Widget child) =>
(context, state) => M3SharedAxisPage(key: state.pageKey, child: child);
String _defaultRouteForRole(String? role) { String _defaultRouteForRole(String? role) {
switch (role) { switch (role) {
case 'it_staff': case 'it_staff':
@@ -77,6 +87,12 @@ final appRouterProvider = Provider<GoRouter>((ref) {
role == 'programmer' || role == 'programmer' ||
role == 'dispatcher' || role == 'dispatcher' ||
role == 'it_staff'; role == 'it_staff';
final isNetworkMapRoute = state.matchedLocation.startsWith('/network-map');
final hasNetworkMapAccess =
role == 'admin' ||
role == 'it_staff' ||
role == 'dispatcher' ||
role == 'programmer';
if (!isSignedIn && !isAuthRoute) { if (!isSignedIn && !isAuthRoute) {
return '/login'; return '/login';
@@ -87,14 +103,18 @@ final appRouterProvider = Provider<GoRouter>((ref) {
return _defaultRouteForRole(role); return _defaultRouteForRole(role);
} }
// Deferred post-login redirect: profile loaded after the initial redirect // Deferred post-login redirect: profile loaded after the initial redirect
// (which fell back to /dashboard because role was null). Only fires once // (which fell back to /dashboard because role was null). Consumed once
// per sign-in and only when the user is still on /dashboard. // per sign-in; only redirects when the user is still on /dashboard.
if (isSignedIn && if (isSignedIn && notifier._needsRoleRedirect && role != null) {
notifier._needsRoleRedirect &&
role != null &&
state.matchedLocation == '/dashboard') {
notifier._needsRoleRedirect = false; notifier._needsRoleRedirect = false;
return _defaultRouteForRole(role); if (state.matchedLocation == '/dashboard') {
return _defaultRouteForRole(role);
}
}
// Role unknown while the profile loads: hold the deep link instead of
// bouncing it. RouterNotifier re-runs this once the profile arrives.
if (isSignedIn && profileAsync.isLoading && role == null) {
return null;
} }
if (isAdminRoute && !isAdmin) { if (isAdminRoute && !isAdmin) {
return '/tickets'; return '/tickets';
@@ -102,6 +122,9 @@ final appRouterProvider = Provider<GoRouter>((ref) {
if (isReportsRoute && !hasReportsAccess) { if (isReportsRoute && !hasReportsAccess) {
return '/tickets'; return '/tickets';
} }
if (isNetworkMapRoute && !hasNetworkMapAccess) {
return '/tickets';
}
// Attendance & Whereabouts: not accessible to standard users // Attendance & Whereabouts: not accessible to standard users
final isStandardOnly = role == 'standard'; final isStandardOnly = role == 'standard';
final isAttendanceRoute = final isAttendanceRoute =
@@ -129,31 +152,19 @@ final appRouterProvider = Provider<GoRouter>((ref) {
routes: [ routes: [
GoRoute( GoRoute(
path: '/settings/teams', path: '/settings/teams',
pageBuilder: (context, state) => M3SharedAxisPage( pageBuilder: _page(const TeamsScreen()),
key: state.pageKey,
child: const TeamsScreen(),
),
), ),
GoRoute( GoRoute(
path: '/settings/app-update', path: '/settings/app-update',
pageBuilder: (context, state) => M3SharedAxisPage( pageBuilder: _page(const AppUpdateScreen()),
key: state.pageKey,
child: const AppUpdateScreen(),
),
), ),
GoRoute( GoRoute(
path: '/dashboard', path: '/dashboard',
pageBuilder: (context, state) => M3SharedAxisPage( pageBuilder: _page(const DashboardScreen()),
key: state.pageKey,
child: const DashboardScreen(),
),
), ),
GoRoute( GoRoute(
path: '/tickets', path: '/tickets',
pageBuilder: (context, state) => M3SharedAxisPage( pageBuilder: _page(const TicketsListScreen()),
key: state.pageKey,
child: const TicketsListScreen(),
),
routes: [ routes: [
GoRoute( GoRoute(
path: ':id', path: ':id',
@@ -168,10 +179,7 @@ final appRouterProvider = Provider<GoRouter>((ref) {
), ),
GoRoute( GoRoute(
path: '/tasks', path: '/tasks',
pageBuilder: (context, state) => M3SharedAxisPage( pageBuilder: _page(const TasksListScreen()),
key: state.pageKey,
child: const TasksListScreen(),
),
routes: [ routes: [
GoRoute( GoRoute(
path: ':id', path: ':id',
@@ -186,10 +194,7 @@ final appRouterProvider = Provider<GoRouter>((ref) {
), ),
GoRoute( GoRoute(
path: '/it-service-requests', path: '/it-service-requests',
pageBuilder: (context, state) => M3SharedAxisPage( pageBuilder: _page(const ItServiceRequestsListScreen()),
key: state.pageKey,
child: const ItServiceRequestsListScreen(),
),
routes: [ routes: [
GoRoute( GoRoute(
path: ':id', path: ':id',
@@ -204,81 +209,107 @@ final appRouterProvider = Provider<GoRouter>((ref) {
), ),
GoRoute( GoRoute(
path: '/announcements', path: '/announcements',
pageBuilder: (context, state) => M3SharedAxisPage( pageBuilder: _page(const AnnouncementsScreen()),
key: state.pageKey,
child: const AnnouncementsScreen(),
),
), ),
GoRoute( GoRoute(
path: '/workforce', path: '/workforce',
pageBuilder: (context, state) => M3SharedAxisPage( pageBuilder: _page(const WorkforceScreen()),
key: state.pageKey,
child: const WorkforceScreen(),
),
), ),
GoRoute( GoRoute(
path: '/attendance', path: '/attendance',
pageBuilder: (context, state) => M3SharedAxisPage( pageBuilder: _page(const AttendanceScreen()),
key: state.pageKey,
child: const AttendanceScreen(),
),
), ),
GoRoute( GoRoute(
path: '/whereabouts', path: '/whereabouts',
pageBuilder: (context, state) => M3SharedAxisPage( pageBuilder: _page(const WhereaboutsScreen()),
key: state.pageKey,
child: const WhereaboutsScreen(),
),
),
GoRoute(
path: '/reports',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const ReportsScreen(),
),
), ),
GoRoute(path: '/reports', pageBuilder: _page(const ReportsScreen())),
GoRoute( GoRoute(
path: '/settings/users', path: '/settings/users',
pageBuilder: (context, state) => M3SharedAxisPage( pageBuilder: _page(const UserManagementScreen()),
key: state.pageKey,
child: const UserManagementScreen(),
),
), ),
GoRoute( GoRoute(
path: '/settings/offices', path: '/settings/offices',
pageBuilder: (context, state) => M3SharedAxisPage( pageBuilder: _page(const OfficesScreen()),
key: state.pageKey,
child: const OfficesScreen(),
),
), ),
GoRoute( GoRoute(
path: '/settings/geofence-test', path: '/settings/geofence-test',
pageBuilder: (context, state) => M3SharedAxisPage( pageBuilder: _page(const GeofenceTestScreen()),
key: state.pageKey,
child: const GeofenceTestScreen(),
),
), ),
GoRoute( GoRoute(
path: '/settings/permissions', path: '/settings/permissions',
pageBuilder: (context, state) => M3SharedAxisPage( pageBuilder: _page(const PermissionsScreen()),
key: state.pageKey, ),
child: const PermissionsScreen(), GoRoute(
), path: '/network-map',
pageBuilder: _page(const NetworkMapOverviewScreen()),
routes: [
GoRoute(
path: 'import',
pageBuilder: _page(const NetworkMapImportScreen()),
routes: [
GoRoute(
path: ':importId/review',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: NetworkMapImportReviewScreen(
importId: state.pathParameters['importId'] ?? '',
),
),
),
],
),
GoRoute(
path: 'vlans',
pageBuilder: _page(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( GoRoute(
path: '/notifications', path: '/notifications',
pageBuilder: (context, state) => M3SharedAxisPage( pageBuilder: _page(const NotificationsScreen()),
key: state.pageKey,
child: const NotificationsScreen(),
),
),
GoRoute(
path: '/profile',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const ProfileScreen(),
),
), ),
GoRoute(path: '/profile', pageBuilder: _page(const ProfileScreen())),
], ],
), ),
], ],
File diff suppressed because it is too large Load Diff
+30 -19
View File
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/app_status_colors.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
@@ -111,8 +113,8 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
.check_circle .check_circle
: Icons.cancel, : Icons.cancel,
color: inside color: inside
? Colors.green ? AppStatusColors.of(context).success.color
: Colors.red, : AppStatusColors.of(context).danger.color,
), ),
const SizedBox( const SizedBox(
width: 8, width: 8,
@@ -166,8 +168,8 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
? Icons.check_circle ? Icons.check_circle
: Icons.cancel, : Icons.cancel,
color: inside color: inside
? Colors.green ? AppStatusColors.of(context).success.color
: Colors.red, : AppStatusColors.of(context).danger.color,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
@@ -275,10 +277,10 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
width: 40, width: 40,
height: 40, height: 40,
// `child` is used by newer flutter_map Marker API. // `child` is used by newer flutter_map Marker API.
child: const Icon( child: Icon(
Icons.person_pin_circle, Icons.person_pin_circle,
size: 36, size: 36,
color: Colors.blue, color: Theme.of(context).colorScheme.primary,
), ),
), ),
); );
@@ -289,10 +291,12 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
polygons: [ polygons: [
Polygon( Polygon(
points: polygonPoints, points: polygonPoints,
color: Colors.green.withValues( color: AppStatusColors.of(context)
alpha: 0.15, .success
), .color
borderColor: Colors.green, .withValues(alpha: 0.15),
borderColor:
AppStatusColors.of(context).success.color,
borderStrokeWidth: 2, borderStrokeWidth: 2,
), ),
], ],
@@ -304,9 +308,10 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
circles: [ circles: [
CircleMarker( CircleMarker(
point: LatLng(cfg!.lat!, cfg.lng!), point: LatLng(cfg!.lat!, cfg.lng!),
color: Colors.green.withValues( color: AppStatusColors.of(context)
alpha: 0.10, .success
), .color
.withValues(alpha: 0.10),
borderStrokeWidth: 2, borderStrokeWidth: 2,
useRadiusInMeter: true, useRadiusInMeter: true,
radius: cfg.radiusMeters ?? 100, radius: cfg.radiusMeters ?? 100,
@@ -387,11 +392,15 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
width: 12, width: 12,
height: 12, height: 12,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.green.withValues( color: AppStatusColors.of(
context,
).success.color.withValues(
alpha: 0.25, alpha: 0.25,
), ),
border: Border.all( border: Border.all(
color: Colors.green, color: AppStatusColors.of(
context,
).success.color,
), ),
), ),
), ),
@@ -420,14 +429,16 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
top: 6.0, top: 6.0,
), ),
child: Row( child: Row(
children: const [ children: [
Icon( Icon(
Icons.person_pin_circle, Icons.person_pin_circle,
size: 14, size: 14,
color: Colors.blue, color: Theme.of(
context,
).colorScheme.primary,
), ),
SizedBox(width: 8), const SizedBox(width: 8),
Text('You'), const Text('You'),
], ],
), ),
), ),
+1 -1
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import '../../theme/m3_motion.dart'; import '../../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/office.dart'; import '../../models/office.model.dart';
import '../../providers/profile_provider.dart'; import '../../providers/profile_provider.dart';
import '../../providers/tickets_provider.dart'; import '../../providers/tickets_provider.dart';
import '../../providers/services_provider.dart'; import '../../providers/services_provider.dart';
@@ -1,9 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/m3_motion.dart'; import '../../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../models/office.dart'; import '../../models/office.model.dart';
import '../../models/profile.dart'; import '../../models/profile.model.dart';
import '../../models/ticket_message.dart'; import '../../models/ticket_message.dart';
import '../../models/user_office.dart'; import '../../models/user_office.dart';
import '../../providers/admin_user_provider.dart'; import '../../providers/admin_user_provider.dart';
@@ -713,9 +714,7 @@ String _formatLastActiveLabel(DateTime? value) {
if (diff.inHours < 1) return '${diff.inMinutes}m ago'; if (diff.inHours < 1) return '${diff.inMinutes}m ago';
if (diff.inDays < 1) return '${diff.inHours}h ago'; if (diff.inDays < 1) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago'; if (diff.inDays < 7) return '${diff.inDays}d ago';
final month = value.month.toString().padLeft(2, '0'); return DateFormat('yyyy-MM-dd').format(value);
final day = value.day.toString().padLeft(2, '0');
return '${value.year}-$month-$day';
} }
class _StatusBadge extends StatelessWidget { class _StatusBadge extends StatelessWidget {
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/announcement_comment.dart'; import '../../models/announcement_comment.model.dart';
import '../../providers/announcements_provider.dart'; import '../../providers/announcements_provider.dart';
import '../../providers/profile_provider.dart'; import '../../providers/profile_provider.dart';
import '../../utils/app_time.dart'; import '../../utils/app_time.dart';
@@ -49,10 +49,9 @@ class _AnnouncementCommentsSectionState
// Comment was posted; only push notification delivery failed. // Comment was posted; only push notification delivery failed.
_controller.clear(); _controller.clear();
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( showInfoSnackBar(
const SnackBar( context,
content: Text('Comment posted, but notifications may not have been sent.'), 'Comment posted, but notifications may not have been sent.',
),
); );
} }
} catch (e) { } catch (e) {
@@ -225,7 +224,7 @@ class _CommentTile extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
Flexible( Flexible(
child: Text( child: Text(
_relativeTime(comment.createdAt), AppTime.relative(comment.createdAt),
style: tt.labelSmall style: tt.labelSmall
?.copyWith(color: cs.onSurfaceVariant), ?.copyWith(color: cs.onSurfaceVariant),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -252,12 +251,3 @@ class _CommentTile extends StatelessWidget {
} }
} }
String _relativeTime(DateTime dt) {
final now = AppTime.now();
final diff = now.difference(dt);
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 < 7) return '${diff.inDays}d ago';
return AppTime.formatDate(dt);
}
@@ -4,7 +4,7 @@ import 'dart:math' as math show min;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/announcement.dart'; import '../../models/announcement.model.dart';
import '../../providers/announcements_provider.dart'; import '../../providers/announcements_provider.dart';
import '../../providers/profile_provider.dart'; import '../../providers/profile_provider.dart';
import '../../theme/m3_motion.dart'; import '../../theme/m3_motion.dart';
@@ -381,7 +381,7 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
children: [ children: [
Flexible( Flexible(
child: Text( child: Text(
_relativeTime(widget.announcement.createdAt), AppTime.relative(widget.announcement.createdAt),
style: tt.labelSmall style: tt.labelSmall
?.copyWith(color: cs.onSurfaceVariant), ?.copyWith(color: cs.onSurfaceVariant),
), ),
@@ -578,16 +578,6 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
// Helpers // Helpers
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
String _relativeTime(DateTime dt) {
final now = AppTime.now();
final diff = now.difference(dt);
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 < 7) return '${diff.inDays}d ago';
return AppTime.formatDate(dt);
}
String _roleLabel(String role) { String _roleLabel(String role) {
const labels = { const labels = {
'admin': 'Admin', 'admin': 'Admin',
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/announcement.dart'; import '../../models/announcement.model.dart';
import '../../providers/announcements_provider.dart'; import '../../providers/announcements_provider.dart';
import '../../theme/m3_motion.dart'; import '../../theme/m3_motion.dart';
import '../../utils/app_time.dart'; import '../../utils/app_time.dart';
@@ -260,12 +260,9 @@ class _CreateAnnouncementContentState
widget.editing != null ? 'Announcement updated.' : 'Announcement posted.'); widget.editing != null ? 'Announcement updated.' : 'Announcement posted.');
} on AnnouncementNotificationException { } on AnnouncementNotificationException {
if (mounted) { if (mounted) {
final messenger = ScaffoldMessenger.of(context);
Navigator.of(context).pop(); Navigator.of(context).pop();
messenger.showSnackBar( showInfoSnackBarGlobal(
const SnackBar( 'Posted, but some notifications failed to send.',
content: Text('Posted, but some notifications failed to send.'),
),
); );
} }
} catch (e) { } catch (e) {
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,590 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../models/attendance_log.model.dart';
import '../../models/it_service_request.model.dart';
import '../../models/pass_slip.model.dart';
import '../../models/task.model.dart';
import '../../models/ticket.model.dart';
import '../../theme/app_surfaces.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) =>
DateFormat('EEE, MMM d, yyyy', 'en_US').format(d);
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(AppSurfaces.of(context).chipRadius),
),
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
+6 -3
View File
@@ -72,9 +72,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
email: _emailController.text.trim(), email: _emailController.text.trim(),
password: _passwordController.text, password: _passwordController.text,
); );
if (response.session != null && mounted) { // On success the router's auth redirect sends each role to its landing
context.go('/tickets'); // route (_defaultRouteForRole), so don't navigate here.
} else if (mounted) { if (response.session == null && mounted) {
showInfoSnackBar(context, 'Check your email to confirm sign-in.'); showInfoSnackBar(context, 'Check your email to confirm sign-in.');
} }
} on Exception catch (error) { } on Exception catch (error) {
@@ -205,6 +205,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
labelText: 'Password', labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outlined), prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton( suffixIcon: IconButton(
tooltip: _obscurePassword
? 'Show password'
: 'Hide password',
icon: Icon( icon: Icon(
_obscurePassword _obscurePassword
? Icons.visibility_outlined ? Icons.visibility_outlined
+13 -5
View File
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/tickets_provider.dart'; import '../../providers/tickets_provider.dart';
import '../../theme/app_status_colors.dart';
import '../../theme/m3_motion.dart'; import '../../theme/m3_motion.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
@@ -202,6 +203,9 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen>
labelText: 'Password', labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outlined), prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton( suffixIcon: IconButton(
tooltip: _obscurePassword
? 'Show password'
: 'Hide password',
icon: Icon( icon: Icon(
_obscurePassword _obscurePassword
? Icons.visibility_outlined ? Icons.visibility_outlined
@@ -280,6 +284,9 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen>
labelText: 'Confirm password', labelText: 'Confirm password',
prefixIcon: const Icon(Icons.lock_outlined), prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton( suffixIcon: IconButton(
tooltip: _obscureConfirm
? 'Show password'
: 'Hide password',
icon: Icon( icon: Icon(
_obscureConfirm _obscureConfirm
? Icons.visibility_outlined ? Icons.visibility_outlined
@@ -557,23 +564,24 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen>
} }
final normalized = (score / 6).clamp(0.0, 1.0); final normalized = (score / 6).clamp(0.0, 1.0);
final s = AppStatusColors.of(context);
String label; String label;
Color color; Color color;
if (normalized <= 0.2) { if (normalized <= 0.2) {
label = 'Very weak'; label = 'Very weak';
color = Colors.red; color = s.danger.color;
} else if (normalized <= 0.4) { } else if (normalized <= 0.4) {
label = 'Weak'; label = 'Weak';
color = Colors.deepOrange; color = s.danger.color;
} else if (normalized <= 0.6) { } else if (normalized <= 0.6) {
label = 'Fair'; label = 'Fair';
color = Colors.orange; color = s.warning.color;
} else if (normalized <= 0.8) { } else if (normalized <= 0.8) {
label = 'Strong'; label = 'Strong';
color = Colors.green; color = s.info.color;
} else { } else {
label = 'Excellent'; label = 'Excellent';
color = Colors.teal; color = s.success.color;
} }
setState(() { setState(() {
+129 -147
View File
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/m3_motion.dart'; import '../../theme/m3_motion.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -5,18 +6,18 @@ import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/attendance_log.dart'; import '../../models/attendance_log.model.dart';
import '../../models/duty_schedule.dart'; import '../../models/duty_schedule.model.dart';
import '../../models/leave_of_absence.dart'; import '../../models/leave_of_absence.model.dart';
import '../../models/live_position.dart'; import '../../models/live_position.dart';
import '../../models/pass_slip.dart'; import '../../models/pass_slip.model.dart';
import '../../models/profile.dart'; import '../../models/profile.model.dart';
import '../../models/task.dart'; import '../../models/task.model.dart';
import '../../models/task_assignment.dart'; import '../../models/task_assignment.dart';
import '../../models/ticket.dart'; import '../../models/ticket.model.dart';
import '../../models/ticket_message.dart'; import '../../models/ticket_message.dart';
import '../../models/it_service_request.dart'; import '../../models/it_service_request.model.dart';
import '../../models/it_service_request_assignment.dart'; import '../../models/it_service_request_assignment.model.dart';
import '../../providers/attendance_provider.dart'; import '../../providers/attendance_provider.dart';
import '../../providers/leave_provider.dart'; import '../../providers/leave_provider.dart';
import '../../providers/pass_slip_provider.dart'; import '../../providers/pass_slip_provider.dart';
@@ -27,7 +28,7 @@ import '../../providers/whereabouts_provider.dart';
import '../../providers/workforce_provider.dart'; import '../../providers/workforce_provider.dart';
import '../../providers/it_service_request_provider.dart'; import '../../providers/it_service_request_provider.dart';
import '../../providers/teams_provider.dart'; import '../../providers/teams_provider.dart';
import '../../models/team.dart'; import '../../models/team.model.dart';
import '../../models/team_member.dart'; import '../../models/team_member.dart';
import 'dart:math' as math; import 'dart:math' as math;
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
@@ -37,8 +38,11 @@ import '../../widgets/app_breakpoints.dart';
import '../../providers/realtime_controller.dart'; import '../../providers/realtime_controller.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../theme/app_status_colors.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/app_metric_card.dart';
import '../../widgets/app_page_header.dart'; import '../../widgets/app_page_header.dart';
import '../../widgets/app_section_header.dart';
import '../../utils/app_time.dart'; import '../../utils/app_time.dart';
class DashboardMetrics { class DashboardMetrics {
@@ -407,10 +411,7 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
AppTime.now().difference(livePos.updatedAt) > AppTime.now().difference(livePos.updatedAt) >
const Duration(minutes: 15); const Duration(minutes: 15);
if (stale) { if (stale) {
final diff = AppTime.now().difference(livePos.updatedAt); final ago = AppTime.relative(livePos.updatedAt);
final ago = diff.inMinutes < 60
? '${diff.inMinutes}m ago'
: '${diff.inHours}h ago';
whereabouts = livePos.inPremise whereabouts = livePos.inPremise
? 'Last seen in premise \u00b7 $ago' ? 'Last seen in premise \u00b7 $ago'
: 'Last seen outside \u00b7 $ago'; : 'Last seen outside \u00b7 $ago';
@@ -628,6 +629,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
// Sound/vibration settings and openAppSettings() don't apply on web.
if (kIsWeb) return;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final seen = prefs.getBool('has_seen_notif_showcase') ?? false; final seen = prefs.getBool('has_seen_notif_showcase') ?? false;
if (!seen) { if (!seen) {
@@ -672,13 +675,13 @@ class _DashboardScreenState extends State<DashboardScreen> {
builder: (context, constraints) { builder: (context, constraints) {
final sections = <Widget>[ final sections = <Widget>[
const SizedBox(height: 16), const SizedBox(height: 16),
_sectionTitle(context, 'IT Staff Pulse'), const AppSectionHeader('IT Staff Pulse'),
const _StaffTable(), const _StaffTable(),
const SizedBox(height: 12), const SizedBox(height: 12),
_sectionTitle(context, 'Team Activity'), const AppSectionHeader('Team Activity'),
const _StaffActivityChart(), const _StaffActivityChart(),
const SizedBox(height: 20), const SizedBox(height: 20),
_sectionTitle(context, 'Core Daily KPIs'), const AppSectionHeader('Core Daily KPIs'),
_cardGrid(context, [ _cardGrid(context, [
_MetricCard( _MetricCard(
title: 'New tickets today', title: 'New tickets today',
@@ -694,7 +697,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
), ),
]), ]),
const SizedBox(height: 20), const SizedBox(height: 20),
_sectionTitle(context, 'Task Flow'), const AppSectionHeader('Task Flow'),
_cardGrid(context, [ _cardGrid(context, [
_MetricCard( _MetricCard(
title: 'Tasks created', title: 'Tasks created',
@@ -712,7 +715,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
), ),
]), ]),
const SizedBox(height: 20), const SizedBox(height: 20),
_sectionTitle(context, 'TAT / Response'), const AppSectionHeader('TAT / Response'),
_cardGrid(context, [ _cardGrid(context, [
_MetricCard( _MetricCard(
title: 'Avg response', title: 'Avg response',
@@ -775,18 +778,6 @@ class _DashboardScreenState extends State<DashboardScreen> {
); );
} }
Widget _sectionTitle(BuildContext context, String title) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
Widget _cardGrid(BuildContext context, List<Widget> cards) { Widget _cardGrid(BuildContext context, List<Widget> cards) {
return LayoutBuilder( return LayoutBuilder(
@@ -905,7 +896,6 @@ class _MetricCard extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final cs = Theme.of(context).colorScheme;
// Only watch the single string value for this card so unrelated metric // Only watch the single string value for this card so unrelated metric
// updates don't rebuild the whole card. This makes updates feel much // updates don't rebuild the whole card. This makes updates feel much
// smoother and avoids full-page refreshes. // smoother and avoids full-page refreshes.
@@ -918,46 +908,7 @@ class _MetricCard extends ConsumerWidget {
), ),
), ),
); );
return AnimatedContainer( return AppMetricCard(title: title, value: value);
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: cs.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
color: cs.onSurfaceVariant,
),
),
const SizedBox(height: 12),
// Animate only the metric text (not the whole card) for a
// subtle, smooth update.
AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, anim) =>
FadeTransition(opacity: anim, child: child),
child: MonoText(
value,
key: ValueKey(value),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
color: cs.onSurface,
),
),
),
],
),
);
} }
} }
@@ -967,14 +918,16 @@ class _StaffTable extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
// M3 Expressive: tonal surface container, 28 dp radius for large containers. final isDark = cs.brightness == Brightness.dark;
// Cyber/IT panel: flat tonal fill + hairline outline.
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.surfaceContainerLow, color: isDark ? cs.surfaceContainer : cs.surfaceContainerLowest,
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
AppSurfaces.of(context).containerRadius, AppSurfaces.of(context).containerRadius,
), ),
border: Border.all(color: cs.outlineVariant),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -1059,18 +1012,23 @@ class _StaffTableBody extends ConsumerWidget {
); );
if (providerState == 'loading') { if (providerState == 'loading') {
return const Text('Loading staff...'); return _panelHint(context, Icons.hourglass_empty_rounded, 'Loading staff…');
} }
if (providerState.startsWith('error:')) { if (providerState.startsWith('error:')) {
final err = providerState.substring(6); final err = providerState.substring(6);
return Text('Failed to load staff: $err'); return _panelHint(
context,
Icons.error_outline_rounded,
'Failed to load staff: $err',
);
} }
if (staffRows.isEmpty) { if (staffRows.isEmpty) {
return Text( return _panelHint(
context,
Icons.groups_outlined,
'No IT staff available.', 'No IT staff available.',
style: Theme.of(context).textTheme.bodySmall,
); );
} }
@@ -1110,36 +1068,24 @@ class _StaffRow extends StatelessWidget {
); );
} }
// IT Staff cell: avatar on mobile, name on desktop, with team color dot // IT Staff cell: name (ellipsized on narrow screens) with team color dot.
Widget staffCell; // Mobile used initials-only avatars, which are ambiguous and need a
if (isMobile) { // long-press tooltip to identify anyone.
staffCell = Row( final staffCell = Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
teamMarker, teamMarker,
const SizedBox(width: 4), const SizedBox(width: 6),
Flexible( Flexible(
child: Tooltip( child: Text(
message: row.name, row.name,
child: ProfileAvatar( style: valueStyle,
fullName: row.name, maxLines: 1,
avatarUrl: row.avatarUrl, overflow: TextOverflow.ellipsis,
radius: 14,
),
),
), ),
], ),
); ],
} else { );
staffCell = Row(
mainAxisSize: MainAxisSize.min,
children: [
teamMarker,
const SizedBox(width: 6),
Flexible(child: Text(row.name, style: valueStyle)),
],
);
}
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),
@@ -1154,20 +1100,24 @@ class _StaffRow extends StatelessWidget {
Expanded( Expanded(
flex: 4, flex: 4,
child: Center( child: Center(
child: Text( child: Builder(
row.whereabouts, builder: (context) {
style: valueStyle?.copyWith( final status = AppStatusColors.of(context);
color: row.whereabouts == 'In premise' final Color? color = row.whereabouts == 'In premise'
? Colors.green ? status.success.color
: row.whereabouts == 'Outside premise' : (row.whereabouts == 'Outside premise' ||
? Colors.grey row.whereabouts == 'Tracking off' ||
: row.whereabouts == 'Tracking off' row.whereabouts.startsWith('Last seen'))
? Colors.grey ? status.neutral.color
: row.whereabouts.startsWith('Last seen') : null;
? Colors.grey return Text(
: null, row.whereabouts,
fontWeight: FontWeight.w600, style: valueStyle?.copyWith(
), color: color,
fontWeight: FontWeight.w600,
),
);
},
), ),
), ),
), ),
@@ -1214,21 +1164,24 @@ class _StaffActivityChart extends ConsumerWidget {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final isDark = cs.brightness == Brightness.dark;
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.surfaceContainerLow, color: isDark ? cs.surfaceContainer : cs.surfaceContainerLowest,
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
AppSurfaces.of(context).containerRadius, AppSurfaces.of(context).containerRadius,
), ),
border: Border.all(color: cs.outlineVariant),
), ),
child: metricsAsync.when( child: metricsAsync.when(
data: (metrics) { data: (metrics) {
final staffRows = metrics.staffRows; final staffRows = metrics.staffRows;
if (staffRows.isEmpty) { if (staffRows.isEmpty) {
return Text( return _panelHint(
context,
Icons.bar_chart_outlined,
'No staff activity data available.', 'No staff activity data available.',
style: Theme.of(context).textTheme.bodySmall,
); );
} }
@@ -1405,16 +1358,16 @@ class _StaffActivityChart extends ConsumerWidget {
vertical: 2, vertical: 2,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withAlpha( color: cs.inverseSurface.withValues(alpha: 0.9),
(0.35 * 255).round(), borderRadius: BorderRadius.circular(
AppSurfaces.of(context).chipRadius,
), ),
borderRadius: BorderRadius.circular(8),
), ),
child: Text( child: MonoText(
total.toString(), total.toString(),
style: Theme.of(context).textTheme.bodySmall style: Theme.of(context).textTheme.bodySmall
?.copyWith( ?.copyWith(
color: Colors.white, color: cs.onInverseSurface,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
@@ -1546,8 +1499,13 @@ class _StaffActivityChart extends ConsumerWidget {
}, },
); );
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () =>
error: (error, stack) => Text('Failed to load activity: $error'), _panelHint(context, Icons.hourglass_empty_rounded, 'Loading activity…'),
error: (error, stack) => _panelHint(
context,
Icons.error_outline_rounded,
'Failed to load activity: $error',
),
), ),
); );
} }
@@ -1568,29 +1526,28 @@ class _PulseStatusPill extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final s = AppStatusColors.of(context);
final (Color bg, Color fg) = switch (label.toLowerCase()) { final (Color bg, Color fg) = switch (label.toLowerCase()) {
'arrival' => (Colors.amber.shade100, Colors.amber.shade900), 'arrival' => (s.success.container, s.success.onContainer),
'late' => (Colors.red.shade100, Colors.red.shade900), 'late' => (s.warning.container, s.warning.onContainer),
'noon break' => (Colors.blue.shade100, Colors.blue.shade900), 'noon break' => (s.info.container, s.info.onContainer),
'vacant' => (Colors.green.shade100, Colors.green.shade900), 'vacant' => (s.neutral.container, s.neutral.onContainer),
'on task' => (Colors.purple.shade100, Colors.purple.shade900), 'on task' => (cs.primaryContainer, cs.onPrimaryContainer),
'on event' => (Colors.purple.shade100, Colors.purple.shade900), 'on event' => (cs.tertiaryContainer, cs.onTertiaryContainer),
'in triage' => (Colors.orange.shade100, Colors.orange.shade900), 'in triage' => (s.warning.container, s.warning.onContainer),
'early out' => (Colors.deepOrange.shade100, Colors.deepOrange.shade900), 'early out' => (s.warning.container, s.warning.onContainer),
'on leave' => (Colors.teal.shade100, Colors.teal.shade900), 'on leave' => (s.info.container, s.info.onContainer),
'absent' => (Colors.red.shade200, Colors.red.shade900), 'absent' => (s.danger.container, s.danger.onContainer),
'off duty' => (Colors.grey.shade200, Colors.grey.shade700), 'off duty' => (s.neutral.container, s.neutral.onContainer),
_ => ( _ => (cs.tertiaryContainer, cs.onTertiaryContainer),
Theme.of(context).colorScheme.tertiaryContainer,
Theme.of(context).colorScheme.onTertiaryContainer,
),
}; };
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: bg, color: bg,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
), ),
child: Text( child: Text(
label, label,
@@ -1603,6 +1560,31 @@ class _PulseStatusPill extends StatelessWidget {
} }
} }
/// Compact, muted, centered hint for empty/loading/error states inside a
/// bounded dashboard panel (too small for the full-screen AppEmptyView).
Widget _panelHint(BuildContext context, IconData icon, String message) {
final cs = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 18, color: cs.onSurfaceVariant),
const SizedBox(width: 8),
Flexible(
child: Text(
message,
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
),
],
),
);
}
Duration? _averageDuration(List<Duration> durations) { Duration? _averageDuration(List<Duration> durations) {
if (durations.isEmpty) { if (durations.isEmpty) {
return null; return null;
@@ -9,12 +9,12 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill; import 'package:flutter_quill/flutter_quill.dart' as quill;
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import '../../models/it_service_request.dart'; import '../../models/it_service_request.model.dart';
import '../../models/it_service_request_assignment.dart'; import '../../models/it_service_request_assignment.model.dart';
import '../../models/it_service_request_activity_log.dart'; import '../../models/it_service_request_activity_log.model.dart';
import '../../models/it_service_request_action.dart'; import '../../models/it_service_request_action.model.dart';
import '../../models/office.dart'; import '../../models/office.model.dart';
import '../../models/profile.dart'; import '../../models/profile.model.dart';
import '../../providers/it_service_request_provider.dart'; import '../../providers/it_service_request_provider.dart';
import '../../providers/notifications_provider.dart'; import '../../providers/notifications_provider.dart';
import '../../providers/profile_provider.dart'; import '../../providers/profile_provider.dart';
@@ -22,12 +22,14 @@ import '../../providers/supabase_provider.dart';
import '../../providers/tickets_provider.dart'; import '../../providers/tickets_provider.dart';
import '../../services/ai_service.dart'; import '../../services/ai_service.dart';
import '../../utils/app_time.dart'; import '../../utils/app_time.dart';
import '../../utils/debouncer.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
import '../../widgets/app_breakpoints.dart'; import '../../widgets/app_breakpoints.dart';
import '../../widgets/gemini_animated_text_field.dart'; import '../../widgets/gemini_animated_text_field.dart';
import '../../widgets/m3_card.dart'; import '../../widgets/m3_card.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../theme/app_status_colors.dart';
import '../../widgets/status_pill.dart'; import '../../widgets/status_pill.dart';
import 'it_service_request_pdf.dart'; import 'it_service_request_pdf.dart';
@@ -85,7 +87,7 @@ class _ItServiceRequestDetailScreenState
Timer? _eventDetailsDebounce; Timer? _eventDetailsDebounce;
Timer? _remarksDebounce; Timer? _remarksDebounce;
Timer? _actionDebounce; final _actionDebounce = Debouncer(const Duration(milliseconds: 800));
String _eventDetailsLastPlain = ''; String _eventDetailsLastPlain = '';
String _remarksLastPlain = ''; String _remarksLastPlain = '';
@@ -147,7 +149,7 @@ class _ItServiceRequestDetailScreenState
_actionTakenController?.dispose(); _actionTakenController?.dispose();
_eventDetailsDebounce?.cancel(); _eventDetailsDebounce?.cancel();
_remarksDebounce?.cancel(); _remarksDebounce?.cancel();
_actionDebounce?.cancel(); _actionDebounce.cancel();
_savedBadgeTimer?.cancel(); _savedBadgeTimer?.cancel();
_saveAnimController.dispose(); _saveAnimController.dispose();
_tabController.dispose(); _tabController.dispose();
@@ -410,13 +412,13 @@ class _ItServiceRequestDetailScreenState
), ),
) )
: _hasRecentSave : _hasRecentSave
? const Padding( ? Padding(
key: ValueKey('isr_saved'), key: const ValueKey('isr_saved'),
padding: EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Icon( child: Icon(
Icons.check_circle, Icons.check_circle,
size: 18, size: 18,
color: Colors.green, color: AppStatusColors.of(context).success.color,
), ),
) )
: const SizedBox(key: ValueKey('isr_idle')), : const SizedBox(key: ValueKey('isr_idle')),
@@ -554,7 +556,10 @@ class _ItServiceRequestDetailScreenState
if (request.requestNumber != null) if (request.requestNumber != null)
MonoText(request.requestNumber!), MonoText(request.requestNumber!),
const SizedBox(width: 12), const SizedBox(width: 12),
StatusPill(label: ItServiceRequestStatus.label(request.status)), StatusPill.kind(
_isrStatusKind(request.status),
label: ItServiceRequestStatus.label(request.status),
),
const Spacer(), const Spacer(),
Text( Text(
'Created ${AppTime.formatDate(request.createdAt)}', 'Created ${AppTime.formatDate(request.createdAt)}',
@@ -1230,10 +1235,10 @@ class _ItServiceRequestDetailScreenState
child: const Icon(Icons.save, size: 14), child: const Icon(Icons.save, size: 14),
) )
: isSaved : isSaved
? const Icon( ? Icon(
Icons.check_circle, Icons.check_circle,
key: ValueKey('label_saved'), key: const ValueKey('label_saved'),
color: Colors.green, color: AppStatusColors.of(context).success.color,
size: 14, size: 14,
) )
: const SizedBox(key: ValueKey('label_idle')), : const SizedBox(key: ValueKey('label_idle')),
@@ -1388,8 +1393,7 @@ class _ItServiceRequestDetailScreenState
final plain = _actionTakenController!.document.toPlainText().trim(); final plain = _actionTakenController!.document.toPlainText().trim();
if (plain == _actionLastPlain) return; if (plain == _actionLastPlain) return;
_actionLastPlain = plain; _actionLastPlain = plain;
_actionDebounce?.cancel(); _actionDebounce.run(() async {
_actionDebounce = Timer(const Duration(milliseconds: 800), () async {
final delta = _actionTakenController!.document.toDelta(); final delta = _actionTakenController!.document.toDelta();
final deltaJson = jsonEncode(delta.toJson()); final deltaJson = jsonEncode(delta.toJson());
if (mounted) { if (mounted) {
@@ -1584,9 +1588,6 @@ class _ItServiceRequestDetailScreenState
final controller = TextEditingController(); final controller = TextEditingController();
return AlertDialog( return AlertDialog(
title: const Text('Cancellation Reason'), title: const Text('Cancellation Reason'),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
content: TextField( content: TextField(
controller: controller, controller: controller,
decoration: const InputDecoration( decoration: const InputDecoration(
@@ -1836,7 +1837,6 @@ class _StaffPickerDialogState extends State<_StaffPickerDialog> {
.toList(); .toList();
return AlertDialog( return AlertDialog(
title: const Text('Select IT Staff'), title: const Text('Select IT Staff'),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
content: SizedBox( content: SizedBox(
width: 350, width: 350,
height: 400, height: 400,
@@ -2245,3 +2245,16 @@ class _EvidenceSectionState extends ConsumerState<_EvidenceSection> {
); );
} }
} }
/// Maps an IT service request status to a semantic [StatusKind]
/// (amber=pending approval, blue=scheduled/in-progress, green=completed,
/// grey=cancelled).
StatusKind _isrStatusKind(String status) => switch (status) {
ItServiceRequestStatus.pendingApproval => StatusKind.warning,
ItServiceRequestStatus.scheduled => StatusKind.info,
ItServiceRequestStatus.inProgress => StatusKind.info,
ItServiceRequestStatus.inProgressDryRun => StatusKind.info,
ItServiceRequestStatus.completed => StatusKind.success,
ItServiceRequestStatus.cancelled => StatusKind.neutral,
_ => StatusKind.neutral,
};
@@ -8,10 +8,10 @@ import 'package:pdf/widgets.dart' as pw;
import 'package:pdf/pdf.dart' as pdf; import 'package:pdf/pdf.dart' as pdf;
import 'package:printing/printing.dart'; import 'package:printing/printing.dart';
import '../../models/it_service_request.dart'; import '../../models/it_service_request.model.dart';
import '../../models/it_service_request_assignment.dart'; import '../../models/it_service_request_assignment.model.dart';
import '../../models/office.dart'; import '../../models/office.model.dart';
import '../../models/profile.dart'; import '../../models/profile.model.dart';
import '../../utils/app_time.dart'; import '../../utils/app_time.dart';
/// Build PDF bytes for IT Service Request Form. /// Build PDF bytes for IT Service Request Form.
@@ -5,10 +5,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../models/it_service_request.dart'; import '../../models/it_service_request.model.dart';
import '../../models/it_service_request_assignment.dart'; import '../../models/it_service_request_assignment.model.dart';
import '../../models/office.dart'; import '../../models/office.model.dart';
import '../../models/profile.dart'; import '../../models/profile.model.dart';
import '../../brick/cache_helpers.dart'; import '../../brick/cache_helpers.dart';
import '../../providers/it_service_request_provider.dart'; import '../../providers/it_service_request_provider.dart';
import '../../providers/profile_provider.dart'; import '../../providers/profile_provider.dart';
@@ -17,6 +17,8 @@ import '../../providers/tickets_provider.dart';
import '../../theme/m3_motion.dart'; import '../../theme/m3_motion.dart';
import '../../utils/app_time.dart'; import '../../utils/app_time.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
import '../../theme/app_status_colors.dart';
import '../../widgets/app_status_summary.dart';
import '../../widgets/m3_card.dart'; import '../../widgets/m3_card.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/reconnect_overlay.dart'; import '../../widgets/reconnect_overlay.dart';
@@ -381,9 +383,6 @@ class _ItServiceRequestsListScreenState
builder: (ctx, setDialogState) { builder: (ctx, setDialogState) {
return AlertDialog( return AlertDialog(
title: const Text('New IT Service Request'), title: const Text('New IT Service Request'),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
content: SizedBox( content: SizedBox(
width: 400, width: 400,
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -430,9 +429,16 @@ class _ItServiceRequestsListScreenState
onPressed: () => Navigator.pop(ctx, false), onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
FilledButton( // Disabled until named, so the dialog never closes and drops
onPressed: () => Navigator.pop(ctx, true), // the user's input on a validation failure.
child: const Text('Create'), ValueListenableBuilder<TextEditingValue>(
valueListenable: nameController,
builder: (context, value, child) => FilledButton(
onPressed: value.text.trim().isEmpty
? null
: () => Navigator.pop(ctx, true),
child: const Text('Create'),
),
), ),
], ],
); );
@@ -442,10 +448,6 @@ class _ItServiceRequestsListScreenState
); );
if (result != true || !context.mounted) return; if (result != true || !context.mounted) return;
if (nameController.text.trim().isEmpty) {
showWarningSnackBar(context, 'Event name is required');
return;
}
try { try {
final ctrl = ref.read(itServiceRequestControllerProvider); final ctrl = ref.read(itServiceRequestControllerProvider);
@@ -534,7 +536,6 @@ class _StatusSummaryRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final pending = requests final pending = requests
.where((r) => r.status == ItServiceRequestStatus.pendingApproval) .where((r) => r.status == ItServiceRequestStatus.pendingApproval)
.length; .length;
@@ -552,88 +553,38 @@ class _StatusSummaryRow extends StatelessWidget {
.where((r) => r.status == ItServiceRequestStatus.completed) .where((r) => r.status == ItServiceRequestStatus.completed)
.length; .length;
return SingleChildScrollView( return AppStatusSummaryRow(
scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), items: [
child: Row( AppStatusSummaryItem(
children: [ label: 'Pending',
_SummaryChip( count: pending,
label: 'Pending', kind: StatusKind.warning,
count: pending, selected: selectedStatus == ItServiceRequestStatus.pendingApproval,
color: cs.tertiary, onTap: () => onStatusTap(ItServiceRequestStatus.pendingApproval),
selected: selectedStatus == ItServiceRequestStatus.pendingApproval,
onTap: () => onStatusTap(ItServiceRequestStatus.pendingApproval),
),
const SizedBox(width: 8),
_SummaryChip(
label: 'Scheduled',
count: scheduled,
color: cs.primary,
selected: selectedStatus == ItServiceRequestStatus.scheduled,
onTap: () => onStatusTap(ItServiceRequestStatus.scheduled),
),
const SizedBox(width: 8),
_SummaryChip(
label: 'In Progress',
count: inProgress,
color: cs.secondary,
selected: selectedStatus == ItServiceRequestStatus.inProgress,
onTap: () => onStatusTap(ItServiceRequestStatus.inProgress),
),
const SizedBox(width: 8),
_SummaryChip(
label: 'Completed',
count: completed,
color: Colors.green,
selected: selectedStatus == ItServiceRequestStatus.completed,
onTap: () => onStatusTap(ItServiceRequestStatus.completed),
),
],
),
);
}
}
class _SummaryChip extends StatelessWidget {
const _SummaryChip({
required this.label,
required this.count,
required this.color,
required this.selected,
required this.onTap,
});
final String label;
final int count;
final Color color;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return M3Card.filled(
onTap: onTap,
color: selected
? color.withValues(alpha: 0.2)
: Theme.of(context).colorScheme.surfaceContainerHighest,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
count.toString(),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(width: 8),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
), ),
), AppStatusSummaryItem(
label: 'Scheduled',
count: scheduled,
kind: StatusKind.neutral,
selected: selectedStatus == ItServiceRequestStatus.scheduled,
onTap: () => onStatusTap(ItServiceRequestStatus.scheduled),
),
AppStatusSummaryItem(
label: 'In Progress',
count: inProgress,
kind: StatusKind.info,
selected: selectedStatus == ItServiceRequestStatus.inProgress,
onTap: () => onStatusTap(ItServiceRequestStatus.inProgress),
),
AppStatusSummaryItem(
label: 'Completed',
count: completed,
kind: StatusKind.success,
selected: selectedStatus == ItServiceRequestStatus.completed,
onTap: () => onStatusTap(ItServiceRequestStatus.completed),
),
],
); );
} }
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,687 @@
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 '../../theme/app_status_colors.dart';
import '../../providers/network_map/network_devices_provider.dart';
import '../../providers/network_map/network_sites_provider.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/responsive_body.dart';
import 'widgets/port_edit_dialog.dart';
import 'widgets/port_list_tile.dart';
/// Add or edit a device. If [deviceId] is null, creates a new device.
class NetworkMapDeviceEditScreen extends ConsumerStatefulWidget {
const NetworkMapDeviceEditScreen({
super.key,
this.deviceId,
this.initialSiteId,
});
final String? deviceId;
final String? initialSiteId;
@override
ConsumerState<NetworkMapDeviceEditScreen> createState() =>
_NetworkMapDeviceEditScreenState();
}
class _NetworkMapDeviceEditScreenState
extends ConsumerState<NetworkMapDeviceEditScreen> {
final _formKey = GlobalKey<FormState>();
final _nameCtrl = TextEditingController();
final _vendorCtrl = TextEditingController();
final _modelCtrl = TextEditingController();
final _serialCtrl = TextEditingController();
final _mgmtIpCtrl = TextEditingController();
final _macCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
NetworkDeviceKind _kind = NetworkDeviceKind.switchDevice;
NetworkDeviceRole? _role;
NetworkDeviceStatus _status = NetworkDeviceStatus.unknown;
String? _siteId;
String? _locationId;
bool _saving = false;
bool _initialized = false;
@override
void dispose() {
_nameCtrl.dispose();
_vendorCtrl.dispose();
_modelCtrl.dispose();
_serialCtrl.dispose();
_mgmtIpCtrl.dispose();
_macCtrl.dispose();
_notesCtrl.dispose();
super.dispose();
}
void _prefillFromDevice(NetworkDevice d, List<NetworkLocation> locations) {
if (_initialized) return;
_nameCtrl.text = d.name;
_vendorCtrl.text = d.vendor ?? '';
_modelCtrl.text = d.model ?? '';
_serialCtrl.text = d.serial ?? '';
_mgmtIpCtrl.text = d.mgmtIp ?? '';
_macCtrl.text = d.mac ?? '';
_notesCtrl.text = d.notes ?? '';
_kind = d.kind;
_role = d.role;
_status = d.status;
if (d.locationId != null) {
_locationId = d.locationId;
for (final l in locations) {
if (l.id == d.locationId) {
_siteId = l.siteId;
break;
}
}
}
_initialized = true;
}
/// Resolve a location_id to save. Preference order:
/// 1. Explicitly picked location.
/// 2. Site picked but no specific location → use/create the site's default.
/// 3. Nothing picked → null (unassigned).
Future<String?> _resolveLocationIdToSave() async {
if (_locationId != null) return _locationId;
if (_siteId == null) return null;
final loc = await ref
.read(networkSitesControllerProvider)
.ensureDefaultLocation(_siteId!);
return loc.id;
}
Future<void> _showAddLocationDialog() async {
if (_siteId == null) return;
final nameCtrl = TextEditingController();
NetworkLocationKind kind = NetworkLocationKind.room;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setLocal) => AlertDialog(
title: const Text('New location'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameCtrl,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Name (e.g. Floor 3, Rack U-12)',
),
onChanged: (_) => setLocal(() {}),
),
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(
// Keep Create disabled until a name is entered, instead of
// silently discarding an empty submit after close (QA-048).
onPressed: nameCtrl.text.trim().isEmpty
? null
: () => Navigator.pop(ctx, true),
child: const Text('Create'),
),
],
),
),
);
if (ok == true && nameCtrl.text.trim().isNotEmpty) {
final created = await ref.read(networkSitesControllerProvider).createLocation(
siteId: _siteId!,
name: nameCtrl.text.trim(),
kind: kind,
);
if (mounted) setState(() => _locationId = created.id);
}
}
String _locationKindLabel(NetworkLocationKind k) {
switch (k) {
case NetworkLocationKind.building:
return 'Building';
case NetworkLocationKind.floor:
return 'Floor';
case NetworkLocationKind.room:
return 'Room';
case NetworkLocationKind.rack:
return 'Rack';
case NetworkLocationKind.wallJack:
return 'Wall jack';
case NetworkLocationKind.other:
return 'Other';
}
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() => _saving = true);
try {
final controller = ref.read(networkDevicesControllerProvider);
final locationId = await _resolveLocationIdToSave();
if (widget.deviceId == null) {
await controller.createDevice(
name: _nameCtrl.text.trim(),
kind: _kind,
role: _role,
status: _status,
vendor: _vendorCtrl.text.trim().isEmpty ? null : _vendorCtrl.text.trim(),
model: _modelCtrl.text.trim().isEmpty ? null : _modelCtrl.text.trim(),
serial: _serialCtrl.text.trim().isEmpty ? null : _serialCtrl.text.trim(),
mgmtIp: _mgmtIpCtrl.text.trim().isEmpty ? null : _mgmtIpCtrl.text.trim(),
mac: _macCtrl.text.trim().isEmpty ? null : _macCtrl.text.trim(),
locationId: locationId,
notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(),
);
} else {
final existing =
await ref.read(networkDeviceByIdProvider(widget.deviceId!).future);
if (existing == null) return;
final updated = existing.copyWith(
name: _nameCtrl.text.trim(),
kind: _kind,
role: _role,
status: _status,
vendor: _vendorCtrl.text.trim().isEmpty ? null : _vendorCtrl.text.trim(),
model: _modelCtrl.text.trim().isEmpty ? null : _modelCtrl.text.trim(),
serial: _serialCtrl.text.trim().isEmpty ? null : _serialCtrl.text.trim(),
mgmtIp: _mgmtIpCtrl.text.trim().isEmpty ? null : _mgmtIpCtrl.text.trim(),
mac: _macCtrl.text.trim().isEmpty ? null : _macCtrl.text.trim(),
locationId: locationId,
notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(),
);
await controller.updateDevice(updated);
}
if (mounted) context.pop();
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _addPort(String deviceId) async {
await showPortEditDialog(
context: context,
ref: ref,
deviceId: deviceId,
);
}
Icon _statusIcon(BuildContext context) {
final cs = Theme.of(context).colorScheme;
switch (_status) {
case NetworkDeviceStatus.online:
return Icon(
Icons.circle,
size: 14,
color: AppStatusColors.of(context).success.color,
);
case NetworkDeviceStatus.offline:
return Icon(Icons.circle, size: 14, color: cs.error);
case NetworkDeviceStatus.warning:
return Icon(
Icons.circle,
size: 14,
color: AppStatusColors.of(context).warning.color,
);
case NetworkDeviceStatus.unknown:
return Icon(Icons.circle_outlined, size: 14, color: cs.onSurfaceVariant);
}
}
@override
Widget build(BuildContext context) {
final existingAsync = widget.deviceId == null
? const AsyncValue<NetworkDevice?>.data(null)
: ref.watch(networkDeviceByIdProvider(widget.deviceId!));
final sitesAsync = ref.watch(networkSitesProvider);
final locationsAsync = ref.watch(networkLocationsProvider);
if (!_initialized && widget.deviceId == null && widget.initialSiteId != null) {
_siteId = widget.initialSiteId;
}
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
title: Text(widget.deviceId == null ? 'New device' : 'Edit device'),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save'),
),
),
],
),
body: existingAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => AppErrorView(error: e),
data: (device) {
if (device != null) {
_prefillFromDevice(device, locationsAsync.valueOrNull ?? const []);
}
final locationsForSite = (locationsAsync.valueOrNull ?? const [])
.where((l) => _siteId != null && l.siteId == _siteId)
.toList();
return ResponsiveBody(
maxWidth: 720,
child: Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── Identity ────────────────────────────────────────────
_SectionCard(
icon: Icons.badge_outlined,
title: 'Identity',
children: [
TextFormField(
controller: _nameCtrl,
autofocus: widget.deviceId == null,
decoration: const InputDecoration(labelText: 'Name *'),
validator: (v) => (v == null || v.trim().isEmpty)
? 'Name is required'
: null,
),
const SizedBox(height: 12),
_FieldRow(
left: DropdownButtonFormField<NetworkDeviceKind>(
initialValue: _kind,
items: NetworkDeviceKind.values
.map((k) => DropdownMenuItem(
value: k,
child: Text(k.label),
))
.toList(),
onChanged: (v) => setState(() => _kind = v ?? _kind),
decoration: const InputDecoration(labelText: 'Type *'),
),
right: DropdownButtonFormField<NetworkDeviceRole?>(
initialValue: _role,
items: [
const DropdownMenuItem<NetworkDeviceRole?>(
value: null,
child: Text('(none)'),
),
for (final r in NetworkDeviceRole.values)
DropdownMenuItem(value: r, child: Text(r.label)),
],
onChanged: (v) => setState(() => _role = v),
decoration: const InputDecoration(labelText: 'Role'),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<NetworkDeviceStatus>(
initialValue: _status,
items: NetworkDeviceStatus.values
.map((s) => DropdownMenuItem(
value: s,
child: Text(s.label),
))
.toList(),
onChanged: (v) => setState(
() => _status = v ?? NetworkDeviceStatus.unknown),
decoration: InputDecoration(
labelText: 'Status',
prefixIcon: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: _statusIcon(context),
),
prefixIconConstraints:
const BoxConstraints(minWidth: 0, minHeight: 0),
),
),
],
),
const SizedBox(height: 16),
// ── Location ─────────────────────────────────────────────
_SectionCard(
icon: Icons.location_on_outlined,
title: 'Location',
children: [
DropdownButtonFormField<String?>(
initialValue: _siteId,
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('Unassigned'),
),
for (final s in sitesAsync.valueOrNull ?? [])
DropdownMenuItem(value: s.id, child: Text(s.name)),
],
onChanged: (v) => setState(() {
_siteId = v;
_locationId = null;
}),
decoration: const InputDecoration(
labelText: 'Site',
helperText:
'Devices must be assigned to a site to appear on the topology canvas.',
),
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: DropdownButtonFormField<String?>(
initialValue: _locationId,
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('(Default — auto-create "Main")'),
),
for (final l in locationsForSite)
DropdownMenuItem(
value: l.id,
child: Text(
'${l.name} · ${_locationKindLabel(l.kind)}',
),
),
],
onChanged: _siteId == null
? null
: (v) => setState(() => _locationId = v),
decoration: InputDecoration(
labelText: 'Location',
helperText: _siteId == null
? 'Pick a site first.'
: 'Building / floor / room / rack within the site.',
enabled: _siteId != null,
),
),
),
const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: 'Add new location to this site',
onPressed:
_siteId == null ? null : _showAddLocationDialog,
icon: const Icon(Icons.add_location_alt_outlined),
),
],
),
],
),
const SizedBox(height: 16),
// ── Hardware Details ─────────────────────────────────────
_SectionCard(
icon: Icons.memory_outlined,
title: 'Hardware Details',
children: [
_FieldRow(
left: TextFormField(
controller: _vendorCtrl,
decoration: const InputDecoration(
labelText: 'Vendor',
hintText: 'e.g. Ruijie, TP-Link',
),
),
right: TextFormField(
controller: _modelCtrl,
decoration: const InputDecoration(labelText: 'Model'),
),
),
const SizedBox(height: 12),
_FieldRow(
left: TextFormField(
controller: _serialCtrl,
decoration:
const InputDecoration(labelText: 'Serial'),
),
right: TextFormField(
controller: _mgmtIpCtrl,
decoration: const InputDecoration(
labelText: 'Management IP'),
keyboardType: TextInputType.text,
),
),
const SizedBox(height: 12),
TextFormField(
controller: _macCtrl,
decoration:
const InputDecoration(labelText: 'MAC address'),
),
const SizedBox(height: 12),
TextFormField(
controller: _notesCtrl,
decoration: const InputDecoration(labelText: 'Notes'),
maxLines: 4,
),
],
),
// ── Ports (edit mode only) ────────────────────────────────
if (widget.deviceId != null) ...[
const SizedBox(height: 16),
_PortsSectionCard(
deviceId: widget.deviceId!,
onAddPort: () => _addPort(widget.deviceId!),
),
],
const SizedBox(height: 24),
],
),
),
),
);
},
),
);
}
}
// ── Helper widgets ────────────────────────────────────────────────────────────
/// Outlined card with a section header (icon + title) and field children.
class _SectionCard extends StatelessWidget {
const _SectionCard({
required this.icon,
required this.title,
required this.children,
});
final IconData icon;
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
return Card.outlined(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(icon, size: 16, color: cs.primary),
const SizedBox(width: 8),
Text(
title,
style: tt.labelLarge?.copyWith(color: cs.primary),
),
],
),
const SizedBox(height: 16),
...children,
],
),
),
);
}
}
/// Renders two widgets side-by-side on wide screens (≥480dp), stacked on narrow.
class _FieldRow extends StatelessWidget {
const _FieldRow({
required this.left,
required this.right,
});
final Widget left;
final Widget right;
static const double threshold = 480;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= threshold) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: left),
const SizedBox(width: 12),
Expanded(child: right),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
left,
const SizedBox(height: 12),
right,
],
);
},
);
}
}
/// Ports section card with header and add-port button.
class _PortsSectionCard extends StatelessWidget {
const _PortsSectionCard({
required this.deviceId,
required this.onAddPort,
});
final String deviceId;
final VoidCallback onAddPort;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
return Card.outlined(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.cable_outlined, size: 16, color: cs.primary),
const SizedBox(width: 8),
Text(
'Ports',
style: tt.labelLarge?.copyWith(color: cs.primary),
),
const Spacer(),
FilledButton.tonal(
onPressed: onAddPort,
child: const Text('Add port'),
),
],
),
const SizedBox(height: 8),
_PortsList(deviceId: deviceId),
],
),
),
);
}
}
class _PortsList extends ConsumerWidget {
const _PortsList({required this.deviceId});
final String deviceId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final cs = Theme.of(context).colorScheme;
final portsAsync = ref.watch(networkPortsByDeviceProvider(deviceId));
return portsAsync.when(
loading: () => const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
error: (e, _) => Text('Error: $e'),
data: (ports) {
if (ports.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
children: [
Icon(Icons.cable_outlined,
size: 32, color: cs.onSurfaceVariant),
const SizedBox(height: 8),
Text(
'No ports configured yet.',
style: TextStyle(color: cs.onSurfaceVariant),
),
],
),
);
}
return Column(
children: [
for (final p in ports)
PortListTile(
port: p,
canEdit: true,
onTap: (ctx) => showPortEditDialog(
context: ctx,
ref: ref,
deviceId: deviceId,
existing: p,
),
onDelete: () => ref
.read(networkDevicesControllerProvider)
.deletePort(p.id, deviceId: deviceId),
),
],
);
},
);
}
}
@@ -0,0 +1,234 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/network/network_device.dart';
import '../../providers/network_map/network_devices_provider.dart';
import '../../providers/profile_provider.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/responsive_body.dart';
import 'widgets/link_edit_dialog.dart';
import 'widgets/port_edit_dialog.dart';
import 'widgets/port_list_tile.dart';
class NetworkMapDeviceScreen extends ConsumerWidget {
const NetworkMapDeviceScreen({super.key, required this.deviceId});
final String deviceId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final deviceAsync = ref.watch(networkDeviceByIdProvider(deviceId));
final portsAsync = ref.watch(networkPortsByDeviceProvider(deviceId));
final linksAsync = ref.watch(networkLinksProvider);
final allPortsAsync = ref.watch(networkPortsProvider);
final allDevicesAsync = ref.watch(networkDevicesProvider);
final profileAsync = ref.watch(currentProfileProvider);
final canEdit = profileAsync.maybeWhen(
data: (p) => p?.role == 'admin' || p?.role == 'it_staff',
orElse: () => false,
);
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
title: deviceAsync.when(
data: (d) => Text(d?.name ?? 'Device'),
loading: () => const Text('Device'),
error: (_, _) => const Text('Device'),
),
actions: [
if (canEdit)
IconButton(
tooltip: 'Edit device',
icon: const Icon(Icons.edit_outlined),
onPressed: () => context.go('/network-map/device/$deviceId/edit'),
),
],
),
body: deviceAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => AppErrorView(
error: e,
onRetry: () => ref.invalidate(networkDeviceByIdProvider(deviceId)),
),
data: (device) {
if (device == null) {
return const AppEmptyView(
icon: Icons.device_unknown_outlined,
title: 'Device not found',
);
}
final ports = portsAsync.valueOrNull ?? const [];
final links = linksAsync.valueOrNull ?? const [];
final allPorts = allPortsAsync.valueOrNull ?? const [];
final allDevices = allDevicesAsync.valueOrNull ?? const [];
final portToDevice = <String, String>{
for (final p in allPorts) p.id: p.deviceId,
};
final portLabel = <String, String>{
for (final p in allPorts) p.id: p.portNumber,
};
final deviceById = <String, NetworkDevice>{
for (final d in allDevices) d.id: d,
};
// For each port of this device, find the link and the other end.
String? otherDeviceName(String portId) {
for (final link in links) {
String? otherPort;
if (link.portA == portId) otherPort = link.portB;
if (link.portB == portId) otherPort = link.portA;
if (otherPort == null) continue;
final otherDevId = portToDevice[otherPort];
if (otherDevId == null) return null;
return deviceById[otherDevId]?.name;
}
return null;
}
String? otherPortLabel(String portId) {
for (final link in links) {
if (link.portA == portId) return portLabel[link.portB];
if (link.portB == portId) return portLabel[link.portA];
}
return null;
}
String? linkIdForPort(String portId) {
for (final link in links) {
if (link.portA == portId || link.portB == portId) return link.id;
}
return null;
}
return ResponsiveBody(
maxWidth: 720,
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 12),
children: [
_MetaCard(device: device),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
'Ports (${ports.length})',
style: Theme.of(context).textTheme.titleMedium,
),
),
if (ports.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text('No ports configured yet.'),
)
else
for (final port in ports)
PortListTile(
port: port,
linkedDeviceName: otherDeviceName(port.id),
linkedPortLabel: otherPortLabel(port.id),
canEdit: canEdit,
onTap: canEdit
? (ctx) => showPortEditDialog(
context: ctx,
ref: ref,
deviceId: device.id,
existing: port,
)
: null,
onConnect: canEdit
? (ctx) => showLinkEditDialog(
context: ctx,
ref: ref,
portId: port.id,
currentDeviceId: device.id,
)
: null,
onDisconnect: canEdit
? (_) {
final lid = linkIdForPort(port.id);
if (lid != null) {
ref
.read(networkDevicesControllerProvider)
.deleteLink(lid);
}
}
: null,
onDelete: canEdit
? () => ref
.read(networkDevicesControllerProvider)
.deletePort(port.id)
: null,
),
if (device.notes != null && device.notes!.trim().isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
'Notes',
style: Theme.of(context).textTheme.titleMedium,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(device.notes!),
),
],
],
),
);
},
),
);
}
}
class _MetaCard extends StatelessWidget {
const _MetaCard({required this.device});
final NetworkDevice device;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
final rows = <(String, String)>[
('Type', device.kind.label),
if (device.role != null) ('Role', device.role!.label),
if (device.vendor != null) ('Vendor', device.vendor!),
if (device.model != null) ('Model', device.model!),
if (device.serial != null) ('Serial', device.serial!),
if (device.mgmtIp != null) ('Mgmt IP', device.mgmtIp!),
if (device.mac != null) ('MAC', device.mac!),
];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(device.name, style: tt.titleLarge),
const SizedBox(height: 8),
for (final (key, value) in rows)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
SizedBox(
width: 90,
child: Text(key,
style: tt.labelMedium?.copyWith(color: cs.onSurfaceVariant)),
),
Expanded(child: Text(value, style: tt.bodyMedium)),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,259 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/network/network_device.dart';
import '../../models/network/network_link.dart';
import '../../models/network/network_port.dart';
import '../../providers/network_map/network_devices_provider.dart';
import '../../providers/network_map/network_import_provider.dart';
import '../../providers/network_map/network_sites_provider.dart';
import '../../utils/snackbar.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) {
showSuccessSnackBar(
context,
'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,361 @@
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 '../../utils/snackbar.dart';
import '../../widgets/app_metric_card.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: AppMetricCard(
title: 'Sites',
value: siteCount.toString(),
icon: Icons.location_city_outlined,
animateValue: false,
),
),
const SizedBox(width: 12),
Expanded(
child: AppMetricCard(
title: 'Devices',
value: deviceCount.toString(),
icon: Icons.devices_outlined,
animateValue: false,
),
),
],
);
}
}
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) {
showWarningSnackBar(context, '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) {
showSuccessSnackBar(context, 'Moved ${devices.length} devices.');
}
}
}
class _SiteCard extends StatelessWidget {
const _SiteCard({
required this.name,
required this.address,
required this.deviceCount,
required this.onTap,
});
final String name;
final String? address;
final int deviceCount;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
CircleAvatar(
backgroundColor: cs.primaryContainer,
child: Icon(Icons.location_city, color: cs.onPrimaryContainer),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: tt.titleMedium),
if (address != null && address!.isNotEmpty)
Text(address!, style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant)),
],
),
),
Text('$deviceCount devices', style: tt.labelMedium?.copyWith(color: cs.onSurfaceVariant)),
const SizedBox(width: 8),
Icon(Icons.chevron_right, color: cs.onSurfaceVariant),
],
),
),
),
);
}
}
@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/network/topology_graph.dart';
import '../../providers/network_map/network_sites_provider.dart';
import '../../providers/network_map/network_topology_provider.dart';
import '../../providers/profile_provider.dart';
import '../../widgets/app_state_view.dart';
import 'widgets/topology_canvas.dart';
import 'widgets/topology_legend.dart';
import 'widgets/topology_minimap.dart';
import 'widgets/zoom_controls.dart';
class NetworkMapSiteScreen extends ConsumerStatefulWidget {
const NetworkMapSiteScreen({super.key, required this.siteId});
final String siteId;
@override
ConsumerState<NetworkMapSiteScreen> createState() =>
_NetworkMapSiteScreenState();
}
class _NetworkMapSiteScreenState extends ConsumerState<NetworkMapSiteScreen> {
late final TransformationController _transformController;
Map<String, Offset> _nodePositions = const {};
Size _canvasSize = const Size(600, 400);
@override
void initState() {
super.initState();
_transformController = TransformationController();
}
@override
void dispose() {
_transformController.dispose();
super.dispose();
}
void _onLayoutUpdated(Map<String, Offset> positions, Size canvasSize) {
if (!mounted) return;
setState(() {
_nodePositions = positions;
_canvasSize = canvasSize;
});
}
@override
Widget build(BuildContext context) {
final topologyAsync = ref.watch(topologyForSiteProvider(widget.siteId));
final viewMode = ref.watch(topologyViewModeProvider);
final sitesAsync = ref.watch(networkSitesProvider);
final profileAsync = ref.watch(currentProfileProvider);
final canEdit = profileAsync.maybeWhen(
data: (p) => p?.role == 'admin' || p?.role == 'it_staff',
orElse: () => false,
);
final siteName = sitesAsync.valueOrNull
?.firstWhere(
(s) => s.id == widget.siteId,
orElse: () => sitesAsync.valueOrNull!.first,
)
.name;
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/network-map'),
),
title: Text(siteName ?? 'Site'),
actions: [
SegmentedButton<TopologyViewMode>(
segments: const [
ButtonSegment(
value: TopologyViewMode.physical,
label: Text('Physical'),
icon: Icon(Icons.business_outlined),
),
ButtonSegment(
value: TopologyViewMode.logical,
label: Text('Logical'),
icon: Icon(Icons.account_tree_outlined),
),
],
selected: {viewMode},
onSelectionChanged: (s) {
ref.read(topologyViewModeProvider.notifier).state = s.first;
},
),
const SizedBox(width: 8),
if (canEdit)
IconButton(
tooltip: 'Add device',
icon: const Icon(Icons.add_circle_outline),
onPressed: () =>
context.go('/network-map/site/${widget.siteId}/device/new'),
),
],
),
body: topologyAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => AppErrorView(
error: e,
onRetry: () => ref.invalidate(topologyForSiteProvider(widget.siteId)),
),
data: (graph) => _CanvasWithOverlays(
graph: graph,
viewMode: viewMode,
siteId: widget.siteId,
transformController: _transformController,
nodePositions: _nodePositions,
canvasSize: _canvasSize,
onLayoutUpdated: _onLayoutUpdated,
),
),
);
}
}
/// Canvas + all overlay widgets (legend, zoom controls, minimap) in a Stack.
class _CanvasWithOverlays extends StatelessWidget {
const _CanvasWithOverlays({
required this.graph,
required this.viewMode,
required this.siteId,
required this.transformController,
required this.nodePositions,
required this.canvasSize,
required this.onLayoutUpdated,
});
final TopologyGraph graph;
final TopologyViewMode viewMode;
final String siteId;
final TransformationController transformController;
final Map<String, Offset> nodePositions;
final Size canvasSize;
final void Function(Map<String, Offset>, Size) onLayoutUpdated;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
return Stack(
children: [
// ── Main topology canvas ─────────────────────────────────────
Positioned.fill(
child: TopologyCanvas(
graph: graph,
viewMode: viewMode,
transformationController: transformController,
onLayoutUpdated: onLayoutUpdated,
onDeviceTap: (device) =>
context.push('/network-map/device/${device.id}'),
),
),
// ── Legend — bottom-left ─────────────────────────────────────
Positioned(
left: 16,
bottom: 16,
child: const TopologyLegend(),
),
// ── Zoom controls + minimap — bottom-right ───────────────────
Positioned(
right: 16,
bottom: 16,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Minimap above zoom controls
TopologyMinimap(
graph: graph,
nodePositions: nodePositions,
canvasSize: canvasSize,
controller: transformController,
viewportSize: viewportSize,
),
const SizedBox(height: 8),
ZoomControls(
controller: transformController,
canvasSize: canvasSize,
viewportSize: viewportSize,
),
],
),
),
],
);
},
);
}
}
@@ -0,0 +1,163 @@
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) => StatefulBuilder(
// Keep Create disabled until the input is valid, instead of accepting
// the click and silently discarding out-of-range/empty input after the
// dialog closes (QA-048; matches the QA-029 disabled-until-valid fix).
builder: (ctx, setDialogState) {
final parsedId = int.tryParse(idCtrl.text.trim());
final isValid = parsedId != null &&
parsedId >= 1 &&
parsedId <= 4094 &&
nameCtrl.text.trim().isNotEmpty;
return 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,
onChanged: (_) => setDialogState(() {}),
),
const SizedBox(height: 8),
TextField(
controller: nameCtrl,
decoration: const InputDecoration(labelText: 'Name'),
onChanged: (_) => setDialogState(() {}),
),
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: isValid ? () => Navigator.pop(ctx, true) : null,
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,193 @@
import 'package:flutter/material.dart';
import '../../../models/network/network_device.dart';
import '../../../theme/app_status_colors.dart';
import 'network_device_icon.dart';
/// A styled node representing one network device on the topology canvas.
///
/// Accepts an explicit [nodeSize] so role-based sizing can be applied by the
/// canvas without this widget needing to know the layout strategy.
class DeviceNode extends StatelessWidget {
const DeviceNode({
super.key,
required this.device,
this.portCount,
this.isSelected = false,
this.isHighlighted = false,
this.nodeSize = const Size(160, 110),
this.onTap,
});
final NetworkDevice device;
final int? portCount;
final bool isSelected;
final bool isHighlighted;
final Size nodeSize;
final VoidCallback? onTap;
Color _accentForRole(ColorScheme cs, NetworkDeviceRole? role) {
switch (role) {
case NetworkDeviceRole.core:
return cs.error;
case NetworkDeviceRole.distribution:
return cs.tertiary;
case NetworkDeviceRole.access:
return cs.primary;
case NetworkDeviceRole.edge:
return cs.secondary;
case NetworkDeviceRole.endpoint:
case null:
return cs.outline;
}
}
Color? _statusColor(BuildContext context, NetworkDeviceStatus status) {
final s = AppStatusColors.of(context);
switch (status) {
case NetworkDeviceStatus.online:
return s.success.color;
case NetworkDeviceStatus.offline:
return Theme.of(context).colorScheme.error;
case NetworkDeviceStatus.warning:
return s.warning.color;
case NetworkDeviceStatus.unknown:
return null; // No dot shown
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
final accent = _accentForRole(cs, device.role);
final statusColor = _statusColor(context, device.status);
final bgColor = isSelected
? cs.primaryContainer
: isHighlighted
? cs.secondaryContainer.withValues(alpha: 0.6)
: cs.surfaceContainerHigh;
final borderColor = isSelected
? cs.primary
: isHighlighted
? cs.primary.withValues(alpha: 0.8)
: accent.withValues(alpha: 0.6);
final borderWidth = isSelected ? 2.0 : (isHighlighted ? 1.5 : 1.0);
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: borderColor, width: borderWidth),
boxShadow: (isHighlighted || isSelected)
? [
BoxShadow(
color: cs.primary.withValues(alpha: 0.18),
blurRadius: 12,
offset: const Offset(0, 2),
),
]
: null,
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Stack(
children: [
Container(
width: nodeSize.width,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
NetworkDeviceIcon(
kind: device.kind,
color: accent,
size: 20,
),
const SizedBox(width: 6),
Expanded(
child: Text(
device.kind.label,
style: tt.labelSmall?.copyWith(color: accent),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 4),
Text(
device.name,
style: tt.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (device.vendor != null || device.model != null) ...[
const SizedBox(height: 2),
Text(
[
if (device.vendor != null) device.vendor,
if (device.model != null) device.model,
].whereType<String>().join(' • '),
style: tt.labelSmall?.copyWith(
color: cs.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
if (portCount != null) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.cable_outlined,
size: 12,
color: cs.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'$portCount ports',
style: tt.labelSmall?.copyWith(
color: cs.onSurfaceVariant,
),
),
],
),
],
],
),
),
// Status dot — top-right, only shown when status is not unknown
if (statusColor != null)
Positioned(
top: 8,
right: 8,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: statusColor,
shape: BoxShape.circle,
border: Border.all(
color: bgColor,
width: 1.5,
),
),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,326 @@
import 'package:flutter/material.dart';
/// Side-by-side review of an AI extraction result.
///
/// Left column: items the AI proposed (devices, ports, links, vlans), each
/// togglable on/off with confidence tag. Right column: a summary panel that
/// shows what will be applied. Apply button persists the toggled-on items.
class ImportDiffView extends StatefulWidget {
const ImportDiffView({
super.key,
required this.aiResult,
required this.onApply,
});
final Map<String, dynamic> aiResult;
final Future<void> Function({
required List<Map<String, dynamic>> acceptedDevices,
required List<Map<String, dynamic>> acceptedPorts,
required List<Map<String, dynamic>> acceptedLinks,
required List<Map<String, dynamic>> acceptedVlans,
required List<Map<String, dynamic>> acceptedSites,
}) onApply;
@override
State<ImportDiffView> createState() => _ImportDiffViewState();
}
class _ImportDiffViewState extends State<ImportDiffView> {
late final Set<int> _enabledDevices;
late final Set<int> _enabledPorts;
late final Set<int> _enabledLinks;
late final Set<int> _enabledVlans;
late final Set<int> _enabledSites;
bool _applying = false;
List<Map<String, dynamic>> _asList(dynamic value) {
if (value is! List) return const [];
return value
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
}
@override
void initState() {
super.initState();
_enabledDevices = {
for (var i = 0; i < _asList(widget.aiResult['devices']).length; i++) i,
};
_enabledPorts = {
for (var i = 0; i < _asList(widget.aiResult['ports']).length; i++) i,
};
_enabledLinks = {
for (var i = 0; i < _asList(widget.aiResult['links']).length; i++) i,
};
_enabledVlans = {
for (var i = 0; i < _asList(widget.aiResult['vlans']).length; i++) i,
};
_enabledSites = {
for (var i = 0; i < _asList(widget.aiResult['sites']).length; i++) i,
};
}
Color _confidenceColor(BuildContext context, String? c) {
final cs = Theme.of(context).colorScheme;
switch (c) {
case 'high':
return cs.primary;
case 'medium':
return cs.tertiary;
case 'low':
return cs.error;
default:
return cs.outline;
}
}
Widget _itemTile({
required String primary,
String? secondary,
String? confidence,
required bool enabled,
required ValueChanged<bool?> onToggle,
}) {
return CheckboxListTile(
dense: true,
value: enabled,
onChanged: onToggle,
controlAffinity: ListTileControlAffinity.leading,
title: Text(primary),
subtitle: secondary != null ? Text(secondary) : null,
secondary: confidence == null
? null
: Chip(
label: Text(confidence),
backgroundColor:
_confidenceColor(context, confidence).withValues(alpha: 0.15),
labelStyle: TextStyle(color: _confidenceColor(context, confidence)),
side: BorderSide.none,
padding: const EdgeInsets.symmetric(horizontal: 4),
),
);
}
Future<void> _onApplyPressed() async {
setState(() => _applying = true);
try {
final allSites = _asList(widget.aiResult['sites']);
final allDevices = _asList(widget.aiResult['devices']);
final allPorts = _asList(widget.aiResult['ports']);
final allLinks = _asList(widget.aiResult['links']);
final allVlans = _asList(widget.aiResult['vlans']);
await widget.onApply(
acceptedSites: [
for (var i = 0; i < allSites.length; i++)
if (_enabledSites.contains(i)) allSites[i],
],
acceptedDevices: [
for (var i = 0; i < allDevices.length; i++)
if (_enabledDevices.contains(i)) allDevices[i],
],
acceptedPorts: [
for (var i = 0; i < allPorts.length; i++)
if (_enabledPorts.contains(i)) allPorts[i],
],
acceptedLinks: [
for (var i = 0; i < allLinks.length; i++)
if (_enabledLinks.contains(i)) allLinks[i],
],
acceptedVlans: [
for (var i = 0; i < allVlans.length; i++)
if (_enabledVlans.contains(i)) allVlans[i],
],
);
} finally {
if (mounted) setState(() => _applying = false);
}
}
@override
Widget build(BuildContext context) {
final sites = _asList(widget.aiResult['sites']);
final devices = _asList(widget.aiResult['devices']);
final ports = _asList(widget.aiResult['ports']);
final links = _asList(widget.aiResult['links']);
final vlans = _asList(widget.aiResult['vlans']);
final notes = widget.aiResult['notes']?.toString() ?? '';
return Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
if (notes.isNotEmpty)
Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.info_outline, size: 18),
const SizedBox(width: 8),
Expanded(child: Text(notes)),
],
),
),
),
if (sites.isNotEmpty) ...[
_sectionHeader('Sites (${sites.length})'),
for (var i = 0; i < sites.length; i++)
_itemTile(
primary: sites[i]['name']?.toString() ?? '(unnamed)',
secondary: sites[i]['address']?.toString(),
enabled: _enabledSites.contains(i),
onToggle: (v) => setState(() {
if (v == true) {
_enabledSites.add(i);
} else {
_enabledSites.remove(i);
}
}),
),
],
if (devices.isNotEmpty) ...[
_sectionHeader('Devices (${devices.length})'),
for (var i = 0; i < devices.length; i++)
_itemTile(
primary: devices[i]['name']?.toString() ?? '(unnamed)',
secondary: [
devices[i]['kind']?.toString() ?? '',
if (devices[i]['vendor'] != null) devices[i]['vendor'],
if (devices[i]['model'] != null) devices[i]['model'],
].whereType<String>().where((s) => s.isNotEmpty).join(' • '),
confidence: devices[i]['confidence']?.toString(),
enabled: _enabledDevices.contains(i),
onToggle: (v) => setState(() {
if (v == true) {
_enabledDevices.add(i);
} else {
_enabledDevices.remove(i);
}
}),
),
],
if (ports.isNotEmpty) ...[
_sectionHeader('Ports (${ports.length})'),
for (var i = 0; i < ports.length; i++)
_itemTile(
primary:
'${ports[i]['device_name'] ?? '?'} · ${ports[i]['port_number'] ?? '?'}',
secondary: ports[i]['port_kind']?.toString(),
enabled: _enabledPorts.contains(i),
onToggle: (v) => setState(() {
if (v == true) {
_enabledPorts.add(i);
} else {
_enabledPorts.remove(i);
}
}),
),
],
if (links.isNotEmpty) ...[
_sectionHeader('Links (${links.length})'),
for (var i = 0; i < links.length; i++)
_itemTile(
primary:
'${links[i]['device_a'] ?? '?'} ↔ ${links[i]['device_b'] ?? '?'}',
secondary: [
if (links[i]['port_a'] != null) 'A: ${links[i]['port_a']}',
if (links[i]['port_b'] != null) 'B: ${links[i]['port_b']}',
if (links[i]['link_kind'] != null) links[i]['link_kind'],
].whereType<String>().join(' • '),
confidence: links[i]['confidence']?.toString(),
enabled: _enabledLinks.contains(i),
onToggle: (v) => setState(() {
if (v == true) {
_enabledLinks.add(i);
} else {
_enabledLinks.remove(i);
}
}),
),
],
if (vlans.isNotEmpty) ...[
_sectionHeader('VLANs (${vlans.length})'),
for (var i = 0; i < vlans.length; i++)
_itemTile(
primary:
'VLAN ${vlans[i]['vlan_id'] ?? '?'} · ${vlans[i]['name'] ?? ''}',
secondary: vlans[i]['description']?.toString(),
enabled: _enabledVlans.contains(i),
onToggle: (v) => setState(() {
if (v == true) {
_enabledVlans.add(i);
} else {
_enabledVlans.remove(i);
}
}),
),
],
if (sites.isEmpty &&
devices.isEmpty &&
ports.isEmpty &&
links.isEmpty &&
vlans.isEmpty)
const Padding(
padding: EdgeInsets.all(24),
child: Center(
child: Text(
'Extraction returned no structured items. You can still '
'discard this import and start from a blank canvas.',
),
),
),
],
),
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Row(
children: [
Expanded(
child: Text(
'Selected: ${_enabledSites.length} sites, '
'${_enabledDevices.length} devices, '
'${_enabledLinks.length} links',
style: Theme.of(context).textTheme.labelSmall,
),
),
FilledButton.icon(
onPressed: _applying ? null : _onApplyPressed,
icon: _applying
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
label: Text(_applying ? 'Applying…' : 'Apply selected'),
),
],
),
),
),
],
);
}
Widget _sectionHeader(String label) {
return Padding(
padding: const EdgeInsets.only(top: 16, bottom: 4),
child: Text(
label,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
);
}
}
@@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/network/network_link.dart';
import '../../../models/network/network_port.dart';
import '../../../providers/network_map/network_devices_provider.dart';
import '../../../utils/snackbar.dart';
/// Opens an M3 dialog to connect [portId] to a port on another device.
///
/// Returns true if a link was successfully created.
Future<bool> showLinkEditDialog({
required BuildContext context,
required WidgetRef ref,
required String portId,
required String currentDeviceId,
}) async {
final result = await showDialog<bool>(
context: context,
builder: (_) => _LinkEditDialog(
portId: portId,
currentDeviceId: currentDeviceId,
),
);
return result == true;
}
class _LinkEditDialog extends ConsumerStatefulWidget {
const _LinkEditDialog({
required this.portId,
required this.currentDeviceId,
});
final String portId;
final String currentDeviceId;
@override
ConsumerState<_LinkEditDialog> createState() => _LinkEditDialogState();
}
class _LinkEditDialogState extends ConsumerState<_LinkEditDialog> {
String? _selectedDeviceId;
String? _selectedPortId;
NetworkLinkKind? _linkKind;
final _cableLabelCtrl = TextEditingController();
bool _saving = false;
@override
void dispose() {
_cableLabelCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
final portA = widget.portId;
final portB = _selectedPortId;
if (portB == null) return;
setState(() => _saving = true);
try {
ref.invalidate(networkLinksProvider);
final controller = ref.read(networkDevicesControllerProvider);
await controller.createLink(
portA: portA,
portB: portB,
linkKind: _linkKind,
cableLabel: _cableLabelCtrl.text.trim().isEmpty
? null
: _cableLabelCtrl.text.trim(),
);
if (mounted) Navigator.of(context).pop(true);
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Failed to create link: $e');
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final devicesAsync = ref.watch(networkDevicesProvider);
final portsAsync = _selectedDeviceId != null
? ref.watch(networkPortsByDeviceProvider(_selectedDeviceId!))
: const AsyncValue<List<NetworkPort>>.data([]);
final allLinksAsync = ref.watch(networkLinksProvider);
final otherDevices = (devicesAsync.valueOrNull ?? const [])
.where((d) => d.id != widget.currentDeviceId)
.toList()
..sort((a, b) => a.name.compareTo(b.name));
final allLinks = allLinksAsync.valueOrNull ?? const [];
final usedPortIds = <String>{
for (final link in allLinks) ...[link.portA, link.portB],
};
final devicePorts = portsAsync.valueOrNull ?? const [];
final availablePorts =
devicePorts.where((p) => !usedPortIds.contains(p.id)).toList();
final canSave = _selectedPortId != null && !_saving;
return AlertDialog(
title: const Text('Connect port'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<String>(
initialValue: _selectedDeviceId,
decoration:
const InputDecoration(labelText: 'Remote device *'),
items: [
for (final d in otherDevices)
DropdownMenuItem(value: d.id, child: Text(d.name)),
],
onChanged: (v) => setState(() {
_selectedDeviceId = v;
_selectedPortId = null;
}),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
key: ValueKey(_selectedDeviceId),
initialValue: _selectedPortId,
decoration: InputDecoration(
labelText: 'Remote port *',
helperText: _selectedDeviceId == null
? 'Select a device first'
: availablePorts.isEmpty
? 'No available ports on this device'
: null,
),
items: [
for (final p in availablePorts)
DropdownMenuItem(
value: p.id,
child: Text(p.portNumber),
),
],
onChanged: _selectedDeviceId == null
? null
: (v) => setState(() => _selectedPortId = v),
),
const SizedBox(height: 12),
DropdownButtonFormField<NetworkLinkKind?>(
initialValue: _linkKind,
decoration: const InputDecoration(labelText: 'Link type'),
items: [
const DropdownMenuItem<NetworkLinkKind?>(
value: null,
child: Text('(unknown)'),
),
for (final k in NetworkLinkKind.values)
DropdownMenuItem(value: k, child: Text(k.label)),
],
onChanged: (v) => setState(() => _linkKind = v),
),
const SizedBox(height: 12),
TextField(
controller: _cableLabelCtrl,
decoration: const InputDecoration(
labelText: 'Cable label',
helperText: 'e.g. "CAT6-01", "SMF-A3" (optional)',
),
),
],
),
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: canSave ? _save : null,
child: Text(_saving ? 'Saving…' : 'Connect'),
),
],
);
}
}
@@ -0,0 +1,374 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../models/network/network_device.dart';
/// Custom-painted network equipment icon for each [NetworkDeviceKind].
///
/// Draws simplified but recognizable symbols matching professional network
/// topology diagram conventions (Cisco-style silhouettes).
class NetworkDeviceIcon extends StatelessWidget {
const NetworkDeviceIcon({
super.key,
required this.kind,
required this.color,
this.size = 20,
});
final NetworkDeviceKind kind;
final Color color;
final double size;
@override
Widget build(BuildContext context) {
return SizedBox(
width: size,
height: size,
child: CustomPaint(
painter: _DeviceIconPainter(kind: kind, color: color),
),
);
}
}
class _DeviceIconPainter extends CustomPainter {
_DeviceIconPainter({required this.kind, required this.color});
final NetworkDeviceKind kind;
final Color color;
@override
void paint(Canvas canvas, Size size) {
switch (kind) {
case NetworkDeviceKind.router:
_drawRouter(canvas, size);
case NetworkDeviceKind.switchDevice:
_drawSwitch(canvas, size);
case NetworkDeviceKind.ap:
_drawAp(canvas, size);
case NetworkDeviceKind.firewall:
_drawFirewall(canvas, size);
case NetworkDeviceKind.server:
_drawServer(canvas, size);
case NetworkDeviceKind.endpoint:
_drawEndpoint(canvas, size);
case NetworkDeviceKind.patchPanel:
_drawPatchPanel(canvas, size);
case NetworkDeviceKind.other:
_drawOther(canvas, size);
}
}
Paint get _stroke => Paint()
..color = color
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..strokeWidth = 1.4;
Paint get _fill => Paint()
..color = color
..style = PaintingStyle.fill;
// ─── Router: cylinder body + 4 arrows ─────────────────────────────────────
void _drawRouter(Canvas canvas, Size s) {
final cx = s.width / 2;
final cy = s.height / 2;
final r = s.width * 0.28;
final p = _stroke;
// Circle body
canvas.drawCircle(Offset(cx, cy), r, p);
// 4 directional arrows at N/S/E/W
const arrowLen = 0.22;
const arrowHead = 0.1;
for (var i = 0; i < 4; i++) {
final angle = i * math.pi / 2;
final ax = cx + math.cos(angle) * (r + s.width * arrowLen);
final ay = cy + math.sin(angle) * (r + s.width * arrowLen);
final bx = cx + math.cos(angle) * (r + 1);
final by = cy + math.sin(angle) * (r + 1);
canvas.drawLine(Offset(bx, by), Offset(ax, ay), p);
// Arrowhead
final perp = angle + math.pi / 2;
final hw = s.width * arrowHead;
canvas.drawPath(
Path()
..moveTo(ax, ay)
..lineTo(
ax - math.cos(angle) * hw + math.cos(perp) * hw / 2,
ay - math.sin(angle) * hw + math.sin(perp) * hw / 2,
)
..lineTo(
ax - math.cos(angle) * hw - math.cos(perp) * hw / 2,
ay - math.sin(angle) * hw - math.sin(perp) * hw / 2,
)
..close(),
_fill,
);
}
}
// ─── Switch: rectangle + port lines ───────────────────────────────────────
void _drawSwitch(Canvas canvas, Size s) {
final w = s.width * 0.86;
final h = s.height * 0.42;
final left = (s.width - w) / 2;
final top = (s.height - h) / 2;
final p = _stroke;
// Body
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(left, top, w, h),
const Radius.circular(2),
),
p,
);
// Port lines (8 evenly spaced)
const ports = 8;
final portSpacing = w / (ports + 1);
final portH = h * 0.5;
final portTop = top + h * 0.2;
for (var i = 1; i <= ports; i++) {
final px = left + portSpacing * i;
canvas.drawLine(
Offset(px, portTop),
Offset(px, portTop + portH),
p,
);
}
// Two legs at bottom center
final legX1 = s.width * 0.38;
final legX2 = s.width * 0.62;
final bodyBottom = top + h;
canvas.drawLine(
Offset(legX1, bodyBottom),
Offset(legX1, s.height * 0.85),
p,
);
canvas.drawLine(
Offset(legX2, bodyBottom),
Offset(legX2, s.height * 0.85),
p,
);
canvas.drawLine(
Offset(legX1, s.height * 0.85),
Offset(legX2, s.height * 0.85),
p,
);
}
// ─── AP: semicircle waves + stem ──────────────────────────────────────────
void _drawAp(Canvas canvas, Size s) {
final cx = s.width / 2;
final base = s.height * 0.72;
final p = _stroke;
// Stem
canvas.drawLine(Offset(cx, base), Offset(cx, s.height * 0.88), p);
canvas.drawLine(
Offset(cx - s.width * 0.15, s.height * 0.88),
Offset(cx + s.width * 0.15, s.height * 0.88),
p,
);
// 3 concentric arcs from narrow to wide
for (var i = 1; i <= 3; i++) {
final r = s.width * 0.13 * i;
final rect = Rect.fromCircle(center: Offset(cx, base), radius: r);
canvas.drawArc(rect, math.pi, -math.pi, false, p);
}
}
// ─── Firewall: brick-wall grid ─────────────────────────────────────────────
void _drawFirewall(Canvas canvas, Size s) {
final w = s.width * 0.8;
final h = s.height * 0.72;
final left = (s.width - w) / 2;
final top = (s.height - h) / 2;
final p = _stroke;
// Outer rect
canvas.drawRect(Rect.fromLTWH(left, top, w, h), p);
// 3 rows, 2 staggered bricks each
final rowH = h / 3;
for (var row = 0; row < 3; row++) {
final rowTop = top + row * rowH;
// horizontal mortar line
if (row > 0) {
canvas.drawLine(
Offset(left, rowTop),
Offset(left + w, rowTop),
p,
);
}
// vertical mortar — offset every other row by half a brick
final offset = (row % 2 == 0) ? w / 2 : w / 4;
canvas.drawLine(
Offset(left + offset, rowTop),
Offset(left + offset, rowTop + rowH),
p,
);
}
}
// ─── Server: rack unit with LED dots ──────────────────────────────────────
void _drawServer(Canvas canvas, Size s) {
final w = s.width * 0.72;
final h = s.height * 0.78;
final left = (s.width - w) / 2;
final top = (s.height - h) / 2;
final p = _stroke;
final fp = _fill;
// Body
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(left, top, w, h),
const Radius.circular(2),
),
p,
);
// 3 horizontal rack-unit dividers + LED dots
final unitH = h / 3;
for (var i = 0; i < 3; i++) {
final unitTop = top + i * unitH;
if (i > 0) {
canvas.drawLine(
Offset(left, unitTop),
Offset(left + w, unitTop),
p,
);
}
// Small LED dot on right side of each unit
canvas.drawCircle(
Offset(left + w - w * 0.15, unitTop + unitH / 2),
s.width * 0.05,
fp,
);
}
}
// ─── Endpoint: monitor + stand ─────────────────────────────────────────────
void _drawEndpoint(Canvas canvas, Size s) {
final monW = s.width * 0.76;
final monH = s.height * 0.52;
final left = (s.width - monW) / 2;
final top = s.height * 0.08;
final p = _stroke;
// Monitor bezel
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(left, top, monW, monH),
const Radius.circular(2),
),
p,
);
// Screen inner (inset 2dp)
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(left + 3, top + 3, monW - 6, monH - 6),
const Radius.circular(1),
),
p,
);
// Neck
final neckX = s.width / 2;
final neckTop = top + monH;
canvas.drawLine(
Offset(neckX, neckTop),
Offset(neckX, neckTop + s.height * 0.18),
p,
);
// Base
canvas.drawLine(
Offset(s.width * 0.25, s.height * 0.88),
Offset(s.width * 0.75, s.height * 0.88),
p,
);
}
// ─── Patch panel: flat rect with 2 rows of port circles ──────────────────
void _drawPatchPanel(Canvas canvas, Size s) {
final w = s.width * 0.9;
final h = s.height * 0.44;
final left = (s.width - w) / 2;
final top = (s.height - h) / 2;
final p = _stroke;
// Body
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(left, top, w, h),
const Radius.circular(2),
),
p,
);
// 2 rows × 5 port circles
const cols = 5;
const rows = 2;
final colSpacing = w / (cols + 1);
final rowSpacing = h / (rows + 1);
final dotR = s.width * 0.04;
for (var row = 1; row <= rows; row++) {
for (var col = 1; col <= cols; col++) {
canvas.drawCircle(
Offset(left + colSpacing * col, top + rowSpacing * row),
dotR,
p,
);
}
}
}
// ─── Other: circle with question mark ─────────────────────────────────────
void _drawOther(Canvas canvas, Size s) {
final cx = s.width / 2;
final cy = s.height / 2;
final r = s.width * 0.42;
final p = _stroke;
canvas.drawCircle(Offset(cx, cy), r, p);
// "?" drawn as a small arc + dot
final tp = TextPainter(
text: TextSpan(
text: '?',
style: TextStyle(
fontSize: s.width * 0.52,
fontWeight: FontWeight.w700,
color: color,
height: 1,
),
),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(
canvas,
Offset(cx - tp.width / 2, cy - tp.height / 2),
);
}
@override
bool shouldRepaint(covariant _DeviceIconPainter old) =>
old.kind != kind || old.color != color;
}

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