diff --git a/analysis_options.yaml b/analysis_options.yaml index 8716ac97..5edcab28 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -6,3 +6,10 @@ analyzer: - lib/brick/*.g.dart - lib/brick/**/*.g.dart - lib/brick/db/*.dart + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** diff --git a/docs/qa/QA_REPORT.md b/docs/qa/QA_REPORT.md new file mode 100644 index 00000000..9da247e7 --- /dev/null +++ b/docs/qa/QA_REPORT.md @@ -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 `` 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) diff --git a/docs/qa/screenshots/QA-008-offices-z-to-a.png b/docs/qa/screenshots/QA-008-offices-z-to-a.png new file mode 100644 index 00000000..6b2c9b02 Binary files /dev/null and b/docs/qa/screenshots/QA-008-offices-z-to-a.png differ diff --git a/docs/qa/screenshots/QA-009-tasks-status-after.png b/docs/qa/screenshots/QA-009-tasks-status-after.png new file mode 100644 index 00000000..ae424dbd Binary files /dev/null and b/docs/qa/screenshots/QA-009-tasks-status-after.png differ diff --git a/docs/qa/screenshots/QA-025-raw-auth-error-before.png b/docs/qa/screenshots/QA-025-raw-auth-error-before.png new file mode 100644 index 00000000..4e5c499a Binary files /dev/null and b/docs/qa/screenshots/QA-025-raw-auth-error-before.png differ diff --git a/docs/qa/screenshots/QA-026-isr-create-missing-migration.png b/docs/qa/screenshots/QA-026-isr-create-missing-migration.png new file mode 100644 index 00000000..4dc9ff12 Binary files /dev/null and b/docs/qa/screenshots/QA-026-isr-create-missing-migration.png differ diff --git a/docs/qa/screenshots/QA-030-tickets-shortid-after.png b/docs/qa/screenshots/QA-030-tickets-shortid-after.png new file mode 100644 index 00000000..1ab7c6ef Binary files /dev/null and b/docs/qa/screenshots/QA-030-tickets-shortid-after.png differ diff --git a/docs/qa/screenshots/QA-030-tickets-uuid-before.png b/docs/qa/screenshots/QA-030-tickets-uuid-before.png new file mode 100644 index 00000000..8aca78d0 Binary files /dev/null and b/docs/qa/screenshots/QA-030-tickets-uuid-before.png differ diff --git a/docs/qa/screenshots/QA-032-dashboard-hours-before.png b/docs/qa/screenshots/QA-032-dashboard-hours-before.png new file mode 100644 index 00000000..196b132a Binary files /dev/null and b/docs/qa/screenshots/QA-032-dashboard-hours-before.png differ diff --git a/docs/qa/screenshots/QA-035-pulse-initials-before.png b/docs/qa/screenshots/QA-035-pulse-initials-before.png new file mode 100644 index 00000000..e4dd91cc Binary files /dev/null and b/docs/qa/screenshots/QA-035-pulse-initials-before.png differ diff --git a/docs/qa/screenshots/login-mobile-dark.png b/docs/qa/screenshots/login-mobile-dark.png new file mode 100644 index 00000000..d418cbea Binary files /dev/null and b/docs/qa/screenshots/login-mobile-dark.png differ diff --git a/docs/qa/screenshots/signup-mobile-dark-validation.png b/docs/qa/screenshots/signup-mobile-dark-validation.png new file mode 100644 index 00000000..f8735d37 Binary files /dev/null and b/docs/qa/screenshots/signup-mobile-dark-validation.png differ diff --git a/lib/app.dart b/lib/app.dart index c453509e..024586d0 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -6,7 +6,7 @@ import 'package:flutter_quill/flutter_quill.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'routing/app_router.dart'; -import 'models/profile.dart'; +import 'models/profile.model.dart'; import 'providers/connectivity_provider.dart'; import 'providers/profile_provider.dart'; import 'services/background_location_service.dart'; diff --git a/lib/main.dart b/lib/main.dart index 577c9039..da8d5ab7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -249,8 +249,7 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { Future main() async { WidgetsFlutterBinding.ensureInitialized(); - // The flag optionally hides annoying WASM warnings in your Chrome dev console - pdfrxFlutterInitialize(dismissPdfiumWasmWarnings: true); + pdfrxFlutterInitialize(); // initialize Firebase before anything that uses messaging try { @@ -768,6 +767,10 @@ class _UpdateCheckWrapperState extends State { @override 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( debugShowCheckedModeBanner: false, theme: AppTheme.light(), @@ -791,14 +794,12 @@ class _UpdateCheckWrapperState extends State { ), ); }, - child: kIsWeb + child: _done ? const NotificationBridge(child: TasqApp()) - : (_done - ? const NotificationBridge(child: TasqApp()) - : UpdateCheckingScreen( - checkForUpdates: _checkForUpdates, - onCompleted: _handleUpdateComplete, - )), + : UpdateCheckingScreen( + checkForUpdates: _checkForUpdates, + onCompleted: _handleUpdateComplete, + ), ), ); } diff --git a/lib/models/announcement.dart b/lib/models/announcement.dart deleted file mode 100644 index 537dc6b9..00000000 --- a/lib/models/announcement.dart +++ /dev/null @@ -1 +0,0 @@ -export 'announcement.model.dart'; diff --git a/lib/models/announcement_comment.dart b/lib/models/announcement_comment.dart deleted file mode 100644 index 52118e32..00000000 --- a/lib/models/announcement_comment.dart +++ /dev/null @@ -1 +0,0 @@ -export 'announcement_comment.model.dart'; diff --git a/lib/models/attendance_log.dart b/lib/models/attendance_log.dart deleted file mode 100644 index d558ba26..00000000 --- a/lib/models/attendance_log.dart +++ /dev/null @@ -1 +0,0 @@ -export 'attendance_log.model.dart'; diff --git a/lib/models/chat_message.dart b/lib/models/chat_message.dart deleted file mode 100644 index b5af515b..00000000 --- a/lib/models/chat_message.dart +++ /dev/null @@ -1 +0,0 @@ -export 'chat_message.model.dart'; diff --git a/lib/models/duty_schedule.dart b/lib/models/duty_schedule.dart deleted file mode 100644 index b8e42f82..00000000 --- a/lib/models/duty_schedule.dart +++ /dev/null @@ -1 +0,0 @@ -export 'duty_schedule.model.dart'; diff --git a/lib/models/it_service_request.dart b/lib/models/it_service_request.dart deleted file mode 100644 index d68ec1bb..00000000 --- a/lib/models/it_service_request.dart +++ /dev/null @@ -1 +0,0 @@ -export 'it_service_request.model.dart'; diff --git a/lib/models/it_service_request_action.dart b/lib/models/it_service_request_action.dart deleted file mode 100644 index 53ba8b4d..00000000 --- a/lib/models/it_service_request_action.dart +++ /dev/null @@ -1 +0,0 @@ -export 'it_service_request_action.model.dart'; diff --git a/lib/models/it_service_request_activity_log.dart b/lib/models/it_service_request_activity_log.dart deleted file mode 100644 index f424d71b..00000000 --- a/lib/models/it_service_request_activity_log.dart +++ /dev/null @@ -1 +0,0 @@ -export 'it_service_request_activity_log.model.dart'; diff --git a/lib/models/it_service_request_assignment.dart b/lib/models/it_service_request_assignment.dart deleted file mode 100644 index 00effcdd..00000000 --- a/lib/models/it_service_request_assignment.dart +++ /dev/null @@ -1 +0,0 @@ -export 'it_service_request_assignment.model.dart'; diff --git a/lib/models/leave_of_absence.dart b/lib/models/leave_of_absence.dart deleted file mode 100644 index 94614ffe..00000000 --- a/lib/models/leave_of_absence.dart +++ /dev/null @@ -1 +0,0 @@ -export 'leave_of_absence.model.dart'; diff --git a/lib/models/notification_item.dart b/lib/models/notification_item.dart deleted file mode 100644 index 98b96b7a..00000000 --- a/lib/models/notification_item.dart +++ /dev/null @@ -1 +0,0 @@ -export 'notification_item.model.dart'; diff --git a/lib/models/office.dart b/lib/models/office.dart deleted file mode 100644 index f20d7117..00000000 --- a/lib/models/office.dart +++ /dev/null @@ -1 +0,0 @@ -export 'office.model.dart'; diff --git a/lib/models/pass_slip.dart b/lib/models/pass_slip.dart deleted file mode 100644 index a60eca34..00000000 --- a/lib/models/pass_slip.dart +++ /dev/null @@ -1 +0,0 @@ -export 'pass_slip.model.dart'; diff --git a/lib/models/profile.dart b/lib/models/profile.dart deleted file mode 100644 index 0098a0ab..00000000 --- a/lib/models/profile.dart +++ /dev/null @@ -1 +0,0 @@ -export 'profile.model.dart'; diff --git a/lib/models/service.dart b/lib/models/service.dart deleted file mode 100644 index 486a7371..00000000 --- a/lib/models/service.dart +++ /dev/null @@ -1 +0,0 @@ -export 'service.model.dart'; diff --git a/lib/models/swap_request.dart b/lib/models/swap_request.dart deleted file mode 100644 index 74d3816d..00000000 --- a/lib/models/swap_request.dart +++ /dev/null @@ -1 +0,0 @@ -export 'swap_request.model.dart'; diff --git a/lib/models/task.dart b/lib/models/task.dart deleted file mode 100644 index 1239b1e1..00000000 --- a/lib/models/task.dart +++ /dev/null @@ -1 +0,0 @@ -export 'task.model.dart'; diff --git a/lib/models/task_activity_log.dart b/lib/models/task_activity_log.dart deleted file mode 100644 index de0b5529..00000000 --- a/lib/models/task_activity_log.dart +++ /dev/null @@ -1 +0,0 @@ -export 'task_activity_log.model.dart'; diff --git a/lib/models/team.dart b/lib/models/team.dart deleted file mode 100644 index 08c303de..00000000 --- a/lib/models/team.dart +++ /dev/null @@ -1 +0,0 @@ -export 'team.model.dart'; diff --git a/lib/models/ticket.dart b/lib/models/ticket.dart deleted file mode 100644 index c7f9e39d..00000000 --- a/lib/models/ticket.dart +++ /dev/null @@ -1 +0,0 @@ -export 'ticket.model.dart'; diff --git a/lib/providers/announcements_provider.dart b/lib/providers/announcements_provider.dart index fd62f9b8..e67c3ae5 100644 --- a/lib/providers/announcements_provider.dart +++ b/lib/providers/announcements_provider.dart @@ -3,8 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uuid/uuid.dart'; import '../brick/cache_helpers.dart'; -import '../models/announcement.dart'; -import '../models/announcement_comment.dart'; +import '../models/announcement.model.dart'; +import '../models/announcement_comment.model.dart'; import '../utils/app_time.dart'; import '../utils/snackbar.dart' show isOfflineSaveError; import 'connectivity_provider.dart'; @@ -273,35 +273,37 @@ final announcementCommentsProvider = ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); } - final wrapper = StreamRecoveryWrapper( - stream: client - .from('announcement_comments') - .stream(primaryKey: ['id']) - .eq('announcement_id', announcementId) - .order('created_at'), - onPollData: () async { - final data = await client - .from('announcement_comments') - .select() - .eq('announcement_id', announcementId) - .order('created_at'); - return data.map(AnnouncementComment.fromMap).toList(); - }, - fromMap: AnnouncementComment.fromMap, - channelName: 'announcement_comments_$announcementId', - onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, - onOfflineData: () async { - final all = await cachedListFromBrick(); - final filtered = all - .where((c) => c.announcementId == announcementId) - .toList() - ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); - return applyPending(filtered); - }, - onCacheMirror: (rows) => mirrorBatchToBrick( - rows, - tag: 'announcement_comments'), - ); + final wrapper = StreamRecoveryWrapper( + stream: client + .from('announcement_comments') + .stream(primaryKey: ['id']) + .eq('announcement_id', announcementId) + .order('created_at', ascending: true), + onPollData: () async { + final data = await client + .from('announcement_comments') + .select() + .eq('announcement_id', announcementId) + .order('created_at', ascending: true); + return data.map(AnnouncementComment.fromMap).toList(); + }, + fromMap: AnnouncementComment.fromMap, + channelName: 'announcement_comments_$announcementId', + onStatusChanged: ref + .read(realtimeControllerProvider) + .handleChannelStatus, + onOfflineData: () async { + final all = await cachedListFromBrick(); + final filtered = + all.where((c) => c.announcementId == announcementId).toList() + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + return applyPending(filtered); + }, + onCacheMirror: (rows) => mirrorBatchToBrick( + rows, + tag: 'announcement_comments', + ), + ); ref.onDispose(wrapper.dispose); return wrapper.stream.map((result) => applyPending(result.data)); diff --git a/lib/providers/attendance_provider.dart b/lib/providers/attendance_provider.dart index c062658b..341a66be 100644 --- a/lib/providers/attendance_provider.dart +++ b/lib/providers/attendance_provider.dart @@ -8,7 +8,7 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:uuid/uuid.dart'; import '../brick/cache_helpers.dart'; -import '../models/attendance_log.dart'; +import '../models/attendance_log.model.dart'; import '../utils/app_time.dart'; import '../utils/snackbar.dart' show isOfflineSaveError; import 'connectivity_provider.dart'; diff --git a/lib/providers/chat_provider.dart b/lib/providers/chat_provider.dart index 58fd62cc..435d2a8f 100644 --- a/lib/providers/chat_provider.dart +++ b/lib/providers/chat_provider.dart @@ -4,7 +4,7 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:uuid/uuid.dart'; import '../brick/cache_helpers.dart'; -import '../models/chat_message.dart'; +import '../models/chat_message.model.dart'; import '../utils/app_time.dart'; import '../utils/snackbar.dart' show isOfflineSaveError; import 'connectivity_provider.dart'; @@ -42,21 +42,22 @@ final chatMessagesProvider = StreamProvider.family, String>(( byId.putIfAbsent(p.id, () => p); } 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( stream: client .from('chat_messages') .stream(primaryKey: ['id']) .eq('thread_id', threadId) - .order('created_at'), + .order('created_at', ascending: false), onPollData: () async { final data = await client .from('chat_messages') .select() .eq('thread_id', threadId) - .order('created_at'); + .order('created_at', ascending: false); return data.map(ChatMessage.fromMap).toList(); }, fromMap: ChatMessage.fromMap, @@ -64,10 +65,8 @@ final chatMessagesProvider = StreamProvider.family, String>(( onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, onOfflineData: () async { final all = await cachedListFromBrick(); - final filtered = all - .where((m) => m.threadId == threadId) - .toList() - ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + final filtered = all.where((m) => m.threadId == threadId).toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); return applyPending(filtered); }, onCacheMirror: (rows) => diff --git a/lib/providers/it_service_request_provider.dart b/lib/providers/it_service_request_provider.dart index fd2160dc..4886495d 100644 --- a/lib/providers/it_service_request_provider.dart +++ b/lib/providers/it_service_request_provider.dart @@ -9,10 +9,10 @@ import 'package:flutter/material.dart'; import 'package:uuid/uuid.dart'; import '../brick/cache_helpers.dart'; -import '../models/it_service_request.dart'; -import '../models/it_service_request_assignment.dart'; -import '../models/it_service_request_activity_log.dart'; -import '../models/it_service_request_action.dart'; +import '../models/it_service_request.model.dart'; +import '../models/it_service_request_assignment.model.dart'; +import '../models/it_service_request_activity_log.model.dart'; +import '../models/it_service_request_action.model.dart'; import '../utils/app_time.dart'; import '../utils/snackbar.dart' show isOfflineSaveError; import 'connectivity_provider.dart'; diff --git a/lib/providers/leave_provider.dart b/lib/providers/leave_provider.dart index 9e954281..c9f378d2 100644 --- a/lib/providers/leave_provider.dart +++ b/lib/providers/leave_provider.dart @@ -4,7 +4,7 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:uuid/uuid.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/snackbar.dart' show isOfflineSaveError; import 'connectivity_provider.dart'; diff --git a/lib/providers/network_map/network_devices_provider.dart b/lib/providers/network_map/network_devices_provider.dart index 29b15e1b..db8c3eac 100644 --- a/lib/providers/network_map/network_devices_provider.dart +++ b/lib/providers/network_map/network_devices_provider.dart @@ -8,7 +8,7 @@ import '../supabase_provider.dart'; final networkDevicesProvider = FutureProvider>((ref) async { final client = ref.watch(supabaseClientProvider); - final rows = await client.from('network_devices').select().order('name'); + final rows = await client.from('network_devices').select().order('name', ascending: true); return (rows as List) .map((r) => NetworkDevice.fromMap(r as Map)) .toList(); @@ -16,7 +16,7 @@ final networkDevicesProvider = FutureProvider>((ref) async { final networkPortsProvider = FutureProvider>((ref) async { final client = ref.watch(supabaseClientProvider); - final rows = await client.from('network_ports').select().order('port_number'); + final rows = await client.from('network_ports').select().order('port_number', ascending: true); return (rows as List) .map((r) => NetworkPort.fromMap(r as Map)) .toList(); @@ -32,7 +32,7 @@ final networkLinksProvider = FutureProvider>((ref) async { final networkVlansProvider = FutureProvider>((ref) async { final client = ref.watch(supabaseClientProvider); - final rows = await client.from('network_vlans').select().order('vlan_id'); + final rows = await client.from('network_vlans').select().order('vlan_id', ascending: true); return (rows as List) .map((r) => NetworkVlan.fromMap(r as Map)) .toList(); @@ -57,7 +57,7 @@ final networkPortsByDeviceProvider = .from('network_ports') .select() .eq('device_id', deviceId) - .order('port_number'); + .order('port_number', ascending: true); return (rows as List) .map((r) => NetworkPort.fromMap(r as Map)) .toList(); diff --git a/lib/providers/network_map/network_sites_provider.dart b/lib/providers/network_map/network_sites_provider.dart index 6ecea7c5..596a6e86 100644 --- a/lib/providers/network_map/network_sites_provider.dart +++ b/lib/providers/network_map/network_sites_provider.dart @@ -6,7 +6,7 @@ import '../supabase_provider.dart'; final networkSitesProvider = FutureProvider>((ref) async { final client = ref.watch(supabaseClientProvider); - final rows = await client.from('network_sites').select().order('name'); + final rows = await client.from('network_sites').select().order('name', ascending: true); return (rows as List) .map((r) => NetworkSite.fromMap(r as Map)) .toList(); @@ -14,7 +14,7 @@ final networkSitesProvider = FutureProvider>((ref) async { final networkLocationsProvider = FutureProvider>((ref) async { final client = ref.watch(supabaseClientProvider); - final rows = await client.from('network_locations').select().order('name'); + final rows = await client.from('network_locations').select().order('name', ascending: true); return (rows as List) .map((r) => NetworkLocation.fromMap(r as Map)) .toList(); diff --git a/lib/providers/notifications_provider.dart b/lib/providers/notifications_provider.dart index 3abaff49..5cbbe556 100644 --- a/lib/providers/notifications_provider.dart +++ b/lib/providers/notifications_provider.dart @@ -4,7 +4,7 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import '../utils/device_id.dart'; import '../brick/cache_helpers.dart'; -import '../models/notification_item.dart'; +import '../models/notification_item.model.dart'; import '../utils/app_time.dart'; import '../utils/snackbar.dart' show isOfflineSaveError; import 'connectivity_provider.dart'; diff --git a/lib/providers/pass_slip_provider.dart b/lib/providers/pass_slip_provider.dart index 8fc8ba64..f01b79b4 100644 --- a/lib/providers/pass_slip_provider.dart +++ b/lib/providers/pass_slip_provider.dart @@ -4,7 +4,7 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:uuid/uuid.dart'; import '../brick/cache_helpers.dart'; -import '../models/pass_slip.dart'; +import '../models/pass_slip.model.dart'; import '../utils/app_time.dart'; import '../utils/snackbar.dart' show isOfflineSaveError; import 'connectivity_provider.dart'; diff --git a/lib/providers/profile_provider.dart b/lib/providers/profile_provider.dart index 591c20eb..f70169e7 100644 --- a/lib/providers/profile_provider.dart +++ b/lib/providers/profile_provider.dart @@ -9,7 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../brick/cache_helpers.dart'; -import '../models/profile.dart'; +import '../models/profile.model.dart'; import '../utils/snackbar.dart' show isOfflineSaveError; import 'auth_provider.dart'; import 'connectivity_provider.dart'; @@ -165,9 +165,12 @@ final profilesProvider = StreamProvider>((ref) { stream: client .from('profiles') .stream(primaryKey: ['id']) - .order('full_name'), + .order('full_name', ascending: true), 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(); }, fromMap: Profile.fromMap, diff --git a/lib/providers/services_provider.dart b/lib/providers/services_provider.dart index 58146000..9a52b018 100644 --- a/lib/providers/services_provider.dart +++ b/lib/providers/services_provider.dart @@ -1,7 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../brick/cache_helpers.dart'; -import '../models/service.dart'; +import '../models/service.model.dart'; import 'supabase_provider.dart'; import 'stream_recovery.dart'; import 'realtime_controller.dart'; @@ -10,9 +10,15 @@ final servicesProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); final wrapper = StreamRecoveryWrapper( - stream: client.from('services').stream(primaryKey: ['id']).order('name'), + stream: client + .from('services') + .stream(primaryKey: ['id']) + .order('name', ascending: true), 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(); }, fromMap: Service.fromMap, @@ -33,7 +39,10 @@ final servicesProvider = StreamProvider>((ref) { final servicesOnceProvider = FutureProvider>((ref) async { 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) .map((r) => Service.fromMap(r as Map)) .toList(); diff --git a/lib/providers/stream_recovery.dart b/lib/providers/stream_recovery.dart index 38d7b1f6..e62ea33d 100644 --- a/lib/providers/stream_recovery.dart +++ b/lib/providers/stream_recovery.dart @@ -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:math' as math; import 'package:flutter/foundation.dart'; diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index fa9b32db..1d8e00e1 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -9,8 +9,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../brick/cache_helpers.dart'; import '../brick/repository.dart'; -import '../models/task.dart'; -import '../models/task_activity_log.dart'; +import '../models/task.model.dart'; +import '../models/task_activity_log.model.dart'; import 'connectivity_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; diff --git a/lib/providers/teams_provider.dart b/lib/providers/teams_provider.dart index e21d5558..a6b171bc 100644 --- a/lib/providers/teams_provider.dart +++ b/lib/providers/teams_provider.dart @@ -2,7 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../brick/cache_helpers.dart'; import '../brick/cache_warmer.dart'; -import '../models/team.dart'; +import '../models/team.model.dart'; import '../models/team_member.dart'; import 'realtime_controller.dart'; import 'stream_recovery.dart'; @@ -13,9 +13,15 @@ final teamsProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); final wrapper = StreamRecoveryWrapper( - stream: client.from('teams').stream(primaryKey: ['id']).order('name'), + stream: client + .from('teams') + .stream(primaryKey: ['id']) + .order('name', ascending: true), 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(); }, fromMap: Team.fromMap, diff --git a/lib/providers/tickets_provider.dart b/lib/providers/tickets_provider.dart index 95949420..84da74aa 100644 --- a/lib/providers/tickets_provider.dart +++ b/lib/providers/tickets_provider.dart @@ -10,8 +10,8 @@ import 'package:uuid/uuid.dart'; import '../brick/cache_helpers.dart'; import '../brick/repository.dart'; import '../utils/snackbar.dart' show isOfflineSaveError; -import '../models/office.dart'; -import '../models/ticket.dart'; +import '../models/office.model.dart'; +import '../models/ticket.model.dart'; import '../models/ticket_message.dart'; import 'profile_provider.dart'; import 'supabase_provider.dart'; @@ -39,9 +39,15 @@ final officesProvider = StreamProvider>((ref) { } final wrapper = StreamRecoveryWrapper( - stream: client.from('offices').stream(primaryKey: ['id']).order('name'), + stream: client + .from('offices') + .stream(primaryKey: ['id']) + .order('name', ascending: true), 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(); }, fromMap: Office.fromMap, @@ -94,7 +100,10 @@ final officesProvider = StreamProvider>((ref) { final officesOnceProvider = FutureProvider>((ref) async { 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) .map((row) => Office.fromMap(row as Map)) .toList(); diff --git a/lib/providers/user_offices_provider.dart b/lib/providers/user_offices_provider.dart index 06107402..28f6bd74 100644 --- a/lib/providers/user_offices_provider.dart +++ b/lib/providers/user_offices_provider.dart @@ -52,9 +52,9 @@ final userOfficesProvider = StreamProvider>((ref) { stream: client .from('user_offices') .stream(primaryKey: ['user_id', 'office_id']) - .order('created_at'), + .order('created_at', ascending: true), 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(); }, fromMap: UserOffice.fromMap, diff --git a/lib/providers/workforce_provider.dart b/lib/providers/workforce_provider.dart index c2cb16ca..3cecd442 100644 --- a/lib/providers/workforce_provider.dart +++ b/lib/providers/workforce_provider.dart @@ -7,8 +7,8 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import '../brick/cache_helpers.dart'; import '../models/app_settings.dart'; -import '../models/duty_schedule.dart'; -import '../models/swap_request.dart'; +import '../models/duty_schedule.model.dart'; +import '../models/swap_request.model.dart'; import 'profile_provider.dart'; import 'supabase_provider.dart'; import 'stream_recovery.dart'; @@ -65,12 +65,12 @@ final dutySchedulesProvider = StreamProvider>((ref) { stream: client .from('duty_schedules') .stream(primaryKey: ['id']) - .order('start_time'), + .order('start_time', ascending: true), onPollData: () async { final data = await client .from('duty_schedules') .select() - .order('start_time'); + .order('start_time', ascending: true); return data.map(DutySchedule.fromMap).toList(); }, fromMap: DutySchedule.fromMap, @@ -89,13 +89,16 @@ final dutySchedulesProvider = StreamProvider>((ref) { // Immediate poll so any changes that happened while this provider was // 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(); - // 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 // 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(); }); ref.onDispose(dutyRefreshTimer.cancel); @@ -135,7 +138,7 @@ final dutySchedulesForUserProvider = .eq('user_id', userId) /* exclude past schedules by ensuring the shift has not ended */ .gte('end_time', nowIso) - .order('start_time') + .order('start_time', ascending: true) as List; return rows .map((r) => DutySchedule.fromMap(r as Map)) @@ -201,13 +204,14 @@ final swapRequestsProvider = StreamProvider>((ref) { // Immediate poll: fetch fresh data right away so any status changes that // 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(); - // 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 // 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(); }); ref.onDispose(refreshTimer.cancel); diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 4967486c..6f8888cc 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -41,6 +41,9 @@ import '../theme/m3_motion.dart'; import '../utils/navigation.dart'; +Page Function(BuildContext, GoRouterState) _page(Widget child) => + (context, state) => M3SharedAxisPage(key: state.pageKey, child: child); + String _defaultRouteForRole(String? role) { switch (role) { case 'it_staff': @@ -100,14 +103,18 @@ final appRouterProvider = Provider((ref) { return _defaultRouteForRole(role); } // Deferred post-login redirect: profile loaded after the initial redirect - // (which fell back to /dashboard because role was null). Only fires once - // per sign-in and only when the user is still on /dashboard. - if (isSignedIn && - notifier._needsRoleRedirect && - role != null && - state.matchedLocation == '/dashboard') { + // (which fell back to /dashboard because role was null). Consumed once + // per sign-in; only redirects when the user is still on /dashboard. + if (isSignedIn && notifier._needsRoleRedirect && role != null) { 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) { return '/tickets'; @@ -145,31 +152,19 @@ final appRouterProvider = Provider((ref) { routes: [ GoRoute( path: '/settings/teams', - pageBuilder: (context, state) => M3SharedAxisPage( - key: state.pageKey, - child: const TeamsScreen(), - ), + pageBuilder: _page(const TeamsScreen()), ), GoRoute( path: '/settings/app-update', - pageBuilder: (context, state) => M3SharedAxisPage( - key: state.pageKey, - child: const AppUpdateScreen(), - ), + pageBuilder: _page(const AppUpdateScreen()), ), GoRoute( path: '/dashboard', - pageBuilder: (context, state) => M3SharedAxisPage( - key: state.pageKey, - child: const DashboardScreen(), - ), + pageBuilder: _page(const DashboardScreen()), ), GoRoute( path: '/tickets', - pageBuilder: (context, state) => M3SharedAxisPage( - key: state.pageKey, - child: const TicketsListScreen(), - ), + pageBuilder: _page(const TicketsListScreen()), routes: [ GoRoute( path: ':id', @@ -184,10 +179,7 @@ final appRouterProvider = Provider((ref) { ), GoRoute( path: '/tasks', - pageBuilder: (context, state) => M3SharedAxisPage( - key: state.pageKey, - child: const TasksListScreen(), - ), + pageBuilder: _page(const TasksListScreen()), routes: [ GoRoute( path: ':id', @@ -202,10 +194,7 @@ final appRouterProvider = Provider((ref) { ), GoRoute( path: '/it-service-requests', - pageBuilder: (context, state) => M3SharedAxisPage( - key: state.pageKey, - child: const ItServiceRequestsListScreen(), - ), + pageBuilder: _page(const ItServiceRequestsListScreen()), routes: [ GoRoute( path: ':id', @@ -220,80 +209,44 @@ final appRouterProvider = Provider((ref) { ), GoRoute( path: '/announcements', - pageBuilder: (context, state) => M3SharedAxisPage( - key: state.pageKey, - child: const AnnouncementsScreen(), - ), + pageBuilder: _page(const AnnouncementsScreen()), ), GoRoute( path: '/workforce', - pageBuilder: (context, state) => M3SharedAxisPage( - key: state.pageKey, - child: const WorkforceScreen(), - ), + pageBuilder: _page(const WorkforceScreen()), ), GoRoute( path: '/attendance', - pageBuilder: (context, state) => M3SharedAxisPage( - key: state.pageKey, - child: const AttendanceScreen(), - ), + pageBuilder: _page(const AttendanceScreen()), ), GoRoute( path: '/whereabouts', - pageBuilder: (context, state) => M3SharedAxisPage( - key: state.pageKey, - child: const WhereaboutsScreen(), - ), - ), - GoRoute( - path: '/reports', - pageBuilder: (context, state) => M3SharedAxisPage( - key: state.pageKey, - child: const ReportsScreen(), - ), + pageBuilder: _page(const WhereaboutsScreen()), ), + GoRoute(path: '/reports', pageBuilder: _page(const ReportsScreen())), GoRoute( path: '/settings/users', - pageBuilder: (context, state) => M3SharedAxisPage( - key: state.pageKey, - child: const UserManagementScreen(), - ), + pageBuilder: _page(const UserManagementScreen()), ), GoRoute( path: '/settings/offices', - pageBuilder: (context, state) => M3SharedAxisPage( - key: state.pageKey, - child: const OfficesScreen(), - ), + pageBuilder: _page(const OfficesScreen()), ), GoRoute( path: '/settings/geofence-test', - pageBuilder: (context, state) => M3SharedAxisPage( - key: state.pageKey, - child: const GeofenceTestScreen(), - ), + pageBuilder: _page(const GeofenceTestScreen()), ), GoRoute( path: '/settings/permissions', - pageBuilder: (context, state) => M3SharedAxisPage( - key: state.pageKey, - child: const PermissionsScreen(), - ), + pageBuilder: _page(const PermissionsScreen()), ), GoRoute( path: '/network-map', - pageBuilder: (context, state) => M3SharedAxisPage( - key: state.pageKey, - child: const NetworkMapOverviewScreen(), - ), + pageBuilder: _page(const NetworkMapOverviewScreen()), routes: [ GoRoute( path: 'import', - pageBuilder: (context, state) => M3SharedAxisPage( - key: state.pageKey, - child: const NetworkMapImportScreen(), - ), + pageBuilder: _page(const NetworkMapImportScreen()), routes: [ GoRoute( path: ':importId/review', @@ -308,10 +261,7 @@ final appRouterProvider = Provider((ref) { ), GoRoute( path: 'vlans', - pageBuilder: (context, state) => M3SharedAxisPage( - key: state.pageKey, - child: const NetworkMapVlanScreen(), - ), + pageBuilder: _page(const NetworkMapVlanScreen()), ), GoRoute( path: 'site/:siteId', @@ -357,18 +307,9 @@ final appRouterProvider = Provider((ref) { ), GoRoute( path: '/notifications', - pageBuilder: (context, state) => M3SharedAxisPage( - key: state.pageKey, - child: const NotificationsScreen(), - ), - ), - GoRoute( - path: '/profile', - pageBuilder: (context, state) => M3SharedAxisPage( - key: state.pageKey, - child: const ProfileScreen(), - ), + pageBuilder: _page(const NotificationsScreen()), ), + GoRoute(path: '/profile', pageBuilder: _page(const ProfileScreen())), ], ), ], diff --git a/lib/screens/admin/offices_screen.dart b/lib/screens/admin/offices_screen.dart index cf460a65..dda356e8 100644 --- a/lib/screens/admin/offices_screen.dart +++ b/lib/screens/admin/offices_screen.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../../theme/m3_motion.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../models/office.dart'; +import '../../models/office.model.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../providers/services_provider.dart'; diff --git a/lib/screens/admin/user_management_screen.dart b/lib/screens/admin/user_management_screen.dart index 927f8a10..cbdea12f 100644 --- a/lib/screens/admin/user_management_screen.dart +++ b/lib/screens/admin/user_management_screen.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; import '../../theme/m3_motion.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; -import '../../models/office.dart'; -import '../../models/profile.dart'; +import '../../models/office.model.dart'; +import '../../models/profile.model.dart'; import '../../models/ticket_message.dart'; import '../../models/user_office.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.inDays < 1) return '${diff.inHours}h ago'; if (diff.inDays < 7) return '${diff.inDays}d ago'; - final month = value.month.toString().padLeft(2, '0'); - final day = value.day.toString().padLeft(2, '0'); - return '${value.year}-$month-$day'; + return DateFormat('yyyy-MM-dd').format(value); } class _StatusBadge extends StatelessWidget { diff --git a/lib/screens/announcements/announcement_comments_section.dart b/lib/screens/announcements/announcement_comments_section.dart index 37452098..ffa4d96a 100644 --- a/lib/screens/announcements/announcement_comments_section.dart +++ b/lib/screens/announcements/announcement_comments_section.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.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/profile_provider.dart'; import '../../utils/app_time.dart'; @@ -49,10 +49,9 @@ class _AnnouncementCommentsSectionState // Comment was posted; only push notification delivery failed. _controller.clear(); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Comment posted, but notifications may not have been sent.'), - ), + showInfoSnackBar( + context, + 'Comment posted, but notifications may not have been sent.', ); } } catch (e) { @@ -225,7 +224,7 @@ class _CommentTile extends StatelessWidget { const SizedBox(width: 8), Flexible( child: Text( - _relativeTime(comment.createdAt), + AppTime.relative(comment.createdAt), style: tt.labelSmall ?.copyWith(color: cs.onSurfaceVariant), 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); -} diff --git a/lib/screens/announcements/announcements_screen.dart b/lib/screens/announcements/announcements_screen.dart index 5ea3e2bc..278d6c66 100644 --- a/lib/screens/announcements/announcements_screen.dart +++ b/lib/screens/announcements/announcements_screen.dart @@ -4,7 +4,7 @@ import 'dart:math' as math show min; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../models/announcement.dart'; +import '../../models/announcement.model.dart'; import '../../providers/announcements_provider.dart'; import '../../providers/profile_provider.dart'; import '../../theme/m3_motion.dart'; @@ -381,7 +381,7 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> { children: [ Flexible( child: Text( - _relativeTime(widget.announcement.createdAt), + AppTime.relative(widget.announcement.createdAt), style: tt.labelSmall ?.copyWith(color: cs.onSurfaceVariant), ), @@ -578,16 +578,6 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> { // 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) { const labels = { 'admin': 'Admin', diff --git a/lib/screens/announcements/create_announcement_dialog.dart b/lib/screens/announcements/create_announcement_dialog.dart index 0a81fd79..ff451610 100644 --- a/lib/screens/announcements/create_announcement_dialog.dart +++ b/lib/screens/announcements/create_announcement_dialog.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../models/announcement.dart'; +import '../../models/announcement.model.dart'; import '../../providers/announcements_provider.dart'; import '../../theme/m3_motion.dart'; import '../../utils/app_time.dart'; @@ -260,12 +260,9 @@ class _CreateAnnouncementContentState widget.editing != null ? 'Announcement updated.' : 'Announcement posted.'); } on AnnouncementNotificationException { if (mounted) { - final messenger = ScaffoldMessenger.of(context); Navigator.of(context).pop(); - messenger.showSnackBar( - const SnackBar( - content: Text('Posted, but some notifications failed to send.'), - ), + showInfoSnackBarGlobal( + 'Posted, but some notifications failed to send.', ); } } catch (e) { diff --git a/lib/screens/attendance/attendance_screen.dart b/lib/screens/attendance/attendance_screen.dart index 34d0dcbe..eacd2fc4 100644 --- a/lib/screens/attendance/attendance_screen.dart +++ b/lib/screens/attendance/attendance_screen.dart @@ -8,11 +8,11 @@ import 'package:geolocator/geolocator.dart'; import 'package:latlong2/latlong.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import '../../models/attendance_log.dart'; -import '../../models/duty_schedule.dart'; -import '../../models/leave_of_absence.dart'; -import '../../models/pass_slip.dart'; -import '../../models/profile.dart'; +import '../../models/attendance_log.model.dart'; +import '../../models/duty_schedule.model.dart'; +import '../../models/leave_of_absence.model.dart'; +import '../../models/pass_slip.model.dart'; +import '../../models/profile.model.dart'; import '../../models/rotation_config.dart'; import '../../providers/attendance_provider.dart'; import '../../providers/debug_settings_provider.dart'; @@ -26,8 +26,8 @@ import '../../providers/whereabouts_provider.dart'; import '../../providers/workforce_provider.dart'; import '../../providers/notifications_provider.dart'; import '../../providers/it_service_request_provider.dart'; -import '../../models/it_service_request.dart'; -import '../../models/swap_request.dart'; +import '../../models/it_service_request.model.dart'; +import '../../models/swap_request.model.dart'; import '../../providers/tasks_provider.dart'; import '../../providers/tickets_provider.dart'; import 'dtr_pdf.dart'; diff --git a/lib/screens/attendance/logbook_day_activity.dart b/lib/screens/attendance/logbook_day_activity.dart index 3bdf59b1..77a02a1e 100644 --- a/lib/screens/attendance/logbook_day_activity.dart +++ b/lib/screens/attendance/logbook_day_activity.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; -import '../../models/attendance_log.dart'; -import '../../models/it_service_request.dart'; -import '../../models/pass_slip.dart'; -import '../../models/task.dart'; -import '../../models/ticket.dart'; +import '../../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/m3_motion.dart'; import '../../utils/app_time.dart'; @@ -392,16 +393,8 @@ class LogbookDayActivitySheet extends ConsumerWidget { ); } - static String _formatDate(DateTime d) { - const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; - const months = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', - ]; - final wd = weekdays[d.weekday - 1]; - final mo = months[d.month - 1]; - return '$wd, $mo ${d.day}, ${d.year}'; - } + static String _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+')); diff --git a/lib/screens/attendance/work_log_tab.dart b/lib/screens/attendance/work_log_tab.dart index 2af935be..f003b07e 100644 --- a/lib/screens/attendance/work_log_tab.dart +++ b/lib/screens/attendance/work_log_tab.dart @@ -8,14 +8,14 @@ import 'package:intl/intl.dart'; import 'package:printing/printing.dart'; import 'package:share_plus/share_plus.dart'; -import '../../models/attendance_log.dart'; -import '../../models/it_service_request.dart'; -import '../../models/it_service_request_assignment.dart'; -import '../../models/pass_slip.dart'; -import '../../models/task.dart'; -import '../../models/task_activity_log.dart'; +import '../../models/attendance_log.model.dart'; +import '../../models/it_service_request.model.dart'; +import '../../models/it_service_request_assignment.model.dart'; +import '../../models/pass_slip.model.dart'; +import '../../models/task.model.dart'; +import '../../models/task_activity_log.model.dart'; import '../../models/task_assignment.dart'; -import '../../models/ticket.dart'; +import '../../models/ticket.model.dart'; import '../../providers/attendance_provider.dart'; import '../../providers/it_service_request_provider.dart'; import '../../providers/pass_slip_provider.dart'; @@ -24,6 +24,7 @@ import '../../providers/tasks_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../theme/m3_motion.dart'; import '../../utils/app_time.dart'; +import '../../utils/snackbar.dart'; import 'work_log_pdf.dart'; // ─── Domain types ──────────────────────────────────────────────────────────── @@ -674,9 +675,7 @@ class _WorkLogTabState extends ConsumerState { } } catch (e) { if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Export failed: $e')), - ); + showErrorSnackBar(context, 'Export failed: $e'); } } finally { entry.remove(); @@ -691,7 +690,6 @@ class _WorkLogTabState extends ConsumerState { String personName, String rangeLabel, ) async { - final messenger = ScaffoldMessenger.of(context); try { final range = _effectiveDateRange; final modeLabel = switch (_viewMode) { @@ -730,9 +728,7 @@ class _WorkLogTabState extends ConsumerState { ); } catch (e) { if (!context.mounted) return; - messenger.showSnackBar( - SnackBar(content: Text('Export failed: $e')), - ); + showErrorSnackBar(context, 'Export failed: $e'); } } @@ -862,7 +858,6 @@ class _WorkLogTabState extends ConsumerState { String personName, List<_DailySummaryData> multiDaySummaries, ) async { - final messenger = ScaffoldMessenger.of(context); try { late Uint8List bytes; if (!_isMultiDay || _isDrilling) { @@ -901,9 +896,7 @@ class _WorkLogTabState extends ConsumerState { ); } catch (e) { if (!context.mounted) return; - messenger.showSnackBar( - SnackBar(content: Text('Failed to generate PDF: $e')), - ); + showErrorSnackBar(context, 'Failed to generate PDF: $e'); } } diff --git a/lib/screens/auth/login_screen.dart b/lib/screens/auth/login_screen.dart index a5faed8a..c10d5fd3 100644 --- a/lib/screens/auth/login_screen.dart +++ b/lib/screens/auth/login_screen.dart @@ -72,9 +72,9 @@ class _LoginScreenState extends ConsumerState email: _emailController.text.trim(), password: _passwordController.text, ); - if (response.session != null && mounted) { - context.go('/tickets'); - } else if (mounted) { + // On success the router's auth redirect sends each role to its landing + // route (_defaultRouteForRole), so don't navigate here. + if (response.session == null && mounted) { showInfoSnackBar(context, 'Check your email to confirm sign-in.'); } } on Exception catch (error) { @@ -205,6 +205,9 @@ class _LoginScreenState extends ConsumerState labelText: 'Password', prefixIcon: const Icon(Icons.lock_outlined), suffixIcon: IconButton( + tooltip: _obscurePassword + ? 'Show password' + : 'Hide password', icon: Icon( _obscurePassword ? Icons.visibility_outlined diff --git a/lib/screens/auth/signup_screen.dart b/lib/screens/auth/signup_screen.dart index 70f2daab..f3922ce6 100644 --- a/lib/screens/auth/signup_screen.dart +++ b/lib/screens/auth/signup_screen.dart @@ -202,6 +202,9 @@ class _SignUpScreenState extends ConsumerState labelText: 'Password', prefixIcon: const Icon(Icons.lock_outlined), suffixIcon: IconButton( + tooltip: _obscurePassword + ? 'Show password' + : 'Hide password', icon: Icon( _obscurePassword ? Icons.visibility_outlined @@ -280,6 +283,9 @@ class _SignUpScreenState extends ConsumerState labelText: 'Confirm password', prefixIcon: const Icon(Icons.lock_outlined), suffixIcon: IconButton( + tooltip: _obscureConfirm + ? 'Show password' + : 'Hide password', icon: Icon( _obscureConfirm ? Icons.visibility_outlined diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart index 5af60d11..e7ab558f 100644 --- a/lib/screens/dashboard/dashboard_screen.dart +++ b/lib/screens/dashboard/dashboard_screen.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../theme/m3_motion.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 '../../models/attendance_log.dart'; -import '../../models/duty_schedule.dart'; -import '../../models/leave_of_absence.dart'; +import '../../models/attendance_log.model.dart'; +import '../../models/duty_schedule.model.dart'; +import '../../models/leave_of_absence.model.dart'; import '../../models/live_position.dart'; -import '../../models/pass_slip.dart'; -import '../../models/profile.dart'; -import '../../models/task.dart'; +import '../../models/pass_slip.model.dart'; +import '../../models/profile.model.dart'; +import '../../models/task.model.dart'; import '../../models/task_assignment.dart'; -import '../../models/ticket.dart'; +import '../../models/ticket.model.dart'; import '../../models/ticket_message.dart'; -import '../../models/it_service_request.dart'; -import '../../models/it_service_request_assignment.dart'; +import '../../models/it_service_request.model.dart'; +import '../../models/it_service_request_assignment.model.dart'; import '../../providers/attendance_provider.dart'; import '../../providers/leave_provider.dart'; import '../../providers/pass_slip_provider.dart'; @@ -27,7 +28,7 @@ import '../../providers/whereabouts_provider.dart'; import '../../providers/workforce_provider.dart'; import '../../providers/it_service_request_provider.dart'; import '../../providers/teams_provider.dart'; -import '../../models/team.dart'; +import '../../models/team.model.dart'; import '../../models/team_member.dart'; import 'dart:math' as math; import '../../widgets/responsive_body.dart'; @@ -407,10 +408,7 @@ final dashboardMetricsProvider = Provider>((ref) { AppTime.now().difference(livePos.updatedAt) > const Duration(minutes: 15); if (stale) { - final diff = AppTime.now().difference(livePos.updatedAt); - final ago = diff.inMinutes < 60 - ? '${diff.inMinutes}m ago' - : '${diff.inHours}h ago'; + final ago = AppTime.relative(livePos.updatedAt); whereabouts = livePos.inPremise ? 'Last seen in premise \u00b7 $ago' : 'Last seen outside \u00b7 $ago'; @@ -628,6 +626,8 @@ class _DashboardScreenState extends State { void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) async { + // Sound/vibration settings and openAppSettings() don't apply on web. + if (kIsWeb) return; final prefs = await SharedPreferences.getInstance(); final seen = prefs.getBool('has_seen_notif_showcase') ?? false; if (!seen) { @@ -1110,36 +1110,24 @@ class _StaffRow extends StatelessWidget { ); } - // IT Staff cell: avatar on mobile, name on desktop, with team color dot - Widget staffCell; - if (isMobile) { - staffCell = Row( - mainAxisSize: MainAxisSize.min, - children: [ - teamMarker, - const SizedBox(width: 4), - Flexible( - child: Tooltip( - message: row.name, - child: ProfileAvatar( - fullName: row.name, - avatarUrl: row.avatarUrl, - radius: 14, - ), - ), + // IT Staff cell: name (ellipsized on narrow screens) with team color dot. + // Mobile used initials-only avatars, which are ambiguous and need a + // long-press tooltip to identify anyone. + final staffCell = Row( + mainAxisSize: MainAxisSize.min, + children: [ + teamMarker, + const SizedBox(width: 6), + Flexible( + child: Text( + row.name, + style: valueStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ], - ); - } else { - staffCell = Row( - mainAxisSize: MainAxisSize.min, - children: [ - teamMarker, - const SizedBox(width: 6), - Flexible(child: Text(row.name, style: valueStyle)), - ], - ); - } + ), + ], + ); return Padding( padding: const EdgeInsets.symmetric(vertical: 6), diff --git a/lib/screens/it_service_requests/it_service_request_detail_screen.dart b/lib/screens/it_service_requests/it_service_request_detail_screen.dart index d1d83a8e..b795650a 100644 --- a/lib/screens/it_service_requests/it_service_request_detail_screen.dart +++ b/lib/screens/it_service_requests/it_service_request_detail_screen.dart @@ -9,12 +9,12 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; import 'package:skeletonizer/skeletonizer.dart'; -import '../../models/it_service_request.dart'; -import '../../models/it_service_request_assignment.dart'; -import '../../models/it_service_request_activity_log.dart'; -import '../../models/it_service_request_action.dart'; -import '../../models/office.dart'; -import '../../models/profile.dart'; +import '../../models/it_service_request.model.dart'; +import '../../models/it_service_request_assignment.model.dart'; +import '../../models/it_service_request_activity_log.model.dart'; +import '../../models/it_service_request_action.model.dart'; +import '../../models/office.model.dart'; +import '../../models/profile.model.dart'; import '../../providers/it_service_request_provider.dart'; import '../../providers/notifications_provider.dart'; import '../../providers/profile_provider.dart'; @@ -22,6 +22,7 @@ import '../../providers/supabase_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../services/ai_service.dart'; import '../../utils/app_time.dart'; +import '../../utils/debouncer.dart'; import '../../utils/snackbar.dart'; import '../../widgets/app_breakpoints.dart'; import '../../widgets/gemini_animated_text_field.dart'; @@ -85,7 +86,7 @@ class _ItServiceRequestDetailScreenState Timer? _eventDetailsDebounce; Timer? _remarksDebounce; - Timer? _actionDebounce; + final _actionDebounce = Debouncer(const Duration(milliseconds: 800)); String _eventDetailsLastPlain = ''; String _remarksLastPlain = ''; @@ -147,7 +148,7 @@ class _ItServiceRequestDetailScreenState _actionTakenController?.dispose(); _eventDetailsDebounce?.cancel(); _remarksDebounce?.cancel(); - _actionDebounce?.cancel(); + _actionDebounce.cancel(); _savedBadgeTimer?.cancel(); _saveAnimController.dispose(); _tabController.dispose(); @@ -1388,8 +1389,7 @@ class _ItServiceRequestDetailScreenState final plain = _actionTakenController!.document.toPlainText().trim(); if (plain == _actionLastPlain) return; _actionLastPlain = plain; - _actionDebounce?.cancel(); - _actionDebounce = Timer(const Duration(milliseconds: 800), () async { + _actionDebounce.run(() async { final delta = _actionTakenController!.document.toDelta(); final deltaJson = jsonEncode(delta.toJson()); if (mounted) { diff --git a/lib/screens/it_service_requests/it_service_request_pdf.dart b/lib/screens/it_service_requests/it_service_request_pdf.dart index bb595216..87199e99 100644 --- a/lib/screens/it_service_requests/it_service_request_pdf.dart +++ b/lib/screens/it_service_requests/it_service_request_pdf.dart @@ -8,10 +8,10 @@ import 'package:pdf/widgets.dart' as pw; import 'package:pdf/pdf.dart' as pdf; import 'package:printing/printing.dart'; -import '../../models/it_service_request.dart'; -import '../../models/it_service_request_assignment.dart'; -import '../../models/office.dart'; -import '../../models/profile.dart'; +import '../../models/it_service_request.model.dart'; +import '../../models/it_service_request_assignment.model.dart'; +import '../../models/office.model.dart'; +import '../../models/profile.model.dart'; import '../../utils/app_time.dart'; /// Build PDF bytes for IT Service Request Form. diff --git a/lib/screens/it_service_requests/it_service_requests_list_screen.dart b/lib/screens/it_service_requests/it_service_requests_list_screen.dart index 5bb01797..9b65c482 100644 --- a/lib/screens/it_service_requests/it_service_requests_list_screen.dart +++ b/lib/screens/it_service_requests/it_service_requests_list_screen.dart @@ -5,10 +5,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../models/it_service_request.dart'; -import '../../models/it_service_request_assignment.dart'; -import '../../models/office.dart'; -import '../../models/profile.dart'; +import '../../models/it_service_request.model.dart'; +import '../../models/it_service_request_assignment.model.dart'; +import '../../models/office.model.dart'; +import '../../models/profile.model.dart'; import '../../brick/cache_helpers.dart'; import '../../providers/it_service_request_provider.dart'; import '../../providers/profile_provider.dart'; @@ -430,9 +430,16 @@ class _ItServiceRequestsListScreenState onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel'), ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Create'), + // Disabled until named, so the dialog never closes and drops + // the user's input on a validation failure. + ValueListenableBuilder( + valueListenable: nameController, + builder: (context, value, child) => FilledButton( + onPressed: value.text.trim().isEmpty + ? null + : () => Navigator.pop(ctx, true), + child: const Text('Create'), + ), ), ], ); @@ -442,10 +449,6 @@ class _ItServiceRequestsListScreenState ); if (result != true || !context.mounted) return; - if (nameController.text.trim().isEmpty) { - showWarningSnackBar(context, 'Event name is required'); - return; - } try { final ctrl = ref.read(itServiceRequestControllerProvider); diff --git a/lib/screens/network_map/network_map_device_edit_screen.dart b/lib/screens/network_map/network_map_device_edit_screen.dart index 2eaf839a..baa6a2d0 100644 --- a/lib/screens/network_map/network_map_device_edit_screen.dart +++ b/lib/screens/network_map/network_map_device_edit_screen.dart @@ -112,6 +112,7 @@ class _NetworkMapDeviceEditScreenState decoration: const InputDecoration( labelText: 'Name (e.g. Floor 3, Rack U-12)', ), + onChanged: (_) => setLocal(() {}), ), const SizedBox(height: 12), DropdownButtonFormField( @@ -135,7 +136,11 @@ class _NetworkMapDeviceEditScreenState child: const Text('Cancel'), ), FilledButton( - onPressed: () => Navigator.pop(ctx, true), + // 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'), ), ], diff --git a/lib/screens/network_map/network_map_import_review_screen.dart b/lib/screens/network_map/network_map_import_review_screen.dart index 795f56ee..a126e90c 100644 --- a/lib/screens/network_map/network_map_import_review_screen.dart +++ b/lib/screens/network_map/network_map_import_review_screen.dart @@ -8,6 +8,7 @@ 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'; @@ -247,12 +248,11 @@ class NetworkMapImportReviewScreen extends ConsumerWidget { ); if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text( - 'Applied ${createdDeviceIds.length} devices, ' - '${createdLinkIds.length} links.', - ), - )); + showSuccessSnackBar( + context, + 'Applied ${createdDeviceIds.length} devices, ' + '${createdLinkIds.length} links.', + ); context.go('/network-map'); } } diff --git a/lib/screens/network_map/network_map_overview_screen.dart b/lib/screens/network_map/network_map_overview_screen.dart index 7a9681f4..e8174df9 100644 --- a/lib/screens/network_map/network_map_overview_screen.dart +++ b/lib/screens/network_map/network_map_overview_screen.dart @@ -6,6 +6,7 @@ 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_page_header.dart'; import '../../widgets/app_state_view.dart'; import '../../widgets/responsive_body.dart'; @@ -276,9 +277,7 @@ class _UnassignedSection extends ConsumerWidget { ) async { final sites = ref.read(networkSitesProvider).valueOrNull ?? const []; if (sites.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Create a site first.')), - ); + showWarningSnackBar(context, 'Create a site first.'); return; } String? picked = sites.first.id; @@ -321,9 +320,7 @@ class _UnassignedSection extends ConsumerWidget { } if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Moved ${devices.length} devices.')), - ); + showSuccessSnackBar(context, 'Moved ${devices.length} devices.'); } } } diff --git a/lib/screens/network_map/network_map_vlan_screen.dart b/lib/screens/network_map/network_map_vlan_screen.dart index f8e851de..ea601beb 100644 --- a/lib/screens/network_map/network_map_vlan_screen.dart +++ b/lib/screens/network_map/network_map_vlan_screen.dart @@ -98,40 +98,56 @@ class NetworkMapVlanScreen extends ConsumerWidget { final descCtrl = TextEditingController(); final ok = await showDialog( context: context, - builder: (ctx) => AlertDialog( - title: const Text('New VLAN'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: idCtrl, - keyboardType: TextInputType.number, - decoration: const InputDecoration(labelText: 'VLAN ID (1-4094)'), - autofocus: true, + 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)', + ), + ), + ], ), - const SizedBox(height: 8), - TextField( - controller: nameCtrl, - decoration: const InputDecoration(labelText: 'Name'), - ), - const SizedBox(height: 8), - TextField( - controller: descCtrl, - decoration: - const InputDecoration(labelText: 'Description (optional)'), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Create'), - ), - ], + 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()); diff --git a/lib/screens/network_map/widgets/link_edit_dialog.dart b/lib/screens/network_map/widgets/link_edit_dialog.dart index b319ab27..55ff1d45 100644 --- a/lib/screens/network_map/widgets/link_edit_dialog.dart +++ b/lib/screens/network_map/widgets/link_edit_dialog.dart @@ -4,6 +4,7 @@ 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. /// @@ -69,12 +70,7 @@ class _LinkEditDialogState extends ConsumerState<_LinkEditDialog> { if (mounted) Navigator.of(context).pop(true); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to create link: $e'), - backgroundColor: Theme.of(context).colorScheme.error, - ), - ); + showErrorSnackBar(context, 'Failed to create link: $e'); } } finally { if (mounted) setState(() => _saving = false); diff --git a/lib/screens/network_map/widgets/topology_legend.dart b/lib/screens/network_map/widgets/topology_legend.dart index e2c93ff8..ee6d6100 100644 --- a/lib/screens/network_map/widgets/topology_legend.dart +++ b/lib/screens/network_map/widgets/topology_legend.dart @@ -128,7 +128,7 @@ class _TopologyLegendState extends State opacity: _fade, child: SizeTransition( sizeFactor: _fade, - axisAlignment: -1, + alignment: const Alignment(-1.0, -1.0), child: Padding( padding: const EdgeInsets.only(top: 6), child: Material( diff --git a/lib/screens/notifications/notifications_screen.dart b/lib/screens/notifications/notifications_screen.dart index 6b48c0fc..825e548d 100644 --- a/lib/screens/notifications/notifications_screen.dart +++ b/lib/screens/notifications/notifications_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:permission_handler/permission_handler.dart'; -import '../../models/notification_item.dart'; +import '../../models/notification_item.model.dart'; import '../../services/notification_service.dart'; import '../../providers/notifications_provider.dart'; import '../../providers/profile_provider.dart'; diff --git a/lib/screens/profile/profile_screen.dart b/lib/screens/profile/profile_screen.dart index 862117fb..b740430c 100644 --- a/lib/screens/profile/profile_screen.dart +++ b/lib/screens/profile/profile_screen.dart @@ -2,8 +2,9 @@ import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:intl/intl.dart'; -import '../../models/office.dart'; +import '../../models/office.model.dart'; import '../../providers/auth_provider.dart' show sessionProvider; import '../../providers/profile_provider.dart'; import '../../providers/tickets_provider.dart'; @@ -426,9 +427,7 @@ class _ProfileScreenState extends ConsumerState { ); } - String _formatDate(DateTime dt) { - return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; - } + String _formatDate(DateTime dt) => DateFormat('yyyy-MM-dd').format(dt); Future _pickAvatar(ImageSource source) async { try { diff --git a/lib/screens/reports/reports_screen.dart b/lib/screens/reports/reports_screen.dart index 48f5abf7..88e70e55 100644 --- a/lib/screens/reports/reports_screen.dart +++ b/lib/screens/reports/reports_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../theme/m3_motion.dart'; import '../../providers/reports_provider.dart'; +import '../../utils/snackbar.dart'; import '../../widgets/app_page_header.dart'; import 'report_date_filter.dart'; import 'report_widget_selector.dart'; @@ -59,9 +60,7 @@ class _ReportsScreenState extends ConsumerState { await ReportPdfExport.sharePdf(pdfBytes); } catch (e) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('PDF export failed: $e'))); + showErrorSnackBar(context, 'PDF export failed: $e'); } } finally { if (mounted) setState(() => _exporting = false); diff --git a/lib/screens/tasks/it_job_checklist_tab.dart b/lib/screens/tasks/it_job_checklist_tab.dart index ce1d32b4..f2a99c11 100644 --- a/lib/screens/tasks/it_job_checklist_tab.dart +++ b/lib/screens/tasks/it_job_checklist_tab.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; -import '../../models/task.dart'; +import '../../models/task.model.dart'; import '../../models/task_assignment.dart'; import '../../providers/notifications_provider.dart'; import '../../providers/profile_provider.dart'; diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index f75f6045..b6979c0a 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -4,13 +4,13 @@ import '../../theme/m3_motion.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../models/profile.dart'; -import '../../models/task.dart'; +import '../../models/profile.model.dart'; +import '../../models/task.model.dart'; import '../../models/task_assignment.dart'; -import '../../models/task_activity_log.dart'; -import '../../models/ticket.dart'; +import '../../models/task_activity_log.model.dart'; +import '../../models/ticket.model.dart'; import '../../models/ticket_message.dart'; -import '../../models/office.dart'; +import '../../models/office.model.dart'; import '../../providers/notifications_provider.dart'; import 'dart:async'; import 'dart:convert'; @@ -27,6 +27,7 @@ import '../../providers/typing_provider.dart'; import '../../providers/realtime_controller.dart'; import 'package:skeletonizer/skeletonizer.dart'; import '../../utils/app_time.dart'; +import '../../utils/debouncer.dart'; import '../../utils/snackbar.dart'; import '../../utils/subject_suggestions.dart'; import '../../widgets/app_breakpoints.dart'; @@ -100,16 +101,16 @@ class _TaskDetailScreenState extends ConsumerState final _receivedController = TextEditingController(); // Rich text editor for Action taken quill.QuillController? _actionController; - Timer? _actionDebounce; + final _actionDebounce = Debouncer(const Duration(milliseconds: 700)); /// Tracks the last plain-text snapshot so the addListener callback can /// distinguish real edits from cursor / selection / focus notifications. String _actionLastPlain = ''; late final FocusNode _actionFocusNode; late final ScrollController _actionScrollController; - Timer? _requestedDebounce; - Timer? _notedDebounce; - Timer? _receivedDebounce; + final _requestedDebounce = Debouncer(const Duration(milliseconds: 700)); + final _notedDebounce = Debouncer(const Duration(milliseconds: 700)); + final _receivedDebounce = Debouncer(const Duration(milliseconds: 700)); // Seeding/state tracking for signatory fields String? _seededTaskId; bool _requestedSaving = false; @@ -173,10 +174,10 @@ class _TaskDetailScreenState extends ConsumerState _requestedController.dispose(); _notedController.dispose(); _receivedController.dispose(); - _requestedDebounce?.cancel(); - _notedDebounce?.cancel(); - _receivedDebounce?.cancel(); - _actionDebounce?.cancel(); + _requestedDebounce.cancel(); + _notedDebounce.cancel(); + _receivedDebounce.cancel(); + _actionDebounce.cancel(); _elapsedTicker?.cancel(); _actionController?.dispose(); _actionFocusNode.dispose(); @@ -242,8 +243,9 @@ class _TaskDetailScreenState extends ConsumerState final pending = ref.read(offlinePendingTasksProvider); if (pending.isEmpty) return; final liveIds = live.map((t) => t.id).toSet(); - final stillPending = - pending.where((t) => !liveIds.contains(t.id)).toList(); + final stillPending = pending + .where((t) => !liveIds.contains(t.id)) + .toList(); if (stillPending.length != pending.length) { ref.read(offlinePendingTasksProvider.notifier).state = stillPending; } @@ -270,8 +272,9 @@ class _TaskDetailScreenState extends ConsumerState ); final showAssign = canAssign && task.status != 'completed'; final liveAssignments = assignmentsAsync.valueOrNull ?? []; - final pendingAssignUserIds = - ref.watch(offlinePendingAssignmentsProvider)[task.id]; + final pendingAssignUserIds = ref.watch( + offlinePendingAssignmentsProvider, + )[task.id]; final assignments = pendingAssignUserIds != null ? [ ...liveAssignments.where((a) => a.taskId != task.id), @@ -322,7 +325,8 @@ class _TaskDetailScreenState extends ConsumerState senderId: payload['sender_id'] as String?, content: payload['content'] as String? ?? '', createdAt: payload['created_at'] != null - ? DateTime.tryParse(payload['created_at'] as String) ?? DateTime.now() + ? DateTime.tryParse(payload['created_at'] as String) ?? + DateTime.now() : DateTime.now(), ); }); @@ -371,7 +375,7 @@ class _TaskDetailScreenState extends ConsumerState // Seed action taken plain text controller from persisted JSON or raw text try { - _actionDebounce?.cancel(); + _actionDebounce.cancel(); _actionController?.dispose(); if (task.actionTaken != null && task.actionTaken!.isNotEmpty) { try { @@ -407,48 +411,44 @@ class _TaskDetailScreenState extends ConsumerState if (currentPlain == _actionLastPlain) return; // no text change _actionLastPlain = currentPlain; - _actionDebounce?.cancel(); - _actionDebounce = Timer( - const Duration(milliseconds: 700), - () async { - if (!mounted) return; - final plain = currentPlain; + _actionDebounce.run(() async { + if (!mounted) return; + final plain = currentPlain; + if (mounted) { + setState(() { + _actionSaving = true; + _actionSaved = false; + }); + } + try { + final deltaJson = jsonEncode( + _actionController?.document.toDelta().toJson(), + ); + await ref + .read(tasksControllerProvider) + .updateTask(taskId: task.id, actionTaken: deltaJson); if (mounted) { setState(() { - _actionSaving = true; - _actionSaved = false; + _actionSaved = plain.isNotEmpty; }); } - try { - final deltaJson = jsonEncode( - _actionController?.document.toDelta().toJson(), - ); - await ref - .read(tasksControllerProvider) - .updateTask(taskId: task.id, actionTaken: deltaJson); - if (mounted) { - setState(() { - _actionSaved = plain.isNotEmpty; - }); - } - } catch (e) { - debugPrint('[TasQ] action-taken auto-save error: $e'); - } finally { - if (mounted) { - setState(() { - _actionSaving = false; - }); - } - if (_actionSaved) { - Future.delayed(const Duration(seconds: 2), () { - if (mounted) { - setState(() => _actionSaved = false); - } - }); - } + } catch (e) { + debugPrint('[TasQ] action-taken auto-save error: $e'); + } finally { + if (mounted) { + setState(() { + _actionSaving = false; + }); } - }, - ); + if (_actionSaved) { + Future.delayed(const Duration(seconds: 2), () { + if (mounted) { + setState(() => _actionSaved = false); + } + }); + } + } + }); }); } @@ -462,10 +462,9 @@ class _TaskDetailScreenState extends ConsumerState // ── Hero zone ──────────────────────────────────────────────── Container( decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .primaryContainer - .withValues(alpha: 0.12), + color: Theme.of( + context, + ).colorScheme.primaryContainer.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(12), ), padding: const EdgeInsets.fromLTRB(16, 12, 8, 12), @@ -480,16 +479,11 @@ class _TaskDetailScreenState extends ConsumerState task.title.isNotEmpty ? task.title : 'Task ${task.taskNumber ?? task.id}', - style: Theme.of(context) - .textTheme - .headlineMedium + style: Theme.of(context).textTheme.headlineMedium ?.copyWith(fontWeight: FontWeight.w700), ), ), - SyncPendingBadge( - isPending: isPending, - compact: true, - ), + SyncPendingBadge(isPending: isPending, compact: true), const SizedBox(width: 4), Builder( builder: (ctx) { @@ -519,9 +513,9 @@ class _TaskDetailScreenState extends ConsumerState _createdByLabel(profilesAsync, task, ticket), style: Theme.of(context).textTheme.labelMedium ?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, ), ), ], @@ -653,338 +647,711 @@ class _TaskDetailScreenState extends ConsumerState child: Material( color: Theme.of(context).colorScheme.surfaceContainerLow, child: ExpansionTile( - title: const Text('Details'), - initiallyExpanded: isWide, - childrenPadding: const EdgeInsets.symmetric(horizontal: 0), - children: [ - DefaultTabController( - length: 5, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TabBar( - labelColor: Theme.of(context).colorScheme.onSurface, - indicatorColor: Theme.of( - context, - ).colorScheme.primary, - tabs: isWide - ? const [ - Tab(text: 'Assignees'), - Tab(text: 'Type & Category'), - Tab(text: 'Signatories'), - Tab(text: 'Action taken'), - Tab(text: 'Attachments'), - ] - : const [ - Tab(icon: Icon(Icons.person)), - Tab(icon: Icon(Icons.category)), - Tab(icon: Icon(Icons.check_circle)), - Tab(icon: Icon(Icons.description)), - Tab(icon: Icon(Icons.attach_file)), - ], - ), - const SizedBox(height: 8), - SizedBox( - height: isWide ? 360 : 300, - child: TabBarView( - children: [ - // Assignees (Tab 1) - Stack( + title: const Text('Details'), + initiallyExpanded: isWide, + childrenPadding: const EdgeInsets.symmetric( + horizontal: 0, + ), + children: [ + DefaultTabController( + length: 5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TabBar( + labelColor: Theme.of( + context, + ).colorScheme.onSurface, + indicatorColor: Theme.of( + context, + ).colorScheme.primary, + tabs: isWide + ? const [ + Tab(text: 'Assignees'), + Tab(text: 'Type & Category'), + Tab(text: 'Signatories'), + Tab(text: 'Action taken'), + Tab(text: 'Attachments'), + ] + : const [ + Tab(icon: Icon(Icons.person)), + Tab(icon: Icon(Icons.category)), + Tab(icon: Icon(Icons.check_circle)), + Tab(icon: Icon(Icons.description)), + Tab(icon: Icon(Icons.attach_file)), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: isWide ? 360 : 300, + child: TabBarView( children: [ - Positioned.fill( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.only( - top: 8.0, - bottom: 92, - ), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - TaskAssignmentSection( - taskId: task.id, - canAssign: showAssign, + // Assignees (Tab 1) + Stack( + children: [ + Positioned.fill( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only( + top: 8.0, + bottom: 92, ), - const SizedBox(height: 12), - const SizedBox.shrink(), - ], + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + TaskAssignmentSection( + taskId: task.id, + canAssign: showAssign, + ), + const SizedBox(height: 12), + const SizedBox.shrink(), + ], + ), + ), ), ), - ), - ), - if (canUpdateStatus && - task.status == 'in_progress') - Positioned( - right: 8, - bottom: 8, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment.end, - children: [ - IconButton.filledTonal( - tooltip: isTaskPaused - ? 'Resume task' - : 'Pause task', - onPressed: _pauseActionInFlight - ? null - : () async { - setState( - () => - _pauseActionInFlight = - true, - ); - try { - if (isTaskPaused) { - await ref - .read( - tasksControllerProvider, - ) - .resumeTask( - taskId: task.id, - ); - ref.invalidate( - taskActivityLogsProvider( - task.id, - ), - ); - if (mounted) { - showSuccessSnackBarGlobal( - 'Task resumed', - ); - } - } else { - await ref - .read( - tasksControllerProvider, - ) - .pauseTask( - taskId: task.id, - ); - ref.invalidate( - taskActivityLogsProvider( - task.id, - ), - ); - if (mounted) { - showInfoSnackBarGlobal( - 'Task paused', - ); - } - } - } catch (e) { - if (mounted) { - showErrorSnackBarGlobal( - e.toString(), - ); - } - } finally { - if (mounted) { + if (canUpdateStatus && + task.status == 'in_progress') + Positioned( + right: 8, + bottom: 8, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.end, + children: [ + IconButton.filledTonal( + tooltip: isTaskPaused + ? 'Resume task' + : 'Pause task', + onPressed: + _pauseActionInFlight + ? null + : () async { setState( () => _pauseActionInFlight = - false, + true, ); - } - } - }, - icon: _pauseActionInFlight - ? const SizedBox( - width: 18, - height: 18, - child: - CircularProgressIndicator( - strokeWidth: 2, + try { + if (isTaskPaused) { + await ref + .read( + tasksControllerProvider, + ) + .resumeTask( + taskId: + task.id, + ); + ref.invalidate( + taskActivityLogsProvider( + task.id, + ), + ); + if (mounted) { + showSuccessSnackBarGlobal( + 'Task resumed', + ); + } + } else { + await ref + .read( + tasksControllerProvider, + ) + .pauseTask( + taskId: + task.id, + ); + ref.invalidate( + taskActivityLogsProvider( + task.id, + ), + ); + if (mounted) { + showInfoSnackBarGlobal( + 'Task paused', + ); + } + } + } catch (e) { + if (mounted) { + showErrorSnackBarGlobal( + e.toString(), + ); + } + } finally { + if (mounted) { + setState( + () => + _pauseActionInFlight = + false, + ); + } + } + }, + icon: _pauseActionInFlight + ? const SizedBox( + width: 18, + height: 18, + child: + CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Icon( + isTaskPaused + ? Icons.play_arrow + : Icons.pause, + ), + ), + const SizedBox(height: 4), + Text( + 'Elapsed ${_formatDurationClock(elapsedDuration)}', + style: Theme.of( + context, + ).textTheme.labelSmall, + ), + ], + ), + ), + ], + ), + + // Type & Category + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only( + top: 8.0, + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + if (!canUpdateStatus) ...[ + _MetaBadge( + label: 'Type', + value: + task.requestType ?? 'None', + ), + const SizedBox(height: 8), + _MetaBadge( + label: 'Category', + value: + task.requestCategory ?? + 'None', + ), + ] else ...[ + const Text('Type'), + const SizedBox(height: 6), + DropdownButtonFormField( + initialValue: task.requestType, + decoration: InputDecoration( + suffixIcon: _typeSaving + ? SizedBox( + width: 16, + height: 16, + child: + ScaleTransition( + scale: + _savePulse, + child: + const Icon( + Icons + .save, + size: 14, + ), + ), + ) + : _typeSaved + ? SizedBox( + width: 16, + height: 16, + child: Stack( + alignment: Alignment + .center, + children: const [ + Icon( + Icons.save, + size: 14, + color: Colors + .green, + ), + Positioned( + right: -2, + bottom: -2, + child: Icon( + Icons.check, + size: 10, + color: Colors + .white, + ), + ), + ], ), - ) - : Icon( - isTaskPaused - ? Icons.play_arrow - : Icons.pause, + ) + : null, + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('None'), + ), + for (final t + in requestTypeOptions) + DropdownMenuItem( + value: t, + child: Text(t), ), - ), - const SizedBox(height: 4), - Text( - 'Elapsed ${_formatDurationClock(elapsedDuration)}', - style: Theme.of( - context, - ).textTheme.labelSmall, - ), + ], + onChanged: (v) async { + setState(() { + _typeSaving = true; + _typeSaved = false; + }); + try { + await ref + .read( + tasksControllerProvider, + ) + .updateTask( + taskId: task.id, + requestType: v, + ); + setState( + () => _typeSaved = + v != null && + v.isNotEmpty, + ); + } catch (_) { + } finally { + setState( + () => _typeSaving = false, + ); + if (_typeSaved) { + Future.delayed( + const Duration( + seconds: 2, + ), + () { + if (mounted) { + setState( + () => _typeSaved = + false, + ); + } + }, + ); + } + } + }, + ), + if (task.requestType == + 'Other') ...[ + const SizedBox(height: 8), + TextFormField( + initialValue: + task.requestTypeOther, + decoration: InputDecoration( + hintText: 'Details', + suffixIcon: _typeSaving + ? SizedBox( + width: 16, + height: 16, + child: ScaleTransition( + scale: _savePulse, + child: const Icon( + Icons.save, + size: 14, + ), + ), + ) + : _typeSaved + ? SizedBox( + width: 16, + height: 16, + child: Stack( + alignment: + Alignment + .center, + children: const [ + Icon( + Icons.save, + size: 14, + color: Colors + .green, + ), + Positioned( + right: -2, + bottom: -2, + child: Icon( + Icons.check, + size: 10, + color: Colors + .white, + ), + ), + ], + ), + ) + : null, + ), + onChanged: (text) async { + setState(() { + _typeSaving = true; + _typeSaved = false; + }); + try { + await ref + .read( + tasksControllerProvider, + ) + .updateTask( + taskId: task.id, + requestTypeOther: + text.isEmpty + ? null + : text, + ); + setState( + () => _typeSaved = + text.isNotEmpty, + ); + } catch (_) { + } finally { + setState( + () => + _typeSaving = false, + ); + if (_typeSaved) { + Future.delayed( + const Duration( + seconds: 2, + ), + () { + if (mounted) { + setState( + () => + _typeSaved = + false, + ); + } + }, + ); + } + } + }, + ), + ], + const SizedBox(height: 8), + const Text('Category'), + const SizedBox(height: 6), + DropdownButtonFormField( + initialValue: + task.requestCategory, + decoration: InputDecoration( + suffixIcon: _categorySaving + ? SizedBox( + width: 16, + height: 16, + child: + ScaleTransition( + scale: + _savePulse, + child: + const Icon( + Icons + .save, + size: 14, + ), + ), + ) + : _categorySaved + ? SizedBox( + width: 16, + height: 16, + child: Stack( + alignment: Alignment + .center, + children: const [ + Icon( + Icons.save, + size: 14, + color: Colors + .green, + ), + Positioned( + right: -2, + bottom: -2, + child: Icon( + Icons.check, + size: 10, + color: Colors + .white, + ), + ), + ], + ), + ) + : null, + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('None'), + ), + for (final c + in requestCategoryOptions) + DropdownMenuItem( + value: c, + child: Text(c), + ), + ], + onChanged: (v) async { + setState(() { + _categorySaving = true; + _categorySaved = false; + }); + try { + await ref + .read( + tasksControllerProvider, + ) + .updateTask( + taskId: task.id, + requestCategory: v, + ); + setState( + () => _categorySaved = + v != null && + v.isNotEmpty, + ); + } catch (_) { + } finally { + setState( + () => _categorySaving = + false, + ); + if (_categorySaved) { + Future.delayed( + const Duration( + seconds: 2, + ), + () { + if (mounted) { + setState( + () => + _categorySaved = + false, + ); + } + }, + ); + } + } + }, + ), + ], + const SizedBox(height: 12), ], ), ), - ], - ), + ), - // Type & Category - SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - if (!canUpdateStatus) ...[ - _MetaBadge( - label: 'Type', - value: task.requestType ?? 'None', - ), - const SizedBox(height: 8), - _MetaBadge( - label: 'Category', - value: - task.requestCategory ?? 'None', - ), - ] else ...[ - const Text('Type'), - const SizedBox(height: 6), - DropdownButtonFormField( - initialValue: task.requestType, - decoration: InputDecoration( - suffixIcon: _typeSaving - ? SizedBox( - width: 16, - height: 16, - child: ScaleTransition( - scale: _savePulse, - child: const Icon( - Icons.save, - size: 14, - ), - ), - ) - : _typeSaved - ? SizedBox( - width: 16, - height: 16, - child: Stack( - alignment: - Alignment.center, - children: const [ - Icon( - Icons.save, - size: 14, - color: Colors.green, - ), - Positioned( - right: -2, - bottom: -2, - child: Icon( - Icons.check, - size: 10, - color: - Colors.white, - ), - ), - ], - ), - ) - : null, + // Signatories (editable) + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only( + top: 8.0, + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'Requested by', + style: Theme.of( + context, + ).textTheme.bodySmall, ), - items: [ - const DropdownMenuItem( - value: null, - child: Text('None'), - ), - for (final t - in requestTypeOptions) - DropdownMenuItem( - value: t, - child: Text(t), + const SizedBox(height: 6), + TypeAheadFormField( + textFieldConfiguration: TextFieldConfiguration( + controller: + _requestedController, + decoration: InputDecoration( + hintText: + 'Requester name or id', + suffixIcon: _requestedSaving + ? SizedBox( + width: 16, + height: 16, + child: + ScaleTransition( + scale: + _savePulse, + child: + const Icon( + Icons + .save, + size: 14, + ), + ), + ) + : _requestedSaved + ? SizedBox( + width: 16, + height: 16, + child: Stack( + alignment: Alignment + .center, + children: const [ + Icon( + Icons.save, + size: 14, + color: Colors + .green, + ), + Positioned( + right: -2, + bottom: -2, + child: Icon( + Icons.check, + size: 10, + color: Colors + .white, + ), + ), + ], + ), + ) + : null, ), - ], - onChanged: (v) async { - setState(() { - _typeSaving = true; - _typeSaved = false; - }); - try { - await ref - .read( - tasksControllerProvider, - ) - .updateTask( - taskId: task.id, - requestType: v, - ); - setState( - () => _typeSaved = - v != null && v.isNotEmpty, - ); - } catch (_) { - } finally { - setState( - () => _typeSaving = false, - ); - if (_typeSaved) { - Future.delayed( - const Duration(seconds: 2), - () { - if (mounted) { - setState( - () => _typeSaved = - false, + onChanged: (v) { + _requestedDebounce.run(() async { + final name = v.trim(); + setState(() { + _requestedSaving = true; + _requestedSaved = false; + }); + try { + await ref + .read( + tasksControllerProvider, + ) + .updateTask( + taskId: task.id, + requestedBy: + name.isEmpty + ? null + : name, + ); + if (name.isNotEmpty) { + try { + await ref + .read( + supabaseClientProvider, + ) + .from('clients') + .upsert({ + 'name': name, + }); + } catch (_) {} + } + setState(() { + _requestedSaved = + name.isNotEmpty; + }); + } catch (_) { + } finally { + setState(() { + _requestedSaving = + false; + }); + if (_requestedSaved) { + Future.delayed( + const Duration( + seconds: 2, + ), + () { + if (mounted) { + setState( + () => + _requestedSaved = + false, + ); + } + }, ); } - }, - ); - } - } - }, - ), - if (task.requestType == 'Other') ...[ - const SizedBox(height: 8), - TextFormField( - initialValue: - task.requestTypeOther, - decoration: InputDecoration( - hintText: 'Details', - suffixIcon: _typeSaving - ? SizedBox( - width: 16, - height: 16, - child: ScaleTransition( - scale: _savePulse, - child: const Icon( - Icons.save, - size: 14, - ), - ), - ) - : _typeSaved - ? SizedBox( - width: 16, - height: 16, - child: Stack( - alignment: - Alignment.center, - children: const [ - Icon( - Icons.save, - size: 14, - color: - Colors.green, - ), - Positioned( - right: -2, - bottom: -2, - child: Icon( - Icons.check, - size: 10, - color: Colors - .white, - ), - ), - ], - ), - ) - : null, + } + }); + }, ), - onChanged: (text) async { + suggestionsCallback: (pattern) async { + final profiles = + ref + .watch(profilesProvider) + .valueOrNull ?? + []; + final fromProfiles = profiles + .map( + (p) => p.fullName.isEmpty + ? p.id + : p.fullName, + ) + .where( + (n) => n + .toLowerCase() + .contains( + pattern + .toLowerCase(), + ), + ) + .toList(); + try { + final clientRows = await ref + .read( + supabaseClientProvider, + ) + .from('clients') + .select('name') + .ilike( + 'name', + '%$pattern%', + ); + final clientNames = + (clientRows + as List?) + ?.map( + (r) => + r['name'] + as String, + ) + .whereType() + .toList() ?? + []; + final merged = { + ...fromProfiles, + ...clientNames, + }.toList(); + return merged; + } catch (_) { + return fromProfiles; + } + }, + itemBuilder: + (context, suggestion) => + ListTile( + title: Text(suggestion), + ), + onSuggestionSelected: (suggestion) async { + _requestedDebounce.cancel(); + _requestedController.text = + suggestion; setState(() { - _typeSaving = true; - _typeSaved = false; + _requestedSaving = true; + _requestedSaved = false; }); try { await ref @@ -993,21 +1360,34 @@ class _TaskDetailScreenState extends ConsumerState ) .updateTask( taskId: task.id, - requestTypeOther: - text.isEmpty + requestedBy: + suggestion.isEmpty ? null - : text, + : suggestion, ); + if (suggestion.isNotEmpty) { + try { + await ref + .read( + supabaseClientProvider, + ) + .from('clients') + .upsert({ + 'name': suggestion, + }); + } catch (_) {} + } setState( - () => _typeSaved = - text.isNotEmpty, + () => _requestedSaved = + suggestion.isNotEmpty, ); } catch (_) { } finally { setState( - () => _typeSaving = false, + () => _requestedSaving = + false, ); - if (_typeSaved) { + if (_requestedSaved) { Future.delayed( const Duration( seconds: 2, @@ -1015,8 +1395,9 @@ class _TaskDetailScreenState extends ConsumerState () { if (mounted) { setState( - () => _typeSaved = - false, + () => + _requestedSaved = + false, ); } }, @@ -1025,505 +1406,185 @@ class _TaskDetailScreenState extends ConsumerState } }, ), - ], - const SizedBox(height: 8), - const Text('Category'), - const SizedBox(height: 6), - DropdownButtonFormField( - initialValue: task.requestCategory, - decoration: InputDecoration( - suffixIcon: _categorySaving - ? SizedBox( - width: 16, - height: 16, - child: ScaleTransition( - scale: _savePulse, - child: const Icon( - Icons.save, - size: 14, - ), - ), - ) - : _categorySaved - ? SizedBox( - width: 16, - height: 16, - child: Stack( - alignment: - Alignment.center, - children: const [ - Icon( - Icons.save, - size: 14, - color: Colors.green, - ), - Positioned( - right: -2, - bottom: -2, - child: Icon( - Icons.check, - size: 10, - color: - Colors.white, - ), - ), - ], - ), - ) - : null, + + const SizedBox(height: 12), + Text( + 'Noted by (Supervisor/Senior)', + style: Theme.of( + context, + ).textTheme.bodySmall, ), - items: [ - const DropdownMenuItem( - value: null, - child: Text('None'), - ), - for (final c - in requestCategoryOptions) - DropdownMenuItem( - value: c, - child: Text(c), + const SizedBox(height: 6), + TypeAheadFormField( + textFieldConfiguration: TextFieldConfiguration( + controller: _notedController, + decoration: InputDecoration( + hintText: 'Supervisor/Senior', + suffixIcon: _notedSaving + ? SizedBox( + width: 16, + height: 16, + child: + ScaleTransition( + scale: + _savePulse, + child: + const Icon( + Icons + .save, + size: 14, + ), + ), + ) + : _notedSaved + ? SizedBox( + width: 16, + height: 16, + child: Stack( + alignment: Alignment + .center, + children: const [ + Icon( + Icons.save, + size: 14, + color: Colors + .green, + ), + Positioned( + right: -2, + bottom: -2, + child: Icon( + Icons.check, + size: 10, + color: Colors + .white, + ), + ), + ], + ), + ) + : null, ), - ], - onChanged: (v) async { - setState(() { - _categorySaving = true; - _categorySaved = false; - }); - try { - await ref - .read( - tasksControllerProvider, - ) - .updateTask( - taskId: task.id, - requestCategory: v, - ); - setState( - () => _categorySaved = - v != null && v.isNotEmpty, - ); - } catch (_) { - } finally { - setState( - () => _categorySaving = false, - ); - if (_categorySaved) { - Future.delayed( - const Duration(seconds: 2), - () { - if (mounted) { - setState( - () => _categorySaved = - false, + onChanged: (v) { + _notedDebounce.run(() async { + final name = v.trim(); + setState(() { + _notedSaving = true; + _notedSaved = false; + }); + try { + await ref + .read( + tasksControllerProvider, + ) + .updateTask( + taskId: task.id, + notedBy: + name.isEmpty + ? null + : name, + ); + if (name.isNotEmpty) { + try { + await ref + .read( + supabaseClientProvider, + ) + .from('clients') + .upsert({ + 'name': name, + }); + } catch (_) {} + } + setState(() { + _notedSaved = + name.isNotEmpty; + }); + } catch (_) { + // ignore + } finally { + setState(() { + _notedSaving = false; + }); + if (_notedSaved) { + Future.delayed( + const Duration( + seconds: 2, + ), + () { + if (mounted) { + setState( + () => + _notedSaved = + false, + ); + } + }, ); } - }, - ); - } - } - }, - ), - ], - const SizedBox(height: 12), - ], - ), - ), - ), - - // Signatories (editable) - SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - 'Requested by', - style: Theme.of( - context, - ).textTheme.bodySmall, - ), - const SizedBox(height: 6), - TypeAheadFormField( - textFieldConfiguration: TextFieldConfiguration( - controller: _requestedController, - decoration: InputDecoration( - hintText: 'Requester name or id', - suffixIcon: _requestedSaving - ? SizedBox( - width: 16, - height: 16, - child: ScaleTransition( - scale: _savePulse, - child: const Icon( - Icons.save, - size: 14, - ), - ), - ) - : _requestedSaved - ? SizedBox( - width: 16, - height: 16, - child: Stack( - alignment: - Alignment.center, - children: const [ - Icon( - Icons.save, - size: 14, - color: Colors.green, - ), - Positioned( - right: -2, - bottom: -2, - child: Icon( - Icons.check, - size: 10, - color: - Colors.white, - ), - ), - ], - ), - ) - : null, - ), - onChanged: (v) { - _requestedDebounce?.cancel(); - _requestedDebounce = Timer( - const Duration( - milliseconds: 700, - ), - () async { - final name = v.trim(); - setState(() { - _requestedSaving = true; - _requestedSaved = false; + } }); - try { - await ref - .read( - tasksControllerProvider, - ) - .updateTask( - taskId: task.id, - requestedBy: - name.isEmpty - ? null - : name, - ); - if (name.isNotEmpty) { - try { - await ref - .read( - supabaseClientProvider, - ) - .from('clients') - .upsert({ - 'name': name, - }); - } catch (_) {} - } - setState(() { - _requestedSaved = - name.isNotEmpty; - }); - } catch (_) { - } finally { - setState(() { - _requestedSaving = false; - }); - if (_requestedSaved) { - Future.delayed( - const Duration( - seconds: 2, - ), - () { - if (mounted) { - setState( - () => - _requestedSaved = - false, - ); - } - }, - ); - } - } }, - ); - }, - ), - suggestionsCallback: (pattern) async { - final profiles = - ref - .watch(profilesProvider) - .valueOrNull ?? - []; - final fromProfiles = profiles - .map( - (p) => p.fullName.isEmpty - ? p.id - : p.fullName, - ) - .where( - (n) => - n.toLowerCase().contains( - pattern.toLowerCase(), - ), - ) - .toList(); - try { - final clientRows = await ref - .read(supabaseClientProvider) - .from('clients') - .select('name') - .ilike('name', '%$pattern%'); - final clientNames = - (clientRows as List?) - ?.map( - (r) => - r['name'] as String, - ) - .whereType() - .toList() ?? - []; - final merged = { - ...fromProfiles, - ...clientNames, - }.toList(); - return merged; - } catch (_) { - return fromProfiles; - } - }, - itemBuilder: (context, suggestion) => - ListTile(title: Text(suggestion)), - onSuggestionSelected: (suggestion) async { - _requestedDebounce?.cancel(); - _requestedController.text = - suggestion; - setState(() { - _requestedSaving = true; - _requestedSaved = false; - }); - try { - await ref - .read(tasksControllerProvider) - .updateTask( - taskId: task.id, - requestedBy: - suggestion.isEmpty - ? null - : suggestion, - ); - if (suggestion.isNotEmpty) { + ), + suggestionsCallback: (pattern) async { + final profiles = + ref + .watch(profilesProvider) + .valueOrNull ?? + []; + final fromProfiles = profiles + .map( + (p) => p.fullName.isEmpty + ? p.id + : p.fullName, + ) + .where( + (n) => n + .toLowerCase() + .contains( + pattern + .toLowerCase(), + ), + ) + .toList(); try { - await ref + final clientRows = await ref .read( supabaseClientProvider, ) .from('clients') - .upsert({ - 'name': suggestion, - }); - } catch (_) {} - } - setState( - () => _requestedSaved = - suggestion.isNotEmpty, - ); - } catch (_) { - } finally { - setState( - () => _requestedSaving = false, - ); - if (_requestedSaved) { - Future.delayed( - const Duration(seconds: 2), - () { - if (mounted) { - setState( - () => _requestedSaved = - false, + .select('name') + .ilike( + 'name', + '%$pattern%', ); - } - }, - ); - } - } - }, - ), - - const SizedBox(height: 12), - Text( - 'Noted by (Supervisor/Senior)', - style: Theme.of( - context, - ).textTheme.bodySmall, - ), - const SizedBox(height: 6), - TypeAheadFormField( - textFieldConfiguration: TextFieldConfiguration( - controller: _notedController, - decoration: InputDecoration( - hintText: 'Supervisor/Senior', - suffixIcon: _notedSaving - ? SizedBox( - width: 16, - height: 16, - child: ScaleTransition( - scale: _savePulse, - child: const Icon( - Icons.save, - size: 14, - ), + final clientNames = + (clientRows + as List?) + ?.map( + (r) => + r['name'] + as String, + ) + .whereType() + .toList() ?? + []; + final merged = { + ...fromProfiles, + ...clientNames, + }.toList(); + return merged; + } catch (_) { + return fromProfiles; + } + }, + itemBuilder: + (context, suggestion) => + ListTile( + title: Text(suggestion), ), - ) - : _notedSaved - ? SizedBox( - width: 16, - height: 16, - child: Stack( - alignment: - Alignment.center, - children: const [ - Icon( - Icons.save, - size: 14, - color: Colors.green, - ), - Positioned( - right: -2, - bottom: -2, - child: Icon( - Icons.check, - size: 10, - color: - Colors.white, - ), - ), - ], - ), - ) - : null, - ), - onChanged: (v) { - _notedDebounce?.cancel(); - _notedDebounce = Timer( - const Duration( - milliseconds: 700, - ), - () async { - final name = v.trim(); - setState(() { - _notedSaving = true; - _notedSaved = false; - }); - try { - await ref - .read( - tasksControllerProvider, - ) - .updateTask( - taskId: task.id, - notedBy: name.isEmpty - ? null - : name, - ); - if (name.isNotEmpty) { - try { - await ref - .read( - supabaseClientProvider, - ) - .from('clients') - .upsert({ - 'name': name, - }); - } catch (_) {} - } - setState(() { - _notedSaved = - name.isNotEmpty; - }); - } catch (_) { - // ignore - } finally { - setState(() { - _notedSaving = false; - }); - if (_notedSaved) { - Future.delayed( - const Duration( - seconds: 2, - ), - () { - if (mounted) { - setState( - () => - _notedSaved = - false, - ); - } - }, - ); - } - } - }, - ); - }, - ), - suggestionsCallback: (pattern) async { - final profiles = - ref - .watch(profilesProvider) - .valueOrNull ?? - []; - final fromProfiles = profiles - .map( - (p) => p.fullName.isEmpty - ? p.id - : p.fullName, - ) - .where( - (n) => - n.toLowerCase().contains( - pattern.toLowerCase(), - ), - ) - .toList(); - try { - final clientRows = await ref - .read(supabaseClientProvider) - .from('clients') - .select('name') - .ilike('name', '%$pattern%'); - final clientNames = - (clientRows as List?) - ?.map( - (r) => - r['name'] as String, - ) - .whereType() - .toList() ?? - []; - final merged = { - ...fromProfiles, - ...clientNames, - }.toList(); - return merged; - } catch (_) { - return fromProfiles; - } - }, - itemBuilder: (context, suggestion) => - ListTile(title: Text(suggestion)), - onSuggestionSelected: - (suggestion) async { - _notedDebounce?.cancel(); + onSuggestionSelected: (suggestion) async { + _notedDebounce.cancel(); _notedController.text = suggestion; setState(() { @@ -1580,176 +1641,187 @@ class _TaskDetailScreenState extends ConsumerState } } }, - ), - - const SizedBox(height: 12), - Text( - 'Received by', - style: Theme.of( - context, - ).textTheme.bodySmall, - ), - const SizedBox(height: 6), - TypeAheadFormField( - textFieldConfiguration: TextFieldConfiguration( - controller: _receivedController, - decoration: InputDecoration( - hintText: 'Receiver name or id', - suffixIcon: _receivedSaving - ? SizedBox( - width: 16, - height: 16, - child: ScaleTransition( - scale: _savePulse, - child: const Icon( - Icons.save, - size: 14, - ), - ), - ) - : _receivedSaved - ? SizedBox( - width: 16, - height: 16, - child: Stack( - alignment: - Alignment.center, - children: const [ - Icon( - Icons.save, - size: 14, - color: Colors.green, - ), - Positioned( - right: -2, - bottom: -2, - child: Icon( - Icons.check, - size: 10, - color: - Colors.white, - ), - ), - ], - ), - ) - : null, ), - onChanged: (v) { - _receivedDebounce?.cancel(); - _receivedDebounce = Timer( - const Duration( - milliseconds: 700, - ), - () async { - final name = v.trim(); - setState(() { - _receivedSaving = true; - _receivedSaved = false; - }); - try { - await ref - .read( - tasksControllerProvider, + + const SizedBox(height: 12), + Text( + 'Received by', + style: Theme.of( + context, + ).textTheme.bodySmall, + ), + const SizedBox(height: 6), + TypeAheadFormField( + textFieldConfiguration: TextFieldConfiguration( + controller: _receivedController, + decoration: InputDecoration( + hintText: + 'Receiver name or id', + suffixIcon: _receivedSaving + ? SizedBox( + width: 16, + height: 16, + child: + ScaleTransition( + scale: + _savePulse, + child: + const Icon( + Icons + .save, + size: 14, + ), + ), ) - .updateTask( - taskId: task.id, - receivedBy: - name.isEmpty - ? null - : name, + : _receivedSaved + ? SizedBox( + width: 16, + height: 16, + child: Stack( + alignment: Alignment + .center, + children: const [ + Icon( + Icons.save, + size: 14, + color: Colors + .green, + ), + Positioned( + right: -2, + bottom: -2, + child: Icon( + Icons.check, + size: 10, + color: Colors + .white, + ), + ), + ], + ), + ) + : null, + ), + onChanged: (v) { + _receivedDebounce.run(() async { + final name = v.trim(); + setState(() { + _receivedSaving = true; + _receivedSaved = false; + }); + try { + await ref + .read( + tasksControllerProvider, + ) + .updateTask( + taskId: task.id, + receivedBy: + name.isEmpty + ? null + : name, + ); + if (name.isNotEmpty) { + try { + await ref + .read( + supabaseClientProvider, + ) + .from('clients') + .upsert({ + 'name': name, + }); + } catch (_) {} + } + setState(() { + _receivedSaved = + name.isNotEmpty; + }); + } catch (_) { + // ignore + } finally { + setState(() { + _receivedSaving = false; + }); + if (_receivedSaved) { + Future.delayed( + const Duration( + seconds: 2, + ), + () { + if (mounted) { + setState( + () => + _receivedSaved = + false, + ); + } + }, ); - if (name.isNotEmpty) { - try { - await ref - .read( - supabaseClientProvider, - ) - .from('clients') - .upsert({ - 'name': name, - }); - } catch (_) {} + } } - setState(() { - _receivedSaved = - name.isNotEmpty; - }); - } catch (_) { - // ignore - } finally { - setState(() { - _receivedSaving = false; - }); - if (_receivedSaved) { - Future.delayed( - const Duration( - seconds: 2, - ), - () { - if (mounted) { - setState( - () => - _receivedSaved = - false, - ); - } - }, - ); - } - } + }); }, - ); - }, - ), - suggestionsCallback: (pattern) async { - final profiles = - ref - .watch(profilesProvider) - .valueOrNull ?? - []; - final fromProfiles = profiles - .map( - (p) => p.fullName.isEmpty - ? p.id - : p.fullName, - ) - .where( - (n) => - n.toLowerCase().contains( - pattern.toLowerCase(), - ), - ) - .toList(); - try { - final clientRows = await ref - .read(supabaseClientProvider) - .from('clients') - .select('name') - .ilike('name', '%$pattern%'); - final clientNames = - (clientRows as List?) - ?.map( - (r) => - r['name'] as String, + ), + suggestionsCallback: (pattern) async { + final profiles = + ref + .watch(profilesProvider) + .valueOrNull ?? + []; + final fromProfiles = profiles + .map( + (p) => p.fullName.isEmpty + ? p.id + : p.fullName, + ) + .where( + (n) => n + .toLowerCase() + .contains( + pattern + .toLowerCase(), + ), + ) + .toList(); + try { + final clientRows = await ref + .read( + supabaseClientProvider, ) - .whereType() - .toList() ?? - []; - final merged = { - ...fromProfiles, - ...clientNames, - }.toList(); - return merged; - } catch (_) { - return fromProfiles; - } - }, - itemBuilder: (context, suggestion) => - ListTile(title: Text(suggestion)), - onSuggestionSelected: - (suggestion) async { - _receivedDebounce?.cancel(); + .from('clients') + .select('name') + .ilike( + 'name', + '%$pattern%', + ); + final clientNames = + (clientRows + as List?) + ?.map( + (r) => + r['name'] + as String, + ) + .whereType() + .toList() ?? + []; + final merged = { + ...fromProfiles, + ...clientNames, + }.toList(); + return merged; + } catch (_) { + return fromProfiles; + } + }, + itemBuilder: + (context, suggestion) => + ListTile( + title: Text(suggestion), + ), + onSuggestionSelected: (suggestion) async { + _receivedDebounce.cancel(); _receivedController.text = suggestion; setState(() { @@ -1808,875 +1880,880 @@ class _TaskDetailScreenState extends ConsumerState } } }, + ), + ], ), - ], - ), - ), - ), - - // Action taken (rich text) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text('Action taken'), - const Spacer(), - IconButton( - tooltip: - 'Improve action taken with Gemini', - icon: Image.asset( - 'assets/gemini_icon.png', - width: 24, - height: 24, - errorBuilder: - (context, error, stackTrace) { - return const Icon( - Icons.auto_awesome, - ); - }, - ), - onPressed: () => - _processActionTakenWithGemini( - context, - ref, - ), - ), - ], ), - const SizedBox(height: 6), - // Toolbar + editor with inline save indicator - GeminiAnimatedBorder( - isProcessing: _actionProcessing, - child: Container( - height: isWide ? 260 : 220, - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - border: Border.all( - color: Theme.of( - context, - ).colorScheme.outline, - ), - borderRadius: BorderRadius.circular( - 8, - ), - ), - child: Stack( + ), + + // Action taken (rich text) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( children: [ - Column( + const Text('Action taken'), + const Spacer(), + IconButton( + tooltip: + 'Improve action taken with Gemini', + icon: Image.asset( + 'assets/gemini_icon.png', + width: 24, + height: 24, + errorBuilder: + ( + context, + error, + stackTrace, + ) { + return const Icon( + Icons.auto_awesome, + ); + }, + ), + onPressed: () => + _processActionTakenWithGemini( + context, + ref, + ), + ), + ], + ), + const SizedBox(height: 6), + // Toolbar + editor with inline save indicator + GeminiAnimatedBorder( + isProcessing: _actionProcessing, + child: Container( + height: isWide ? 260 : 220, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outline, + ), + borderRadius: + BorderRadius.circular(8), + ), + child: Stack( children: [ - isWide - ? Row( - children: [ - IconButton( - tooltip: 'Bold', - icon: const Icon( - Icons - .format_bold, - ), - onPressed: () => - _actionController - ?.formatSelection( + Column( + children: [ + isWide + ? Row( + children: [ + IconButton( + tooltip: + 'Bold', + icon: const Icon( + Icons + .format_bold, + ), + onPressed: () => + _actionController?.formatSelection( quill .Attribute .bold, ), - ), - IconButton( - tooltip: 'Italic', - icon: const Icon( - Icons - .format_italic, - ), - onPressed: () => - _actionController - ?.formatSelection( + ), + IconButton( + tooltip: + 'Italic', + icon: const Icon( + Icons + .format_italic, + ), + onPressed: () => + _actionController?.formatSelection( quill .Attribute .italic, ), - ), - IconButton( - tooltip: - 'Underline', - icon: const Icon( - Icons - .format_underlined, - ), - onPressed: () => - _actionController - ?.formatSelection( + ), + IconButton( + tooltip: + 'Underline', + icon: const Icon( + Icons + .format_underlined, + ), + onPressed: () => + _actionController?.formatSelection( quill .Attribute .underline, ), - ), - IconButton( - tooltip: - 'Bullet list', - icon: const Icon( - Icons - .format_list_bulleted, - ), - onPressed: () => - _actionController - ?.formatSelection( + ), + IconButton( + tooltip: + 'Bullet list', + icon: const Icon( + Icons + .format_list_bulleted, + ), + onPressed: () => + _actionController?.formatSelection( quill .Attribute .ul, ), - ), - IconButton( - tooltip: - 'Numbered list', - icon: const Icon( - Icons - .format_list_numbered, - ), - onPressed: () => - _actionController - ?.formatSelection( + ), + IconButton( + tooltip: + 'Numbered list', + icon: const Icon( + Icons + .format_list_numbered, + ), + onPressed: () => + _actionController?.formatSelection( quill .Attribute .ol, ), - ), - const SizedBox( - width: 8, - ), - IconButton( - tooltip: - 'Heading 2', - icon: const Icon( - Icons - .format_size, - ), - onPressed: () => - _actionController - ?.formatSelection( + ), + const SizedBox( + width: 8, + ), + IconButton( + tooltip: + 'Heading 2', + icon: const Icon( + Icons + .format_size, + ), + onPressed: () => + _actionController?.formatSelection( quill .Attribute .h2, ), - ), - IconButton( - tooltip: - 'Heading 3', - icon: const Icon( - Icons - .format_size, - size: 18, - ), - onPressed: () => - _actionController - ?.formatSelection( + ), + IconButton( + tooltip: + 'Heading 3', + icon: const Icon( + Icons + .format_size, + size: 18, + ), + onPressed: () => + _actionController?.formatSelection( quill .Attribute .h3, ), - ), - IconButton( - tooltip: 'Undo', - icon: const Icon( - Icons.undo, - ), - onPressed: () => - _actionController - ?.undo(), - ), - IconButton( - tooltip: 'Redo', - icon: const Icon( - Icons.redo, - ), - onPressed: () => - _actionController - ?.redo(), - ), - IconButton( - tooltip: - 'Insert link', - icon: const Icon( - Icons.link, - ), - onPressed: () async { - final urlCtrl = - TextEditingController(); - final res = await m3ShowDialog( - context: - context, - builder: (ctx) => AlertDialog( - title: const Text( - 'Insert link', - ), - content: TextField( - controller: - urlCtrl, - decoration: const InputDecoration( - hintText: - 'https://', - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of( - ctx, - ).pop(), - child: const Text( - 'Cancel', - ), - ), - TextButton( - onPressed: () => - Navigator.of( - ctx, - ).pop( - urlCtrl.text.trim(), - ), - child: const Text( - 'Insert', - ), - ), - ], + ), + IconButton( + tooltip: + 'Undo', + icon: const Icon( + Icons.undo, ), - ); - if (res == - null || - res.isEmpty) { - return; - } - final sel = - _actionController - ?.selection ?? - const TextSelection.collapsed( - offset: 0, + onPressed: () => + _actionController + ?.undo(), + ), + IconButton( + tooltip: + 'Redo', + icon: const Icon( + Icons.redo, + ), + onPressed: () => + _actionController + ?.redo(), + ), + IconButton( + tooltip: + 'Insert link', + icon: const Icon( + Icons.link, + ), + onPressed: () async { + final urlCtrl = + TextEditingController(); + final res = await m3ShowDialog( + context: + context, + builder: (ctx) => AlertDialog( + title: const Text( + 'Insert link', + ), + content: TextField( + controller: + urlCtrl, + decoration: const InputDecoration( + hintText: + 'https://', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of( + ctx, + ).pop(), + child: const Text( + 'Cancel', + ), + ), + TextButton( + onPressed: () => + Navigator.of( + ctx, + ).pop( + urlCtrl.text.trim(), + ), + child: const Text( + 'Insert', + ), + ), + ], + ), ); - final start = sel - .baseOffset; - final end = sel - .extentOffset; - if (!sel.isCollapsed && - end > - start) { - final len = - end - - start; - try { - _actionController - ?.document - .delete( - start, - len, + if (res == + null || + res.isEmpty) { + return; + } + final sel = + _actionController + ?.selection ?? + const TextSelection.collapsed( + offset: + 0, ); - } catch (_) {} - _actionController - ?.document - .insert( - start, - res, - ); - } else { - _actionController - ?.document - .insert( - start, - res, - ); - } - }, - ), - IconButton( - tooltip: - 'Insert image', - icon: const Icon( - Icons.image, - ), - onPressed: () async { - try { - final r = await FilePicker - .platform - .pickFiles( + final start = + sel.baseOffset; + final end = + sel.extentOffset; + if (!sel.isCollapsed && + end > + start) { + final len = + end - + start; + try { + _actionController + ?.document + .delete( + start, + len, + ); + } catch ( + _ + ) {} + _actionController + ?.document + .insert( + start, + res, + ); + } else { + _actionController + ?.document + .insert( + start, + res, + ); + } + }, + ), + IconButton( + tooltip: + 'Insert image', + icon: const Icon( + Icons.image, + ), + onPressed: () async { + try { + final r = await FilePicker.platform.pickFiles( withData: true, type: FileType .image, ); - if (r == - null || - r + if (r == + null || + r.files.isEmpty) { + return; + } + final file = r .files - .isEmpty) { - return; - } - final file = r - .files - .first; - final bytes = - file.bytes; - if (bytes == - null) { - return; - } - final ext = - file.extension ?? - 'png'; - String? url; - try { - url = await ref - .read( - tasksControllerProvider, - ) - .uploadActionImage( - taskId: - task.id, - bytes: - bytes, - extension: - ext, + .first; + final bytes = + file.bytes; + if (bytes == + null) { + return; + } + final ext = + file.extension ?? + 'png'; + String? + url; + try { + url = await ref + .read( + tasksControllerProvider, + ) + .uploadActionImage( + taskId: task.id, + bytes: bytes, + extension: ext, + ); + } catch ( + e + ) { + showErrorSnackBar( + context, + 'Upload error: $e', ); - } catch (e) { - showErrorSnackBar( - context, - 'Upload error: $e', - ); - return; - } - if (url == - null) { - showErrorSnackBar( - context, - 'Image upload failed (no URL returned)', - ); - return; - } - final trimmedUrl = - url.trim(); - final idx = - _actionController - ?.selection - .baseOffset ?? - 0; - // ignore: avoid_print - print( - 'inserting image embed idx=$idx url=$trimmedUrl', - ); - _actionController - ?.document - .insert( - idx, - quill - .BlockEmbed.image( - trimmedUrl, - ), + return; + } + if (url == + null) { + showErrorSnackBar( + context, + 'Image upload failed (no URL returned)', + ); + return; + } + final trimmedUrl = + url.trim(); + final idx = + _actionController + ?.selection + .baseOffset ?? + 0; + // ignore: avoid_print + print( + 'inserting image embed idx=$idx url=$trimmedUrl', ); - } catch (_) {} - }, - ), - ], - ) - : SingleChildScrollView( - scrollDirection: - Axis.horizontal, - child: Row( - children: [ - IconButton( - tooltip: 'Bold', - icon: const Icon( - Icons - .format_bold, - ), - onPressed: () => - _actionController?.formatSelection( - quill - .Attribute - .bold, - ), - ), - IconButton( - tooltip: - 'Italic', - icon: const Icon( - Icons - .format_italic, - ), - onPressed: () => - _actionController?.formatSelection( - quill - .Attribute - .italic, - ), - ), - IconButton( - tooltip: - 'Underline', - icon: const Icon( - Icons - .format_underlined, - ), - onPressed: () => - _actionController?.formatSelection( - quill - .Attribute - .underline, - ), - ), - IconButton( - tooltip: - 'Bullet list', - icon: const Icon( - Icons - .format_list_bulleted, - ), - onPressed: () => - _actionController - ?.formatSelection( - quill - .Attribute - .ul, - ), - ), - IconButton( - tooltip: - 'Numbered list', - icon: const Icon( - Icons - .format_list_numbered, - ), - onPressed: () => - _actionController - ?.formatSelection( - quill - .Attribute - .ol, - ), - ), - const SizedBox( - width: 8, - ), - IconButton( - tooltip: - 'Heading 2', - icon: const Icon( - Icons - .format_size, - ), - onPressed: () => - _actionController - ?.formatSelection( - quill - .Attribute - .h2, - ), - ), - IconButton( - tooltip: - 'Heading 3', - icon: const Icon( - Icons - .format_size, - size: 18, - ), - onPressed: () => - _actionController - ?.formatSelection( - quill - .Attribute - .h3, - ), - ), - IconButton( - tooltip: 'Undo', - icon: - const Icon( - Icons - .undo, - ), - onPressed: () => - _actionController - ?.undo(), - ), - IconButton( - tooltip: 'Redo', - icon: - const Icon( - Icons - .redo, - ), - onPressed: () => - _actionController - ?.redo(), - ), - IconButton( - tooltip: - 'Insert link', - icon: - const Icon( - Icons - .link, - ), - onPressed: () async { - final urlCtrl = - TextEditingController(); - final res = await m3ShowDialog( - context: - context, - builder: (ctx) => AlertDialog( - title: const Text( - 'Insert link', - ), - content: TextField( - controller: - urlCtrl, - decoration: const InputDecoration( - hintText: - 'https://', - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of( - ctx, - ).pop(), - child: const Text( - 'Cancel', - ), - ), - TextButton( - onPressed: () => Navigator.of(ctx).pop( - urlCtrl.text.trim(), - ), - child: const Text( - 'Insert', - ), - ), - ], - ), - ); - if (res == - null || - res.isEmpty) { - return; - } - final sel = - _actionController - ?.selection ?? - const TextSelection.collapsed( - offset: - 0, - ); - final start = - sel.baseOffset; - final end = sel - .extentOffset; - if (!sel.isCollapsed && - end > - start) { - final len = - end - - start; - try { _actionController ?.document - .delete( - start, - len, + .insert( + idx, + quill.BlockEmbed.image( + trimmedUrl, + ), ); } catch ( _ ) {} - _actionController - ?.document - .insert( - start, - res, - ); - } else { - _actionController - ?.document - .insert( - start, - res, - ); - } - }, - ), - IconButton( - tooltip: - 'Insert image', - icon: const Icon( - Icons.image, + }, ), - onPressed: () async { - try { - final r = await FilePicker - .platform - .pickFiles( + ], + ) + : SingleChildScrollView( + scrollDirection: + Axis.horizontal, + child: Row( + children: [ + IconButton( + tooltip: + 'Bold', + icon: const Icon( + Icons + .format_bold, + ), + onPressed: () => + _actionController?.formatSelection( + quill + .Attribute + .bold, + ), + ), + IconButton( + tooltip: + 'Italic', + icon: const Icon( + Icons + .format_italic, + ), + onPressed: () => + _actionController?.formatSelection( + quill + .Attribute + .italic, + ), + ), + IconButton( + tooltip: + 'Underline', + icon: const Icon( + Icons + .format_underlined, + ), + onPressed: () => + _actionController?.formatSelection( + quill + .Attribute + .underline, + ), + ), + IconButton( + tooltip: + 'Bullet list', + icon: const Icon( + Icons + .format_list_bulleted, + ), + onPressed: () => + _actionController?.formatSelection( + quill + .Attribute + .ul, + ), + ), + IconButton( + tooltip: + 'Numbered list', + icon: const Icon( + Icons + .format_list_numbered, + ), + onPressed: () => + _actionController?.formatSelection( + quill + .Attribute + .ol, + ), + ), + const SizedBox( + width: 8, + ), + IconButton( + tooltip: + 'Heading 2', + icon: const Icon( + Icons + .format_size, + ), + onPressed: () => + _actionController?.formatSelection( + quill + .Attribute + .h2, + ), + ), + IconButton( + tooltip: + 'Heading 3', + icon: const Icon( + Icons + .format_size, + size: 18, + ), + onPressed: () => + _actionController?.formatSelection( + quill + .Attribute + .h3, + ), + ), + IconButton( + tooltip: + 'Undo', + icon: const Icon( + Icons + .undo, + ), + onPressed: () => + _actionController + ?.undo(), + ), + IconButton( + tooltip: + 'Redo', + icon: const Icon( + Icons + .redo, + ), + onPressed: () => + _actionController + ?.redo(), + ), + IconButton( + tooltip: + 'Insert link', + icon: const Icon( + Icons + .link, + ), + onPressed: () async { + final urlCtrl = + TextEditingController(); + final res = await m3ShowDialog( + context: + context, + builder: (ctx) => AlertDialog( + title: const Text( + 'Insert link', + ), + content: TextField( + controller: + urlCtrl, + decoration: const InputDecoration( + hintText: 'https://', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of( + ctx, + ).pop(), + child: const Text( + 'Cancel', + ), + ), + TextButton( + onPressed: () => + Navigator.of( + ctx, + ).pop( + urlCtrl.text.trim(), + ), + child: const Text( + 'Insert', + ), + ), + ], + ), + ); + if (res == + null || + res.isEmpty) { + return; + } + final sel = + _actionController + ?.selection ?? + const TextSelection.collapsed( + offset: + 0, + ); + final start = + sel.baseOffset; + final end = + sel.extentOffset; + if (!sel.isCollapsed && + end > + start) { + final len = + end - + start; + try { + _actionController?.document.delete( + start, + len, + ); + } catch ( + _ + ) {} + _actionController + ?.document + .insert( + start, + res, + ); + } else { + _actionController + ?.document + .insert( + start, + res, + ); + } + }, + ), + IconButton( + tooltip: + 'Insert image', + icon: const Icon( + Icons + .image, + ), + onPressed: () async { + try { + final r = await FilePicker.platform.pickFiles( withData: true, type: FileType.image, ); - if (r == - null || - r + if (r == + null || + r.files.isEmpty) { + return; + } + final file = r .files - .isEmpty) { - return; - } - final file = r - .files - .first; - final bytes = - file.bytes; - if (bytes == - null) { - return; - } - final ext = - file.extension ?? - 'png'; - String? url; - try { - url = await ref - .read( - tasksControllerProvider, - ) - .uploadActionImage( - taskId: - task.id, - bytes: - bytes, - extension: - ext, + .first; + final bytes = + file.bytes; + if (bytes == + null) { + return; + } + final ext = + file.extension ?? + 'png'; + String? + url; + try { + url = await ref + .read( + tasksControllerProvider, + ) + .uploadActionImage( + taskId: task.id, + bytes: bytes, + extension: ext, + ); + } catch ( + e + ) { + showErrorSnackBar( + context, + 'Upload error: $e', ); - } catch ( - e - ) { - showErrorSnackBar( - context, - 'Upload error: $e', - ); - return; - } - if (url == - null) { - showErrorSnackBar( - context, - 'Image upload failed (no URL returned)', - ); - return; - } - final trimmedUrl = - url.trim(); - final idx = - _actionController - ?.selection - .baseOffset ?? - 0; - // ignore: avoid_print - print( - 'inserting image embed idx=$idx url=$trimmedUrl', - ); - _actionController - ?.document - .insert( + return; + } + if (url == + null) { + showErrorSnackBar( + context, + 'Image upload failed (no URL returned)', + ); + return; + } + final trimmedUrl = + url.trim(); + final idx = + _actionController?.selection.baseOffset ?? + 0; + // ignore: avoid_print + print( + 'inserting image embed idx=$idx url=$trimmedUrl', + ); + _actionController?.document.insert( idx, quill .BlockEmbed.image( trimmedUrl, ), ); - } catch (_) {} - }, + } catch ( + _ + ) {} + }, + ), + ], ), - ], + ), + Expanded( + child: MouseRegion( + cursor: + SystemMouseCursors + .text, + child: quill.QuillEditor.basic( + controller: + _actionController!, + focusNode: + _actionFocusNode, + scrollController: + _actionScrollController, + config: quill.QuillEditorConfig( + embedBuilders: const [ + _ImageEmbedBuilder(), + ], + scrollable: true, + padding: + EdgeInsets + .zero, + ), ), ), - Expanded( - child: MouseRegion( - cursor: SystemMouseCursors - .text, - child: quill.QuillEditor.basic( - controller: - _actionController!, - focusNode: - _actionFocusNode, - scrollController: - _actionScrollController, - config: quill.QuillEditorConfig( - embedBuilders: const [ - _ImageEmbedBuilder(), - ], - scrollable: true, - padding: - EdgeInsets.zero, - ), ), - ), + ], + ), + Positioned( + right: 6, + bottom: 6, + child: _actionSaving + ? SizedBox( + width: 20, + height: 20, + child: ScaleTransition( + scale: _savePulse, + child: const Icon( + Icons.save, + size: 16, + ), + ), + ) + : _actionSaved + ? SizedBox( + width: 20, + height: 20, + child: Stack( + alignment: + Alignment + .center, + children: const [ + Icon( + Icons.save, + size: 16, + color: Colors + .green, + ), + Positioned( + right: -2, + bottom: -2, + child: Icon( + Icons.check, + size: 10, + color: Colors + .white, + ), + ), + ], + ), + ) + : const SizedBox.shrink(), ), ], ), - Positioned( - right: 6, - bottom: 6, - child: _actionSaving - ? SizedBox( - width: 20, - height: 20, - child: ScaleTransition( - scale: _savePulse, - child: const Icon( - Icons.save, - size: 16, - ), - ), - ) - : _actionSaved - ? SizedBox( - width: 20, - height: 20, - child: Stack( - alignment: - Alignment.center, - children: const [ - Icon( - Icons.save, - size: 16, - color: - Colors.green, - ), - Positioned( - right: -2, - bottom: -2, - child: Icon( - Icons.check, - size: 10, - color: Colors - .white, - ), - ), - ], - ), - ) - : const SizedBox.shrink(), - ), - ], - ), - ), - ), - ], - ), - ), - // Attachments (Tab 5) - SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - const Text('File Attachments'), - const SizedBox(height: 12), - Center( - child: FilledButton.icon( - icon: const Icon(Icons.upload_file), - label: const Text( - 'Upload File (Max 25MB)', ), - onPressed: () => - _uploadTaskAttachment(task.id), ), + ], + ), + ), + // Attachments (Tab 5) + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only( + top: 8.0, ), - const SizedBox(height: 16), - Builder( - builder: (context) { - // Load attachments once per task - if (_seededTaskId != null && - _seededTaskId == task.id && - _attachments == null && - !_loadingAttachments) { - WidgetsBinding.instance - .addPostFrameCallback((_) { - _loadAttachments(task.id); - }); - } - if (_loadingAttachments) { - return const Center( - child: - CircularProgressIndicator(), - ); - } - final files = _attachments ?? []; - if (files.isEmpty) { - return const Center( - child: Padding( - padding: EdgeInsets.all(16.0), - child: Text( - 'No attachments yet', - style: TextStyle( - color: Colors.grey, - ), - ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + const Text('File Attachments'), + const SizedBox(height: 12), + Center( + child: FilledButton.icon( + icon: const Icon( + Icons.upload_file, ), - ); - } - return ListView.separated( - shrinkWrap: true, - physics: - const NeverScrollableScrollPhysics(), - itemCount: files.length, - separatorBuilder: - (context, index) => - const Divider(), - itemBuilder: (context, index) { - final file = files[index]; - return ListTile( - leading: const Icon( - Icons.insert_drive_file, - ), - title: Text(file), - subtitle: Text( - 'Tap to download', - style: Theme.of( - context, - ).textTheme.bodySmall, - ), - trailing: IconButton( - icon: const Icon( - Icons.delete, - color: Colors.red, + label: const Text( + 'Upload File (Max 25MB)', + ), + onPressed: () => + _uploadTaskAttachment( + task.id, ), - onPressed: () => - _deleteTaskAttachment( + ), + ), + const SizedBox(height: 16), + Builder( + builder: (context) { + // Load attachments once per task + if (_seededTaskId != null && + _seededTaskId == task.id && + _attachments == null && + !_loadingAttachments) { + WidgetsBinding.instance + .addPostFrameCallback(( + _, + ) { + _loadAttachments( task.id, - file, - ), - ), - onTap: () => - _downloadTaskAttachment( - task.id, - file, + ); + }); + } + if (_loadingAttachments) { + return const Center( + child: + CircularProgressIndicator(), + ); + } + final files = + _attachments ?? []; + if (files.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all( + 16.0, ), + child: Text( + 'No attachments yet', + style: TextStyle( + color: Colors.grey, + ), + ), + ), + ); + } + return ListView.separated( + shrinkWrap: true, + physics: + const NeverScrollableScrollPhysics(), + itemCount: files.length, + separatorBuilder: + (context, index) => + const Divider(), + itemBuilder: (context, index) { + final file = files[index]; + return ListTile( + leading: const Icon( + Icons.insert_drive_file, + ), + title: Text(file), + subtitle: Text( + 'Tap to download', + style: Theme.of( + context, + ).textTheme.bodySmall, + ), + trailing: IconButton( + icon: const Icon( + Icons.delete, + color: Colors.red, + ), + onPressed: () => + _deleteTaskAttachment( + task.id, + file, + ), + ), + onTap: () => + _downloadTaskAttachment( + task.id, + file, + ), + ); + }, ); }, - ); - }, + ), + ], ), - ], + ), ), - ), + ], ), - ], - ), + ), + ], ), - ], - ), + ), + ], ), - ], - ), ), ), ], @@ -2847,8 +2924,9 @@ class _TaskDetailScreenState extends ConsumerState color: Theme.of(context) .colorScheme .surfaceContainerHighest, - borderRadius: - BorderRadius.circular(28), + borderRadius: BorderRadius.circular( + 28, + ), ), padding: const EdgeInsets.only( left: 16, @@ -2867,8 +2945,7 @@ class _TaskDetailScreenState extends ConsumerState border: InputBorder.none, isDense: true, contentPadding: - const EdgeInsets - .symmetric( + const EdgeInsets.symmetric( vertical: 10, ), ), @@ -2903,18 +2980,17 @@ class _TaskDetailScreenState extends ConsumerState IconButton( tooltip: 'Send', onPressed: canSendMessages - ? () => - _handleSendMessage( - task, - profilesAsync - .valueOrNull ?? - [], - ref.read( - currentUserIdProvider, - ), - canSendMessages, - typingChannelId, - ) + ? () => _handleSendMessage( + task, + profilesAsync + .valueOrNull ?? + [], + ref.read( + currentUserIdProvider, + ), + canSendMessages, + typingChannelId, + ) : null, icon: const Icon( Icons.send_rounded, @@ -3979,7 +4055,7 @@ class _TaskDetailScreenState extends ConsumerState var showTitleGemini = false; var titleDeepSeek = false; var descDeepSeek = false; - Timer? titleTypingTimer; + final titleTypingDebounce = Debouncer(const Duration(milliseconds: 700)); try { await m3ShowDialog( @@ -3996,196 +4072,194 @@ class _TaskDetailScreenState extends ConsumerState content: SizedBox( width: 600, child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Expanded( - child: GeminiAnimatedBorder( - isProcessing: titleProcessing, - useDeepSeekColors: titleDeepSeek, - child: KeyboardListener( - focusNode: FocusNode(), - onKeyEvent: (event) { - // Only react to actual key-down (typing). - if (event is! KeyDownEvent && - event is! KeyRepeatEvent) { - return; - } - // Skip modifier-only keys. - if (event.character == null || - event.character!.isEmpty) { - return; - } - titleTypingTimer?.cancel(); - if (showTitleGemini) { - setDialogState( - () => showTitleGemini = false, - ); - } - titleTypingTimer = Timer( - const Duration(milliseconds: 700), - () { + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: GeminiAnimatedBorder( + isProcessing: titleProcessing, + useDeepSeekColors: titleDeepSeek, + child: KeyboardListener( + focusNode: FocusNode(), + onKeyEvent: (event) { + // Only react to actual key-down (typing). + if (event is! KeyDownEvent && + event is! KeyRepeatEvent) { + return; + } + // Skip modifier-only keys. + if (event.character == null || + event.character!.isEmpty) { + return; + } + if (showTitleGemini) { + setDialogState( + () => showTitleGemini = false, + ); + } + titleTypingDebounce.run(() { if (titleCtrl.text.trim().isNotEmpty) { setDialogState( () => showTitleGemini = true, ); } - }, - ); - }, - child: TypeAheadFormField( - textFieldConfiguration: - TextFieldConfiguration( - controller: titleCtrl, - enabled: !saving, - decoration: const InputDecoration( - labelText: 'Title', + }); + }, + child: TypeAheadFormField( + textFieldConfiguration: + TextFieldConfiguration( + controller: titleCtrl, + enabled: !saving, + decoration: const InputDecoration( + labelText: 'Title', + ), ), - ), - suggestionsCallback: (pattern) async { - return SubjectSuggestionEngine.suggest( - existingSubjects: existingSubjects, - query: pattern, - limit: 8, - ); - }, - itemBuilder: (context, suggestion) => - ListTile( - dense: true, - title: Text(suggestion), - ), - onSuggestionSelected: (suggestion) { - titleTypingTimer?.cancel(); - titleCtrl - ..text = suggestion - ..selection = TextSelection.collapsed( - offset: suggestion.length, + suggestionsCallback: (pattern) async { + return SubjectSuggestionEngine.suggest( + existingSubjects: existingSubjects, + query: pattern, + limit: 8, ); - setDialogState( - () => showTitleGemini = false, - ); - }, + }, + itemBuilder: (context, suggestion) => + ListTile( + dense: true, + title: Text(suggestion), + ), + onSuggestionSelected: (suggestion) { + titleTypingDebounce.cancel(); + titleCtrl + ..text = suggestion + ..selection = TextSelection.collapsed( + offset: suggestion.length, + ); + setDialogState( + () => showTitleGemini = false, + ); + }, + ), ), ), ), - ), - // Show Gemini button only after the user pauses - // typing and has not selected a suggestion. - if (showTitleGemini) - GeminiButton( - textController: titleCtrl, - onTextUpdated: (updatedText) { - titleTypingTimer?.cancel(); - setDialogState(() { - titleCtrl.text = updatedText; - showTitleGemini = false; - }); - }, - onProcessingStateChanged: (isProcessing) { - setDialogState(() { - titleProcessing = isProcessing; - }); - }, - onProviderChanged: (isDeepSeek) { - setDialogState( - () => titleDeepSeek = isDeepSeek, + // Show Gemini button only after the user pauses + // typing and has not selected a suggestion. + if (showTitleGemini) + GeminiButton( + textController: titleCtrl, + onTextUpdated: (updatedText) { + titleTypingDebounce.cancel(); + setDialogState(() { + titleCtrl.text = updatedText; + showTitleGemini = false; + }); + }, + onProcessingStateChanged: (isProcessing) { + setDialogState(() { + titleProcessing = isProcessing; + }); + }, + onProviderChanged: (isDeepSeek) { + setDialogState( + () => titleDeepSeek = isDeepSeek, + ); + }, + tooltip: 'Improve task title with Gemini', + promptBuilder: (_) => + 'Fix the spelling and grammar of this IT ' + 'helpdesk ticket subject. Make it concise, ' + 'clear, and professional in English. ' + 'Return ONLY the corrected subject, ' + 'no explanations:', + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: GeminiAnimatedTextField( + controller: descCtrl, + enabled: !saving, + labelText: 'Description', + maxLines: 4, + isProcessing: descProcessing, + useDeepSeekColors: descDeepSeek, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: GeminiButton( + textController: descCtrl, + onTextUpdated: (updatedText) { + setDialogState(() { + descCtrl.text = updatedText; + }); + }, + onProcessingStateChanged: (isProcessing) { + setDialogState(() { + descProcessing = isProcessing; + }); + }, + onProviderChanged: (isDeepSeek) { + setDialogState( + () => descDeepSeek = isDeepSeek, + ); + }, + tooltip: 'Improve description with Gemini', + promptBuilder: (_) { + final subject = titleCtrl.text.trim(); + final hint = subject.isNotEmpty + ? 'about "$subject" ' + : ''; + return 'Improve this IT helpdesk ticket ' + 'description ${hint}for clarity and ' + 'professionalism. Fix grammar and translate ' + 'to English. Return ONLY the improved ' + 'description, no explanations:'; + }, + ), + ), + ], + ), + const SizedBox(height: 8), + Consumer( + builder: (dialogContext, dialogRef, _) { + final officesAsync = dialogRef.watch( + officesOnceProvider, + ); + return officesAsync.when( + data: (offices) { + final officesSorted = List.from(offices) + ..sort( + (a, b) => a.name.toLowerCase().compareTo( + b.name.toLowerCase(), + ), + ); + return OfficeSelectorField( + offices: officesSorted, + selectedOfficeId: selectedOffice, + allowUnassigned: true, + onChanged: saving + ? null + : (v) => setDialogState( + () => selectedOffice = v, + ), ); }, - tooltip: 'Improve task title with Gemini', - promptBuilder: (_) => - 'Fix the spelling and grammar of this IT ' - 'helpdesk ticket subject. Make it concise, ' - 'clear, and professional in English. ' - 'Return ONLY the corrected subject, ' - 'no explanations:', - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: GeminiAnimatedTextField( - controller: descCtrl, - enabled: !saving, - labelText: 'Description', - maxLines: 4, - isProcessing: descProcessing, - useDeepSeekColors: descDeepSeek, - ), - ), - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: GeminiButton( - textController: descCtrl, - onTextUpdated: (updatedText) { - setDialogState(() { - descCtrl.text = updatedText; - }); - }, - onProcessingStateChanged: (isProcessing) { - setDialogState(() { - descProcessing = isProcessing; - }); - }, - onProviderChanged: (isDeepSeek) { - setDialogState(() => descDeepSeek = isDeepSeek); - }, - tooltip: 'Improve description with Gemini', - promptBuilder: (_) { - final subject = titleCtrl.text.trim(); - final hint = subject.isNotEmpty - ? 'about "$subject" ' - : ''; - return 'Improve this IT helpdesk ticket ' - 'description ${hint}for clarity and ' - 'professionalism. Fix grammar and translate ' - 'to English. Return ONLY the improved ' - 'description, no explanations:'; - }, - ), - ), - ], - ), - const SizedBox(height: 8), - Consumer( - builder: (dialogContext, dialogRef, _) { - final officesAsync = dialogRef.watch( - officesOnceProvider, - ); - return officesAsync.when( - data: (offices) { - final officesSorted = List.from(offices) - ..sort( - (a, b) => a.name.toLowerCase().compareTo( - b.name.toLowerCase(), - ), - ); - return OfficeSelectorField( - offices: officesSorted, - selectedOfficeId: selectedOffice, - allowUnassigned: true, - onChanged: saving - ? null - : (v) => setDialogState( - () => selectedOffice = v, - ), - ); - }, - loading: () => const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: LinearProgressIndicator(), - ), - error: (error, _) => const SizedBox.shrink(), - ); - }, - ), - ], + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: LinearProgressIndicator(), + ), + error: (error, _) => const SizedBox.shrink(), + ); + }, + ), + ], + ), ), ), - ), actions: [ TextButton( onPressed: saving @@ -4254,7 +4328,7 @@ class _TaskDetailScreenState extends ConsumerState }, ); } finally { - titleTypingTimer?.cancel(); + titleTypingDebounce.cancel(); } } @@ -4309,7 +4383,7 @@ class _TaskDetailScreenState extends ConsumerState {'insert': '$trimmed\n'}, ]); - _actionDebounce?.cancel(); + _actionDebounce.cancel(); await ref .read(tasksControllerProvider) .updateTask(taskId: widget.taskId, actionTaken: deltaJson); @@ -4326,7 +4400,7 @@ class _TaskDetailScreenState extends ConsumerState TextSelection.collapsed(offset: trimmed.length), ); // Cancel any debounce that may have slipped through. - _actionDebounce?.cancel(); + _actionDebounce.cancel(); } if (context.mounted) { @@ -4961,8 +5035,8 @@ class _TaskPendingBannerState extends State<_TaskPendingBanner> child: Text( 'Pending sync — created offline. Edits will queue automatically.', style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: scheme.onErrorContainer, - ), + color: scheme.onErrorContainer, + ), ), ), ], diff --git a/lib/screens/tasks/task_pdf.dart b/lib/screens/tasks/task_pdf.dart index b401d2ab..52bbe910 100644 --- a/lib/screens/tasks/task_pdf.dart +++ b/lib/screens/tasks/task_pdf.dart @@ -10,11 +10,11 @@ import 'package:pdf/pdf.dart' as pdf; import 'package:printing/printing.dart'; import 'package:pdfrx/pdfrx.dart'; -import '../../models/task.dart'; -import '../../models/ticket.dart'; -import '../../models/task_activity_log.dart'; +import '../../models/task.model.dart'; +import '../../models/ticket.model.dart'; +import '../../models/task_activity_log.model.dart'; import '../../models/task_assignment.dart'; -import '../../models/profile.dart'; +import '../../models/profile.model.dart'; import '../../utils/app_time.dart'; Future buildTaskPdfBytes( diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index b25652a7..0cf816e7 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -7,14 +7,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:tasq/utils/app_time.dart'; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; -import '../../models/notification_item.dart'; -import '../../models/office.dart'; +import '../../models/notification_item.model.dart'; +import '../../models/office.model.dart'; import '../../widgets/office_picker.dart'; -import '../../models/profile.dart'; -import '../../models/task.dart'; +import '../../models/profile.model.dart'; +import '../../models/task.model.dart'; import '../../models/task_assignment.dart'; -import '../../models/ticket.dart'; +import '../../models/ticket.model.dart'; import '../../providers/notifications_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tasks_provider.dart'; @@ -1411,14 +1412,8 @@ String _assignedAgent(Map profileById, String? userId) { return profile.fullName.isNotEmpty ? profile.fullName : profile.id; } -String _formatTimestamp(DateTime value) { - final year = value.year.toString().padLeft(4, '0'); - final month = value.month.toString().padLeft(2, '0'); - final day = value.day.toString().padLeft(2, '0'); - final hour = value.hour.toString().padLeft(2, '0'); - final minute = value.minute.toString().padLeft(2, '0'); - return '$year-$month-$day $hour:$minute'; -} +String _formatTimestamp(DateTime value) => + DateFormat('yyyy-MM-dd HH:mm').format(value); class _StatusBadge extends StatelessWidget { const _StatusBadge({required this.status}); diff --git a/lib/screens/teams/teams_screen.dart b/lib/screens/teams/teams_screen.dart index 207de466..465132dc 100644 --- a/lib/screens/teams/teams_screen.dart +++ b/lib/screens/teams/teams_screen.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import '../../theme/m3_motion.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../models/office.dart'; -import '../../models/profile.dart'; -import '../../models/team.dart'; +import '../../models/office.model.dart'; +import '../../models/profile.model.dart'; +import '../../models/team.model.dart'; import '../../providers/teams_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tickets_provider.dart'; diff --git a/lib/screens/tickets/ticket_detail_screen.dart b/lib/screens/tickets/ticket_detail_screen.dart index 75450226..fd909176 100644 --- a/lib/screens/tickets/ticket_detail_screen.dart +++ b/lib/screens/tickets/ticket_detail_screen.dart @@ -4,9 +4,9 @@ import 'package:tasq/utils/app_time.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../models/office.dart'; -import '../../models/profile.dart'; -import '../../models/ticket.dart'; +import '../../models/office.model.dart'; +import '../../models/profile.model.dart'; +import '../../models/ticket.model.dart'; import '../../models/ticket_message.dart'; import '../../providers/notifications_provider.dart'; import '../../providers/supabase_provider.dart'; diff --git a/lib/screens/tickets/tickets_list_screen.dart b/lib/screens/tickets/tickets_list_screen.dart index 20cb35ec..9816219e 100644 --- a/lib/screens/tickets/tickets_list_screen.dart +++ b/lib/screens/tickets/tickets_list_screen.dart @@ -3,17 +3,19 @@ import '../../theme/m3_motion.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tasq/utils/app_time.dart'; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; -import '../../models/office.dart'; +import '../../models/office.model.dart'; import '../../widgets/office_picker.dart'; -import '../../models/notification_item.dart'; -import '../../models/profile.dart'; -import '../../models/ticket.dart'; +import '../../models/notification_item.model.dart'; +import '../../models/profile.model.dart'; +import '../../models/ticket.model.dart'; import '../../providers/notifications_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../providers/realtime_controller.dart'; import '../../providers/typing_provider.dart'; +import '../../providers/user_offices_provider.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/reconnect_overlay.dart'; import 'package:skeletonizer/skeletonizer.dart'; @@ -288,7 +290,10 @@ class _TicketsListScreenState extends ConsumerState { TasQColumn( header: 'Ticket ID', technical: true, - cellBuilder: (context, ticket) => Text(ticket.id), + cellBuilder: (context, ticket) => Tooltip( + message: ticket.id, + child: Text(_shortId(ticket.id)), + ), ), TasQColumn( header: 'Subject', @@ -362,7 +367,7 @@ class _TicketsListScreenState extends ConsumerState { const SizedBox(height: 2), Text('Filed by: $assigned'), const SizedBox(height: 4), - MonoText('ID ${ticket.id}'), + MonoText('ID ${_shortId(ticket.id)}'), const SizedBox(height: 2), Text(_formatTimestamp(ticket.createdAt)), ], @@ -450,6 +455,17 @@ class _TicketsListScreenState extends ConsumerState { return Consumer( builder: (context, ref, _) { final officesAsync = ref.watch(officesProvider); + final profile = ref.watch(currentProfileProvider).valueOrNull; + final isGlobal = const { + 'admin', + 'programmer', + 'dispatcher', + 'it_staff', + }.contains(profile?.role); + final myOfficeIds = { + for (final a in ref.watch(userOfficesProvider).valueOrNull ?? []) + if (a.userId == profile?.id) a.officeId, + }; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -468,8 +484,18 @@ class _TicketsListScreenState extends ConsumerState { ), const SizedBox(height: 12), officesAsync.when( - data: (offices) { - if (offices.isEmpty) return const Text('No offices assigned.'); + data: (allOffices) { + // ticketsProvider only shows non-global users their own + // offices' tickets, so only offer those offices here — + // otherwise the new ticket vanishes from the filer's list. + final offices = isGlobal + ? allOffices + : allOffices + .where((o) => myOfficeIds.contains(o.id)) + .toList(); + if (offices.isEmpty) { + return const Text('No offices assigned.'); + } final officesSorted = List.from(offices) ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); return OfficeSelectorField( @@ -761,6 +787,11 @@ class _StatusSummaryCard extends StatelessWidget { } } +/// First 8 chars of the UUID, like a short git hash; the full id is in the +/// tooltip. Tickets have no human-friendly number. +String _shortId(String id) => + id.length > 8 ? id.substring(0, 8).toUpperCase() : id; + String _assignedAgent(Map profileById, String? userId) { if (userId == null || userId.isEmpty) { return 'Unassigned'; @@ -772,14 +803,8 @@ String _assignedAgent(Map profileById, String? userId) { return profile.fullName.isNotEmpty ? profile.fullName : profile.id; } -String _formatTimestamp(DateTime value) { - final year = value.year.toString().padLeft(4, '0'); - final month = value.month.toString().padLeft(2, '0'); - final day = value.day.toString().padLeft(2, '0'); - final hour = value.hour.toString().padLeft(2, '0'); - final minute = value.minute.toString().padLeft(2, '0'); - return '$year-$month-$day $hour:$minute'; -} +String _formatTimestamp(DateTime value) => + DateFormat('yyyy-MM-dd HH:mm').format(value); class _StatusBadge extends StatelessWidget { const _StatusBadge({required this.status}); diff --git a/lib/screens/whereabouts/whereabouts_screen.dart b/lib/screens/whereabouts/whereabouts_screen.dart index 1dca4b68..254c907f 100644 --- a/lib/screens/whereabouts/whereabouts_screen.dart +++ b/lib/screens/whereabouts/whereabouts_screen.dart @@ -4,9 +4,9 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart' show LatLng; import '../../models/app_settings.dart'; -import '../../models/attendance_log.dart'; +import '../../models/attendance_log.model.dart'; import '../../models/live_position.dart'; -import '../../models/profile.dart'; +import '../../models/profile.model.dart'; import '../../providers/attendance_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/whereabouts_provider.dart'; diff --git a/lib/screens/workforce/rotation_settings_dialog.dart b/lib/screens/workforce/rotation_settings_dialog.dart index 8966fe10..d3ca42c1 100644 --- a/lib/screens/workforce/rotation_settings_dialog.dart +++ b/lib/screens/workforce/rotation_settings_dialog.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../models/profile.dart'; +import '../../models/profile.model.dart'; import '../../models/rotation_config.dart'; import '../../providers/profile_provider.dart'; import '../../providers/rotation_config_provider.dart'; @@ -287,9 +287,8 @@ class _RotationOrderTabState extends ConsumerState<_RotationOrderTab> { child: ReorderableListView.builder( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), itemCount: order.length, - onReorder: (oldIndex, newIndex) { + onReorderItem: (oldIndex, newIndex) { setState(() { - if (newIndex > oldIndex) newIndex--; final item = order.removeAt(oldIndex); order.insert(newIndex, item); }); @@ -422,9 +421,8 @@ class _FridayAmTabState extends ConsumerState<_FridayAmTab> { child: ReorderableListView.builder( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), itemCount: order.length, - onReorder: (oldIndex, newIndex) { + onReorderItem: (oldIndex, newIndex) { setState(() { - if (newIndex > oldIndex) newIndex--; final item = order.removeAt(oldIndex); order.insert(newIndex, item); }); @@ -1394,7 +1392,9 @@ class _HolidaySettingsTabState extends ConsumerState<_HolidaySettingsTab> { child: const Text('Cancel'), ), FilledButton( - onPressed: () => Navigator.of(dialogContext).pop(true), + onPressed: name.trim().isEmpty + ? null + : () => Navigator.of(dialogContext).pop(true), child: const Text('Add'), ), ], @@ -1404,7 +1404,7 @@ class _HolidaySettingsTabState extends ConsumerState<_HolidaySettingsTab> { }, ); - if (confirmed != true || name.isEmpty) return; + if (confirmed != true || name.trim().isEmpty) return; setState(() { _holidays = [ diff --git a/lib/screens/workforce/workforce_screen.dart b/lib/screens/workforce/workforce_screen.dart index 99324bc1..a5180bbe 100644 --- a/lib/screens/workforce/workforce_screen.dart +++ b/lib/screens/workforce/workforce_screen.dart @@ -4,10 +4,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tasq/utils/app_time.dart'; import 'package:timezone/timezone.dart' as tz; -import '../../models/duty_schedule.dart'; -import '../../models/profile.dart'; +import '../../models/duty_schedule.model.dart'; +import '../../models/profile.model.dart'; import '../../models/rotation_config.dart'; -import '../../models/swap_request.dart'; +import '../../models/swap_request.model.dart'; import '../../providers/profile_provider.dart'; import '../../providers/rotation_config_provider.dart'; @@ -1005,7 +1005,13 @@ class _ScheduleGeneratorPanelState rotationConfig: rotationConfig, ); generated.sort((a, b) => a.startTime.compareTo(b.startTime)); - final warnings = _buildWarnings(start, end, generated, rotationConfig); + final warnings = _buildWarnings( + start, + end, + generated, + schedules, + rotationConfig, + ); if (!mounted) return; setState(() { @@ -1229,6 +1235,7 @@ class _ScheduleGeneratorPanelState _startDate ?? AppTime.now(), _endDate ?? AppTime.now(), _draftSchedules, + ref.read(dutySchedulesProvider).valueOrNull ?? const [], ref.read(rotationConfigProvider).valueOrNull, ); }); @@ -1455,6 +1462,7 @@ class _ScheduleGeneratorPanelState _startDate ?? result.startTime, _endDate ?? result.endTime, _draftSchedules, + ref.read(dutySchedulesProvider).valueOrNull ?? const [], ref.read(rotationConfigProvider).valueOrNull, ); }); @@ -2171,6 +2179,7 @@ class _ScheduleGeneratorPanelState DateTime start, DateTime end, List<_DraftSchedule> drafts, + List existing, RotationConfig? rotationConfig, ) { final warnings = []; @@ -2187,15 +2196,27 @@ class _ScheduleGeneratorPanelState required.addAll({'am', 'pm'}); } - final available = drafts - .where( - (draft) => - draft.startTime.year == day.year && - draft.startTime.month == day.month && - draft.startTime.day == day.day, - ) - .map((draft) => _normalizeShiftType(draft.shiftType)) - .toSet(); + final available = { + ...drafts + .where( + (draft) => + draft.startTime.year == day.year && + draft.startTime.month == day.month && + draft.startTime.day == day.day, + ) + .map((draft) => _coverageShiftType(draft.shiftType)), + // Shifts already committed for this day count as covered, so + // regenerating a partially/fully scheduled range doesn't report + // false "uncovered" gaps that contradict the Schedule tab. + ...existing + .where( + (schedule) => + schedule.startTime.year == day.year && + schedule.startTime.month == day.month && + schedule.startTime.day == day.day, + ) + .map((schedule) => _coverageShiftType(schedule.shiftType)), + }; for (final shift in required) { if (!available.contains(shift)) { @@ -2219,6 +2240,17 @@ class _ScheduleGeneratorPanelState return value; } + // The uncovered-shifts check requires a generic 'on_call' each day, but the + // generator emits the weekend variants 'on_call_saturday'/'on_call_sunday'. + // Fold those to 'on_call' for coverage only (not for templates/generation), + // otherwise every Saturday and Sunday reports a false "missing On Call". + String _coverageShiftType(String value) { + if (value == 'on_call_saturday' || value == 'on_call_sunday') { + return 'on_call'; + } + return _normalizeShiftType(value); + } + String _shiftLabel(String value, RotationConfig? rotationConfig) { final configured = rotationConfig?.shiftTypes.firstWhere( (s) => s.id == value, diff --git a/lib/services/gemini_service.dart b/lib/services/gemini_service.dart deleted file mode 100644 index e80fb62e..00000000 --- a/lib/services/gemini_service.dart +++ /dev/null @@ -1,144 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:google_generative_ai/google_generative_ai.dart'; -import 'package:http/http.dart' as http; - -class GeminiService { - static final GeminiService _instance = GeminiService._internal(); - - late final String _apiKey; - - /// Cache of valid model IDs (flash/lite, supporting generateContent). - List _validModels = []; - - factory GeminiService() => _instance; - - GeminiService._internal() { - final apiKey = dotenv.env['GEMINI_API_KEY']; - if (apiKey == null || apiKey.isEmpty) { - throw Exception('GEMINI_API_KEY not found in .env file'); - } - _apiKey = apiKey; - } - - // --------------------------------------------------------------------------- - // Model discovery - // --------------------------------------------------------------------------- - - /// Queries the Gemini REST API for available models and caches those that - /// - support `generateContent`, AND - /// - contain "flash" or "lite" in their name (free-tier / fast models). - /// - /// Returns a stable cached list on subsequent calls. - Future> _getValidModels() async { - if (_validModels.isNotEmpty) return _validModels; - - try { - final uri = Uri.parse( - 'https://generativelanguage.googleapis.com/v1beta/models?key=$_apiKey', - ); - final response = await http.get(uri); - - if (response.statusCode == 200) { - final data = jsonDecode(response.body) as Map; - final rawModels = (data['models'] as List?) ?? []; - - final discovered = []; - for (final m in rawModels) { - final fullName = (m['name'] as String? ?? ''); - final lower = fullName.toLowerCase(); - final methods = - (m['supportedGenerationMethods'] as List?) ?? []; - - if (methods.contains('generateContent') && - (lower.contains('flash') || lower.contains('lite'))) { - // Strip the "models/" prefix so it can be passed directly to - // GenerativeModel(model: ...). - final id = fullName.startsWith('models/') - ? fullName.substring('models/'.length) - : fullName; - discovered.add(id); - } - } - - // Sort descending so newer/more-capable models are tried first. - discovered.sort((a, b) => b.compareTo(a)); - _validModels = discovered; - } - } catch (_) { - // Fall back to hard-coded list of known free-tier models below. - } - - // If discovery failed or returned nothing, use safe known fallbacks. - if (_validModels.isEmpty) { - _validModels = [ - 'gemini-2.5-flash-lite', - 'gemini-2.5-flash', - 'gemini-2.0-flash', - 'gemini-1.5-flash', - ]; - } - - return _validModels; - } - - // --------------------------------------------------------------------------- - // Public API - // --------------------------------------------------------------------------- - - /// Fixes spelling/grammar, improves clarity, and translates [text] to - /// professional English. - /// - /// Provide a custom [promptInstruction] to give Gemini field-specific - /// context (subject, description, action-taken). If omitted a sensible - /// default is used. - /// - /// Automatically retries with the next available model on 429 / quota - /// errors to minimise wasted quota calls. - Future enhanceText(String text, {String? promptInstruction}) async { - if (text.trim().isEmpty) return text; - final instruction = - promptInstruction ?? - 'Fix spelling and grammar, improve clarity, and translate to ' - 'professional English. Return ONLY the improved text, no explanations:'; - final prompt = '$instruction\n\n"$text"'; - return _generateWithRetry(prompt, fallback: text); - } - - // --------------------------------------------------------------------------- - // Internal helpers - // --------------------------------------------------------------------------- - - /// Sends [prompt] to Gemini, retrying across all valid models on 429 errors. - /// Returns the model response on success, or throws if all models fail. - Future _generateWithRetry( - String prompt, { - required String fallback, - }) async { - final models = await _getValidModels(); - - for (int i = 0; i < models.length; i++) { - try { - final model = GenerativeModel(model: models[i], apiKey: _apiKey); - final response = await model.generateContent([Content.text(prompt)]); - return response.text ?? fallback; - } catch (e) { - final msg = e.toString().toLowerCase(); - final is429 = - msg.contains('429') || - msg.contains('quota') || - msg.contains('resource_exhausted'); - - if (!is429 || i == models.length - 1) { - // Non-quota error or last model — give up. - throw Exception('Gemini request failed: $e'); - } - // Quota hit — try the next model. - } - } - - // Should not reach here, but safety fallback. - throw Exception('All Gemini models exhausted'); - } -} diff --git a/lib/services/notification_bridge.dart b/lib/services/notification_bridge.dart index a91b7d54..93c12207 100644 --- a/lib/services/notification_bridge.dart +++ b/lib/services/notification_bridge.dart @@ -2,10 +2,28 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; -import '../models/notification_item.dart'; +import '../models/notification_item.model.dart'; import '../providers/notifications_provider.dart'; import '../providers/notification_navigation_provider.dart'; import '../routing/app_router.dart'; +import '../utils/snackbar.dart'; + +/// The newest unread item in [next] that wasn't in [prev], or null. +/// +/// Returns null for the first snapshot ([prev] == null) so the existing inbox +/// doesn't raise a banner on startup. [next] is sorted newest-first. +@visibleForTesting +NotificationItem? newestArrival( + List? prev, + List next, +) { + if (prev == null) return null; + final seen = {for (final n in prev) n.id}; + for (final n in next) { + if (n.isUnread && !seen.contains(n.id)) return n; + } + return null; +} /// Wraps the app and installs both a Supabase realtime listener and the /// FCM handlers described in the frontend design. @@ -24,8 +42,8 @@ class NotificationBridge extends ConsumerStatefulWidget { class _NotificationBridgeState extends ConsumerState with WidgetsBindingObserver { - // store previous notifications to diff - List _prevList = []; + // Last loaded snapshot to diff against; null until the inbox first loads. + List? _prevList; @override void initState() { @@ -92,24 +110,25 @@ class _NotificationBridgeState extends ConsumerState }; void _showBanner(String type, NotificationItem item) { - // Use a post-frame callback so that the ScaffoldMessenger from - // MaterialApp is available in the element tree. + // This widget sits above TasqApp's MaterialApp.router, so + // ScaffoldMessenger.of(context) would resolve to a messenger with no + // Scaffold. Use the app-level messenger key instead. WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - try { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(_bannerLabel(type)), - action: SnackBarAction( - label: 'View', - onPressed: () => - _goToDetail(taskId: item.taskId, ticketId: item.ticketId), + scaffoldMessengerKey.currentState?.showSnackBar( + SnackBar( + content: Text(_bannerLabel(type)), + action: SnackBarAction( + label: 'View', + onPressed: () => _goToDetail( + route: item.announcementId != null ? '/announcements' : null, + taskId: item.taskId, + ticketId: item.ticketId, + itServiceRequestId: item.itServiceRequestId, ), ), - ); - } catch (_) { - // ScaffoldMessenger not available yet (before first frame) — ignore. - } + ), + ); }); } @@ -163,16 +182,12 @@ class _NotificationBridgeState extends ConsumerState previous, next, ) { - final prevList = _prevList; - final nextList = next.maybeWhen( - data: (d) => d, - orElse: () => [], - ); - if (nextList.length > prevList.length) { - final newItem = nextList.last; - _showBanner(newItem.type, newItem); - } + // Loading/error emissions (e.g. invalidate on resume) keep the snapshot. + final nextList = next.valueOrNull; + if (nextList == null) return; + final item = newestArrival(_prevList, nextList); _prevList = nextList; + if (item != null) _showBanner(item.type, item); }); // Listen for pending navigation from local-notification taps diff --git a/lib/theme/m3_motion.dart b/lib/theme/m3_motion.dart index 4f79f275..b48081a7 100644 --- a/lib/theme/m3_motion.dart +++ b/lib/theme/m3_motion.dart @@ -871,44 +871,3 @@ class _M3BounceIconState extends State return ScaleTransition(scale: _entrance, child: badge); } } - -// ═══════════════════════════════════════════════════════════════ -// M3AnimatedCounter — smooth number animation -// ═══════════════════════════════════════════════════════════════ - -/// Animates an integer value from its previous value to [value] using a -/// [TweenAnimationBuilder], producing a smooth counting effect on metric -/// cards and dashboard KPIs. -/// -/// ```dart -/// M3AnimatedCounter( -/// value: totalTasks, -/// style: theme.textTheme.headlineMedium, -/// ) -/// ``` -class M3AnimatedCounter extends StatelessWidget { - const M3AnimatedCounter({ - super.key, - required this.value, - this.style, - this.duration = M3Motion.standard, - }); - - final int value; - final TextStyle? style; - final Duration duration; - - @override - Widget build(BuildContext context) { - if (m3ReducedMotion(context)) { - return Text(value.toString(), style: style); - } - - return TweenAnimationBuilder( - tween: IntTween(begin: 0, end: value), - duration: duration, - curve: M3Motion.emphasizedEnter, - builder: (_, v, _) => Text(v.toString(), style: style), - ); - } -} diff --git a/lib/utils/app_time.dart b/lib/utils/app_time.dart index 9d3b3e74..876c0cec 100644 --- a/lib/utils/app_time.dart +++ b/lib/utils/app_time.dart @@ -29,6 +29,16 @@ class AppTime { return toAppTime(DateTime.parse(value)); } + /// "Just now", "5m ago", "3h ago", "2d ago", then a short date after a week. + static String relative(DateTime value) { + final diff = now().difference(value); + 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 formatDate(value); + } + /// Converts a [DateTime] into a human-readable short date string. /// /// Example: **Jan 05, 2025**. This matches the format previously used by diff --git a/lib/utils/debouncer.dart b/lib/utils/debouncer.dart new file mode 100644 index 00000000..92877c69 --- /dev/null +++ b/lib/utils/debouncer.dart @@ -0,0 +1,17 @@ +import 'dart:async'; + +/// Runs an action after [duration] of inactivity, cancelling any pending +/// run each time [run] is called again. +class Debouncer { + Debouncer(this.duration); + + final Duration duration; + Timer? _timer; + + void run(void Function() action) { + _timer?.cancel(); + _timer = Timer(duration, action); + } + + void cancel() => _timer?.cancel(); +} diff --git a/lib/utils/device_security.dart b/lib/utils/device_security.dart index a3d2c76d..4bd68ac8 100644 --- a/lib/utils/device_security.dart +++ b/lib/utils/device_security.dart @@ -1,12 +1,14 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:safe_device/safe_device.dart'; /// Returns true if the device appears compromised for attendance purposes: /// rooted (Android) / jailbroken (iOS), or developer mode enabled. /// Returns false on any error so a detection failure never blocks a valid user. Future isDeviceCompromised() async { - if (!Platform.isAndroid && !Platform.isIOS) return false; + // dart:io Platform throws on web, so check kIsWeb first. + if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) return false; try { final isJailBroken = await SafeDevice.isJailBroken; final isDeveloperMode = await SafeDevice.isDevelopmentModeEnable; diff --git a/lib/utils/snackbar.dart b/lib/utils/snackbar.dart index 025e3dee..a72be413 100644 --- a/lib/utils/snackbar.dart +++ b/lib/utils/snackbar.dart @@ -29,6 +29,20 @@ ContentType _mapSnackType(SnackType t) { } } +/// Reduces exception dumps that callers interpolate into messages (e.g. +/// `'Error: \$e'` → `Error: PostgrestException(message: X, code: …)`) to +/// just the human-readable `X`, so users never see raw exception syntax. +@visibleForTesting +String humanizeErrorText(String text) => text + .replaceAllMapped( + RegExp( + r'\w*Exception\(message: (.*?)(?:, (?:statusCode|code|details|hint|error):.*)?\)$', + dotAll: true, + ), + (m) => m[1]!, + ) + .replaceAll(RegExp(r'\bException: '), ''); + /// Core function used by all of the convenience helpers below. void showAwesomeSnackBar( BuildContext context, { @@ -49,7 +63,7 @@ void showAwesomeSnackBar( padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), child: AwesomeSnackbarContent( title: title, - message: message, + message: humanizeErrorText(message), contentType: _mapSnackType(snackType), ), ), @@ -176,7 +190,7 @@ void showAwesomeSnackBarGlobal({ padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), child: AwesomeSnackbarContent( title: title, - message: message, + message: humanizeErrorText(message), contentType: _mapSnackType(snackType), ), ), diff --git a/lib/utils/task_duration.dart b/lib/utils/task_duration.dart index 9ee0affc..b281fc81 100644 --- a/lib/utils/task_duration.dart +++ b/lib/utils/task_duration.dart @@ -1,4 +1,4 @@ -import '../models/task_activity_log.dart'; +import '../models/task_activity_log.model.dart'; /// Finds the canonical execution start time for a task. /// diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index f892110b..4d3091c3 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -158,7 +158,11 @@ class _AppNavigationRailState extends State { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - final currentIndex = _currentIndex(widget.location, widget.items); + // Exact match only: routes outside the rail (profile, notifications) + // highlight nothing instead of falling back to Dashboard. + final currentIndex = widget.items.indexWhere( + (item) => _isSelected(widget.location, item.route), + ); // M3 Expressive: tonal surface container instead of a hard border divider. // Custom scrollable layout replaces NavigationRail so items are reachable @@ -481,15 +485,17 @@ List _buildSections(String role) { icon: Icons.groups_outlined, selectedIcon: Icons.groups, ), - NavItem( - label: 'Reports', - route: '/reports', - icon: Icons.analytics_outlined, - selectedIcon: Icons.analytics, - ), + if (!isStandard) + NavItem( + label: 'Reports', + route: '/reports', + icon: Icons.analytics_outlined, + selectedIcon: Icons.analytics, + ), ]; - if (role == 'admin' || role == 'programmer' || role == 'dispatcher') { + // Must match the /settings guard in app_router.dart (admin/programmer). + if (role == 'admin' || role == 'programmer') { return [ NavSection(label: 'Operations', items: mainItems), NavSection( diff --git a/lib/widgets/office_picker.dart b/lib/widgets/office_picker.dart index e8b6bffc..ce868c52 100644 --- a/lib/widgets/office_picker.dart +++ b/lib/widgets/office_picker.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../models/office.dart'; +import '../models/office.model.dart'; import '../theme/m3_motion.dart'; import 'app_breakpoints.dart'; diff --git a/lib/widgets/task_assignment_section.dart b/lib/widgets/task_assignment_section.dart index 8a03b051..051eba3f 100644 --- a/lib/widgets/task_assignment_section.dart +++ b/lib/widgets/task_assignment_section.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../theme/m3_motion.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../models/profile.dart'; +import '../models/profile.model.dart'; import '../models/task_assignment.dart'; import '../providers/profile_provider.dart'; import '../providers/tasks_provider.dart'; @@ -68,13 +68,15 @@ class TaskAssignmentSection extends ConsumerWidget { children: [ Row( children: [ - Text( - 'Assigned IT Staff', - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + Expanded( + child: Text( + 'Assigned IT Staff', + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), ), - const Spacer(), if (canAssign) TextButton.icon( onPressed: () => _showAssignmentDialog( diff --git a/lib/widgets/tasq_adaptive_list.dart b/lib/widgets/tasq_adaptive_list.dart index c2f1351d..d0420a7e 100644 --- a/lib/widgets/tasq_adaptive_list.dart +++ b/lib/widgets/tasq_adaptive_list.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import '../theme/app_typography.dart'; import '../theme/app_surfaces.dart'; import '../theme/m3_motion.dart'; +import 'app_breakpoints.dart'; import 'mono_text.dart'; /// A column configuration for the [TasQAdaptiveList] desktop table view. @@ -359,7 +360,10 @@ class TasQAdaptiveList extends StatelessWidget { // On medium screens (< 1200px) hide columns that are marked hideOnMedium, // preventing the horizontal scrollbar from appearing on tablet viewports. - final isMediumScreen = constraints.maxWidth < 1200; + // Measure the screen, not this list: after the nav rail and page padding + // the list is < 1200px on most desktops, which hid e.g. Tasks' Status. + final isMediumScreen = + MediaQuery.sizeOf(context).width < AppBreakpoints.desktop; final visibleColumns = isMediumScreen ? columns.where((c) => !c.hideOnMedium).toList() : columns; diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart index ae5d19be..07dd4a20 100644 --- a/lib/widgets/update_dialog.dart +++ b/lib/widgets/update_dialog.dart @@ -5,6 +5,7 @@ import 'package:flutter_quill/flutter_quill.dart' as quill; import '../models/app_version.dart'; import '../services/app_update_service.dart'; +import '../utils/snackbar.dart'; /// A reusable dialog that can render both flexible and forced updates and /// report download progress. Callers should wrap this with `showDialog` and @@ -47,9 +48,7 @@ class _UpdateDialogState extends State { _failed = true; _downloading = false; }); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Download failed: $err'))); + showErrorSnackBar(context, 'Download failed: $err'); } } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2056cf53..a2b69fb9 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -11,7 +11,6 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - pdfium_flutter ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index ccd1910f..df8f2ccc 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -16,6 +16,7 @@ import flutter_image_compress_macos import flutter_local_notifications import geolocator_apple import package_info_plus +import pdfium_flutter import printing import quill_native_bridge_macos import share_plus @@ -35,6 +36,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PDFiumFlutterPlugin.register(with: registry.registrar(forPlugin: "PDFiumFlutterPlugin")) PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) QuillNativeBridgePlugin.register(with: registry.registrar(forPlugin: "QuillNativeBridgePlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 63dc7864..89b515a0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -69,10 +69,10 @@ packages: dependency: transitive description: name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.0.9" args: dependency: transitive description: @@ -373,10 +373,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -465,6 +465,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + cupertino_ui: + dependency: transitive + description: + name: cupertino_ui + sha256: c23816577a1280cff409e989541767887011f8f1d8c1863a794ed308af46f2a9 + url: "https://pub.dev" + source: hosted + version: "1.1.1" dart_earcut: dependency: transitive description: @@ -537,22 +545,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.1" - dio: - dependency: "direct main" - description: - name: dio - sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c - url: "https://pub.dev" - source: hosted - version: "5.9.2" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" - url: "https://pub.dev" - source: hosted - version: "2.1.2" ed25519_edwards: dependency: transitive description: @@ -581,10 +573,10 @@ packages: dependency: transitive description: name: ffi - sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.2.0" file: dependency: transitive description: @@ -799,7 +791,7 @@ packages: source: hosted version: "0.1.5" flutter_keyboard_visibility: - dependency: "direct main" + dependency: "direct overridden" description: name: flutter_keyboard_visibility sha256: "98664be7be0e3ffca00de50f7f6a287ab62c763fc8c762e0a21584584a3ff4f8" @@ -935,10 +927,10 @@ packages: dependency: "direct main" description: name: flutter_quill - sha256: b96bb8525afdeaaea52f5d02f525e05cc34acd176467ab6d6f35d434cf14fde2 + sha256: "1f773f5f75e2628d80361c2c840c592791a0a157ebd587c9e37cf2cb8b8519b7" url: "https://pub.dev" source: hosted - version: "11.5.0" + version: "11.6.0" flutter_quill_delta_from_html: dependency: transitive description: @@ -985,10 +977,10 @@ packages: dependency: "direct main" description: name: font_awesome_flutter - sha256: b9011df3a1fa02993630b8fb83526368cf2206a711259830325bab2f1d2a4eb0 + sha256: "09dcde8ab90ffae1a7d65ff2ef96fc62a17ad9d0ce7c127b317ded676b0d5935" url: "https://pub.dev" source: hosted - version: "10.12.0" + version: "11.0.0" functions_client: dependency: transitive description: @@ -1161,10 +1153,10 @@ packages: dependency: transitive description: name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89 url: "https://pub.dev" source: hosted - version: "4.5.4" + version: "4.10.1" image_picker: dependency: "direct main" description: @@ -1233,10 +1225,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" io: dependency: transitive description: @@ -1337,26 +1329,34 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.20" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" + material_ui: + dependency: transitive + description: + name: material_ui + sha256: "134ab2f0843545c4e661e4ba2753fe1bcf0753cb690d5a9ee4870638a40411e9" + url: "https://pub.dev" + source: hosted + version: "1.4.0" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.19.0" mgrs_dart: dependency: transitive description: @@ -1489,10 +1489,10 @@ packages: dependency: "direct main" description: name: pdf - sha256: "28eacad99bffcce2e05bba24e50153890ad0255294f4dd78a17075a2ba5c8416" + sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b url: "https://pub.dev" source: hosted - version: "3.11.3" + version: "3.12.0" pdf_widget_wrapper: dependency: transitive description: @@ -1505,34 +1505,34 @@ packages: dependency: transitive description: name: pdfium_dart - sha256: f1683b9070ddc5c9189c6ee008c285791da66328ce1b882c7162d3393f3a4a74 + sha256: e296a26c030e6e0fb34b32ce7481a3cfa0c2d35ce80f6269551c685759f4c91f url: "https://pub.dev" source: hosted - version: "0.1.3" + version: "0.3.1" pdfium_flutter: dependency: transitive description: name: pdfium_flutter - sha256: "0c8b7d5d11d20a1486eade599648e907067568955bd14a1b06de076a968b60a1" + sha256: "59897274513984b347fc98e7bf71ba457678daea8b5568cd7c688979aeb77765" url: "https://pub.dev" source: hosted - version: "0.1.9" + version: "0.3.1" pdfrx: dependency: "direct main" description: name: pdfrx - sha256: e32e0c786528eec2b3c56b43f59ef1debce3a27c7accd862b95413f949afcfa9 + sha256: "52e78d84a706e5d63d2502689fe8e800d823bc780ac4341b2d3683a4d726505a" url: "https://pub.dev" source: hosted - version: "2.2.24" + version: "2.6.5" pdfrx_engine: dependency: transitive description: name: pdfrx_engine - sha256: a8914433d1f6188b903c53d36b9d7dc908bfa89131591a9db22f1a22470d3a48 + sha256: f74ca9ea4ec722545f054a1d6ee1bf01222409ecd9458e5945c0cc5feaa3d2c6 url: "https://pub.dev" source: hosted - version: "0.3.9" + version: "0.6.1" permission_handler: dependency: "direct main" description: @@ -1649,10 +1649,10 @@ packages: dependency: "direct main" description: name: printing - sha256: "482cd5a5196008f984bb43ed0e47cbfdca7373490b62f3b27b3299275bf22a93" + sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692" url: "https://pub.dev" source: hosted - version: "5.14.2" + version: "5.14.3" proj4dart: dependency: transitive description: @@ -2038,10 +2038,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.12" timezone: dependency: "direct main" description: @@ -2166,10 +2166,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.3" vm_service: dependency: transitive description: @@ -2267,5 +2267,5 @@ packages: source: hosted version: "2.1.0" sdks: - dart: ">=3.10.7 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.13.0 <4.0.0" + flutter: ">=3.47.0" diff --git a/pubspec.yaml b/pubspec.yaml index 8706a577..757807c3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: 'none' version: 0.1.0 environment: - sdk: ^3.10.7 + sdk: ^3.12.0 dependencies: flutter: @@ -15,7 +15,7 @@ dependencies: flutter_riverpod: ^2.6.1 go_router: ^14.6.2 flutter_dotenv: ^5.2.1 - font_awesome_flutter: ^10.7.0 + font_awesome_flutter: ^11.0.0 google_fonts: ^8.0.2 audioplayers: ^6.1.0 geolocator: ^13.0.1 @@ -24,12 +24,11 @@ dependencies: flutter_map: ^8.2.2 latlong2: ^0.9.0 flutter_typeahead: ^4.1.0 - flutter_quill: ^11.5.0 + flutter_quill: ^11.6.0 file_picker: ^10.3.10 pdf: ^3.11.3 - pdfrx: ^2.2.24 + pdfrx: ^2.6.0 printing: ^5.14.2 - flutter_keyboard_visibility: ^5.4.1 awesome_snackbar_content: ^0.1.8 permission_handler: ^12.0.1 flutter_local_notifications: ^20.1.0 @@ -51,7 +50,6 @@ dependencies: google_mlkit_face_detection: ^0.13.2 qr_flutter: ^4.1.0 flutter_image_compress: ^2.4.0 - dio: ^5.1.2 package_info_plus: ^9.0.0 share_plus: ^10.0.0 pub_semver: ^2.1.1 @@ -78,6 +76,7 @@ flutter: - assets/ - assets/fonts/ +# flutter_typeahead pulls in 5.x transitively, and 5.x breaks the Android build (c31187ef). dependency_overrides: flutter_keyboard_visibility: ^6.0.0 diff --git a/supabase/functions/admin_user_management/index.ts b/supabase/functions/admin_user_management/index.ts index 8104e4fa..a7cfb456 100644 --- a/supabase/functions/admin_user_management/index.ts +++ b/supabase/functions/admin_user_management/index.ts @@ -91,7 +91,9 @@ serve(async (req) => { .eq("id", authData.user.id) .maybeSingle(); const role = (profile?.role ?? "").toString().toLowerCase(); - if (profileError || role != "admin") { + // QA-016: admins and programmers may manage users (the UI already exposes + // User Management to both roles). + if (profileError || (role != "admin" && role != "programmer")) { return jsonResponse({ error: "Forbidden" }, 403); } diff --git a/supabase/migrations/20260924090000_isr_rls_hardening.sql b/supabase/migrations/20260924090000_isr_rls_hardening.sql new file mode 100644 index 00000000..29423459 --- /dev/null +++ b/supabase/migrations/20260924090000_isr_rls_hardening.sql @@ -0,0 +1,173 @@ +-- QA-015: it_service_requests and its assignments used USING (true), so any +-- signed-in user could read every office's requests, edit any request, assign +-- staff, or approve (status -> scheduled) via the REST API. The app only hid +-- these actions in the UI. Mirror the app's rules server-side: +-- * admin/programmer/dispatcher/it_staff see and manage all requests +-- * other users see their own requests and their offices' requests, and may +-- edit only their own while draft/pending_approval +-- * assigned staff may update the requests they're assigned to +-- * only admins approve (enforced by trigger; RLS can't compare OLD/NEW) + +-- ----- it_service_requests ----- +DROP POLICY IF EXISTS "Authenticated users can read it_service_requests" ON it_service_requests; +DROP POLICY IF EXISTS "ISR: select" ON it_service_requests; +CREATE POLICY "ISR: select" ON it_service_requests + FOR SELECT TO authenticated USING ( + creator_id = auth.uid() + OR EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin','programmer','dispatcher','it_staff')) + OR office_id IN (SELECT uo.office_id FROM public.user_offices uo + WHERE uo.user_id = auth.uid()) + ); + +DROP POLICY IF EXISTS "Authenticated users can update it_service_requests" ON it_service_requests; +DROP POLICY IF EXISTS "ISR: update" ON it_service_requests; +CREATE POLICY "ISR: update" ON it_service_requests + FOR UPDATE TO authenticated USING ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin','programmer','dispatcher','it_staff')) + OR (creator_id = auth.uid() AND status IN ('draft','pending_approval')) + OR EXISTS (SELECT 1 FROM public.it_service_request_assignments a + WHERE a.request_id = it_service_requests.id + AND a.user_id = auth.uid()) + ); +-- WITH CHECK defaults to USING, so a creator can't move their own request +-- out of draft/pending_approval (e.g. self-approve to 'scheduled'). + +CREATE OR REPLACE FUNCTION public.isr_guard_approval() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + -- Only end-user requests are guarded; service-role jobs have no auth.uid(). + IF auth.uid() IS NOT NULL + AND (NEW.approved_by_user_id IS DISTINCT FROM OLD.approved_by_user_id + OR (OLD.status = 'pending_approval' + AND NEW.status NOT IN ('pending_approval', 'cancelled'))) + AND NOT EXISTS (SELECT 1 FROM public.profiles + WHERE id = auth.uid() AND role = 'admin') THEN + RAISE EXCEPTION 'Only admins can approve IT service requests' + USING ERRCODE = '42501'; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS isr_guard_approval ON it_service_requests; +CREATE TRIGGER isr_guard_approval + BEFORE UPDATE ON it_service_requests + FOR EACH ROW EXECUTE FUNCTION public.isr_guard_approval(); + +-- ----- it_service_request_assignments ----- +-- Same people who may edit the request may assign/unassign staff. +DROP POLICY IF EXISTS "Authenticated users can insert it_service_request_assignments" ON it_service_request_assignments; +DROP POLICY IF EXISTS "ISR assignments: insert" ON it_service_request_assignments; +CREATE POLICY "ISR assignments: insert" ON it_service_request_assignments + FOR INSERT TO authenticated WITH CHECK ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin','programmer','dispatcher','it_staff')) + OR EXISTS (SELECT 1 FROM public.it_service_requests r + WHERE r.id = it_service_request_assignments.request_id + AND r.creator_id = auth.uid() + AND r.status IN ('draft','pending_approval')) + ); + +DROP POLICY IF EXISTS "Authenticated users can delete it_service_request_assignments" ON it_service_request_assignments; +DROP POLICY IF EXISTS "ISR assignments: delete" ON it_service_request_assignments; +CREATE POLICY "ISR assignments: delete" ON it_service_request_assignments + FOR DELETE TO authenticated USING ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin','programmer','dispatcher','it_staff')) + OR EXISTS (SELECT 1 FROM public.it_service_requests r + WHERE r.id = it_service_request_assignments.request_id + AND r.creator_id = auth.uid() + AND r.status IN ('draft','pending_approval')) + ); + +-- ----- insert_it_service_request_with_number ----- +-- Two problems surfaced after the SELECT policy above was tightened: +-- 1. The number generator reads MAX(request_number) under the CALLER's RLS. +-- A non-privileged creator now sees only their own/office rows, so MAX +-- came back empty and it regenerated 'ISR-YYYY-0001', colliding with the +-- unique constraint. It must count ALL rows -> SECURITY DEFINER. +-- 2. Migration 20260604 added an 8-arg overload via CREATE OR REPLACE without +-- dropping the original 7-arg function, leaving two overloads (PGRST203 on +-- partial calls). Drop both signatures, then recreate one. +DROP FUNCTION IF EXISTS insert_it_service_request_with_number(text, text[], uuid, uuid, text, uuid, text); +DROP FUNCTION IF EXISTS insert_it_service_request_with_number(text, text[], uuid, uuid, uuid, text, uuid, text); + +CREATE FUNCTION insert_it_service_request_with_number( + p_event_name text, + p_services text[], + p_creator_id uuid, + p_id uuid DEFAULT NULL, + p_office_id uuid DEFAULT NULL, + p_requested_by text DEFAULT NULL, + p_requested_by_user_id uuid DEFAULT NULL, + p_status text DEFAULT 'draft' +) +RETURNS TABLE(id uuid, request_number text) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_seq int; + v_id uuid; + v_number text; + v_creator uuid; + v_status text; + v_is_privileged boolean; +BEGIN + -- Under SECURITY DEFINER the INSERT bypasses the WITH CHECK creator guard, so + -- pin the creator to the caller (a signed-in user can't forge someone else's + -- ownership). Service-role callers (auth.uid() null) keep the passed value. + v_creator := COALESCE(auth.uid(), p_creator_id); + + -- Alias the table: the RETURNS TABLE(id …) OUT column shadows an unqualified + -- `id`, so reference profiles columns as p.id / p.role. + SELECT EXISTS ( + SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin','programmer','dispatcher','it_staff') + ) INTO v_is_privileged; + + -- Non-privileged creators can only start a request in draft/pending_approval, + -- so they can't create one already 'scheduled' to skip admin approval. + v_status := p_status; + IF auth.uid() IS NOT NULL AND NOT v_is_privileged + AND v_status NOT IN ('draft','pending_approval') THEN + v_status := 'pending_approval'; + END IF; + + -- ponytail: MAX+1 is racy under concurrent creates (two callers can compute + -- the same seq; the loser hits the unique constraint). Fine at this volume; + -- switch to a per-year sequence if ISR creation ever goes concurrent. + SELECT COALESCE(MAX( + CAST(NULLIF(regexp_replace(r.request_number, '^ISR-\d{4}-', ''), '') AS int) + ), 0) + 1 + INTO v_seq + FROM it_service_requests r + WHERE r.request_number LIKE 'ISR-' || EXTRACT(YEAR FROM now())::text || '-%'; + + v_number := 'ISR-' || EXTRACT(YEAR FROM now())::text || '-' || LPAD(v_seq::text, 4, '0'); + v_id := COALESCE(p_id, gen_random_uuid()); + + INSERT INTO it_service_requests ( + id, request_number, event_name, services, + creator_id, office_id, requested_by, requested_by_user_id, status + ) + VALUES ( + v_id, v_number, p_event_name, p_services, + v_creator, p_office_id, p_requested_by, p_requested_by_user_id, v_status + ); + + RETURN QUERY SELECT v_id, v_number; +END; +$$; diff --git a/supabase/migrations/20260926090000_pass_slip_rls_hardening.sql b/supabase/migrations/20260926090000_pass_slip_rls_hardening.sql new file mode 100644 index 00000000..b929d46b --- /dev/null +++ b/supabase/migrations/20260926090000_pass_slip_rls_hardening.sql @@ -0,0 +1,38 @@ +-- QA-046: pass_slips self-approval bypass. +-- +-- The original "pass_slips_update" policy (20260306090200_pass_slips.sql) is: +-- FOR UPDATE USING (user_id = auth.uid() +-- OR profile.role IN ('admin','dispatcher')) +-- with NO WITH CHECK. Postgres then reuses the USING expression as the +-- WITH CHECK, so the row OWNER can PATCH their own pass slip to any values -- +-- including `status = 'approved'`, `approved_by`, `approved_at`. The approve/ +-- reject buttons are only gated in the UI, so any signed-in user could +-- self-approve their own pass slip through the REST API (the same class of +-- flaw as QA-015 on it_service_requests). +-- +-- Intended behaviour (see pass_slip_provider.dart): +-- * admin/dispatcher may approve/reject (and otherwise manage) any slip +-- * the owner may only mark their OWN already-approved slip 'completed' +-- (they return from the excusal); they must never set 'approved'/'rejected'. + +DROP POLICY IF EXISTS "pass_slips_update" ON pass_slips; +CREATE POLICY "pass_slips_update" ON pass_slips FOR UPDATE TO authenticated +USING ( + -- Approvers can act on any slip. + EXISTS ( + SELECT 1 FROM profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin', 'dispatcher') + ) + -- The owner may only touch their own slip once it is approved (to complete it). + OR (user_id = auth.uid() AND status = 'approved') +) +WITH CHECK ( + EXISTS ( + SELECT 1 FROM profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin', 'dispatcher') + ) + -- The owner's only allowed transition is approved -> completed. This blocks + -- self-approval: a pending slip's owner matches neither branch here, and the + -- USING clause above already refuses to expose a non-approved slip to them. + OR (user_id = auth.uid() AND status = 'completed') +); diff --git a/supabase/migrations/20260926100000_swap_requests_rls_hardening.sql b/supabase/migrations/20260926100000_swap_requests_rls_hardening.sql new file mode 100644 index 00000000..74dfd428 --- /dev/null +++ b/supabase/migrations/20260926100000_swap_requests_rls_hardening.sql @@ -0,0 +1,67 @@ +-- QA-047: swap_requests authorization bypass. +-- +-- Live REST probes with a non-privileged it_staff token showed: +-- * SELECT is not scoped: any authenticated user reads EVERY swap (60/60), +-- including 48 they are neither requester nor recipient of. +-- * UPDATE is not scoped: the requester could PATCH their OWN swap straight +-- to status='accepted' (forging the recipient's acceptance), and a total +-- non-participant could PATCH ANY swap's status/recipient. This bypasses the +-- identity checks in respond_shift_swap() entirely (same class as QA-015). +-- +-- The base swap_requests policies live in a migration that predates this repo +-- ("already exists in many deployments"), so their names aren't known here; +-- the DO block drops whatever SELECT/INSERT/UPDATE/DELETE policies exist and +-- this migration then defines the complete, hardened set. Safe because: +-- * respond_shift_swap() (accept/reject/escalate) is SECURITY DEFINER +-- (20260322170000+), so it mutates rows regardless of these policies. +-- * request_shift_swap() inserts with requester_id = auth.uid(). +-- * The only direct table UPDATE the app makes is reassignSwap(), an +-- admin/dispatcher action (workforce_provider.dart:reassignSwap). + +ALTER TABLE public.swap_requests ENABLE ROW LEVEL SECURITY; + +DO $$ +DECLARE + pol record; +BEGIN + FOR pol IN + SELECT policyname FROM pg_policies + WHERE schemaname = 'public' AND tablename = 'swap_requests' + LOOP + EXECUTE format('DROP POLICY IF EXISTS %I ON public.swap_requests', pol.policyname); + END LOOP; +END $$; + +-- SELECT: only the two participants and admins/dispatchers. The provider still +-- filters client-side, so tightening here only removes rows a user shouldn't +-- have seen in the first place. +CREATE POLICY "swap_requests: select" ON public.swap_requests + FOR SELECT TO authenticated USING ( + requester_id = auth.uid() + OR recipient_id = auth.uid() + OR EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin', 'dispatcher')) + ); + +-- INSERT: a user may only open a swap as themselves (request_shift_swap pins +-- requester_id to auth.uid()). +CREATE POLICY "swap_requests: insert" ON public.swap_requests + FOR INSERT TO authenticated WITH CHECK ( + requester_id = auth.uid() + ); + +-- UPDATE: only admins/dispatchers may write the row directly (reassignSwap). +-- Participants accept/reject/escalate through respond_shift_swap(), which is +-- SECURITY DEFINER and therefore not bound by this policy. This is what closes +-- the bypass: a requester/recipient can no longer forge status directly. +CREATE POLICY "swap_requests: update" ON public.swap_requests + FOR UPDATE TO authenticated USING ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin', 'dispatcher')) + ) + WITH CHECK ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin', 'dispatcher')) + ); + +-- No DELETE policy: direct deletes stay blocked (unchanged behaviour). diff --git a/supabase/migrations/20260926110000_isr_attachment_delete_hardening.sql b/supabase/migrations/20260926110000_isr_attachment_delete_hardening.sql new file mode 100644 index 00000000..900829e5 --- /dev/null +++ b/supabase/migrations/20260926110000_isr_attachment_delete_hardening.sql @@ -0,0 +1,25 @@ +-- QA-027: it_service_attachments delete was open to any signed-in user. +-- +-- The bucket stays public for reads (per product decision), but the DELETE +-- policy allowed ANY authenticated user to delete ANY file in the bucket +-- (USING (bucket_id = 'it_service_attachments')). Tighten it to the uploader +-- (storage sets objects.owner to the uploader's auth.uid) plus the privileged +-- IT-request roles that manage requests, so users can't delete each other's +-- attachments. Read/upload policies are left unchanged. + +DROP POLICY IF EXISTS "Authenticated users can delete it_service_attachments" ON storage.objects; +CREATE POLICY "Authenticated users can delete it_service_attachments" + ON storage.objects + FOR DELETE + TO authenticated + USING ( + bucket_id = 'it_service_attachments' + AND ( + owner = auth.uid() + OR EXISTS ( + SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin', 'programmer', 'dispatcher', 'it_staff') + ) + ) + ); diff --git a/supabase/migrations/20260926120000_task_attachment_write_hardening.sql b/supabase/migrations/20260926120000_task_attachment_write_hardening.sql new file mode 100644 index 00000000..6b720f86 --- /dev/null +++ b/supabase/migrations/20260926120000_task_attachment_write_hardening.sql @@ -0,0 +1,47 @@ +-- QA-027 follow-up: task_attachments storage writes were open to the `public` +-- role, i.e. UNAUTHENTICATED users could upload, overwrite, and delete task +-- attachments (worse than the it_service_attachments delete hole). The bucket +-- stays public for reads (bucket.public = true, unchanged); lock down writes: +-- * INSERT: any authenticated user (uploads happen while signed in) +-- * UPDATE/DELETE: the uploader (objects.owner) or a task-privileged role +-- The read policy ("task attachments policy 6srt2u_0") is left untouched. + +DROP POLICY IF EXISTS "task attachments policy 6srt2u_1" ON storage.objects; -- was INSERT/public +CREATE POLICY "task_attachments_insert" ON storage.objects + FOR INSERT TO authenticated + WITH CHECK (bucket_id = 'task_attachments'); + +DROP POLICY IF EXISTS "task attachments policy 6srt2u_2" ON storage.objects; -- was UPDATE/public +CREATE POLICY "task_attachments_update" ON storage.objects + FOR UPDATE TO authenticated + USING ( + bucket_id = 'task_attachments' + AND ( + owner = auth.uid() + OR EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin', 'programmer', 'dispatcher', 'it_staff')) + ) + ) + WITH CHECK ( + bucket_id = 'task_attachments' + AND ( + owner = auth.uid() + OR EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin', 'programmer', 'dispatcher', 'it_staff')) + ) + ); + +DROP POLICY IF EXISTS "task attachments policy 6srt2u_3" ON storage.objects; -- was DELETE/public +CREATE POLICY "task_attachments_delete" ON storage.objects + FOR DELETE TO authenticated + USING ( + bucket_id = 'task_attachments' + AND ( + owner = auth.uid() + OR EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin', 'programmer', 'dispatcher', 'it_staff')) + ) + ); diff --git a/test/announcements_test.dart b/test/announcements_test.dart index 5f8c3148..56558431 100644 --- a/test/announcements_test.dart +++ b/test/announcements_test.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; -import 'package:tasq/models/announcement.dart'; -import 'package:tasq/models/announcement_comment.dart'; +import 'package:tasq/models/announcement.model.dart'; +import 'package:tasq/models/announcement_comment.model.dart'; import 'package:tasq/providers/announcements_provider.dart'; import 'package:tasq/utils/app_time.dart'; diff --git a/test/dashboard_metrics_provider_test.dart b/test/dashboard_metrics_provider_test.dart index b60b46fb..52d18760 100644 --- a/test/dashboard_metrics_provider_test.dart +++ b/test/dashboard_metrics_provider_test.dart @@ -3,19 +3,19 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:tasq/models/it_service_request.dart'; -import 'package:tasq/models/it_service_request_assignment.dart'; -import 'package:tasq/models/profile.dart'; -import 'package:tasq/models/ticket.dart'; -import 'package:tasq/models/task.dart'; +import 'package:tasq/models/it_service_request.model.dart'; +import 'package:tasq/models/it_service_request_assignment.model.dart'; +import 'package:tasq/models/profile.model.dart'; +import 'package:tasq/models/ticket.model.dart'; +import 'package:tasq/models/task.model.dart'; import 'package:tasq/models/task_assignment.dart'; import 'package:tasq/models/ticket_message.dart'; -import 'package:tasq/models/duty_schedule.dart'; -import 'package:tasq/models/attendance_log.dart'; +import 'package:tasq/models/duty_schedule.model.dart'; +import 'package:tasq/models/attendance_log.model.dart'; import 'package:tasq/models/live_position.dart'; -import 'package:tasq/models/leave_of_absence.dart'; -import 'package:tasq/models/pass_slip.dart'; -import 'package:tasq/models/team.dart'; +import 'package:tasq/models/leave_of_absence.model.dart'; +import 'package:tasq/models/pass_slip.model.dart'; +import 'package:tasq/models/team.model.dart'; import 'package:tasq/models/team_member.dart'; import 'package:tasq/screens/dashboard/dashboard_screen.dart'; import 'package:tasq/utils/app_time.dart'; @@ -327,10 +327,13 @@ void main() { final schedule = DutySchedule( id: 's2', + // Anchor the shift around `now` so the shift is always active when the + // suite runs; a hard-coded 08:00–16:00 window made this test pass only + // during daytime and report 'Off duty' (correctly) at night. userId: profile.id, shiftType: 'am', - startTime: DateTime(now.year, now.month, now.day, 8), - endTime: DateTime(now.year, now.month, now.day, 16), + startTime: now.subtract(const Duration(hours: 1)), + endTime: now.add(const Duration(hours: 4)), status: 'scheduled', createdAt: now, checkInAt: null, diff --git a/test/geofence_admin_screen_test.dart b/test/geofence_admin_screen_test.dart index b33803ff..4e13ab0e 100644 --- a/test/geofence_admin_screen_test.dart +++ b/test/geofence_admin_screen_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import 'package:tasq/models/app_settings.dart'; -import 'package:tasq/models/profile.dart'; +import 'package:tasq/models/profile.model.dart'; import 'package:tasq/providers/profile_provider.dart'; import 'package:tasq/providers/workforce_provider.dart'; import 'package:tasq/providers/location_provider.dart'; diff --git a/test/it_job_checklist_test.dart b/test/it_job_checklist_test.dart index 0bc1a125..220fad0a 100644 --- a/test/it_job_checklist_test.dart +++ b/test/it_job_checklist_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:tasq/models/task.dart'; +import 'package:tasq/models/task.model.dart'; import 'package:tasq/models/task_assignment.dart'; -import 'package:tasq/models/notification_item.dart'; +import 'package:tasq/models/notification_item.model.dart'; import 'package:tasq/utils/app_time.dart'; void main() { diff --git a/test/layout_smoke_test.dart b/test/layout_smoke_test.dart index 29c1bd15..6b9c0581 100644 --- a/test/layout_smoke_test.dart +++ b/test/layout_smoke_test.dart @@ -5,20 +5,20 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:awesome_snackbar_content/awesome_snackbar_content.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:tasq/models/attendance_log.dart'; -import 'package:tasq/models/notification_item.dart'; -import 'package:tasq/models/office.dart'; -import 'package:tasq/models/pass_slip.dart'; -import 'package:tasq/models/profile.dart'; -import 'package:tasq/models/task.dart'; +import 'package:tasq/models/attendance_log.model.dart'; +import 'package:tasq/models/notification_item.model.dart'; +import 'package:tasq/models/office.model.dart'; +import 'package:tasq/models/pass_slip.model.dart'; +import 'package:tasq/models/profile.model.dart'; +import 'package:tasq/models/task.model.dart'; import 'package:tasq/models/task_assignment.dart'; -import 'package:tasq/models/ticket.dart'; +import 'package:tasq/models/ticket.model.dart'; import 'package:tasq/models/user_office.dart'; -import 'package:tasq/models/team.dart'; +import 'package:tasq/models/team.model.dart'; import 'package:tasq/utils/app_time.dart'; import 'package:go_router/go_router.dart'; import 'package:tasq/models/team_member.dart'; -import 'package:tasq/models/announcement.dart'; +import 'package:tasq/models/announcement.model.dart'; import 'package:tasq/providers/announcements_provider.dart'; import 'package:tasq/utils/snackbar.dart' show scaffoldMessengerKey; import 'package:tasq/providers/attendance_provider.dart'; @@ -554,6 +554,7 @@ void main() { ); await tester.pumpAndSettle(); + await _pickOffice(tester); await tester.tap(find.text('Create')); await tester.pumpAndSettle(); @@ -597,6 +598,7 @@ void main() { ); await tester.pumpAndSettle(); + await _pickOffice(tester); await tester.tap(find.text('Create')); await tester.pump(); // start saving expect(find.byType(CircularProgressIndicator), findsOneWidget); @@ -627,6 +629,7 @@ void main() { ); await tester.pumpAndSettle(); + await _pickOffice(tester); await tester.tap(find.text('Create')); await tester.pumpAndSettle(); @@ -678,6 +681,7 @@ void main() { ); await tester.pumpAndSettle(); + await _pickOffice(tester); await tester.tap(find.text('Create')); await tester.pump(); expect(find.byType(CircularProgressIndicator), findsOneWidget); @@ -802,7 +806,21 @@ Future _pumpScreen( Future _setSurfaceSize(WidgetTester tester, Size size) async { await tester.binding.setSurfaceSize(size); + // setSurfaceSize alone leaves MediaQuery at the 800x600 default; size the + // view too so breakpoint checks see the same width as the layout. + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; addTearDown(() async { await tester.binding.setSurfaceSize(null); + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); }); } + +/// Office is required in the ticket/task create dialogs. +Future _pickOffice(WidgetTester tester) async { + await tester.tap(find.text('Select office…')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(ListTile, 'HQ').last); + await tester.pumpAndSettle(); +} diff --git a/test/leave_stream_update_test.dart b/test/leave_stream_update_test.dart index ae060703..2f5b45d2 100644 --- a/test/leave_stream_update_test.dart +++ b/test/leave_stream_update_test.dart @@ -2,8 +2,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:tasq/models/leave_of_absence.dart'; -import 'package:tasq/models/profile.dart'; +import 'package:tasq/models/leave_of_absence.model.dart'; +import 'package:tasq/models/profile.model.dart'; import 'package:tasq/screens/attendance/attendance_screen.dart'; import 'package:tasq/providers/leave_provider.dart'; import 'package:tasq/providers/profile_provider.dart'; diff --git a/test/notification_bridge_test.dart b/test/notification_bridge_test.dart new file mode 100644 index 00000000..89321ee0 --- /dev/null +++ b/test/notification_bridge_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tasq/models/notification_item.model.dart'; +import 'package:tasq/services/notification_bridge.dart'; + +NotificationItem _n(String id, {DateTime? readAt}) => NotificationItem( + id: id, + userId: 'u', + actorId: null, + ticketId: null, + taskId: null, + itServiceRequestId: null, + messageId: null, + type: 'assignment', + createdAt: DateTime(2026), + readAt: readAt, +); + +void main() { + test('first snapshot never raises a banner', () { + expect(newestArrival(null, [_n('b'), _n('a')]), isNull); + }); + + test('picks the newest unseen item, not the oldest', () { + final prev = [_n('a')]; + final next = [_n('c'), _n('b'), _n('a')]; // newest-first + expect(newestArrival(prev, next)?.id, 'c'); + }); + + test('ignores already-read and already-seen items', () { + final prev = [_n('a')]; + expect( + newestArrival(prev, [_n('b', readAt: DateTime(2026)), _n('a')]), + isNull, + ); + expect(newestArrival(prev, [_n('a')]), isNull); + }); +} diff --git a/test/offline_sync_test.dart b/test/offline_sync_test.dart index 3151b986..ae3d1ea0 100644 --- a/test/offline_sync_test.dart +++ b/test/offline_sync_test.dart @@ -86,10 +86,10 @@ void main() { await tester.pumpWidget(buildBanner(online: true)); await tester.pump(); - expect(find.byIcon(Icons.wifi_off), findsNothing); + expect(find.byIcon(Icons.wifi_off_rounded), findsNothing); expect( find.text( - 'No internet \u2014 changes saved locally, will sync automatically', + 'No internet \u2014 changes saved locally', ), findsNothing, ); @@ -99,10 +99,10 @@ void main() { await tester.pumpWidget(buildBanner(online: false)); await tester.pump(); - expect(find.byIcon(Icons.wifi_off), findsOneWidget); + expect(find.byIcon(Icons.wifi_off_rounded), findsOneWidget); expect( find.text( - 'No internet \u2014 changes saved locally, will sync automatically', + 'No internet \u2014 changes saved locally', ), findsOneWidget, ); @@ -127,12 +127,12 @@ void main() { ), ); await tester.pump(); - expect(find.byIcon(Icons.wifi_off), findsOneWidget); + expect(find.byIcon(Icons.wifi_off_rounded), findsOneWidget); // Go online — update provider state, then let AnimatedSize animate. container.read(isOnlineProvider.notifier).state = true; await tester.pumpAndSettle(); - expect(find.byIcon(Icons.wifi_off), findsNothing); + expect(find.byIcon(Icons.wifi_off_rounded), findsNothing); }); testWidgets('banner transitions from online to offline', (tester) async { @@ -151,12 +151,12 @@ void main() { ), ); await tester.pump(); - expect(find.byIcon(Icons.wifi_off), findsNothing); + expect(find.byIcon(Icons.wifi_off_rounded), findsNothing); // Go offline — update provider state, then let AnimatedSize animate. container.read(isOnlineProvider.notifier).state = false; await tester.pumpAndSettle(); - expect(find.byIcon(Icons.wifi_off), findsOneWidget); + expect(find.byIcon(Icons.wifi_off_rounded), findsOneWidget); }); testWidgets('child widget is always rendered regardless of connectivity', diff --git a/test/profile_screen_test.dart b/test/profile_screen_test.dart index b618854b..99892209 100644 --- a/test/profile_screen_test.dart +++ b/test/profile_screen_test.dart @@ -3,8 +3,8 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:tasq/models/office.dart'; -import 'package:tasq/models/profile.dart'; +import 'package:tasq/models/office.model.dart'; +import 'package:tasq/models/profile.model.dart'; import 'package:tasq/models/user_office.dart'; import 'package:tasq/providers/profile_provider.dart'; import 'package:tasq/providers/tickets_provider.dart'; diff --git a/test/searchable_multi_select_dropdown_test.dart b/test/searchable_multi_select_dropdown_test.dart index 6326644c..c681565a 100644 --- a/test/searchable_multi_select_dropdown_test.dart +++ b/test/searchable_multi_select_dropdown_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:tasq/models/office.dart'; +import 'package:tasq/models/office.model.dart'; import 'package:tasq/widgets/multi_select_picker.dart'; void main() { diff --git a/test/snackbar_humanize_test.dart b/test/snackbar_humanize_test.dart new file mode 100644 index 00000000..f022eb48 --- /dev/null +++ b/test/snackbar_humanize_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tasq/utils/snackbar.dart'; + +void main() { + test('strips exception dumps down to the message', () { + expect( + humanizeErrorText( + 'Sign in failed: AuthApiException(message: Invalid login credentials, ' + 'statusCode: 400, code: invalid_credentials)', + ), + 'Sign in failed: Invalid login credentials', + ); + expect( + humanizeErrorText( + 'Error: PostgrestException(message: Could not find f(a, b) in the ' + 'schema cache, code: PGRST202, details: x, hint: y)', + ), + 'Error: Could not find f(a, b) in the schema cache', + ); + expect( + humanizeErrorText('Error: Exception: Not authenticated'), + 'Error: Not authenticated', + ); + }); + + test('leaves plain messages alone', () { + expect( + humanizeErrorText('Event name is required'), + 'Event name is required', + ); + }); +} diff --git a/test/task_assignment_section_test.dart b/test/task_assignment_section_test.dart index 9c5ad9bc..fb59e6d7 100644 --- a/test/task_assignment_section_test.dart +++ b/test/task_assignment_section_test.dart @@ -3,8 +3,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'dart:async'; -import 'package:tasq/models/profile.dart'; -import 'package:tasq/models/task.dart'; +import 'package:tasq/models/profile.model.dart'; +import 'package:tasq/models/task.model.dart'; import 'package:tasq/models/task_assignment.dart'; import 'package:tasq/providers/profile_provider.dart'; import 'package:tasq/providers/tasks_provider.dart'; diff --git a/test/tasks_provider_test.dart b/test/tasks_provider_test.dart index bfc12e75..07844761 100644 --- a/test/tasks_provider_test.dart +++ b/test/tasks_provider_test.dart @@ -2,8 +2,8 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:tasq/providers/tasks_provider.dart'; -import 'package:tasq/models/task.dart'; -import 'package:tasq/models/task_activity_log.dart'; +import 'package:tasq/models/task.model.dart'; +import 'package:tasq/models/task_activity_log.model.dart'; import 'package:tasq/utils/app_time.dart'; // Minimal fake supabase client similar to integration test work, diff --git a/test/teams_screen_test.dart b/test/teams_screen_test.dart index 5e0b06e9..76de3550 100644 --- a/test/teams_screen_test.dart +++ b/test/teams_screen_test.dart @@ -3,9 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:tasq/models/office.dart'; -import 'package:tasq/models/profile.dart'; -import 'package:tasq/models/team.dart'; +import 'package:tasq/models/office.model.dart'; +import 'package:tasq/models/profile.model.dart'; +import 'package:tasq/models/team.model.dart'; import 'package:tasq/models/team_member.dart'; import 'package:tasq/providers/teams_provider.dart'; import 'package:tasq/providers/profile_provider.dart'; diff --git a/test/user_management_screen_test.dart b/test/user_management_screen_test.dart index 690bb391..f14d8450 100644 --- a/test/user_management_screen_test.dart +++ b/test/user_management_screen_test.dart @@ -3,8 +3,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:tasq/models/office.dart'; -import 'package:tasq/models/profile.dart'; +import 'package:tasq/models/office.model.dart'; +import 'package:tasq/models/profile.model.dart'; import 'package:tasq/models/ticket_message.dart'; import 'package:tasq/models/user_office.dart'; import 'package:tasq/providers/profile_provider.dart'; diff --git a/test/workforce_swap_test.dart b/test/workforce_swap_test.dart index b55a6c8e..5992a7a9 100644 --- a/test/workforce_swap_test.dart +++ b/test/workforce_swap_test.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:tasq/models/profile.dart'; -import 'package:tasq/models/duty_schedule.dart'; +import 'package:tasq/models/profile.model.dart'; +import 'package:tasq/models/duty_schedule.model.dart'; import 'package:tasq/models/rotation_config.dart'; -import 'package:tasq/models/swap_request.dart'; +import 'package:tasq/models/swap_request.model.dart'; import 'package:tasq/providers/rotation_config_provider.dart'; import 'package:tasq/providers/supabase_provider.dart'; import 'package:tasq/providers/workforce_provider.dart'; diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index a3ed56bc..8f2c6a3a 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -16,7 +16,6 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST flutter_local_notifications_windows - pdfium_flutter ) set(PLUGIN_BUNDLED_LIBRARIES)