47 Commits

Author SHA1 Message Date
redz1029 8220482465 fix(day-sheet): composite key prevents duplicate-key crash for helper+assignee rows
buildDayRows can emit both an 'assignee' row and a 'helper' row for the
same taskId when a programmer previously held a task (accumulated clock
time) and later helped on it after reassignment. ValueKey(row.taskId)
collided in that case, crashing Flutter's widget tree with a red screen.

Fixed by keying on taskId:kind, making each row unique.

Caught during targeted QA on the new loading-state / dialog flows.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 22:48:12 +08:00
redz1029 88848138d9 feat(programmer-tasks): loading states, confirmation & success dialogs
Day sheet dialogs (disapprove, justify): moved the async controller call
inside each dialog so loading state is owned there — button shows a
spinner and is disabled while the RPC is in flight; errors display inline
instead of via a snackbar after dialog close.

Detail screen (approve): added _approvingSheet bool; Approve button shows
CircularProgressIndicator while in flight. On success shows an AlertDialog
"Sheet Approved" with a check icon instead of silently updating state.

Approvals tab (bulk approve): upgraded post-approve feedback from a
snackbar to a success AlertDialog showing the count of approved sheets
and any skipped ones.

Day sheets tab (submit): added confirmation AlertDialog before the
existing submit flow so programmers don't accidentally submit mid-day.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 22:31:50 +08:00
redz1029 2946945ce3 fix(programmer-tasks): tab switching + help-given live data
Tab switching: _buildTabs was recreating TabController on every build
because `_tabs == tabs` compares List by reference identity, not content.
programmerTasksTabsFor() always returns a new list, so the guard was
always false, resetting the controller to initialIndex 0 on every tap.
Fixed by using listEquals from flutter/foundation.

Help given: computeProgrammerStats accumulated helpGivenThisMonth only
from approvedSnapshot rows, which only exist after admin approval.
Programmers who helped others but had pending sheets saw 0m indefinitely.
Fixed by adding helpGivenThisMonthProvider (FutureProvider.family) that
queries programmer_task_work_logs directly for the current Manila month,
and passing it as liveHelpThisMonthSec to computeProgrammerStats.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 22:25:25 +08:00
redz1029 4f60e23003 fix(approvals): show — instead of 0m·0 tasks for pending/disapproved sheets
approvedSnapshot is only populated at approval time; pending and disapproved
sheets had null snapshot so the card always displayed 0m · 0 tasks.

Now totalSecs/taskCount are passed as nullable params from the parent; the
card renders — when no snapshot is available yet.

Also accept camelCase 'daySheets' URL param in parseProgrammerTasksTab
alongside the existing hyphenated 'day-sheets' spelling.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 21:05:58 +08:00
redz1029 68817a70f0 fix(tabs): replace SingleTickerProviderStateMixin with TickerProviderStateMixin
_buildTabs creates a new TabController (new ticker) before disposing the
old one, so two tickers briefly coexist — SingleTickerProviderStateMixin
throws. TickerProviderStateMixin supports multiple concurrent tickers.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 19:55:56 +08:00
redz1029 9f2c14dc6b fix(report): remove dead null checks on non-nullable DaySheetRow.category
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 19:15:59 +08:00
redz1029 1101fb0a97 feat(report): accomplishment report dialog + entry points
Add AccomplishmentReportDialog with programmer/admin modes, date-range
filter (default this month), and include-unapproved toggle. Wire into
DaySheetsTab app-bar action, ApprovalsTab app-bar action, and
ProgrammerDrillDownScreen app-bar button.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 19:09:09 +08:00
redz1029 167e208ffa feat(report): accomplishment report PDF + shared PDF helpers
- lib/utils/pdf/pdf_letterhead.dart: PdfBrand with load(), letterhead(),
  theme(), and pageFooter() shared across CRMC PDF documents
- lib/widgets/pdf_preview_dialog.dart: showPdfPreviewDialog() with
  pdfrx PdfViewer.data preview, Print (Printing.layoutPdf) and
  Download (Printing.sharePdf) buttons
- lib/screens/programmer_tasks/report/accomplishment_report_pdf.dart:
  buildAccomplishmentReportPdf() — A4 MultiPage with letterhead, info
  box, accomplishment table, footnote, summary, signatures, page footer
- test/accomplishment_report_pdf_test.dart: 2 passing tests

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 18:55:45 +08:00
redz1029 87014d1656 fix(report): approverName uses only included days
Move reviewer tracking logic inside the non-empty rows guard in both
approved-only and include-unapproved modes. This ensures zero-row
approved sheets don't pollute the reviewer set, fixing the approverName
calculation to only consider reviewers from included ReportDays.

Add test: approved sheet with zero rows and different reviewer correctly
excluded from approver tracking.

All 9 tests passing.
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 18:47:18 +08:00
redz1029 f56a3057d4 feat(report): accomplishment report data builder
Implement AccomplishmentReportData and buildAccomplishmentReportData() for
generating programmer accomplishment reports. Supports both approved-only and
include-unapproved modes, with proper row filtering, day aggregation, metrics
calculation (totalSeconds, tasksCompleted, helpSeconds, secondsByCategory),
and approver tracking.

All 8 tests passing.
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 18:42:29 +08:00
redz1029 8209719928 fix(programmer): trim day sheet detail screen under 500 lines
Remove inline comment to bring file from 501 to 500 lines,
satisfying the 500-line cap.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 18:37:49 +08:00
redz1029 2585cce36d feat(programmer): sheet-driven stats, monitor status, drill-down history
- Wire daySheetsForUserProvider(userId) into ProgrammerDashboard so
  computeProgrammerStats/computeBadges receive real sheet data instead of
  the placeholder const <ProgrammerDaySheet>[].
- Monitor tab: watch allDaySheetsProvider and build sheetStatusByUser map
  for today; pass it to buildMonitorEntries; add "Sheets pending" chip
  (pending + disapproved today) to the summary row.
- ProgrammerDrillDownScreen: add _RecentDaySheetsPanel below the dashboard
  showing the last 10 sheets (date + status pill) with tap-to-daySheetDetail.
- DaySheetDetailScreen (T17 gaps): replace const [] liveRows with
  programmerDayDataProvider(sheet.workDate) built rows for non-approved
  sheets; fix _onApprove timer check to filter only the sheet programmer's
  own tasks (QN-1).
- Add test/programmer_drill_down_test.dart (3 tests, all passing).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 18:29:28 +08:00
redz1029 5af0ce62d9 feat(notifications): day sheet notification types
Add daySheetId field to NotificationItem model (Brick-managed), extract
notificationTitle/notificationIcon to top-level testable functions, add
five day-sheet notification cases, and wire tap/banner routing for
day-sheet detail and approvals tab.
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 14:54:19 +08:00
redz1029 54ce528b02 feat(programmer): day sheets + approvals tabs
- day_sheet_snapshot_builder.dart: buildSnapshotForSheet (approved →
  stored snapshot, else live buildDayRows), groupSheetsByDate (descending
  keys), approvalCounts (pending/awaiting/approved)
- approvals_tab.dart: status filter chips, programmer dropdown,
  ReportDateFilter.controlled, date-grouped cards with Auto/Early/Round
  badges, checkbox multi-select, bulk Approve selected (n) flow
- day_sheets_tab.dart: Today card (live rows, total, status, Submit day),
  Needs your attention (disapproved), history with AppStatusSummaryRow
  All/Pending/Disapproved/Approved filter
- programmerTasksTabsFor: admin → [monitor, approvals, mine, all];
  programmer → [mine, all, daySheets]
- programmer_tasks_list_screen.dart: wire approvals + daySheets cases

Tests: 15 passing (day_sheet_tabs_test + programmer_tabs_test)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 14:43:52 +08:00
redz1029 1fe9653bdf feat(programmer): day sheet detail screen
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 14:21:22 +08:00
redz1029 91fb023ef2 feat(reports): controlled ReportDateFilter
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 14:13:33 +08:00
redz1029 be16a3cd0d feat(programmer): dated work-log corrections
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 14:08:28 +08:00
redz1029 cc8d8ab8bf feat(programmer): day sheet providers + controller
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 14:00:29 +08:00
redz1029 20e95ffe37 feat(edge): day sheet digest push
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 13:50:31 +08:00
redz1029 e0dd3f6368 feat(db): day sheet RPCs, auto-submit cron, digest queue
Add submit_day_sheet, disapprove_day_sheet, justify_day_sheet,
approve_day_sheets (all SECURITY DEFINER, grant to authenticated)
and auto_submit_day_sheets (revoked from all client roles).

auto_submit_day_sheets:
  1. Reopens approved yesterday sheets for programmers whose timer
     was still running at end of day (amended event).
  2. Auto-promotes stale draft sheets to pending (auto_submitted event).
  3. Inserts day_sheet_digest notifications + scheduled_notifications
     (epoch=YYYYMMDD, ON CONFLICT DO NOTHING) for every admin.

Extends chk_at_least_one_ref on scheduled_notifications to allow
notify_type = 'day_sheet_digest' rows with no FK reference.

Registers pg_cron job 'programmer_day_sheet_autosubmit' at 16:05 UTC
(00:05 Asia/Manila) in a DO $$ EXCEPTION block.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 13:31:26 +08:00
redz1029 e2ff0140ef feat(db): programmer day sheets schema, triggers, RLS
Adds migration 20260927150000_add_programmer_day_sheets.sql:
- manila_today() stable helper (Asia/Manila timezone)
- programmer_day_sheets table with draft/pending/disapproved/approved status,
  unique(programmer_id, work_date), updated_at trigger, RLS
- programmer_day_sheet_events table with kind/body checks, cascade FK, RLS
- work_date column on programmer_task_work_logs (add, backfill, default, not null)
- ensure_day_sheet() SECURITY DEFINER; revoked from public/anon/authenticated
- BEFORE INSERT trigger validate_work_log_date (work_date_locked error code)
- AFTER INSERT trigger on work_logs → ensure_day_sheet
- AFTER INSERT trigger on activity_logs → ensure_day_sheet for assignee
- RLS SELECT policies (own sheet or admin; events through parent sheet)
- notifications.day_sheet_id FK column + partial index
- Add day sheets tables to supabase_realtime publication

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 22:51:41 +08:00
redz1029 e24083356f fix(programmer): resolve profile names in monitor help chips
Pass profileNames Map<String, String> from MonitorTab down to
ProgrammerMonitorCard and HelpPanel; use profileNames[id] ?? id
at all help-chip and help-edge row render sites so full names
display instead of raw UUIDs.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 22:43:28 +08:00
redz1029 7beb8e8713 feat(programmer): admin monitor tab + drill-down
- Add isStrictAdminProvider (role == 'admin' only)
- Add programmer_tasks_tabs.dart with ProgrammerTasksTab enum,
  programmerTasksTabsFor, parseProgrammerTasksTab, programmerTasksTabLabel
- Make ProgrammerTasksListScreen role-aware via initialTab param and
  programmerTasksTabsFor; admin sees Monitor/Mine/All tabs
- Add MonitorTab with AppStatusSummaryRow + responsive programmer grid
  (1/2/3 cols) + HelpPanel side/bottom
- Add ProgrammerMonitorCard with state pills (Working/Paused/Idle/On leave/Pass slip)
- Add HelpPanel ConsumerStatefulWidget with day stepper and help-edge rows
- Add ProgrammerDrillDownScreen (/programmer-tasks/programmers/:userId);
  non-admins see lock empty view
- Router: pass tab query param; add programmers/:userId before :id route
- Tests: 7/7 pass; analyze: 0 new issues

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 22:39:19 +08:00
redz1029 791653f45c fix(programmer): buildMonitorEntries accepts pre-computed leave/passSlip maps
Changed buildMonitorEntries signature to accept Map<String, LeaveOfAbsence> and Map<String, PassSlip> instead of List types, matching the spec requirement that callers pass pre-computed maps from activeLeaveByUser() and activePassSlipByUser().

- Updated function signature for both parameters
- Removed internal calls to activeLeaveByUser/activePassSlipByUser
- Updated state-determination to use direct map lookups
- Updated all test cases to pass computed maps
- All 19 tests passing
- No new analyzer issues

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 22:27:11 +08:00
redz1029 935ee670e7 feat(programmer): monitor view-model
Implements MonitorState enum, HelpEdge class, and buildMonitorEntries/buildHelpEdges functions for the monitor view model. Features include:

- MonitorState enum with values: working, paused, idle, onLeave, passSlip
- HelpEdge class for tracking help between programmers
- buildHelpEdges() to extract helped/helpedBy relationships from work logs
- ProgrammerMonitorEntry with current task tracking and duration calculations
- buildMonitorEntries() with proper state determination, task counting, and sorting

All tests passing. Sort order: working -> paused -> idle -> passSlip -> onLeave, then alphabetical by name.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 22:22:29 +08:00
redz1029 a7fd1e74f1 feat(programmer): dashboard stat tiles + badges
Add StatTiles and BadgesStrip widgets wired into ProgrammerDashboard.

StatTiles shows 2 tiles (focus + completed) when showSheetStats is false,
and 5 tiles (+ streak, first-pass approval, help given) when the viewing
user's role is programmer. BadgesStrip renders earned/locked chips in a
horizontal Wrap with tap-to-dialog detail.

Dashboard feeds todayFocus via buildDayRows/totalSeconds, leaveDays via
approvedLeaveDays, and sheets: const [] until Task 20 wires real data.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 22:13:43 +08:00
redz1029 0c99ef270d refactor: extract staff presence helpers
- Extract activeLeaveByUser, activePassSlipByUser, and approvedLeaveDays to staff_presence.dart utility module
- Replace inline indexing loops in dashboard_screen.dart with helper function calls
- Behavior unchanged, tests added covering all functions

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 22:04:17 +08:00
redz1029 9a40ffec81 fix(programmer): move clock ticker watch to dashboard parent
NowWorkingCard was watching clockTickerProvider but received pre-computed
Duration values from ProgrammerDashboard, which never rebuilt on ticks.
Move ref.watch(clockTickerProvider) to ProgrammerDashboard.build() so
the parent rebuilds every 30 s and passes fresh durations to the card.
Remove the now-unused clockTickerProvider watch (and its import) from
NowWorkingCard.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 21:59:01 +08:00
redz1029 29bcbcaa4d feat(programmer): Mine dashboard hero + task sections
Replace the Mine tab flat list with ProgrammerDashboard — a NowWorkingCard
hero (running task with Pause/Complete, or idle with nextUp Start), a
TODO stub for Task 8 stat tiles/badges, and collapsible TaskSections
(In progress, Paused, Not started always-visible; Completed/Cancelled
start collapsed with Show-all at 10+). groupTasksForDashboard excludes
other assignees and sorts completed/cancelled by terminal date desc.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 21:52:13 +08:00
redz1029 f8c3422715 refactor(programmer): shared task card driven by run states
Extract _TaskCard from the list screen into ProgrammerTaskCard, a
shared ConsumerWidget that reads programmerRunStatesProvider so the
paused-task pill and Start/Resume/Complete buttons are consistent
across any surface that embeds it.

Move the _complete body verbatim to completeProgrammerTaskWithConfirm
in programmer_task_actions.dart; the detail screen's _ActionBar now
calls the shared function.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 21:40:07 +08:00
redz1029 cdfeb954fd feat(programmer): day-sheet model + stats, streak and badges
Add ProgrammerDaySheet model with DaySheetStatus/DaySheetSnapshot,
computeProgrammerStats with streak, first-pass rate, approved totals,
hasCleanWeek, and tiered badge progress via computeBadges. 10 tests pass.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 21:27:03 +08:00
redz1029 ab82af119b feat(programmer): day activity data + run-state provider
Implements DayActivityData, fetchDayActivityData, computeRunStates,
clockTickerProvider, todayKeyProvider, programmerActivitySignalProvider,
programmerDayDataProvider, and programmerRunStatesProvider in
lib/providers/programmer_day_data_provider.dart.

Includes four passing tests in test/programmer_day_data_test.dart
covering run-state computation and day-scoped activity fetching.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 21:16:04 +08:00
redz1029 12842bfa12 fix(programmer): day rows — Delta JSON test + non-assignee row gate
- Add Delta JSON happy-path test case to deltaJsonToPlainText tests
- Fix double-row bug: notes-only assignee rows only when userId == assigneeId
- Remove dead code: tautological taskMap.containsKey check in loop
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 21:08:30 +08:00
redz1029 f9b8831c71 feat(programmer): day rows builder + work-log workDate
- Add workDate field to ProgrammerTaskWorkLog (defaults to Manila day of createdAt)
- Add deltaJsonToPlainText function to quill_field.dart for Delta/plain text conversion
- Implement buildDayRows function to generate day sheet rows with proper sorting
- Implement DaySheetRow serialization and deserialization
- Add comprehensive tests for day rows builder and workDate field
- Verify backward compatibility via existing programmer_task_ledger_test
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 21:04:23 +08:00
redz1029 f071cc722d fix(programmer): reassignment while paused must not open new segment
Capture whether segment was running before reassignment, and only reopen
for new assignee if it was. A paused task that gets reassigned should not
start accruing time for the new assignee until explicitly resumed.

Also add test: reassignment while paused does not open segment.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 20:57:55 +08:00
redz1029 6beb92d3c6 feat(programmer): per-day worked time split with assignee attribution
Implement daily time tracking and splitting for programmer tasks across
Manila midnights with proper assignee attribution, including support for
pauses, reassignments, and adjustments.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 20:53:49 +08:00
redz1029 dd6730f62d Add implementation plan: programmer oversight
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 18:22:05 +08:00
redz1029 7a319b6e16 Add design spec: programmer oversight (day-sheet approvals, monitor, report, dashboard)
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 17:44:44 +08:00
redz1029 b307f66f2c Add PM extension (Projects, Work Logs, Comments) + Quill rich-text UI
Projects: Brick-backed model with list/detail screens and nav entry.
Work Logs: per-contributor time ledger, assignee running-clock +
helper logged minutes with Hours/Minutes input; pause-after-save and
helper-deduct prompts.
Comments: threaded comments with file attachments and Quill composer.
Detail screen: tabbed layout (Work Log / Comments / Activity),
editable title + description via edit dialog, Quill-rendered
descriptions with legacy plain-text fallback.
Create dialog: fixed-width (480px max), Quill description editor.
Shared QuillFieldEditor + QuillReadOnly widgets extracted for reuse.
RLS: hardened activity-log actor binding, immutable creator_id/
created_at triggers, SECURITY INVOKER visibility helper,
append-only work-log and scoped comment policies.
Brick migration for project_id FK on programmer_tasks.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 16:06:26 +08:00
redz1029 627645056b Add Programmer Tasks module (work-log + time tracking)
Separate feature for the `programmer` role to record dev work by category,
distinct from the IT help-desk `tasks` feature. Modeled on the IT Service
Requests pattern (Brick offline-first model -> Riverpod stream/controller ->
list/detail screens); seed of a future Project Management workflow.

- programmer_tasks + programmer_task_activity_logs (category enum, RLS,
  realtime) via supabase migration
- single assignee, self-logged or lead-assigned; assign/reassign logged
- pause-on-switch: starting a task while another runs prompts to pause the
  running one, keeping one active task per user for accurate time attribution
- worked-duration math shared with help-desk tasks via a generic core in
  task_duration.dart (existing callers unchanged)
- list distinguishes running vs paused with a per-task Resume shortcut
- accessible dialog labels; route /programmer-tasks + nav gated to admin/programmer
- unit/widget tests: controller lifecycle, duration math, pause-switch dialog

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 01:13:33 +08:00
redz1029 e7706058b7 UI Phase 2: cosmetic leftovers — compact Profile photo card, fit Users table
- Profile Photo card: replace the tall centered avatar+buttons stack with a
  compact horizontal row (avatar left, caption + Upload/Camera buttons right),
  roughly halving the card height and reclaiming the dead vertical space.
- TasQColumn gains an optional `minWidth`; the adaptive table now sums per-column
  minimums instead of a flat 200 px each. User Management sets narrow widths on
  its short columns (Role/Offices/Status/Last active) so all six columns fit at
  desktop width without the horizontal scroll that was clipping the Status
  column. Other tables keep the 200 px default (unchanged).

Verified: flutter analyze lib clean; flutter build web green; visual QA
(Profile, User Management) in dark.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-26 23:35:19 +08:00
redz1029 4685430406 UI Phase 2: dogfood follow-ups — section headers, role terms, status semantics
Address the remaining concrete items flagged by the dogfood pass.

Section headers (→ AppSectionHeader, azure accent bar + uppercase):
- Attendance: "Today's Schedule", "Pass Slip History", and two "Pending
  Approvals" rows (were plain bold text / a manual tertiary accent bar).

User Management:
- Roles now display human-friendly labels (it_staff → "IT Staff", standard →
  "Standard", …) in the table cell, mobile tile and edit dropdown, instead of
  raw snake_case (Content/terminology consistency).
- _StatusBadge is now semantic via AppStatusColors: Active → success (green),
  Locked → danger (red), else neutral — previously everything was teal, so a
  Locked account read the same as an Active one.

Verified: flutter analyze lib clean; flutter build web green; visual QA
(Attendance, User Management) in dark.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-26 16:44:39 +08:00
redz1029 6bde8630d8 UI Phase 2 (L8): unify Profile section headers with AppSectionHeader
The Profile screen's card section titles used plain bold titleMedium text,
inconsistent with the cyber accent-bar section headers elsewhere. Convert
"Profile Photo", "Face Verification", "Account details", "Password" and
"Offices" to AppSectionHeader so they read the same as the rest of the app.

Verified: flutter analyze lib clean; flutter build web green; visual QA
(Profile) in dark.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-26 16:28:01 +08:00
redz1029 7716460363 UI Phase 2 (L4): ISR status-pill consistency + shared status-kind mapper
The IT Service Requests list tile still used the legacy StatusPill (violet
"DRAFT"); make it semantic like the detail header. Extract the ISR status ->
StatusKind mapping into a shared helper so list and detail stay in sync.

- New isr_status_kind.dart (public isrStatusKind); removed the private duplicate
  from the detail screen.
- _RequestTile now uses StatusPill.kind(isrStatusKind(status), ...).

(The request cards were already content-dense; no layout change needed.)
Verified: flutter analyze lib clean; flutter build web green.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-26 16:11:14 +08:00
redz1029 ab5d385325 UI Phase 2 (L7): tighten Reports chart grid layout
Reduce dead whitespace and align the Reports charts to the cyber card language.
Layout only — charts, data and PDF export untouched.

- report_card_wrapper.dart: flat tonal fill + hairline outline + token radius
  (was surfaceContainerLow + hard 16 radius, no border); uppercase telemetry
  card title to match section headers.
- status_charts.dart / request_distribution_charts.dart: donut charts now use a
  fixed 150px donut with an Expanded legend beside it (was an Expanded donut +
  cramped right legend, leaving a large central gap); card height 220 -> 180.

Verified: flutter analyze lib clean; flutter build web green; visual QA (Reports)
in dark.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-26 16:06:32 +08:00
redz1029 9f764150a2 UI Phase 2 (L0): shared layout primitives + chrome standardization
Extract four shared layout widgets from the best existing implementations and
adopt them across list/overview screens, deleting duplicated inline code.
Layout/consistency only — no behavior change.

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

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

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

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

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

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

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

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-26 15:13:47 +08:00
142 changed files with 21479 additions and 1032 deletions
@@ -0,0 +1,909 @@
# Programmer Oversight Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add daily Day-Sheet approvals (with remarks ↔ justification loop), a live admin Monitor, a PDF Accomplishment Report, and a gamified Mine dashboard to the Programmer Tasks module.
**Architecture:** Pure Dart utilities compute per-day worked time (clipped to Manila midnights, credited to the assignee at the time) and build "day rows"; every view (dashboard, monitor, day sheet, report) renders those rows. Day-sheet state lives in Supabase (`programmer_day_sheets` + append-only `programmer_day_sheet_events`), changed only through SECURITY DEFINER RPCs and triggers; a pg_cron job auto-submits past drafts. Approval stores a JSON snapshot so approved days are fixed records.
**Tech Stack:** Flutter 3.47.5 / Dart 3.13, Riverpod 2.6, supabase_flutter 2.x (Postgres, RLS, realtime, pg_cron, Deno edge functions), `pdf` 3.12 + `printing` 5.14 + `pdfrx` 2.6, flutter_quill 11.6.
**Spec:** `docs/superpowers/specs/2026-09-27-programmer-oversight-design.md`
## Global Constraints
- Run Flutter on the host: `$FLUTTER <args>` means `flatpak-spawn --host sh -c 'cd /home/redz/tasq && ~/flutter/bin/flutter <args>'`.
- Never run `dart format` on existing files (the repo is not formatted; it causes diff churn). New files may be formatted individually.
- Stage files explicitly (`git add <paths>`). The working tree has unrelated modified files (`.claude/`, `.swarm/`, `ruvector.db`, `.mcp.json`) — never `git add -A` / `git add .`. Every commit message ends with `Co-Authored-By: claude-flow <ruv@ruv.net>`.
- Supabase DDL and edge-function deploys are done by the user. Do not call `mcp__supabase__apply_migration` or `deploy_edge_function`; read-only checks via `mcp__supabase__execute_sql` inside `begin; … rollback;` are fine.
- Time zone is Asia/Manila via `AppTime`. **Day keys** are `DateTime.utc(y, m, d)` of the Manila calendar date, produced only by `manilaDay()` / `parseDayKey()` (Task 1). DB `date` values are `yyyy-MM-dd` strings via `formatDayKey()`. `ReportDateRange` uses local `DateTime(y, m, d)` with an **exclusive** `end`; convert with `DateTime.utc(r.start.year, r.start.month, r.start.day)`.
- "Admin" = `profiles.role == 'admin'` via `isStrictAdminProvider` (Task 10) and SQL `role = 'admin'`. Never use `isAdminProvider` (it includes programmers) for approval or monitor gating.
- Status color only via `AppStatusColors` / `StatusKind` (`success, warning, danger, info, neutral`) / `StatusPill.kind`. Section titles `AppSectionHeader(title, trailing:)`; KPI tiles `AppMetricCard(title:, value:, icon:, trailing:)`; codes/durations `TechChip`; cards `M3Card`. No hardcoded status hex.
- Every new file < 500 lines.
- Day sheets and work logs are online-only. When `isOnlineProvider` is false, day-sheet actions are disabled and a banner reads: `You're offline — day sheet actions need a connection.`
- Tests live in `test/`, call `setUpAll(AppTime.initialize)`, and follow the fake-client pattern of `test/programmer_tasks_controller_test.dart` (controller constructed with `fake as dynamic`). Widget tests wrap in `ProviderScope` + `MaterialApp(theme: AppTheme.light())`.
- Known unrelated failures when running the full suite: `theme_overhaul_test`, `profile_screen_test`, `user_management_screen_test`, and time-of-day-dependent cases in `dashboard_metrics_provider_test`.
## Review Focus
1. **Device time zone ≠ Manila** (browser set to UTC): an event at `2026-09-26T16:30Z` belongs to Manila day 2026-09-27, and a day sheet's time never leaks across Manila midnight. → Task 1 test `manilaDay ignores device time zone`.
2. **Legacy plain-text work-log descriptions** (stored before Quill, not Delta JSON): shown verbatim in rows, sheet, and PDF — never blank. → Task 2 test `legacy plain text notes pass through`.
3. **Offline-replayed task with no `started` log but `started_at` set**: its time is still counted from `started_at`. → Task 1 test `falls back to task.startedAt`.
4. **Concurrent review** — two admins act on one sheet, or a bulk approval includes a sheet someone already reviewed: single actions fail with "This sheet was already reviewed."; bulk approves the rest and reports the skipped ones. → Task 14 tests + Task 12 SQL checklist items 5 and 8.
5. **Report period with zero approved days**: the PDF still renders the letterhead, the line `No approved accomplishments in this period.`, and the excluded-days footnote. → Task 21 test `empty period`, Task 22 test.
---
# Part A — Slice 1: Mine dashboard (no schema change)
### Task 1: Day keys and per-day time split
**Files:**
- Create: `lib/utils/programmer_daily_time.dart`
- Test: `test/programmer_daily_time_test.dart`
**Interfaces:**
- Consumes: `ProgrammerTask` (`lib/models/programmer_task.model.dart`), `ProgrammerTaskActivityLog` (`actionType`, `meta`, `createdAt`), `AppTime.fromComponents`.
- Produces:
```dart
DateTime manilaDay(DateTime instant); // DateTime.utc(y,m,d) of the Manila date
DateTime manilaDayStart(DateTime dayKey); // Manila midnight instant (TZDateTime)
DateTime parseDayKey(String yyyyMmDd); // '2026-09-27' -> DateTime.utc(2026,9,27)
String formatDayKey(DateTime dayKey); // -> '2026-09-27'
typedef WorkedInterval = ({String assigneeId, DateTime start, DateTime end});
List<WorkedInterval> workedIntervals({required ProgrammerTask task,
required List<ProgrammerTaskActivityLog> logs, required DateTime now});
/// assigneeId -> dayKey -> seconds (adjustments applied, each bucket >= 0)
Map<String, Map<DateTime, int>> dailySecondsByAssignee({required ProgrammerTask task,
required List<ProgrammerTaskActivityLog> logs, required DateTime now});
```
- [ ] **Step 1: Write the failing tests.** Build fixtures with a helper `_ev(String action, int d, int h, [int m = 0, Map<String, dynamic>? meta])` creating a log at Manila `2026-09-d h:m` (via `AppTime.fromComponents`), and a helper `_task({status, creatorId, assigneeId, startedAt})`.
```dart
test('manilaDay ignores device time zone', () {
expect(manilaDay(DateTime.parse('2026-09-26T16:30:00Z')), DateTime.utc(2026, 9, 27));
expect(manilaDay(DateTime.parse('2026-09-26T15:59:00Z')), DateTime.utc(2026, 9, 26));
});
test('day key round-trip', () {
expect(formatDayKey(parseDayKey('2026-09-27')), '2026-09-27');
});
test('single day with a pause', () {
// started 09:00, paused 11:00, resumed 13:00, completed 15:00 on Sep 27; creator=assignee='a'
expect(dailySecondsByAssignee(task: t, logs: logs, now: now), {'a': {DateTime.utc(2026, 9, 27): 4 * 3600}});
});
test('running across Manila midnight splits by day', () {
// started Sep 26 22:00, status in_progress, now = Sep 27 02:00
expect(result['a'], {DateTime.utc(2026, 9, 26): 7200, DateTime.utc(2026, 9, 27): 7200});
});
test('reassignment credits each assignee for their own span', () {
// creator 'a', current assignee 'b'; started 09:00, reassigned {assignee_id:'b'} 10:00, completed 12:00
expect(result['a']![DateTime.utc(2026, 9, 27)], 3600);
expect(result['b']![DateTime.utc(2026, 9, 27)], 7200);
});
test('initial assignee comes from the first assigned event', () {
// creator 'boss', assigned {assignee_id:'a'}, started 09:00, completed 10:00
expect(result.keys, ['a']);
});
test('adjustment subtracts on the day created and clamps at zero', () {
// started 09:00, adjustment {seconds:1800} 10:00, completed 11:00 -> 5400
// second case: adjustment {seconds: 99999} -> 0
});
test('falls back to task.startedAt when there is no started log', () {
// no 'started' log, task.startedAt = Sep 27 09:00, completed 10:00 -> 3600
});
test('a paused in-progress task stops accruing at the pause', () {
// started 09:00, paused 10:00, status in_progress, now 15:00 -> 3600
});
```
- [ ] **Step 2: Run to verify failure.** Run: `$FLUTTER test test/programmer_daily_time_test.dart` — Expected: FAIL (file/functions not defined).
- [ ] **Step 3: Implement.** Rules the tests don't fully pin:
- Walk logs in **ascending** `createdAt` order (input may be any order).
- Initial assignee = meta `assignee_id` of the first `assigned` event; else, if any `reassigned` event exists, `task.creatorId`; else `task.assigneeId`.
- `started`/`resumed` open a segment (if none open); `paused`/`completed`/`cancelled` close it; `reassigned` closes the open segment for the old assignee and, if it was open, reopens it for `meta['assignee_id']`.
- If no `started` event exists and `task.startedAt != null`, treat `startedAt` as a synthetic `started`.
- An open segment at the end closes at `now` only when `task.status == in_progress`; otherwise at `completedAt ?? cancelledAt ?? now`.
- Split each interval at Manila midnights using `manilaDayStart(nextDayKey)`; adjustments subtract `meta['seconds']` from the bucket of the assignee-at-that-time on `manilaDay(event.createdAt)`; clamp each bucket to ≥ 0.
- [ ] **Step 4: Run to verify pass.** Run: `$FLUTTER test test/programmer_daily_time_test.dart` — Expected: all PASS.
- [ ] **Step 5: Commit.**
```bash
git add lib/utils/programmer_daily_time.dart test/programmer_daily_time_test.dart
git commit -m "feat(programmer): per-day worked time split with assignee attribution
Co-Authored-By: claude-flow <ruv@ruv.net>"
```
---
### Task 2: Day rows, Quill plain text, work-log `workDate`
**Files:**
- Create: `lib/utils/programmer_day_rows.dart`
- Modify: `lib/widgets/quill_field.dart` (add a top-level function), `lib/models/programmer_task_work_log.model.dart` (add `workDate`)
- Test: `test/programmer_day_rows_test.dart`
**Interfaces:**
- Consumes: Task 1 (`manilaDay`, `parseDayKey`, `dailySecondsByAssignee`).
- Produces:
```dart
// quill_field.dart
String deltaJsonToPlainText(String? stored); // Delta JSON -> trimmed text; non-JSON -> stored.trim(); null -> ''
// programmer_task_work_log.model.dart
final DateTime workDate; // ctor param `DateTime? workDate` -> `workDate ?? manilaDay(createdAt)`;
// fromMap: map['work_date'] != null ? parseDayKey(...) : manilaDay(createdAt)
// programmer_day_rows.dart
class DaySheetRow {
final String taskId, title, category, kind; // kind: 'assignee' | 'helper'
final String? projectName, helpedName;
final int seconds;
final List<String> notes;
Map<String, dynamic> toJson(); // keys: task_id,title,category,project_name,kind,helped_name,seconds,notes
factory DaySheetRow.fromJson(Map<String, dynamic> json);
}
List<DaySheetRow> buildDayRows({required String userId, required DateTime day,
required List<ProgrammerTask> tasks,
required Map<String, List<ProgrammerTaskActivityLog>> logsByTask,
required List<ProgrammerTaskWorkLog> workLogs,
required Map<String, String> projectNames, required Map<String, String> profileNames,
required DateTime now});
int totalSeconds(List<DaySheetRow> rows);
```
- [ ] **Step 1: Write the failing tests.**
```dart
test('assignee row: that day seconds + that day notes only', ...); // seconds from Task 1 split; a note from Sep 26 excluded
test('helper row: minutes*60 and helpedName', () {
// work log by 'h' on task assigned to 'a', minutes 45, Sep 27
expect(row.kind, 'helper'); expect(row.seconds, 2700); expect(row.helpedName, 'Ana Cruz');
});
test('notes-only row when the assignee logged but ran no timer', () { expect(row.seconds, 0); });
test('legacy plain text notes pass through', () {
expect(deltaJsonToPlainText('fixed bug'), 'fixed bug');
expect(deltaJsonToPlainText('[{"insert":"Hello\\n"}]'), 'Hello');
expect(deltaJsonToPlainText(null), '');
});
test('rows sort: assignee by seconds desc, then helpers by seconds desc', ...);
test('DaySheetRow json round-trip', () { expect(DaySheetRow.fromJson(r.toJson()).toJson(), r.toJson()); });
test('workDate falls back to the Manila day of createdAt', () {
expect(ProgrammerTaskWorkLog.fromMap({...base, 'created_at': '2026-09-26T16:30:00Z'}).workDate, DateTime.utc(2026, 9, 27));
expect(ProgrammerTaskWorkLog.fromMap({...base, 'work_date': '2026-09-25'}).workDate, DateTime.utc(2026, 9, 25));
});
```
- [ ] **Step 2: Run to verify failure.** Run: `$FLUTTER test test/programmer_day_rows_test.dart` — Expected: FAIL.
- [ ] **Step 3: Implement.** Row rules: an **assignee** row exists for a task when `dailySecondsByAssignee(...)[userId]?[day] > 0` or `userId` authored a `minutes == null` work log on it with `workDate == day`. A **helper** row exists for a task when `userId != task.assigneeId` and `userId` authored work logs with `minutes > 0` and `workDate == day`; `seconds = sum(minutes) * 60`, `helpedName = profileNames[task.assigneeId]`. `notes` = `deltaJsonToPlainText` of that user's work logs on that task that day, oldest first, empty strings dropped. Tasks missing from `tasks` are skipped.
- [ ] **Step 4: Run to verify pass.** Run: `$FLUTTER test test/programmer_day_rows_test.dart test/programmer_task_ledger_test.dart` — Expected: PASS (ledger test proves the model change is backward compatible).
- [ ] **Step 5: Commit** `lib/utils/programmer_day_rows.dart lib/widgets/quill_field.dart lib/models/programmer_task_work_log.model.dart test/programmer_day_rows_test.dart` — message `feat(programmer): day rows builder + work-log workDate`.
---
### Task 3: Day activity data, run states, clock ticker
**Files:**
- Create: `lib/providers/programmer_day_data_provider.dart`
- Test: `test/programmer_day_data_test.dart`
**Interfaces:**
- Consumes: Task 1, Task 2 (`ProgrammerTaskWorkLog.fromMap`), `programmerTasksProvider`, `supabaseClientProvider`.
- Produces:
```dart
enum ProgrammerRunState { running, paused, notStarted, completed, cancelled }
typedef ProgrammerRunInfo = ({ProgrammerRunState state, DateTime? since});
Map<String, ProgrammerRunInfo> computeRunStates(List<ProgrammerTask> tasks,
Map<String, List<ProgrammerTaskActivityLog>> logsByTask); // pure
class DayActivityData {
final DateTime day; final List<ProgrammerTask> tasks;
final Map<String, List<ProgrammerTaskActivityLog>> logsByTask;
final List<ProgrammerTaskWorkLog> workLogs;
}
Future<DayActivityData> fetchDayActivityData(dynamic client,
{required DateTime day, required List<ProgrammerTask> tasks, required DateTime now});
final clockTickerProvider = StreamProvider<DateTime>(...); // AppTime.now() immediately, then every 30 s
final todayKeyProvider = Provider<DateTime>(...); // manilaDay(ticker value ?? AppTime.now())
final programmerActivitySignalProvider = StreamProvider<int>(...); // +1 per realtime INSERT on either log table
final programmerDayDataProvider = FutureProvider.family<DayActivityData, DateTime>(...); // key = day key
final programmerRunStatesProvider = Provider<Map<String, ProgrammerRunInfo>>(...); // from today's day data
```
- [ ] **Step 1: Write the failing tests.** Define a local `_FakeClient` in the test following the controller-test pattern, supporting `select`, `eq`, `inFilter`, `gte`, `lt`, `order`.
```dart
test('computeRunStates: queued -> notStarted; latest paused -> paused since pause time; resumed -> running; completed', ...);
test('fetchDayActivityData includes in-progress tasks and tasks completed that day', ...);
test('fetchDayActivityData excludes a task completed two days earlier', ...);
test('fetchDayActivityData includes a task referenced only by a helper work log that day', ...);
```
- [ ] **Step 2: Run to verify failure.** Run: `$FLUTTER test test/programmer_day_data_test.dart` — Expected: FAIL.
- [ ] **Step 3: Implement.** Candidate tasks for a day = `status == in_progress`, or `startedAt != null && startedAt < dayEnd && (completedAt ?? cancelledAt ?? now) >= dayStart`. Work logs: `programmer_task_work_logs` with `created_at >= dayStart.toUtc()` and `< dayEnd.toUtc()` (Task 15 switches this to `work_date`); their `task_id`s join the candidates. Activity logs: one `select … inFilter('task_id', ids) order created_at desc`, grouped by task. Run state: `in_progress` whose latest of `started/paused/resumed` is `paused` → `paused` (`since` = that event); otherwise `running` (`since` = latest started/resumed, else `startedAt`). The signal provider subscribes one channel `programmer_activity_signal` with two `onPostgresChanges(event: PostgresChangeEvent.insert, …)` handlers and removes it on dispose. `programmerDayDataProvider` watches `programmerTasksProvider`, `programmerActivitySignalProvider`, `supabaseClientProvider`.
- [ ] **Step 4: Run to verify pass.** Run: `$FLUTTER test test/programmer_day_data_test.dart` — Expected: PASS.
- [ ] **Step 5: Commit** — `feat(programmer): day activity data + run-state provider`.
---
### Task 4: Day-sheet model, stats, streak, badges (pure)
**Files:**
- Create: `lib/models/programmer_day_sheet.model.dart`, `lib/utils/programmer_stats.dart`
- Test: `test/programmer_stats_test.dart`
**Interfaces:**
- Consumes: Task 1 (`parseDayKey`, `manilaDay`), Task 2 (`DaySheetRow`).
- Produces:
```dart
class DaySheetStatus { static const draft = 'draft', pending = 'pending',
disapproved = 'disapproved', approved = 'approved';
static String label(String s); } // 'Draft' | 'Pending' | 'Disapproved' | 'Approved'
class DaySheetSnapshot { final int totalSeconds; final List<DaySheetRow> rows;
Map<String, dynamic> toJson(); // {'v':1,'total_seconds':..,'rows':[..]}
factory DaySheetSnapshot.fromJson(Map<String, dynamic> j);
factory DaySheetSnapshot.fromRows(List<DaySheetRow> rows); }
class ProgrammerDaySheet { final String id, programmerId, status; final DateTime workDate; // day key
final DateTime? submittedAt, reviewedAt; final bool autoSubmitted; final String? reviewedBy;
final int resubmissions; final DaySheetSnapshot? approvedSnapshot;
final DateTime createdAt, updatedAt;
factory ProgrammerDaySheet.fromMap(Map<String, dynamic> m);
bool get isFirstPassApproved; } // status == approved && resubmissions == 0
class ProgrammerStats { final Duration todayFocus, helpGivenThisMonth, approvedTotal, helpGivenTotal;
final int completedThisWeek, completedLastWeek, currentStreak, bestStreak, completedTotal, bugFixCompleted;
final double? firstPassRate; final bool hasCleanWeek; }
ProgrammerStats computeProgrammerStats({required String userId, required DateTime now,
required List<ProgrammerTask> tasks, required List<ProgrammerDaySheet> sheets,
required Set<DateTime> leaveDays, required Duration todayFocus});
({int current, int best}) computeStreak({required List<ProgrammerDaySheet> sheets,
required Set<DateTime> leaveDays, required DateTime today});
class BadgeProgress { final String id, title, description; final IconData icon;
final int current, target; final bool earned; }
List<BadgeProgress> computeBadges(ProgrammerStats stats, {required bool includeSheetBadges});
```
- [ ] **Step 1: Write the failing tests.** (2026-09-27 is a Sunday; Mon 21 … Fri 25, Mon 28.)
```dart
test('fromMap parses work_date and snapshot', ...);
test('streak counts weekdays back from today, skipping weekends', () {
// today Mon 28 (no sheet); Wed 23, Thu 24, Fri 25 pending/approved
expect(computeStreak(sheets: s, leaveDays: {}, today: DateTime.utc(2026, 9, 28)).current, 3);
});
test('a disapproved day breaks the streak', () { /* Fri 25 disapproved -> current 0 */ });
test('approved leave days are neutral', () { /* Thu 24 in leaveDays, no sheet; Wed 23 + Fri 25 approved -> 2 */ });
test('best streak finds the longest earlier run', () { /* earlier run of 4 -> best 4 */ });
test('first-pass rate over 30 days', () {
// approved(r=0), approved(r=0), approved(r=1), disapproved -> 0.5 ; none reviewed -> null
});
test('completedThisWeek uses a Monday-start week', ...);
test('tiered badge shows highest earned tier and next target', () {
final b = computeBadges(statsWith(completedTotal: 12), includeSheetBadges: false)
.firstWhere((b) => b.id == 'finisher');
expect(b.earned, isTrue); expect(b.title, 'Finisher · 10'); expect(b.target, 50);
});
test('includeSheetBadges false hides sheet-based badges', () {
expect(ids, isNot(containsAll(['deep_work', 'on_a_roll', 'team_player', 'clean_week'])));
});
```
- [ ] **Step 2: Run to verify failure.** Run: `$FLUTTER test test/programmer_stats_test.dart` — Expected: FAIL.
- [ ] **Step 3: Implement.** Pinned rules:
- **Streak:** a day qualifies if its sheet is `pending` or `approved`. Walk back from `today`: a non-qualifying `today` is skipped (not a break); Sat/Sun and `leaveDays` are skipped; a qualifying day counts; any other day stops. **Best** = longest run scanning from the earliest sheet day to today with the same neutral rules.
- **Weeks** start Monday. `completedThisWeek` = tasks with `assigneeId == userId`, `status == completed`, `completedAt` in this week.
- **firstPassRate:** sheets with `workDate` in the last 30 days (today−29 … today); reviewed = approved ∨ disapproved ∨ `resubmissions > 0`; null when none reviewed.
- **Approved sums** from `approvedSnapshot`: `approvedTotal` = Σ totalSeconds; `helpGivenTotal` / `helpGivenThisMonth` = Σ seconds of `kind == 'helper'` rows (month of `workDate`).
- **hasCleanWeek:** some Mon–Fri where every weekday is in `leaveDays` or `isFirstPassApproved`, with ≥ 1 approved.
- **Badges** (tiers ascending; `target` = lowest unreached tier or top tier when all reached; `earned` = current ≥ first tier; tiered earned title = `'<Title> · <highest reached tier>'`):
| id | title | tiers | current | sheet-based | icon | description |
|---|---|---|---|---|---|---|
| first_finish | First Finish | 1 | completedTotal | no | `Icons.flag_outlined` | Complete your first task |
| finisher | Finisher | 10/50/100 | completedTotal | no | `Icons.task_alt` | Complete 10 / 50 / 100 tasks |
| bug_squasher | Bug Squasher | 10 | bugFixCompleted | no | `Icons.bug_report_outlined` | Complete 10 Bug Fix tasks |
| deep_work | Deep Work | 100/500 | approvedTotal.inHours | yes | `Icons.timer_outlined` | Log 100 / 500 approved hours |
| on_a_roll | On a Roll | 5/10/20 | bestStreak | yes | `Icons.local_fire_department_outlined` | Reach a 5 / 10 / 20-day streak |
| team_player | Team Player | 10 | helpGivenTotal.inHours | yes | `Icons.handshake_outlined` | Give 10 hours of help to teammates |
| clean_week | Clean Week | 1 | hasCleanWeek ? 1 : 0 | yes | `Icons.verified_outlined` | A Mon–Fri week with every day approved first time |
- [ ] **Step 4: Run to verify pass.** Run: `$FLUTTER test test/programmer_stats_test.dart` — Expected: PASS.
- [ ] **Step 5: Commit** — `feat(programmer): day-sheet model + stats, streak and badges`.
---
### Task 5: Shared task card with run-state pill; shared complete action
**Files:**
- Create: `lib/screens/programmer_tasks/widgets/programmer_task_card.dart`
- Modify: `lib/screens/programmer_tasks/programmer_tasks_list_screen.dart` (delete `_TaskCard`, use the new card), `lib/screens/programmer_tasks/programmer_task_actions.dart` (receive moved function), `lib/screens/programmer_tasks/programmer_task_detail_screen.dart` (`_complete` → call moved function)
- Test: `test/programmer_task_card_test.dart`
**Interfaces:**
- Consumes: Task 3 (`programmerRunStatesProvider`, `ProgrammerRunState`).
- Produces:
```dart
class ProgrammerTaskCard extends ConsumerWidget {
const ProgrammerTaskCard({super.key, required this.task, this.assignee, required this.isMine, this.readOnly = false});
}
Future<void> completeProgrammerTaskWithConfirm(BuildContext context, WidgetRef ref, ProgrammerTask task); // body moved verbatim from detail `_complete`
```
- [ ] **Step 1: Write the failing test.** Override `programmerRunStatesProvider` with `{'t1': (state: ProgrammerRunState.paused, since: …)}`.
```dart
testWidgets('paused pill shows for any assignee; Resume only for mine and not read-only', (tester) async {
// isMine: false -> find.text('Paused') findsOneWidget, find.text('Resume') findsNothing
// isMine: true -> find.text('Resume') findsOneWidget
// isMine: true, readOnly: true -> find.text('Resume') findsNothing
});
```
- [ ] **Step 2: Run to verify failure.** `$FLUTTER test test/programmer_task_card_test.dart` — FAIL.
- [ ] **Step 3: Implement** — same layout as the old `_TaskCard`; pill = `StatusPill.kind(StatusKind.warning, label: 'Paused')` when run state is paused, else `programmerTaskStatusKind(task.status)` + `ProgrammerTaskStatus.label`. Start/Resume shown only when `isMine && !readOnly`.
- [ ] **Step 4: Verify.** `$FLUTTER test test/programmer_task_card_test.dart test/programmer_tasks_controller_test.dart` — PASS; `$FLUTTER analyze lib` — no new issues.
- [ ] **Step 5: Commit** — `refactor(programmer): shared task card driven by run states`.
---
### Task 6: ProgrammerDashboard — hero, sections, Mine tab
**Files:**
- Create: `lib/screens/programmer_tasks/dashboard/programmer_dashboard.dart`, `dashboard/now_working_card.dart`, `dashboard/task_sections.dart`
- Modify: `lib/screens/programmer_tasks/programmer_tasks_list_screen.dart` (Mine tab body → `ProgrammerDashboard(userId: me)`)
- Test: `test/programmer_dashboard_sections_test.dart`
**Interfaces:**
- Consumes: Tasks 1, 3, 5 (`dailySecondsByAssignee`, `programmerDayDataProvider`, `programmerRunStatesProvider`, `todayKeyProvider`, `clockTickerProvider`, `ProgrammerTaskCard`, `completeProgrammerTaskWithConfirm`), `startProgrammerTaskWithPausePrompt`, `programmerTasksControllerProvider.pauseTask`.
- Produces:
```dart
class ProgrammerDashboard extends ConsumerWidget { const ProgrammerDashboard({super.key, required this.userId, this.readOnly = false}); }
typedef TaskSectionGroups = ({List<ProgrammerTask> running, List<ProgrammerTask> paused,
List<ProgrammerTask> notStarted, List<ProgrammerTask> completed, List<ProgrammerTask> cancelled});
TaskSectionGroups groupTasksForDashboard(List<ProgrammerTask> tasks,
Map<String, ProgrammerRunInfo> runStates, String userId); // only tasks assigned to userId
class TaskSections extends StatelessWidget { const TaskSections({super.key, required this.groups, required this.profileById, required this.readOnly}); }
class NowWorkingCard extends ConsumerWidget { const NowWorkingCard({super.key, required this.running, required this.nextUp, required this.todayOnTask, required this.totalOnTask, required this.readOnly}); }
```
- [ ] **Step 1: Write the failing tests.**
```dart
test('groupTasksForDashboard splits by run state and excludes other assignees', ...);
test('completed sorted by completedAt desc, cancelled by cancelledAt desc', ...);
testWidgets('Completed and Cancelled start collapsed', (t) async {
// 12 completed: header 'Completed' visible, first completed title not visible
// tap header -> 10 titles visible + text 'Show all (12)'
});
testWidgets('empty sections are hidden', ...); // no 'Cancelled' header when none
```
- [ ] **Step 2: Run to verify failure.** `$FLUTTER test test/programmer_dashboard_sections_test.dart` — FAIL.
- [ ] **Step 3: Implement.** Section headers `AppSectionHeader('<Name>', trailing: Text('<count>'))` with names `In progress`, `Paused`, `Not started`, `Completed`, `Cancelled`. When all groups are empty show the existing empty view (`No tasks assigned to you`). Hero: running task → title, `TechChip` category, `Today <h m> · Total <h m>` (`formatDurationHm`; today = `dailySecondsByAssignee(...)[userId][today]`, total = sum over all days for `userId`), buttons **Pause** (`pauseTask(taskId:)`) and **Complete** (`completeProgrammerTaskWithConfirm`). Nothing running → text `Nothing running` and, if `nextUp != null`, **Start** via `startProgrammerTaskWithPausePrompt`. `readOnly` hides all buttons. Live values re-render on `clockTickerProvider`.
- [ ] **Step 4: Verify.** `$FLUTTER test test/programmer_dashboard_sections_test.dart` — PASS; `$FLUTTER analyze lib` — clean.
- [ ] **Step 5: Commit** — `feat(programmer): Mine dashboard hero + task sections`.
---
### Task 7: Staff presence helpers (leave / pass slip)
**Files:**
- Create: `lib/utils/staff_presence.dart`
- Modify: `lib/screens/dashboard/dashboard_screen.dart:371-386` (replace the two inline indexing loops with calls)
- Test: `test/staff_presence_test.dart`
**Interfaces:**
- Produces:
```dart
Map<String, LeaveOfAbsence> activeLeaveByUser(List<LeaveOfAbsence> leaves, DateTime now); // status approved && start <= now < end
Map<String, PassSlip> activePassSlipByUser(List<PassSlip> slips); // slip.isActive
Set<DateTime> approvedLeaveDays(List<LeaveOfAbsence> leaves, String userId); // Manila day keys covered
```
- [ ] **Step 1: Failing tests** — approved leave spanning `now` included, pending excluded; active pass slip included; `approvedLeaveDays` for a leave Sep 24 08:00 → Sep 25 17:00 == `{utc(2026,9,24), utc(2026,9,25)}`.
- [ ] **Step 2:** `$FLUTTER test test/staff_presence_test.dart` — FAIL.
- [ ] **Step 3: Implement** and swap the dashboard loops (behavior unchanged).
- [ ] **Step 4:** `$FLUTTER test test/staff_presence_test.dart test/dashboard_metrics_provider_test.dart` — PASS (ignore the known time-of-day cases); `$FLUTTER analyze lib` — clean.
- [ ] **Step 5: Commit** — `refactor: extract staff presence helpers`.
---
### Task 8: Stat tiles and badges strip
**Files:**
- Create: `lib/screens/programmer_tasks/dashboard/stat_tiles.dart`, `dashboard/badges_strip.dart`
- Modify: `dashboard/programmer_dashboard.dart` (insert tiles + badges between hero and sections)
- Test: `test/programmer_stat_tiles_test.dart`
**Interfaces:**
- Consumes: Task 4 (`computeProgrammerStats`, `computeBadges`), Task 7 (`approvedLeaveDays`), `leavesProvider`, `profilesProvider`.
- Produces:
```dart
class StatTiles extends StatelessWidget { const StatTiles({super.key, required this.stats, required this.showSheetStats}); }
class BadgesStrip extends StatelessWidget { const BadgesStrip({super.key, required this.badges}); }
```
- [ ] **Step 1: Failing tests.**
```dart
testWidgets('sheet stats hidden when showSheetStats is false', ...); // find.text('STREAK') findsNothing
testWidgets('streak tile shows days and best', ...); // '6 days', 'Best 9'
testWidgets('locked badge shows progress', ...); // '7/10'
```
- [ ] **Step 2:** `$FLUTTER test test/programmer_stat_tiles_test.dart` — FAIL.
- [ ] **Step 3: Implement.** Tiles (`AppMetricCard`, `Wrap`), exact copy:
- `TODAY'S FOCUS` — value `formatDurationHm(todayFocus)`, trailing: a 40 px `CircularProgressIndicator(value: min(1, focus / 8h))` with `/ 8h` label (pattern `reports/widgets/conversion_rate_card.dart:43-55`).
- `COMPLETED THIS WEEK` — value count; trailing `↑N vs last week` / `↓N vs last week` / `Same as last week`.
- `STREAK` — icon `Icons.local_fire_department_outlined`, value `N days` (`1 day`), trailing `Best M`.
- `FIRST-PASS APPROVAL` — value `NN%` or `—`, trailing `Last 30 days`.
- `HELP GIVEN` — value `formatDurationHm`, trailing `This month`.
- `showSheetStats` = the dashboard user's role is `programmer`; when false only the first two tiles render and `computeBadges(includeSheetBadges: false)`.
- Badges: horizontal list of chips; earned = `AppStatusColors` success container; locked = neutral + `current/target`; tap → `AlertDialog` with title + description.
- Dashboard feeds `sheets: const <ProgrammerDaySheet>[]` until Task 20 wires `daySheetsForUserProvider`; `todayFocus` = `totalSeconds(buildDayRows(userId, today, …))`.
- [ ] **Step 4:** `$FLUTTER test test/programmer_stat_tiles_test.dart` — PASS; `$FLUTTER analyze lib` — clean.
- [ ] **Step 5: Commit** — `feat(programmer): dashboard stat tiles + badges`.
**Slice 1 checkpoint:** `$FLUTTER test` (only known failures) and `$FLUTTER build web` succeed.
---
# Part B — Slice 2: Admin Monitor
### Task 9: Monitor view-model (pure)
**Files:**
- Create: `lib/screens/programmer_tasks/monitor/monitor_model.dart`
- Test: `test/programmer_monitor_model_test.dart`
**Interfaces:**
- Consumes: Tasks 2, 3, 7.
- Produces:
```dart
enum MonitorState { working, paused, idle, onLeave, passSlip }
class HelpEdge { final String helperId, assigneeId, taskId, taskTitle; final int minutes; final DateTime at; }
List<HelpEdge> buildHelpEdges({required List<ProgrammerTaskWorkLog> workLogs,
required Map<String, ProgrammerTask> taskById, required DateTime day}); // author != assignee, minutes > 0, workDate == day; newest first
class ProgrammerMonitorEntry { final Profile programmer; final MonitorState state;
final ProgrammerTask? currentTask; final Duration currentTaskToday, todayTotal;
final int inProgress, paused, notStarted; final String? sheetStatus;
final List<HelpEdge> helped, helpedBy; }
List<ProgrammerMonitorEntry> buildMonitorEntries({required List<Profile> programmers,
required DayActivityData today, required Map<String, ProgrammerRunInfo> runStates,
required Map<String, LeaveOfAbsence> leaves, required Map<String, PassSlip> passSlips,
required Map<String, String> sheetStatusByUser, required Map<String, String> projectNames,
required Map<String, String> profileNames, required DateTime now});
```
- [ ] **Step 1: Failing tests** — leave overrides a running task (`onLeave`); pass slip → `passSlip`; running → `working` with that `currentTask`; only paused → `paused` with the most recent `since`; none → `idle`; sort order working, paused, idle, passSlip, onLeave, then name; `helped`/`helpedBy` edges; `todayTotal` equals `totalSeconds(buildDayRows(...))`.
- [ ] **Step 2:** `$FLUTTER test test/programmer_monitor_model_test.dart` — FAIL.
- [ ] **Step 3: Implement** (counts: `inProgress` = running tasks, `paused`, `notStarted` = queued, all for tasks assigned to that programmer).
- [ ] **Step 4:** same command — PASS.
- [ ] **Step 5: Commit** — `feat(programmer): monitor view-model`.
---
### Task 10: Strict admin, role tabs, Monitor tab, drill-down
**Files:**
- Modify: `lib/providers/profile_provider.dart` (add provider), `lib/screens/programmer_tasks/programmer_tasks_list_screen.dart` (role tabs + `initialTab`), `lib/routing/app_router.dart` (query `tab`, route `programmers/:userId` placed before `:id`)
- Create: `lib/screens/programmer_tasks/monitor/monitor_tab.dart`, `monitor/programmer_monitor_card.dart`, `monitor/help_panel.dart`, `lib/screens/programmer_tasks/programmer_drill_down_screen.dart`, `lib/screens/programmer_tasks/programmer_tasks_tabs.dart`
- Test: `test/programmer_tabs_test.dart`
**Interfaces:**
- Consumes: Tasks 6, 7, 9.
- Produces:
```dart
final isStrictAdminProvider = Provider<bool>(...); // currentProfileProvider role == 'admin'
enum ProgrammerTasksTab { monitor, approvals, mine, all, daySheets }
List<ProgrammerTasksTab> programmerTasksTabsFor({required bool isStrictAdmin});
ProgrammerTasksTab? parseProgrammerTasksTab(String? q); // 'monitor'|'approvals'|'mine'|'all'|'day-sheets'
String programmerTasksTabLabel(ProgrammerTasksTab t); // 'Monitor'|'Approvals'|'Mine'|'All'|'Day Sheets'
class ProgrammerTasksListScreen { const ProgrammerTasksListScreen({super.key, this.initialTab}); final String? initialTab; }
class ProgrammerDrillDownScreen extends ConsumerWidget { const ProgrammerDrillDownScreen({super.key, required this.userId}); } // route /programmer-tasks/programmers/:userId
class ProgrammerMonitorCard extends StatelessWidget { const ProgrammerMonitorCard({super.key, required this.entry, required this.onTap}); }
```
- [ ] **Step 1: Failing tests.**
```dart
test('tabs in this slice', () {
expect(programmerTasksTabsFor(isStrictAdmin: true), [ProgrammerTasksTab.monitor, ProgrammerTasksTab.mine, ProgrammerTasksTab.all]);
expect(programmerTasksTabsFor(isStrictAdmin: false), [ProgrammerTasksTab.mine, ProgrammerTasksTab.all]);
});
test('parse tab query', () { expect(parseProgrammerTasksTab('day-sheets'), ProgrammerTasksTab.daySheets); expect(parseProgrammerTasksTab('x'), isNull); });
testWidgets('monitor card pill labels', ...); // onLeave -> 'On leave', passSlip -> 'Pass slip', working -> 'Working'
```
- [ ] **Step 2:** `$FLUTTER test test/programmer_tabs_test.dart` — FAIL.
- [ ] **Step 3: Implement.**
- Pills: working → success `Working`; paused → warning `Paused`; idle → neutral `Idle`; onLeave → info `On leave`; passSlip → info `Pass slip`.
- Monitor tab: `AppStatusSummaryRow` with `Working`, `Paused`, `Idle`, `Away` (leave + pass slip) counts; grid columns by width (< 700 → 1, < 1100 → 2, else 3). Card body per spec §7 (current task + `running <h m> today`, `Today <h m>`, counts, help chips `Helped → <name> · <task> · <m>m` and `Helped by ← <name> · <m>m`). Tap → `context.go('/programmer-tasks/programmers/<id>')`.
- Help panel: `AppSectionHeader("Who's helping whom")`, day stepper (← date →, cannot pass today), rows `<helper> → <assignee> · <task> · <m>m · <time>`, from `programmerDayDataProvider(day)`; empty → `No help logged this day.`
- Drill-down: AppBar title = programmer name; body `ProgrammerDashboard(userId, readOnly: true)`. Non-admins see `AppEmptyView(icon: Icons.lock_outline, title: 'Admins only')`.
- The list screen builds its `TabController` from `programmerTasksTabsFor`, selects `parseProgrammerTasksTab(initialTab)` if present in the list; router passes `state.uri.queryParameters['tab']`.
- [ ] **Step 4:** `$FLUTTER test test/programmer_tabs_test.dart` — PASS; `$FLUTTER analyze lib` — clean.
- [ ] **Step 5: Commit** — `feat(programmer): admin monitor tab + drill-down`.
**Slice 2 checkpoint:** full `$FLUTTER test` + `$FLUTTER build web`; QA harness as admin shows the Monitor with Working/Paused/Idle.
---
# Part C — Slice 3: Day Sheets
### Task 11: Migration — schema, triggers, RLS
**Files:**
- Create: `supabase/migrations/20260927150000_add_programmer_day_sheets.sql` (the spec's single migration is split into this file + Task 12's file for reviewability)
**Interfaces:**
- Produces (DB): `manila_today() returns date`; tables `programmer_day_sheets`, `programmer_day_sheet_events` exactly as spec §5.2 (checks included; events body non-empty for `disapproved`/`justified`); `programmer_task_work_logs.work_date`; `notifications.day_sheet_id`; `ensure_day_sheet(p_programmer uuid, p_date date) returns uuid`; error code `work_date_locked`.
- [ ] **Step 1: Write the SQL.** Every function `set search_path = public`. Pinned behaviors:
1. `manila_today()` = `(now() at time zone 'Asia/Manila')::date`, `stable`.
2. Tables + `unique(programmer_id, work_date)`, index `(status, work_date)`; `updated_at` trigger reusing `set_programmer_tasks_updated_at()`.
3. `work_date`: add nullable → backfill `(created_at at time zone 'Asia/Manila')::date` → `set default manila_today()` → `set not null`; index `(author_id, work_date)`.
4. `ensure_day_sheet` (SECURITY DEFINER; `revoke execute … from public, anon, authenticated`): returns null unless the profile's role is `programmer`; inserts `draft` on conflict do nothing; if the row is `approved` and `p_date = manila_today()` → set `pending` and insert event `kind='amended'`, `actor_id null`, `body 'New work was logged after approval.'`.
5. BEFORE INSERT on work logs (SECURITY DEFINER): `work_date := coalesce(work_date, manila_today())`; future date → `raise exception 'work_date_locked'`; past date allowed only when the author's sheet for that date has status `pending` or `disapproved`, else `raise exception 'work_date_locked'`.
6. AFTER INSERT on work logs → `ensure_day_sheet(new.author_id, new.work_date)`.
7. AFTER INSERT on `programmer_task_activity_logs` when `action_type in ('started','paused','resumed','completed','cancelled','adjustment')` → `ensure_day_sheet(<task's assignee_id>, (new.created_at at time zone 'Asia/Manila')::date)` if assignee not null.
8. RLS on both tables. Sheets SELECT: `programmer_id = auth.uid() or exists (select 1 from profiles p where p.id = auth.uid() and p.role = 'admin')`. Events SELECT: `exists (select 1 from programmer_day_sheets s where s.id = sheet_id)`. No INSERT/UPDATE/DELETE policies.
9. `alter table notifications add column if not exists day_sheet_id uuid references programmer_day_sheets(id) on delete cascade` + index.
10. `alter publication supabase_realtime add table programmer_day_sheets, programmer_day_sheet_events;`
- [ ] **Step 2: Self-check.** Re-read against spec §5.2–5.4; confirm every trigger function that writes sheets is SECURITY DEFINER.
- [ ] **Step 3: Commit** — `feat(db): programmer day sheets schema, triggers, RLS`. (Applied together with Task 12.)
---
### Task 12: Migration — RPCs, auto-submit cron, digest queue; apply + SQL checklist
**Files:**
- Create: `supabase/migrations/20260927150100_programmer_day_sheet_rpcs.sql`
**Interfaces:**
- Consumes: Task 11.
- Produces (all SECURITY DEFINER, `set search_path = public`, `grant execute … to authenticated` except `auto_submit_day_sheets`):
```sql
submit_day_sheet(p_date date) returns jsonb
disapprove_day_sheet(p_sheet uuid, p_remarks text, p_flagged_task_ids uuid[] default '{}') returns jsonb
justify_day_sheet(p_sheet uuid, p_body text) returns jsonb
approve_day_sheets(p_items jsonb) returns jsonb -- [{sheet_id, snapshot}]
auto_submit_day_sheets() returns void -- revoked from public, anon, authenticated
```
Single-sheet RPCs return `{sheet_id, work_date, programmer_id, programmer_name, notify_user_ids}`; approve returns `{approved: [{sheet_id, programmer_id, work_date}], skipped_ids: [...]}`.
Error codes (`raise exception '<code>'`): `not_authorized`, `already_reviewed`, `already_submitted`, `remarks_required`, `body_required`, `invalid_snapshot`, `future_date`.
- [ ] **Step 1: Write the SQL.** Pinned behaviors (lock the sheet row `for update` before status checks):
- **submit:** caller role `programmer`; `p_date > manila_today()` → `future_date`; upsert draft row for (caller, p_date); status ≠ `draft` → `already_submitted`; set `pending`, `submitted_at = now()`, `auto_submitted = false`; event `submitted`; notify **every** `role = 'admin'` profile with `type 'day_sheet_submitted'`, `actor_id = caller`, `day_sheet_id`.
- **disapprove:** caller admin else `not_authorized`; `btrim(p_remarks) = ''` → `remarks_required`; status ≠ `pending` → `already_reviewed`; set `disapproved`, `reviewed_by`, `reviewed_at`; event `disapproved` with body + flags; notify the programmer `day_sheet_disapproved`.
- **justify:** caller = owner else `not_authorized`; blank body → `body_required`; status ≠ `disapproved` → `already_reviewed`; set `pending`, `resubmissions + 1`, `submitted_at = now()`; event `justified`; notify `reviewed_by` (if not null) `day_sheet_justified`.
- **approve:** caller admin; each item's `snapshot` must be an object with a `rows` array else `invalid_snapshot`; status in (`pending`,`disapproved`) → set `approved`, `reviewed_by`, `reviewed_at`, `approved_snapshot`, event `approved`, notify programmer `day_sheet_approved`, append to `approved`; otherwise append to `skipped_ids` (no error).
- **auto_submit:** (1) for each `in_progress` task whose latest of started/paused/resumed is not `paused`, with a `programmer` assignee: `ensure_day_sheet(assignee, manila_today() - 1)`; if that sheet is `approved` → `pending` + event `amended` body `'Timer was still running at the end of the day.'`. (2) every `draft` with `work_date < manila_today()` → `pending`, `submitted_at = now()`, `auto_submitted = true`, event `auto_submitted`. (3) if any sheet is `pending`: for each admin insert `notifications(user_id, actor_id null, type 'day_sheet_digest')` and `scheduled_notifications(user_id, notify_type 'day_sheet_digest', scheduled_for ((manila_today() + time '08:00') at time zone 'Asia/Manila'), epoch to_char(manila_today(), 'YYYYMMDD')::int) on conflict do nothing`.
- Replace `chk_at_least_one_ref` on `scheduled_notifications`, keeping its current five references and adding `or notify_type = 'day_sheet_digest'`.
- Cron in a `DO $$ … EXCEPTION WHEN others THEN RAISE NOTICE … $$` block: unschedule `programmer_day_sheet_autosubmit` (ignore errors), then `cron.schedule('programmer_day_sheet_autosubmit', '5 16 * * *', 'SELECT public.auto_submit_day_sheets();')` (16:05 UTC = 00:05 Manila).
- [ ] **Step 2: Commit** — `feat(db): day sheet RPCs, auto-submit cron, digest queue`.
- [ ] **Step 3: Ask the user to apply** `20260927150000_…sql` then `20260927150100_…sql` (dashboard SQL editor or `supabase db push`). Wait for confirmation.
- [ ] **Step 4: Run the SQL checklist** with `mcp__supabase__execute_sql`, each item in `begin; … rollback;`, impersonating via `set local role authenticated; select set_config('request.jwt.claims', json_build_object('sub','<uuid>','role','authenticated')::text, true);`. Programmer = `pao@mail.com`'s profile id; admin = any `role = 'admin'` id. Setup rows for past dates are inserted as `postgres` before switching role.
| # | Action | Expected |
|---|---|---|
| 1 | programmer `submit_day_sheet(manila_today())` | returns jsonb; sheet `pending` |
| 2 | repeat 1 | error `already_submitted` |
| 3 | programmer `disapprove_day_sheet(id,'x')` | error `not_authorized` |
| 4 | admin disapprove with `' '`, then `'Missing notes'` | `remarks_required`; then `disapproved` + a `day_sheet_disapproved` notification for the programmer |
| 5 | admin disapprove again | `already_reviewed` |
| 6 | programmer inserts a work log with `work_date = manila_today()-1` where no sheet exists; then on a `disapproved` yesterday sheet | `work_date_locked`; then succeeds |
| 7 | programmer justify `''`, then `'Added notes'` | `body_required`; then `pending`, `resubmissions = 1` |
| 8 | admin `approve_day_sheets` with a valid snapshot, then again | id in `approved`; then id in `skipped_ids` |
| 9 | today's sheet approved; programmer inserts a work log today | sheet `pending` + `amended` event |
| 10 | postgres: draft sheet dated yesterday; `select auto_submit_day_sheets()` twice | `pending`, `auto_submitted = true`; one `day_sheet_digest` scheduled row per admin (no duplicate) |
| 11 | a second programmer selects `programmer_day_sheets` | only own rows |
| 12 | `mcp__supabase__get_advisors(type: 'security')` | no new ERROR-level lint on the new tables/functions |
---
### Task 13: Edge function — digest push
**Files:**
- Modify: `supabase/functions/process_scheduled_notifications/index.ts` (new `case 'day_sheet_digest'` in the `notify_type` switch)
- [ ] **Step 1: Implement.** Count `programmer_day_sheets` with `status = 'pending'` (`select('id', { count: 'exact', head: true })`). If 0 → mark the row processed and skip sending. Else `title = 'Day sheets awaiting review'`, `body = \`${n} day sheet${n === 1 ? ' is' : 's are'} awaiting your review.\``, `data.navigate_to = '/programmer-tasks?tab=approvals'`.
- [ ] **Step 2: Commit** — `feat(edge): day sheet digest push`.
- [ ] **Step 3: Ask the user to redeploy** `process_scheduled_notifications`. Verify: insert (as postgres, not rolled back, then delete) a `scheduled_notifications` row for an admin with `notify_type 'day_sheet_digest'`, `scheduled_for now()`, `epoch 1`; within ~1 minute `processed = true` and `last_error is null`.
---
### Task 14: Day-sheet event model, providers, controller
**Files:**
- Create: `lib/models/programmer_day_sheet_event.model.dart`, `lib/providers/programmer_day_sheets_provider.dart`
- Test: `test/programmer_day_sheets_controller_test.dart`
**Interfaces:**
- Consumes: Task 4 (`ProgrammerDaySheet`, `DaySheetSnapshot`), Task 1 (`formatDayKey`, `parseDayKey`), `NotificationsController.sendPush`.
- Produces:
```dart
class ProgrammerDaySheetEvent { final String id, sheetId, kind; final String? actorId, body;
final List<String> flaggedTaskIds; final DateTime createdAt; factory ProgrammerDaySheetEvent.fromMap(Map<String, dynamic> m); }
enum DaySheetError { alreadyReviewed, alreadySubmitted, notAuthorized, remarksRequired, bodyRequired, workDateLocked, invalidSnapshot, unknown }
class DaySheetActionException implements Exception { final DaySheetError code; String get message; }
DaySheetError mapDaySheetError(Object error); // substring match on the codes from Tasks 11–12
typedef PushFn = Future<void> Function({required List<String> userIds, required String title, required String body, Map<String, dynamic>? data});
class BulkApproveResult { final List<String> approvedIds, skippedIds; }
class DaySheetsController {
DaySheetsController(dynamic client, {required PushFn push});
Future<String> submit(DateTime dayKey);
Future<void> disapprove({required String sheetId, required String remarks, required List<String> flaggedTaskIds});
Future<void> justify({required String sheetId, required String body});
Future<BulkApproveResult> approve(List<({String sheetId, DaySheetSnapshot snapshot})> items);
}
final daySheetsControllerProvider = Provider<DaySheetsController>(...);
final myDaySheetsProvider = StreamProvider<List<ProgrammerDaySheet>>(...); // programmer_id = me, work_date desc
final daySheetsForUserProvider = StreamProvider.family<List<ProgrammerDaySheet>, String>(...);
final allDaySheetsProvider = StreamProvider<List<ProgrammerDaySheet>>(...); // RLS-scoped, work_date desc
final daySheetByIdProvider = StreamProvider.family<ProgrammerDaySheet?, String>(...);
final daySheetEventsProvider = StreamProvider.family<List<ProgrammerDaySheetEvent>, String>(...); // created_at asc
```
Error messages (exact): alreadyReviewed `This sheet was already reviewed.` · alreadySubmitted `This day sheet was already submitted.` · notAuthorized `You are not allowed to do that.` · remarksRequired `Remarks are required to disapprove.` · bodyRequired `Please write a justification.` · workDateLocked `That day is locked — you can only add entries to pending or disapproved days.` · invalidSnapshot `Could not approve: the sheet data was invalid.` · unknown `Something went wrong. Please try again.`
Push copy (dates via `AppTime.formatDate`, e.g. `Sep 28, 2026`; `data` always includes `type` and `navigate_to: '/programmer-tasks/day-sheets/<id>'`):
- submitted → `Day sheet submitted` / `<name> submitted their day sheet for <date>`
- disapproved → `Day sheet disapproved` / `Your day sheet for <date> needs changes: <remarks, max 80 chars + '…'>`
- justified → `Day sheet resubmitted` / `<name> justified their day sheet for <date>`
- approved → `Day sheet approved` / `Your day sheet for <date> was approved`
- [ ] **Step 1: Failing tests** (local fake client with `rpc(String name, {Map<String, dynamic>? params})`; a recording `PushFn`):
```dart
test('submit calls submit_day_sheet with p_date and pushes admins', () async {
// rpc returns {'sheet_id':'s1','work_date':'2026-09-28','programmer_name':'Paola Ross','notify_user_ids':['adm']}
expect(fake.calls.single, ('submit_day_sheet', {'p_date': '2026-09-28'}));
expect(push.calls.single.title, 'Day sheet submitted');
expect(push.calls.single.body, 'Paola Ross submitted their day sheet for Sep 28, 2026');
});
test('blank remarks fail fast without an rpc call', ...); // throws DaySheetActionException(remarksRequired); fake.calls isEmpty
test('already_reviewed maps to a friendly message', ...); // message 'This sheet was already reviewed.'
test('approve reports approved and skipped ids and pushes per approved sheet', ...);
test('a failing push does not fail the action', ...);
```
- [ ] **Step 2:** `$FLUTTER test test/programmer_day_sheets_controller_test.dart` — FAIL.
- [ ] **Step 3: Implement.** Controller trims inputs and validates remarks/body before calling the RPC; wraps RPC errors with `mapDaySheetError`; push errors are caught and `debugPrint`ed. Provider wires `push` to `ref.read(notificationsControllerProvider).sendPush`.
- [ ] **Step 4:** same command — PASS.
- [ ] **Step 5: Commit** — `feat(programmer): day sheet providers + controller`.
---
### Task 15: Dated work logs (corrections); day data by `work_date`
**Files:**
- Modify: `lib/providers/programmer_task_work_logs_provider.dart`, `lib/screens/programmer_tasks/widgets/work_log_section.dart`, `lib/providers/programmer_day_data_provider.dart`
- Test: `test/programmer_tasks_controller_test.dart` (extend the `addWorkLog` group), `test/programmer_day_data_test.dart` (update fake filtering)
**Interfaces:**
- Consumes: Task 14 (`DaySheetActionException`, `mapDaySheetError`).
- Produces:
```dart
Future<void> addWorkLog({required String taskId, required String description, int? minutes, DateTime? workDate});
// sends 'work_date': formatDayKey(workDate) only when workDate != null; 'work_date_locked' -> DaySheetActionException(workDateLocked)
const WorkLogSection({super.key, required this.task, required this.isAssignee, this.workDate});
```
- [ ] **Step 1: Failing tests** — `addWorkLog(workDate: utc(2026,9,25))` inserts `work_date: '2026-09-25'`; without `workDate` the key is absent; `fetchDayActivityData` now selects work logs with `eq('work_date', '2026-09-27')`.
- [ ] **Step 2:** `$FLUTTER test test/programmer_tasks_controller_test.dart test/programmer_day_data_test.dart` — FAIL.
- [ ] **Step 3: Implement.** `WorkLogSection` with `workDate != null`: header `AppSectionHeader('Correction for <date>')`, and skip the pause / deduct prompts after saving.
- [ ] **Step 4:** same command — PASS.
- [ ] **Step 5: Commit** — `feat(programmer): dated work-log corrections`.
---
### Task 16: `ReportDateFilter.controlled`
**Files:**
- Modify: `lib/screens/reports/report_date_filter.dart`
- Test: `test/report_date_filter_test.dart`
**Interfaces:**
- Produces:
```dart
const ReportDateFilter({super.key}); // unchanged: binds reportDateRangeProvider
const ReportDateFilter.controlled({super.key, required ReportDateRange value, required ValueChanged<ReportDateRange> onChanged});
ReportDateRange thisMonthRange(DateTime now); // start = DateTime(y,m,1), end = DateTime(y,m+1,1), label 'This Month'
```
- [ ] **Step 1: Failing test** — pump `ReportDateFilter.controlled(value: thisMonthRange(now), onChanged: captured.add)`; tap `Change`, tap preset `Yesterday` → `captured.single.label == 'Yesterday'` and `container.read(reportDateRangeProvider)` still has label `Last 30 Days`.
- [ ] **Step 2:** `$FLUTTER test test/report_date_filter_test.dart` — FAIL.
- [ ] **Step 3: Implement.** Both constructors share the build; the dialog's `current`/`onApply` come from `value`/`onChanged` when controlled, otherwise from the provider.
- [ ] **Step 4:** same command — PASS.
- [ ] **Step 5: Commit** — `feat(reports): controlled ReportDateFilter`.
---
### Task 17: Day-sheet detail screen, dialogs, route
**Files:**
- Create: `lib/screens/programmer_tasks/day_sheets/day_sheet_detail_screen.dart`, `day_sheets/day_sheet_dialogs.dart`, `day_sheets/day_sheet_rows_table.dart`, `day_sheets/day_sheet_thread.dart`
- Modify: `lib/routing/app_router.dart` (route `day-sheets/:id` under `/programmer-tasks`, before `:id`)
- Test: `test/day_sheet_dialogs_test.dart`
**Interfaces:**
- Consumes: Tasks 2, 3, 14, 15.
- Produces:
```dart
class DaySheetDetailScreen extends ConsumerStatefulWidget { const DaySheetDetailScreen({super.key, required this.sheetId}); }
Future<String?> showDisapproveDialog(BuildContext context, {required List<DaySheetRow> flaggedRows});
Future<String?> showJustifyDialog(BuildContext context);
class DaySheetRowsTable extends StatelessWidget { const DaySheetRowsTable({super.key, required this.rows,
required this.flaggedTaskIds, this.selectedTaskIds, this.onSelectionChanged, this.onAddCorrection}); }
class DaySheetThread extends StatelessWidget { const DaySheetThread({super.key, required this.events, required this.profileNames}); }
List<DaySheetRow> rowsForSheet(ProgrammerDaySheet sheet, List<DaySheetRow> liveRows); // approved -> snapshot rows, else liveRows
```
- [ ] **Step 1: Failing tests.**
```dart
testWidgets('disapprove dialog requires remarks', (t) async {
// tap 'Disapprove' with empty field -> 'Remarks are required' visible, dialog stays
// enter 'Missing notes' -> dialog returns 'Missing notes'
});
testWidgets('justify dialog requires a justification', ...); // 'Please write a justification'
test('rowsForSheet uses the snapshot once approved', ...);
```
- [ ] **Step 2:** `$FLUTTER test test/day_sheet_dialogs_test.dart` — FAIL.
- [ ] **Step 3: Implement** per spec §6.3:
- Header: date, programmer, `StatusPill.kind` (draft→neutral, pending→info, disapproved→warning, approved→success), total, `Round N` (N = resubmissions + 1) when `resubmissions > 0`.
- Flags = `flaggedTaskIds` of the latest `disapproved` event; shown while status ≠ approved.
- Thread labels: submitted `Submitted` · auto_submitted `Auto-submitted at end of day` · disapproved `Disapproved` · justified `Justified` · approved `Approved` · amended `Reopened — new work after approval`.
- Owner when status is `disapproved` (or `pending` for a past day): each row shows an **Add correction** icon → dialog with `WorkLogSection(task, isAssignee: task.assigneeId == me, workDate: sheet.workDate)`. Owner when `disapproved`: **Justify & resubmit** → `showJustifyDialog` → `controller.justify`.
- Admin when `pending`/`disapproved`: row checkboxes → **Disapprove** → `showDisapproveDialog(flaggedRows)` → `controller.disapprove`; **Approve** → if the sheet day is today and `programmerRunStatesProvider` has a running task assigned to that programmer, confirm `<name> has a timer running. Work logged after approval will reopen this sheet. Approve anyway?` → `controller.approve([(sheetId: sheet.id, snapshot: DaySheetSnapshot.fromRows(rows))])`.
- `DaySheetActionException.message` → SnackBar; offline banner per Global Constraints.
- [ ] **Step 4:** `$FLUTTER test test/day_sheet_dialogs_test.dart` — PASS; `$FLUTTER analyze lib` — clean.
- [ ] **Step 5: Commit** — `feat(programmer): day sheet detail screen`.
---
### Task 18: Day Sheets tab, Approvals tab, tab wiring
**Files:**
- Create: `lib/screens/programmer_tasks/day_sheets/day_sheets_tab.dart`, `day_sheets/approvals_tab.dart`, `day_sheets/day_sheet_snapshot_builder.dart`
- Modify: `lib/screens/programmer_tasks/programmer_tasks_tabs.dart` (`programmerTasksTabsFor`), `programmer_tasks_list_screen.dart` (tab bodies)
- Test: `test/day_sheet_tabs_test.dart`; update `test/programmer_tabs_test.dart`
**Interfaces:**
- Consumes: Tasks 14, 16, 17.
- Produces:
```dart
Future<DaySheetSnapshot> buildSnapshotForSheet(WidgetRef ref, ProgrammerDaySheet sheet);
Map<DateTime, List<ProgrammerDaySheet>> groupSheetsByDate(List<ProgrammerDaySheet> sheets); // keys descending
({int pending, int awaiting, int approved}) approvalCounts(List<ProgrammerDaySheet> sheets); // awaiting = disapproved
```
Tabs now: admin `[monitor, approvals, mine, all]`, programmer `[mine, all, daySheets]`.
- [ ] **Step 1: Failing tests** — updated `programmerTasksTabsFor` expectations; `groupSheetsByDate` order; `approvalCounts`; widget: `ApprovalsTab` with overridden `allDaySheetsProvider` (3 pending) → select two cards → button text `Approve selected (2)`.
- [ ] **Step 2:** `$FLUTTER test test/day_sheet_tabs_test.dart test/programmer_tabs_test.dart` — FAIL.
- [ ] **Step 3: Implement** per spec §6.2 / §6.4:
- Day Sheets tab: **Today** card (live `buildDayRows` for me, total, status chip — missing sheet shows `Draft`), **Submit day** enabled when status is draft/missing and rows are non-empty → SnackBar `Day submitted`; **Needs your attention** (my `disapproved` sheets); history with `AppStatusSummaryRow` filters All / Pending / Disapproved / Approved; tapping → `/programmer-tasks/day-sheets/<id>`.
- Approvals tab: summary chips `Pending`, `Awaiting justification`, `Approved` (Pending selected by default); programmer dropdown (`All programmers` + names); `ReportDateFilter.controlled` (default `thisMonthRange`); cards grouped by date with name, `TechChip(formatDurationHm(total))`, `<n> tasks`, badge `Auto` if `autoSubmitted` else `Early`, `Round N`; checkboxes on pending cards; **Approve selected (n)** → `buildSnapshotForSheet` each → `approve` → SnackBar `Approved <a>` plus `, <s> already reviewed` when skipped > 0.
- [ ] **Step 4:** same command — PASS; `$FLUTTER analyze lib` — clean.
- [ ] **Step 5: Commit** — `feat(programmer): day sheets + approvals tabs`.
---
### Task 19: Notifications for day sheets
**Files:**
- Modify: `lib/models/notification_item.model.dart` (add `final String? daySheetId;` ← `day_sheet_id`), Brick generated files (regenerated), `lib/screens/notifications/notifications_screen.dart`, `lib/services/notification_bridge.dart`
- Test: `test/notification_titles_test.dart`
**Interfaces:**
- Produces (moved out of the screen's State so they are testable; existing cases unchanged):
```dart
String notificationTitle(String type, String actorName);
IconData notificationIcon(String type);
```
New cases: `day_sheet_submitted` → `'$actorName submitted a day sheet'` / `Icons.assignment_turned_in_outlined`; `day_sheet_disapproved` → `'Day sheet disapproved'` / `Icons.report_outlined`; `day_sheet_justified` → `'$actorName justified a day sheet'` / `Icons.reply_outlined`; `day_sheet_approved` → `'Day sheet approved'` / `Icons.verified_outlined`; `day_sheet_digest` → `'Day sheets awaiting review'` / `Icons.inbox_outlined`. Banner labels: `Day sheet submitted`, `Day sheet disapproved`, `Day sheet justified`, `Day sheet approved`, `Day sheets awaiting review`. Tap: `daySheetId != null` → `/programmer-tasks/day-sheets/<id>`; `day_sheet_digest` → `/programmer-tasks?tab=approvals`.
- [ ] **Step 1: Failing tests** for the five titles/icons and one existing case (`'assignment'` → `'X assigned you'`) to guard the move.
- [ ] **Step 2:** `$FLUTTER test test/notification_titles_test.dart` — FAIL.
- [ ] **Step 3: Implement**, then regenerate Brick: `$FLUTTER pub run build_runner build --delete-conflicting-outputs`; commit the regenerated adapter, `schema.g.dart`, and new `lib/brick/db/*.migration.dart`.
- [ ] **Step 4:** `$FLUTTER test test/notification_titles_test.dart test/notification_bridge_test.dart` — PASS; `$FLUTTER analyze lib` — clean.
- [ ] **Step 5: Commit** — `feat(notifications): day sheet notification types`.
---
### Task 20: Wire sheet data into dashboard, monitor, drill-down
**Files:**
- Modify: `dashboard/programmer_dashboard.dart` (`sheets` ← `daySheetsForUserProvider(userId)`), `monitor/monitor_tab.dart` (`sheetStatusByUser` from `allDaySheetsProvider` for today + summary chip `Sheets pending`), `programmer_drill_down_screen.dart` (`AppSectionHeader('Recent day sheets')` + last 10 sheets → detail route)
- Test: `test/programmer_drill_down_test.dart`
- [ ] **Step 1: Failing test** — drill-down with overridden `daySheetsForUserProvider('u1')` (2 sheets) and admin profile shows `Recent day sheets` and both dates.
- [ ] **Step 2:** `$FLUTTER test test/programmer_drill_down_test.dart` — FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4:** same command — PASS; `$FLUTTER analyze lib` — clean.
- [ ] **Step 5: Commit** — `feat(programmer): sheet-driven stats, monitor status, drill-down history`.
**Slice 3 checkpoint:** full `$FLUTTER test`, `$FLUTTER build web`; QA harness: programmer submits early → admin disapproves with a flag → programmer adds a correction + justifies → admin approves; notifications render and route.
---
# Part D — Slice 4: Accomplishment Report
### Task 21: Report data builder (pure)
**Files:**
- Create: `lib/screens/programmer_tasks/report/accomplishment_report_data.dart`
- Test: `test/accomplishment_report_data_test.dart`
**Interfaces:**
- Consumes: Tasks 2, 4.
- Produces:
```dart
class ReportDay { final DateTime day; final String? statusTag; final List<DaySheetRow> rows; } // statusTag null when approved; 'Pending' | 'Disapproved'
class AccomplishmentReportData {
final String programmerName, positionLabel; // positionLabel: 'Programmer' | 'Administrator'
final DateTime periodStart, periodEndInclusive; // day keys
final List<ReportDay> days; // ascending
final int excludedPending, excludedDisapproved;
final int daysReported, totalSeconds, tasksCompleted, helpSeconds;
final Map<String, int> secondsByCategory;
final String? approverName; }
AccomplishmentReportData buildAccomplishmentReportData({required Profile programmer,
required ReportDateRange range, required bool includeUnapproved,
required List<ProgrammerDaySheet> sheets, required Map<DateTime, List<DaySheetRow>> liveRowsByDay,
required List<ProgrammerTask> tasks, required Map<String, String> profileNames});
```
- [ ] **Step 1: Failing tests.**
```dart
test('approved-only uses snapshots and counts excluded days', ...); // 2 approved, 1 pending, 1 disapproved -> days 2, excludedPending 1, excludedDisapproved 1
test('include unapproved tags days and uses live rows', ...); // statusTag 'Pending' / 'Disapproved'; draft counts as 'Pending'
test('period end is inclusive', ...); // range Sep 1 -> Oct 1 (exclusive) -> periodEndInclusive utc(2026,9,30)
test('approver name only when one reviewer approved every included day', ...);
test('tasksCompleted counts tasks completed in range for the programmer', ...);
test('empty period', () { expect(data.days, isEmpty); expect(data.daysReported, 0); });
```
- [ ] **Step 2:** `$FLUTTER test test/accomplishment_report_data_test.dart` — FAIL.
- [ ] **Step 3: Implement.** Only sheets with `workDate` in `[start, end)` and days with ≥ 1 row are included. `helpSeconds` / `secondsByCategory` sum over included rows (helper rows count toward help and their category).
- [ ] **Step 4:** same command — PASS.
- [ ] **Step 5: Commit** — `feat(report): accomplishment report data`.
---
### Task 22: Shared PDF helpers + report PDF
**Files:**
- Create: `lib/utils/pdf/pdf_letterhead.dart`, `lib/widgets/pdf_preview_dialog.dart`, `lib/screens/programmer_tasks/report/accomplishment_report_pdf.dart`
- Test: `test/accomplishment_report_pdf_test.dart`
**Interfaces:**
- Consumes: Task 21.
- Produces:
```dart
class PdfBrand { final pw.Font regular, bold; final pw.ImageProvider logo;
static Future<PdfBrand> load(); // assets/fonts/Roboto-*.ttf + assets/crmc_logo.png
pw.Widget letterhead(); } // same four lines + 60x60 logo as dtr_pdf.dart `_header`
Future<void> showPdfPreviewDialog(BuildContext context, {required String title,
required String filename, required Future<Uint8List> Function() buildBytes}); // modeled on _DtrPdfDialog: pdfrx PdfViewer.data, Print (Printing.layoutPdf), Download (Printing.sharePdf)
Future<Uint8List> buildAccomplishmentReportPdf(AccomplishmentReportData data);
```
- [ ] **Step 1: Failing tests** (`TestWidgetsFlutterBinding.ensureInitialized()`): a populated report builds non-empty bytes starting with `%PDF`; an empty report builds too (Review Focus 5).
- [ ] **Step 2:** `$FLUTTER test test/accomplishment_report_pdf_test.dart` — FAIL.
- [ ] **Step 3: Implement** layout per spec §9. Exact copy: title `ACCOMPLISHMENT REPORT`; info labels `Name:`, `Position:`, `Period:` (`MMMM d, yyyy – MMMM d, yyyy`, inclusive end); table headers `DATE`, `TASK / PROJECT`, `ACCOMPLISHMENT`, `TIME`; notes joined with `\n`, `(no notes)` when empty; helper rows `Helped <name> — <task>`; status tag in brackets after the date (`[Pending]`); empty → `No approved accomplishments in this period.`; footnote `<n> day(s) in this period are not included (<p> pending, <d> disapproved).` (only when approved-only and n > 0); summary labels `Days reported`, `Total time`, `Tasks completed`, `Help given`, `Time by category`; signatures `Prepared by:` (programmer name + position) and `Approved by:` (approver name or a blank line). A4 `MultiPage`, margin 32, footer `Page x of y`.
- [ ] **Step 4:** same command — PASS.
- [ ] **Step 5: Commit** — `feat(report): accomplishment report PDF + shared PDF helpers`.
---
### Task 23: Generate dialog + entry points
**Files:**
- Create: `lib/screens/programmer_tasks/report/accomplishment_report_dialog.dart`
- Modify: `day_sheets/day_sheets_tab.dart` (app-bar action), `day_sheets/approvals_tab.dart` (action), `programmer_drill_down_screen.dart` (button)
- Test: `test/accomplishment_report_dialog_test.dart`
**Interfaces:**
- Consumes: Tasks 16, 21, 22, `fetchDayActivityData`, `buildDayRows`.
- Produces:
```dart
Future<void> showAccomplishmentReportDialog(BuildContext context, WidgetRef ref, {String? programmerId});
Future<AccomplishmentReportData> loadAccomplishmentReport(WidgetRef ref,
{required Profile programmer, required ReportDateRange range, required bool includeUnapproved});
```
- [ ] **Step 1: Failing tests** — as programmer: no programmer picker, title `Accomplishment Report`, toggle `Include unapproved days (marked)` off by default; as admin with no `programmerId`: picker lists only `role == 'programmer'` profiles and **Generate** is disabled until one is chosen.
- [ ] **Step 2:** `$FLUTTER test test/accomplishment_report_dialog_test.dart` — FAIL.
- [ ] **Step 3: Implement.** Loader selects sheets for the programmer with `work_date` in range; for unapproved days (only when the toggle is on) builds live rows via `fetchDayActivityData` + `buildDayRows`; then `showPdfPreviewDialog(title: 'Accomplishment Report – <name>', filename: 'Accomplishment Report - <name>.pdf')`. Entry labels: `Accomplishment Report` (Day Sheets / Approvals app bars), `Generate Accomplishment Report` (drill-down).
- [ ] **Step 4:** same command — PASS; `$FLUTTER analyze lib` — clean.
- [ ] **Step 5: Commit** — `feat(report): accomplishment report dialog + entry points`.
---
### Task 24: Final verification
- [ ] **Step 1:** `$FLUTTER analyze lib` — no issues.
- [ ] **Step 2:** `$FLUTTER test` — only the known unrelated failures.
- [ ] **Step 3:** `$FLUTTER build web` — succeeds.
- [ ] **Step 4: QA harness** (`~/.cache/tasq-qa/`, fresh `--session` per rebuild; programmer `pao@mail.com`, then an admin; light and dark): log work → Mine dashboard hero/tiles/sections → submit early → admin Monitor shows Working/Paused/On leave and help edges → admin flags + disapproves → programmer adds a correction + justifies → admin approves (single and bulk) → both generate the PDF (approved-only and marked) → notifications render and route. Save screenshots to `~/.cache/tasq-qa/shots/`.
- [ ] **Step 5:** Report results to the user; fix regressions via superpowers:systematic-debugging before claiming completion.
@@ -0,0 +1,448 @@
# Programmer Oversight — Design Spec
**Date:** 2026-09-27 · **Branch:** `feat/programmer-tasks` · **Status:** Draft for review
Adds an oversight layer to the Programmer Tasks module: daily **Day Sheet** approvals with a
remarks ↔ justification loop, a live **admin Monitor**, a PDF **Accomplishment Report**,
and a gamified **Mine dashboard**.
## 1. Background
The Programmer Tasks module records programmers' dev work: tasks with a pause-aware timer,
per-contributor work logs (assignee progress notes + helper-entered minutes), comments, and
projects. Today:
- The admin cannot see who is working on what right now, or who is helping whom.
- Nobody reviews or approves programmers' daily work.
- There is no Accomplishment Report.
- The "Mine" tab is a flat list of cards.
## 2. Goals
1. Admin sees each programmer's live state (working / paused / idle / away), current task,
today's time, and help given/received.
2. Each programmer's work is reviewed per day: approve, or disapprove with remarks; the
programmer justifies (and may append corrections) until approved.
3. Programmers generate their own Accomplishment Report (PDF, date-filtered); the admin
generates one for any programmer.
4. The Mine tab becomes a motivating dashboard with stats, a streak, badges, and tasks
separated into In progress / Paused / Not started / Completed / Cancelled.
### Non-goals
- Live "help sessions" (help is derived from work logs after the fact).
- XP/levels and team leaderboards.
- Editing or deleting existing work-log entries (they stay append-only).
- Migrating the existing DTR / task PDFs to the new shared PDF helpers.
## 3. Decisions
| Topic | Decision |
|---|---|
| Approval unit | **Day Sheet** — one per programmer per day; admin can **flag task rows** in it |
| Submission | **Auto-submit at day end**, plus **manual early submit** |
| Same-day approval | **Allowed**; later work that day reopens the sheet as "amended after approval" |
| On disapprove | Programmer **justifies** and **may append corrective entries**; originals are immutable |
| Late entries | Back-dated work logs allowed on **pending or disapproved** days, never on approved days; no backfilling days that have no sheet |
| Helping | **Derived from work logs** (helper = work-log author ≠ task assignee) |
| Report scope | **Toggle when generating**: approved days only (default) or include unapproved days (marked) |
| Report layout | **By day**: Date · Task/Project · Accomplishment · Time, then summary and signatures |
| Gamification | **Stat tiles + streak + badges** |
| Notifications | **In-app + push** |
| Monitor states | Working / Paused / Idle / **On leave** / **Pass slip** |
| Tabs | Programmer: **Mine · All · Day Sheets** — Admin: **Monitor · Approvals · Mine · All** |
| Core approach | Day-sheet table + append-only review thread + SECURITY DEFINER RPCs + pg_cron auto-submit + approval snapshot |
**"Admin" means `profiles.role = 'admin'` only.** The existing `isAdminProvider`
(`lib/providers/profile_provider.dart:305`) also returns true for programmers, and the
module's RLS treats both roles alike. All approval and monitor gating uses a new
`isStrictAdminProvider` on the client and `role = 'admin'` in SQL.
## 4. Existing code this builds on
| Fact | Where |
|---|---|
| Task status is `queued \| in_progress \| completed \| cancelled`. **Paused** = `in_progress` whose latest lifecycle activity-log event is `paused`. **Not started** = `queued`. | `lib/models/programmer_task.model.dart:39-47` |
| Work logs are append-only; the day is known only from `created_at`; assignee rows have `minutes = null`, helper rows have minutes. | `supabase/migrations/20260927140000_add_programmer_projects_worklogs_comments.sql:90-111` |
| Duration core `computeEffectiveDuration` | `lib/utils/task_duration.dart:38-75` |
| Ledger `computeTaskLedger` credits **all** clock time to the *current* assignee — the per-day split must not do this. | `lib/utils/programmer_task_ledger.dart:46-78` |
| `ReportDateFilter` is hard-wired to `reportDateRangeProvider`; `ReportDateRange.end` is exclusive. | `lib/screens/reports/report_date_filter.dart`, `lib/providers/reports_provider.dart:13-51` |
| Per-person PDF with letterhead + preview dialog (period label is off by one day at :211). | `lib/screens/attendance/dtr_pdf.dart` |
| DTO + pure PDF builder pattern | `lib/screens/attendance/work_log_pdf.dart` |
| Notification `type` is free text; render + tap switch; push via `send_fcm`. | `lib/screens/notifications/notifications_screen.dart:63-140`, `lib/services/notification_bridge.dart:97,152`, `lib/providers/notifications_provider.dart:376` |
| Scheduled push: pg_cron → `scheduled_notifications` (`schedule_id` nullable) → edge function `notify_type` switch. | `supabase/functions/process_scheduled_notifications/index.ts` |
| SECURITY DEFINER RPC that writes status + notifications atomically | `respond_shift_swap`, `supabase/migrations/20260322190000_pm_oncall_companion_swap.sql` |
| Per-user "on leave / pass slip today" logic | `lib/screens/dashboard/dashboard_screen.dart:371-433` |
## 5. Day Sheets
### 5.1 What a Day Sheet contains
One **row per task** the programmer worked on that day:
- task title, category, project;
- **time that day**:
- *Assignee rows*: timer intervals (started/resumed → paused/completed/cancelled)
clipped to Manila day boundaries, credited to **whoever was assignee at that moment**
(walk `created` / `assigned` / `reassigned` events). A later reassignment never changes
a past day. `adjustment` events count against the day they were created.
- *Helper rows*: the helper's entered minutes, labelled "Helped <assignee>".
- **notes**: that day's work-log entries for the task.
The admin flags **rows** when disapproving.
### 5.2 Schema
New migration `supabase/migrations/20260927150000_add_programmer_day_sheets.sql`.
**`programmer_day_sheets`**
| Column | Type | Notes |
|---|---|---|
| `id` | uuid pk | |
| `programmer_id` | uuid → profiles | |
| `work_date` | date | Manila calendar day |
| `status` | text | check `draft \| pending \| disapproved \| approved`, default `draft` |
| `submitted_at` | timestamptz | |
| `auto_submitted` | bool | default false |
| `reviewed_by` | uuid → profiles | last admin to approve/disapprove |
| `reviewed_at` | timestamptz | |
| `resubmissions` | int | default 0; incremented by justify |
| `approved_snapshot` | jsonb | rows as approved (see 5.5) |
| `created_at`, `updated_at` | timestamptz | |
Unique `(programmer_id, work_date)`.
**`programmer_day_sheet_events`** — append-only review thread
| Column | Type | Notes |
|---|---|---|
| `id` | uuid pk | |
| `sheet_id` | uuid → sheets, on delete cascade | |
| `actor_id` | uuid null | null = system |
| `kind` | text | check `submitted \| auto_submitted \| disapproved \| justified \| approved \| amended` |
| `body` | text | remarks / justification; check non-empty for `disapproved` and `justified` |
| `flagged_task_ids` | uuid[] | default `'{}'` |
| `created_at` | timestamptz | |
**`programmer_task_work_logs.work_date`** — `date not null default (now() at time zone
'Asia/Manila')::date`. Existing rows are backfilled from `created_at`. A BEFORE INSERT
guard trigger allows:
- `work_date` = today (Manila); or
- a past `work_date` only if the author's sheet for that date is `pending` or
`disapproved`.
Anything else raises an error.
**`notifications.day_sheet_id`** — `uuid null references programmer_day_sheets on delete
cascade`.
**RLS:** sheets and events are readable by the owner and by `role = 'admin'`. There are no
client INSERT/UPDATE/DELETE policies; every write goes through the RPCs or triggers below.
Both tables are added to the `supabase_realtime` publication.
### 5.3 Status transitions
Each transition is a SECURITY DEFINER RPC with `set search_path = public`, explicit
`auth.uid()`, role, and current-status checks. Each one atomically updates the sheet,
inserts a thread event, and inserts notification rows, then returns the recipient user ids
so the client can call `send_fcm`. A stale status raises `already_reviewed`.
| RPC | Who | From → To | Rule |
|---|---|---|---|
| `submit_day_sheet(p_date)` | owner | draft → pending | Any time, including early. Upserts the row. |
| `disapprove_day_sheet(p_sheet, p_remarks, p_flagged_task_ids)` | admin | pending → disapproved | Remarks required. |
| `justify_day_sheet(p_sheet, p_body)` | owner | disapproved → pending | Justification required; `resubmissions += 1`. |
| `approve_day_sheets(p_items)` — `[{sheet_id, snapshot}]` | admin | pending or disapproved → approved | Same-day allowed. Bulk-capable. Stores the snapshot. |
```
draft ──submit / auto──▶ pending ──approve──▶ approved
│ ▲ │
disapprove justify │ new work on the same day
▼ │ │ (system "amended")
disapproved ◀────────────┘ (back to pending)
```
### 5.4 Automatic behaviour
- **Draft creation.** Triggers upsert a `draft` sheet when a work log is inserted (for the
author, on `work_date`) and when a lifecycle activity-log event is inserted (for the
task's assignee, on the event's Manila date). Only for `role = 'programmer'`.
- **Amended after approval.** A work log or lifecycle event landing on an **approved
sheet for today** sets it back to `pending` and adds a system `amended` event. This is not
counted as a disapproval. The Approve button warns the admin when that programmer has a
timer running.
- **Nightly auto-submit** — `auto_submit_day_sheets()` via pg_cron at 00:05 Manila:
1. For assignees of tasks still running, upsert yesterday's sheet (reopening it as
`amended` if it was approved).
2. Flip every `draft` sheet with `work_date < today` to `pending`
(`auto_submitted = true`, `auto_submitted` event).
3. If anything is pending, insert one `scheduled_notifications` row per admin with
`notify_type = 'day_sheet_digest'`, scheduled for 08:00 Manila.
The cron registration uses the same `DO $$ … EXCEPTION` guard as existing cron
migrations.
### 5.5 Approval snapshot
On approval the admin client sends the rows it displayed:
```json
{
"v": 1,
"total_seconds": 27900,
"rows": [
{
"task_id": "…", "title": "…", "category": "Bug Fix", "project_name": "HIS",
"kind": "assignee", "helped_name": null,
"seconds": 11400, "notes": ["Fixed session timeout…"]
}
]
}
```
Approved days are thereby fixed records: the report and stats read the snapshot, so later
renames or reassignments do not change what was approved.
## 6. Approval UX
### 6.1 Tabs
`programmer_tasks_list_screen.dart` shows tabs by role:
- **Programmer:** Mine · All · Day Sheets
- **Admin:** Monitor · Approvals · Mine · All
### 6.2 Day Sheets tab (programmer)
- **Today card** — today's rows (live), total time, Draft/Submitted chip, **Submit day**.
- **Needs your attention** — disapproved sheets, pinned, warning style.
- **History** — sheets by date with status filter chips (`AppStatusSummaryRow`).
- App bar: **Accomplishment Report**.
### 6.3 Sheet detail — `/programmer-tasks/day-sheets/:id` (both roles)
- Header: date, programmer, status, total time, "Round N" when resubmitted.
- Rows table: task · time · notes. Flagged rows are highlighted (`AppStatusColors`
warning + flag icon).
- Thread timeline, styled like `widgets/activity_timeline.dart`.
- **Programmer, when disapproved:**
- **Add correction** — the existing work-log dialog with `workDate` preset to that day,
flagged tasks listed first.
- Justification field (cannot be empty; modeled on the checkout-justification dialog,
`attendance_screen.dart:1462-1510`).
- **Resubmit**.
- **Admin, when pending or disapproved:**
- Row checkboxes for flagging.
- **Disapprove** — dialog lists the flagged rows; remarks required.
- **Approve** — warns if a timer is running. The admin client builds the snapshot.
### 6.4 Approvals tab (admin)
- Summary chips: Pending · Awaiting justification · Approved.
- Programmer filter and the shared date filter.
- Cards grouped by date: programmer, total time, task count, Early/Auto badge, Round N.
- Multi-select → **Approve selected**. Disapproval stays per sheet because it needs remarks.
## 7. Admin Monitor
- **Summary row:** Working · Paused · Idle · On leave/Pass slip · Sheets pending.
- **One card per programmer** (grid: 1 column on mobile, 2–3 on desktop):
- State pill: **Working** (success) / **Paused** (warning) / **Idle** (neutral) /
**On leave** or **Pass slip** (info; overrides the others).
- Current task: title, category/project `TechChip`s, live "running 1h 12m today".
- Today's total; in-progress / paused / not-started counts; today's sheet status.
- Help chips: "Helped → Ben · task · 45m", "Helped by ← Ana · 30m".
- **Drill-down** (tap a card): `ProgrammerDashboard(userId, readOnly: true)`, their recent
Day Sheets, and **Generate Accomplishment Report** with the programmer preselected.
- **Who's helping whom** panel: helper → assignee · task · minutes · time, with a day
stepper (defaults to today). The working/paused state is always live.
**Data**
- New `programmerRunStatesProvider`: realtime stream of lifecycle activity logs for all
`in_progress` task ids → latest lifecycle event per task → Working vs Paused. It also
replaces the Mine tab's `myRunningProgrammerTaskIdProvider` inference, which currently
marks every in-progress task as paused when offline.
- Today's time: the per-day split (5.1).
- Help: work logs with `work_date = day` whose author ≠ task assignee.
- Leave / pass slip: extract the per-user "today" logic from
`dashboard_screen.dart:371-433` into `lib/utils/staff_presence.dart`, used by both the
dashboard and the Monitor.
- Admin-only. Programmers keep the existing All tab and get no live view of teammates.
## 8. Mine dashboard
A single widget, `ProgrammerDashboard(userId, {readOnly})`, used for the Mine tab and for
the Monitor drill-down.
1. **Now working hero** — the running task with a live timer (today + total) and
Pause/Complete. If nothing is running: "Nothing running" and **Start** on the top
not-started task (via `startProgrammerTaskWithPausePrompt`).
2. **Stat tiles** (`AppMetricCard`):
- **Today's focus** — ring against an 8h target (pattern:
`reports/widgets/conversion_rate_card.dart:43-55`).
- **Completed this week**, with change vs last week.
- **Streak** — consecutive weekdays whose sheet is pending or approved. Weekends and
approved-leave days are skipped (neither count nor break). A disapproved day breaks
the streak until justified. Today counts once submitted. Shows best streak too.
- **First-pass approval rate** (last 30 days) — approved sheets with no `disapproved`
event ÷ reviewed sheets.
- **Help given** this month.
- Streak and approval rate are hidden for admins (admins have no sheets).
3. **Badges** — computed from data each time (no table). Locked badges are greyed out with
progress (e.g. 7/10).
| Badge | Unlocks at |
|---|---|
| First Finish | first completed task |
| Finisher | 10 / 50 / 100 completed tasks |
| Bug Squasher | 10 completed Bug Fix tasks |
| Deep Work | 100 / 500 approved hours |
| On a Roll | best streak 5 / 10 / 20 |
| Team Player | 10h help given |
| Clean Week | a Mon–Fri week with every day approved first time |
4. **Task sections** (`AppSectionHeader` + count): In progress · Paused · Not started ·
Completed (collapsed; last 10 + Show all) · Cancelled (collapsed). The list's private
`_TaskCard` is extracted to `widgets/programmer_task_card.dart`.
**Data:** hour-based stats and badges sum approved snapshots; "today" figures come from the
live per-day split; counts come from `programmerTasksProvider` filtered to the user.
Calculations live in pure functions (`computeProgrammerStats`, `computeBadges`).
## 9. Accomplishment Report (PDF)
**Entry points:** Day Sheets tab app bar (programmer, self); Approvals tab app bar and
Monitor drill-down (admin).
**Generate dialog**
- Programmer picker — admin only; searchable single-select of `role = 'programmer'`
from `profilesProvider`.
- Date filter — `ReportDateFilter.controlled`, default "This Month".
- Toggle — "Include unapproved days (marked)", off by default.
- **Generate** → preview dialog with Print and Download.
**Date filter reuse:** add `ReportDateFilter.controlled({value, onChanged})` to
`lib/screens/reports/report_date_filter.dart`. `_DateFilterDialog` returns the chosen
`ReportDateRange` instead of writing the provider. The default constructor keeps binding
`reportDateRangeProvider`, so the Reports screen is unchanged.
**Layout** (A4 MultiPage, Roboto, CRMC letterhead):
1. Info box — name, position, period (**inclusive** end date: `end − 1 day`).
2. By-day table — Date | Task/Project | Accomplishment | Time.
- Accomplishment = that day's work-log notes (Quill delta → plain text); "(no notes)"
when a row has time but no notes.
- Help rows read "Helped Ana — <task>".
- When unapproved days are included, they carry a Pending/Disapproved tag.
3. Footnote (approved-only mode) — "N days in the period are not included (x pending,
y disapproved)".
4. Summary — days reported, total time, tasks completed in the period, help given, time by
category.
5. Signatures — "Prepared by" the programmer; "Approved by" the reviewing admin's name if
one admin approved every included day, otherwise a blank line.
**Data:** approved days come from `approved_snapshot`; unapproved days (when included) are
computed live. `buildAccomplishmentReportData(...)` (pure) produces a DTO;
`buildAccomplishmentReportPdf(dto)` only draws.
**Shared helpers (used by new code only):** `lib/utils/pdf/pdf_letterhead.dart` (fonts,
logo, letterhead) and `lib/widgets/pdf_preview_dialog.dart` (pdfrx preview,
`Printing.layoutPdf`, `Printing.sharePdf`, modeled on `_DtrPdfDialog`).
## 10. Notifications
| Type | Recipient | Trigger |
|---|---|---|
| `day_sheet_submitted` | all admins | programmer submits early |
| `day_sheet_digest` | each admin, 08:00 | nightly auto-submit left sheets pending; opens Approvals |
| `day_sheet_disapproved` | programmer | admin disapproves (includes remarks) |
| `day_sheet_justified` | the reviewing admin | programmer justifies and resubmits |
| `day_sheet_approved` | programmer | admin approves (single or bulk) |
RPCs insert the notification rows and return recipients; the client sends push through
`NotificationsController.sendPush`. The digest goes through `scheduled_notifications` with
a new case in `process_scheduled_notifications`. New render cases and tap routing go into
`notifications_screen.dart` and `notification_bridge.dart`, and `day_sheet_id` is added to
`notification_item.model.dart`.
## 11. Error handling
- **Stale status** (e.g. two admins act at once) → "This sheet was already reviewed" and
the view refreshes.
- **Empty remarks/justification** → blocked in the dialog and by the DB check.
- **Disallowed back-dated entry** → the guard trigger's error is shown as a clear message.
- **Push failure** → non-blocking; logged, as in existing flows.
- **Offline** → Day Sheets are online-only (like work logs); actions are disabled with a
banner.
## 12. Files
New (each under 500 lines):
- `supabase/migrations/20260927150000_add_programmer_day_sheets.sql`
- `lib/models/programmer_day_sheet.model.dart`,
`lib/models/programmer_day_sheet_event.model.dart` (plain online models)
- `lib/providers/programmer_day_sheets_provider.dart` (streams, RPC controller, push)
- `lib/providers/programmer_run_states_provider.dart`
- `lib/utils/programmer_daily_time.dart` (per-day split with assignee attribution)
- `lib/utils/programmer_stats.dart` (stats, streak, badges)
- `lib/utils/staff_presence.dart`
- `lib/utils/pdf/pdf_letterhead.dart`, `lib/widgets/pdf_preview_dialog.dart`
- `lib/screens/programmer_tasks/dashboard/` — dashboard, hero, stat tiles, badges, sections
- `lib/screens/programmer_tasks/monitor/` — monitor tab, programmer card, help panel
- `lib/screens/programmer_tasks/day_sheets/` — Day Sheets tab, Approvals tab, detail
screen, disapprove/justify dialogs
- `lib/screens/programmer_tasks/report/` — generate dialog, report data, report PDF
- `lib/screens/programmer_tasks/widgets/programmer_task_card.dart`
Modified:
- `lib/screens/programmer_tasks/programmer_tasks_list_screen.dart` — role tabs, card extract
- `lib/models/programmer_task_work_log.model.dart`,
`lib/providers/programmer_task_work_logs_provider.dart`,
`lib/screens/programmer_tasks/widgets/work_log_section.dart` — `workDate`
- `lib/screens/reports/report_date_filter.dart` — controlled constructor
- `lib/providers/profile_provider.dart` — `isStrictAdminProvider`
- `lib/routing/app_router.dart` — day-sheet detail route
- `lib/screens/notifications/notifications_screen.dart`,
`lib/services/notification_bridge.dart`, `lib/models/notification_item.model.dart`
- `supabase/functions/process_scheduled_notifications/index.ts` — digest case
- `lib/screens/dashboard/dashboard_screen.dart` — use `staff_presence.dart`
## 13. Delivery slices
Each slice ships independently on `feat/programmer-tasks`.
1. **Mine dashboard** — run-state provider, per-day split util, dashboard. No schema
change; fixes the offline "everything paused" bug. Approval-based stats appear once
slice 3 lands.
2. **Admin Monitor** — plus the `staff_presence.dart` extraction.
3. **Day Sheets** — migration, RPCs, cron, Day Sheets/Approvals tabs, detail screen,
notifications. **The user applies the migration and redeploys
`process_scheduled_notifications`** (the Supabase migration tool is blocked in this
environment).
4. **Accomplishment Report** — date-filter refactor, PDF helpers, report.
## 14. Testing and verification
- `flutter analyze lib` clean; `flutter test` passes apart from the three known,
unrelated failures (theme_overhaul, profile_screen, user_management).
- **Unit (pure Dart, TDD):** per-day split (midnight crossing, reassignment attribution,
pauses, adjustments); stats and streak (weekends, leave, disapproved days); badges;
report data (approved-only vs marked, snapshot vs live, footnote, inclusive period).
- **Controller (mock-first, pattern of `test/programmer_tasks_controller_test.dart`):**
RPC calls, push invocation, stale-status error mapping.
- **Widget:** Disapprove dialog requires remarks; dashboard sections; tabs by role.
- **SQL checklist** after the migration is applied (rolled-back transactions with
`request.jwt.claims` set per role): every transition allowed/denied by role and status;
back-dated work-log guard; amended-after-approval; `auto_submit_day_sheets()` called
directly; `get_advisors` security check.
- **End-to-end** via the QA harness (programmer, then admin; light and dark): log work →
submit early → admin flags and disapproves → programmer adds a correction and justifies
→ admin approves → both generate the PDF (approved-only and marked) → Monitor shows
Working/Paused/On leave and help edges → notifications render and route.
- `flutter build web` succeeds.
@@ -23,6 +23,9 @@ Future<NotificationItem> _$NotificationItemFromSupabase(
? null
: data['announcement_id'] as String?,
messageId: data['message_id'] == null ? null : data['message_id'] as int?,
daySheetId: data['day_sheet_id'] == null
? null
: data['day_sheet_id'] as String?,
type: data['type'] as String,
createdAt: DateTime.parse(data['created_at'] as String),
readAt: data['read_at'] == null
@@ -49,6 +52,7 @@ Future<Map<String, dynamic>> _$NotificationItemToSupabase(
'it_service_request_id': instance.itServiceRequestId,
'announcement_id': instance.announcementId,
'message_id': instance.messageId,
'day_sheet_id': instance.daySheetId,
'type': instance.type,
'created_at': instance.createdAt.toIso8601String(),
'read_at': instance.readAt?.toIso8601String(),
@@ -77,6 +81,9 @@ Future<NotificationItem> _$NotificationItemFromSqlite(
? null
: data['announcement_id'] as String?,
messageId: data['message_id'] == null ? null : data['message_id'] as int?,
daySheetId: data['day_sheet_id'] == null
? null
: data['day_sheet_id'] as String?,
type: data['type'] as String,
createdAt: DateTime.parse(data['created_at'] as String),
readAt: data['read_at'] == null
@@ -103,6 +110,7 @@ Future<Map<String, dynamic>> _$NotificationItemToSqlite(
'it_service_request_id': instance.itServiceRequestId,
'announcement_id': instance.announcementId,
'message_id': instance.messageId,
'day_sheet_id': instance.daySheetId,
'type': instance.type,
'created_at': instance.createdAt.toIso8601String(),
'read_at': instance.readAt?.toIso8601String(),
@@ -160,6 +168,10 @@ class NotificationItemAdapter
association: false,
columnName: 'message_id',
),
'daySheetId': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'day_sheet_id',
),
'type': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'type',
@@ -245,6 +257,12 @@ class NotificationItemAdapter
iterable: false,
type: int,
),
'daySheetId': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'day_sheet_id',
iterable: false,
type: String,
),
'type': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'type',
@@ -0,0 +1,231 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<ProgrammerProject> _$ProgrammerProjectFromSupabase(
Map<String, dynamic> data, {
required SupabaseProvider provider,
OfflineFirstWithSupabaseRepository? repository,
}) async {
return ProgrammerProject(
id: data['id'] as String,
name: data['name'] as String,
description: data['description'] == null
? null
: data['description'] as String?,
status: data['status'] as String,
creatorId: data['creator_id'] == null
? null
: data['creator_id'] as String?,
createdAt: DateTime.parse(data['created_at'] as String),
updatedAt: DateTime.parse(data['updated_at'] as String),
);
}
Future<Map<String, dynamic>> _$ProgrammerProjectToSupabase(
ProgrammerProject instance, {
required SupabaseProvider provider,
OfflineFirstWithSupabaseRepository? repository,
}) async {
return {
'id': instance.id,
'name': instance.name,
'description': instance.description,
'status': instance.status,
'creator_id': instance.creatorId,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'is_archived': instance.isArchived,
};
}
Future<ProgrammerProject> _$ProgrammerProjectFromSqlite(
Map<String, dynamic> data, {
required SqliteProvider provider,
OfflineFirstWithSupabaseRepository? repository,
}) async {
return ProgrammerProject(
id: data['id'] as String,
name: data['name'] as String,
description: data['description'] == null
? null
: data['description'] as String?,
status: data['status'] as String,
creatorId: data['creator_id'] == null
? null
: data['creator_id'] as String?,
createdAt: DateTime.parse(data['created_at'] as String),
updatedAt: DateTime.parse(data['updated_at'] as String),
)..primaryKey = data['_brick_id'] as int;
}
Future<Map<String, dynamic>> _$ProgrammerProjectToSqlite(
ProgrammerProject instance, {
required SqliteProvider provider,
OfflineFirstWithSupabaseRepository? repository,
}) async {
return {
'id': instance.id,
'name': instance.name,
'description': instance.description,
'status': instance.status,
'creator_id': instance.creatorId,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'is_archived': instance.isArchived ? 1 : 0,
};
}
/// Construct a [ProgrammerProject]
class ProgrammerProjectAdapter
extends OfflineFirstWithSupabaseAdapter<ProgrammerProject> {
ProgrammerProjectAdapter();
@override
final supabaseTableName = 'programmer_projects';
@override
final defaultToNull = true;
@override
final fieldsToSupabaseColumns = {
'id': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'id',
),
'name': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'name',
),
'description': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'description',
),
'status': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'status',
),
'creatorId': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'creator_id',
),
'createdAt': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'created_at',
),
'updatedAt': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'updated_at',
),
'isArchived': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'is_archived',
),
};
@override
final ignoreDuplicates = false;
@override
final uniqueFields = {};
@override
final Map<String, RuntimeSqliteColumnDefinition> fieldsToSqliteColumns = {
'primaryKey': const RuntimeSqliteColumnDefinition(
association: false,
columnName: '_brick_id',
iterable: false,
type: int,
),
'id': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'id',
iterable: false,
type: String,
),
'name': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'name',
iterable: false,
type: String,
),
'description': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'description',
iterable: false,
type: String,
),
'status': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'status',
iterable: false,
type: String,
),
'creatorId': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'creator_id',
iterable: false,
type: String,
),
'createdAt': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'created_at',
iterable: false,
type: DateTime,
),
'updatedAt': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'updated_at',
iterable: false,
type: DateTime,
),
'isArchived': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'is_archived',
iterable: false,
type: bool,
),
};
@override
Future<int?> primaryKeyByUniqueColumns(
ProgrammerProject instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'ProgrammerProject';
@override
Future<ProgrammerProject> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProgrammerProjectFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
ProgrammerProject input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProgrammerProjectToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<ProgrammerProject> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProgrammerProjectFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
ProgrammerProject input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProgrammerProjectToSqlite(
input,
provider: provider,
repository: repository,
);
}
@@ -0,0 +1,197 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<ProgrammerTaskActivityLog> _$ProgrammerTaskActivityLogFromSupabase(
Map<String, dynamic> data, {
required SupabaseProvider provider,
OfflineFirstWithSupabaseRepository? repository,
}) async {
return ProgrammerTaskActivityLog(
id: data['id'] as String,
taskId: data['task_id'] as String,
actorId: data['actor_id'] == null ? null : data['actor_id'] as String?,
actionType: data['action_type'] as String,
meta: data['meta'] == null ? null : data['meta'],
createdAt: DateTime.parse(data['created_at'] as String),
);
}
Future<Map<String, dynamic>> _$ProgrammerTaskActivityLogToSupabase(
ProgrammerTaskActivityLog instance, {
required SupabaseProvider provider,
OfflineFirstWithSupabaseRepository? repository,
}) async {
return {
'id': instance.id,
'task_id': instance.taskId,
'actor_id': instance.actorId,
'action_type': instance.actionType,
'meta': instance.meta,
'created_at': instance.createdAt.toIso8601String(),
};
}
Future<ProgrammerTaskActivityLog> _$ProgrammerTaskActivityLogFromSqlite(
Map<String, dynamic> data, {
required SqliteProvider provider,
OfflineFirstWithSupabaseRepository? repository,
}) async {
return ProgrammerTaskActivityLog(
id: data['id'] as String,
taskId: data['task_id'] as String,
actorId: data['actor_id'] == null ? null : data['actor_id'] as String?,
actionType: data['action_type'] as String,
meta: data['meta'] == null ? null : jsonDecode(data['meta']),
createdAt: DateTime.parse(data['created_at'] as String),
)..primaryKey = data['_brick_id'] as int;
}
Future<Map<String, dynamic>> _$ProgrammerTaskActivityLogToSqlite(
ProgrammerTaskActivityLog instance, {
required SqliteProvider provider,
OfflineFirstWithSupabaseRepository? repository,
}) async {
return {
'id': instance.id,
'task_id': instance.taskId,
'actor_id': instance.actorId,
'action_type': instance.actionType,
'meta': instance.meta != null ? jsonEncode(instance.meta) : null,
'created_at': instance.createdAt.toIso8601String(),
};
}
/// Construct a [ProgrammerTaskActivityLog]
class ProgrammerTaskActivityLogAdapter
extends OfflineFirstWithSupabaseAdapter<ProgrammerTaskActivityLog> {
ProgrammerTaskActivityLogAdapter();
@override
final supabaseTableName = 'programmer_task_activity_logs';
@override
final defaultToNull = true;
@override
final fieldsToSupabaseColumns = {
'id': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'id',
),
'taskId': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'task_id',
),
'actorId': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'actor_id',
),
'actionType': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'action_type',
),
'meta': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'meta',
),
'createdAt': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'created_at',
),
};
@override
final ignoreDuplicates = false;
@override
final uniqueFields = {};
@override
final Map<String, RuntimeSqliteColumnDefinition> fieldsToSqliteColumns = {
'primaryKey': const RuntimeSqliteColumnDefinition(
association: false,
columnName: '_brick_id',
iterable: false,
type: int,
),
'id': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'id',
iterable: false,
type: String,
),
'taskId': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'task_id',
iterable: false,
type: String,
),
'actorId': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'actor_id',
iterable: false,
type: String,
),
'actionType': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'action_type',
iterable: false,
type: String,
),
'meta': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'meta',
iterable: false,
type: Map,
),
'createdAt': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'created_at',
iterable: false,
type: DateTime,
),
};
@override
Future<int?> primaryKeyByUniqueColumns(
ProgrammerTaskActivityLog instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'ProgrammerTaskActivityLog';
@override
Future<ProgrammerTaskActivityLog> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProgrammerTaskActivityLogFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
ProgrammerTaskActivityLog input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProgrammerTaskActivityLogToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<ProgrammerTaskActivityLog> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProgrammerTaskActivityLogFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
ProgrammerTaskActivityLog input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProgrammerTaskActivityLogToSqlite(
input,
provider: provider,
repository: repository,
);
}
@@ -0,0 +1,379 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<ProgrammerTask> _$ProgrammerTaskFromSupabase(
Map<String, dynamic> data, {
required SupabaseProvider provider,
OfflineFirstWithSupabaseRepository? repository,
}) async {
return ProgrammerTask(
id: data['id'] as String,
title: data['title'] as String,
description: data['description'] == null
? null
: data['description'] as String?,
category: data['category'] as String,
status: data['status'] as String,
priority: data['priority'] as int,
assigneeId: data['assignee_id'] == null
? null
: data['assignee_id'] as String?,
creatorId: data['creator_id'] == null
? null
: data['creator_id'] as String?,
projectId: data['project_id'] == null
? null
: data['project_id'] as String?,
createdAt: DateTime.parse(data['created_at'] as String),
startedAt: data['started_at'] == null
? null
: data['started_at'] == null
? null
: DateTime.tryParse(data['started_at'] as String),
completedAt: data['completed_at'] == null
? null
: data['completed_at'] == null
? null
: DateTime.tryParse(data['completed_at'] as String),
cancelledAt: data['cancelled_at'] == null
? null
: data['cancelled_at'] == null
? null
: DateTime.tryParse(data['cancelled_at'] as String),
cancellationReason: data['cancellation_reason'] == null
? null
: data['cancellation_reason'] as String?,
updatedAt: DateTime.parse(data['updated_at'] as String),
);
}
Future<Map<String, dynamic>> _$ProgrammerTaskToSupabase(
ProgrammerTask instance, {
required SupabaseProvider provider,
OfflineFirstWithSupabaseRepository? repository,
}) async {
return {
'id': instance.id,
'title': instance.title,
'description': instance.description,
'category': instance.category,
'status': instance.status,
'priority': instance.priority,
'assignee_id': instance.assigneeId,
'creator_id': instance.creatorId,
'project_id': instance.projectId,
'created_at': instance.createdAt.toIso8601String(),
'started_at': instance.startedAt?.toIso8601String(),
'completed_at': instance.completedAt?.toIso8601String(),
'cancelled_at': instance.cancelledAt?.toIso8601String(),
'cancellation_reason': instance.cancellationReason,
'updated_at': instance.updatedAt.toIso8601String(),
'is_terminal': instance.isTerminal,
};
}
Future<ProgrammerTask> _$ProgrammerTaskFromSqlite(
Map<String, dynamic> data, {
required SqliteProvider provider,
OfflineFirstWithSupabaseRepository? repository,
}) async {
return ProgrammerTask(
id: data['id'] as String,
title: data['title'] as String,
description: data['description'] == null
? null
: data['description'] as String?,
category: data['category'] as String,
status: data['status'] as String,
priority: data['priority'] as int,
assigneeId: data['assignee_id'] == null
? null
: data['assignee_id'] as String?,
creatorId: data['creator_id'] == null
? null
: data['creator_id'] as String?,
projectId: data['project_id'] == null
? null
: data['project_id'] as String?,
createdAt: DateTime.parse(data['created_at'] as String),
startedAt: data['started_at'] == null
? null
: data['started_at'] == null
? null
: DateTime.tryParse(data['started_at'] as String),
completedAt: data['completed_at'] == null
? null
: data['completed_at'] == null
? null
: DateTime.tryParse(data['completed_at'] as String),
cancelledAt: data['cancelled_at'] == null
? null
: data['cancelled_at'] == null
? null
: DateTime.tryParse(data['cancelled_at'] as String),
cancellationReason: data['cancellation_reason'] == null
? null
: data['cancellation_reason'] as String?,
updatedAt: DateTime.parse(data['updated_at'] as String),
)..primaryKey = data['_brick_id'] as int;
}
Future<Map<String, dynamic>> _$ProgrammerTaskToSqlite(
ProgrammerTask instance, {
required SqliteProvider provider,
OfflineFirstWithSupabaseRepository? repository,
}) async {
return {
'id': instance.id,
'title': instance.title,
'description': instance.description,
'category': instance.category,
'status': instance.status,
'priority': instance.priority,
'assignee_id': instance.assigneeId,
'creator_id': instance.creatorId,
'project_id': instance.projectId,
'created_at': instance.createdAt.toIso8601String(),
'started_at': instance.startedAt?.toIso8601String(),
'completed_at': instance.completedAt?.toIso8601String(),
'cancelled_at': instance.cancelledAt?.toIso8601String(),
'cancellation_reason': instance.cancellationReason,
'updated_at': instance.updatedAt.toIso8601String(),
'is_terminal': instance.isTerminal ? 1 : 0,
};
}
/// Construct a [ProgrammerTask]
class ProgrammerTaskAdapter
extends OfflineFirstWithSupabaseAdapter<ProgrammerTask> {
ProgrammerTaskAdapter();
@override
final supabaseTableName = 'programmer_tasks';
@override
final defaultToNull = true;
@override
final fieldsToSupabaseColumns = {
'id': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'id',
),
'title': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'title',
),
'description': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'description',
),
'category': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'category',
),
'status': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'status',
),
'priority': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'priority',
),
'assigneeId': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'assignee_id',
),
'creatorId': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'creator_id',
),
'projectId': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'project_id',
),
'createdAt': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'created_at',
),
'startedAt': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'started_at',
),
'completedAt': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'completed_at',
),
'cancelledAt': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'cancelled_at',
),
'cancellationReason': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'cancellation_reason',
),
'updatedAt': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'updated_at',
),
'isTerminal': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'is_terminal',
),
};
@override
final ignoreDuplicates = false;
@override
final uniqueFields = {};
@override
final Map<String, RuntimeSqliteColumnDefinition> fieldsToSqliteColumns = {
'primaryKey': const RuntimeSqliteColumnDefinition(
association: false,
columnName: '_brick_id',
iterable: false,
type: int,
),
'id': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'id',
iterable: false,
type: String,
),
'title': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'title',
iterable: false,
type: String,
),
'description': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'description',
iterable: false,
type: String,
),
'category': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'category',
iterable: false,
type: String,
),
'status': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'status',
iterable: false,
type: String,
),
'priority': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'priority',
iterable: false,
type: int,
),
'assigneeId': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'assignee_id',
iterable: false,
type: String,
),
'creatorId': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'creator_id',
iterable: false,
type: String,
),
'projectId': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'project_id',
iterable: false,
type: String,
),
'createdAt': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'created_at',
iterable: false,
type: DateTime,
),
'startedAt': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'started_at',
iterable: false,
type: DateTime,
),
'completedAt': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'completed_at',
iterable: false,
type: DateTime,
),
'cancelledAt': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'cancelled_at',
iterable: false,
type: DateTime,
),
'cancellationReason': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'cancellation_reason',
iterable: false,
type: String,
),
'updatedAt': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'updated_at',
iterable: false,
type: DateTime,
),
'isTerminal': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'is_terminal',
iterable: false,
type: bool,
),
};
@override
Future<int?> primaryKeyByUniqueColumns(
ProgrammerTask instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'ProgrammerTask';
@override
Future<ProgrammerTask> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProgrammerTaskFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
ProgrammerTask input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProgrammerTaskToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<ProgrammerTask> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProgrammerTaskFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
ProgrammerTask input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProgrammerTaskToSqlite(
input,
provider: provider,
repository: repository,
);
}
+18
View File
@@ -11,6 +11,12 @@ import 'package:brick_supabase/brick_supabase.dart';
// ignore: unused_import, unused_shown_name, unnecessary_import
import '../utils/app_time.dart';
// ignore: unused_import, unused_shown_name, unnecessary_import
import 'package:tasq/utils/programmer_daily_time.dart';
// ignore: unused_import, unused_shown_name, unnecessary_import
import 'package:tasq/utils/programmer_day_rows.dart';
// ignore: unused_import, unused_shown_name, unnecessary_import
import '../utils/programmer_daily_time.dart';
// ignore: unused_import, unused_shown_name, unnecessary_import
import 'package:tasq/models/team_member.dart';// GENERATED CODE DO NOT EDIT
// ignore: unused_import
import 'dart:convert';
@@ -35,6 +41,9 @@ import '../models/notification_item.model.dart';
import '../models/office.model.dart';
import '../models/pass_slip.model.dart';
import '../models/profile.model.dart';
import '../models/programmer_project.model.dart';
import '../models/programmer_task.model.dart';
import '../models/programmer_task_activity_log.model.dart';
import '../models/service.model.dart';
import '../models/swap_request.model.dart';
import '../models/task.model.dart';
@@ -56,6 +65,9 @@ part 'adapters/notification_item_adapter.g.dart';
part 'adapters/office_adapter.g.dart';
part 'adapters/pass_slip_adapter.g.dart';
part 'adapters/profile_adapter.g.dart';
part 'adapters/programmer_project_adapter.g.dart';
part 'adapters/programmer_task_adapter.g.dart';
part 'adapters/programmer_task_activity_log_adapter.g.dart';
part 'adapters/service_adapter.g.dart';
part 'adapters/swap_request_adapter.g.dart';
part 'adapters/task_adapter.g.dart';
@@ -79,6 +91,9 @@ final Map<Type, SupabaseAdapter<SupabaseModel>> supabaseMappings = {
Office: OfficeAdapter(),
PassSlip: PassSlipAdapter(),
Profile: ProfileAdapter(),
ProgrammerProject: ProgrammerProjectAdapter(),
ProgrammerTask: ProgrammerTaskAdapter(),
ProgrammerTaskActivityLog: ProgrammerTaskActivityLogAdapter(),
Service: ServiceAdapter(),
SwapRequest: SwapRequestAdapter(),
Task: TaskAdapter(),
@@ -104,6 +119,9 @@ final Map<Type, SqliteAdapter<SqliteModel>> sqliteMappings = {
Office: OfficeAdapter(),
PassSlip: PassSlipAdapter(),
Profile: ProfileAdapter(),
ProgrammerProject: ProgrammerProjectAdapter(),
ProgrammerTask: ProgrammerTaskAdapter(),
ProgrammerTaskActivityLog: ProgrammerTaskActivityLogAdapter(),
Service: ServiceAdapter(),
SwapRequest: SwapRequestAdapter(),
Task: TaskAdapter(),
@@ -0,0 +1,80 @@
// GENERATED CODE EDIT WITH CAUTION
// THIS FILE **WILL NOT** BE REGENERATED
// This file should be version controlled and can be manually edited.
part of 'schema.g.dart';
// While migrations are intelligently created, the difference between some commands, such as
// DropTable vs. RenameTable, cannot be determined. For this reason, please review migrations after
// they are created to ensure the correct inference was made.
// The migration version must **always** mirror the file name
const List<MigrationCommand> _migration_20260926162926_up = [
InsertTable('ProgrammerTask'),
InsertTable('ProgrammerTaskActivityLog'),
InsertColumn('id', Column.varchar, onTable: 'ProgrammerTask'),
InsertColumn('title', Column.varchar, onTable: 'ProgrammerTask'),
InsertColumn('description', Column.varchar, onTable: 'ProgrammerTask'),
InsertColumn('category', Column.varchar, onTable: 'ProgrammerTask'),
InsertColumn('status', Column.varchar, onTable: 'ProgrammerTask'),
InsertColumn('priority', Column.integer, onTable: 'ProgrammerTask'),
InsertColumn('assignee_id', Column.varchar, onTable: 'ProgrammerTask'),
InsertColumn('creator_id', Column.varchar, onTable: 'ProgrammerTask'),
InsertColumn('created_at', Column.datetime, onTable: 'ProgrammerTask'),
InsertColumn('started_at', Column.datetime, onTable: 'ProgrammerTask'),
InsertColumn('completed_at', Column.datetime, onTable: 'ProgrammerTask'),
InsertColumn('cancelled_at', Column.datetime, onTable: 'ProgrammerTask'),
InsertColumn('cancellation_reason', Column.varchar, onTable: 'ProgrammerTask'),
InsertColumn('updated_at', Column.datetime, onTable: 'ProgrammerTask'),
InsertColumn('is_terminal', Column.boolean, onTable: 'ProgrammerTask'),
InsertColumn('id', Column.varchar, onTable: 'ProgrammerTaskActivityLog'),
InsertColumn('task_id', Column.varchar, onTable: 'ProgrammerTaskActivityLog'),
InsertColumn('actor_id', Column.varchar, onTable: 'ProgrammerTaskActivityLog'),
InsertColumn('action_type', Column.varchar, onTable: 'ProgrammerTaskActivityLog'),
InsertColumn('meta', Column.varchar, onTable: 'ProgrammerTaskActivityLog'),
InsertColumn('created_at', Column.datetime, onTable: 'ProgrammerTaskActivityLog')
];
const List<MigrationCommand> _migration_20260926162926_down = [
DropTable('ProgrammerTask'),
DropTable('ProgrammerTaskActivityLog'),
DropColumn('id', onTable: 'ProgrammerTask'),
DropColumn('title', onTable: 'ProgrammerTask'),
DropColumn('description', onTable: 'ProgrammerTask'),
DropColumn('category', onTable: 'ProgrammerTask'),
DropColumn('status', onTable: 'ProgrammerTask'),
DropColumn('priority', onTable: 'ProgrammerTask'),
DropColumn('assignee_id', onTable: 'ProgrammerTask'),
DropColumn('creator_id', onTable: 'ProgrammerTask'),
DropColumn('created_at', onTable: 'ProgrammerTask'),
DropColumn('started_at', onTable: 'ProgrammerTask'),
DropColumn('completed_at', onTable: 'ProgrammerTask'),
DropColumn('cancelled_at', onTable: 'ProgrammerTask'),
DropColumn('cancellation_reason', onTable: 'ProgrammerTask'),
DropColumn('updated_at', onTable: 'ProgrammerTask'),
DropColumn('is_terminal', onTable: 'ProgrammerTask'),
DropColumn('id', onTable: 'ProgrammerTaskActivityLog'),
DropColumn('task_id', onTable: 'ProgrammerTaskActivityLog'),
DropColumn('actor_id', onTable: 'ProgrammerTaskActivityLog'),
DropColumn('action_type', onTable: 'ProgrammerTaskActivityLog'),
DropColumn('meta', onTable: 'ProgrammerTaskActivityLog'),
DropColumn('created_at', onTable: 'ProgrammerTaskActivityLog')
];
//
// DO NOT EDIT BELOW THIS LINE
//
@Migratable(
version: '20260926162926',
up: _migration_20260926162926_up,
down: _migration_20260926162926_down,
)
class Migration20260926162926 extends Migration {
const Migration20260926162926()
: super(
version: 20260926162926,
up: _migration_20260926162926_up,
down: _migration_20260926162926_down,
);
}
@@ -0,0 +1,54 @@
// GENERATED CODE EDIT WITH CAUTION
// THIS FILE **WILL NOT** BE REGENERATED
// This file should be version controlled and can be manually edited.
part of 'schema.g.dart';
// While migrations are intelligently created, the difference between some commands, such as
// DropTable vs. RenameTable, cannot be determined. For this reason, please review migrations after
// they are created to ensure the correct inference was made.
// The migration version must **always** mirror the file name
const List<MigrationCommand> _migration_20260927030348_up = [
InsertTable('ProgrammerProject'),
InsertColumn('id', Column.varchar, onTable: 'ProgrammerProject'),
InsertColumn('name', Column.varchar, onTable: 'ProgrammerProject'),
InsertColumn('description', Column.varchar, onTable: 'ProgrammerProject'),
InsertColumn('status', Column.varchar, onTable: 'ProgrammerProject'),
InsertColumn('creator_id', Column.varchar, onTable: 'ProgrammerProject'),
InsertColumn('created_at', Column.datetime, onTable: 'ProgrammerProject'),
InsertColumn('updated_at', Column.datetime, onTable: 'ProgrammerProject'),
InsertColumn('is_archived', Column.boolean, onTable: 'ProgrammerProject'),
InsertColumn('project_id', Column.varchar, onTable: 'ProgrammerTask')
];
const List<MigrationCommand> _migration_20260927030348_down = [
DropTable('ProgrammerProject'),
DropColumn('id', onTable: 'ProgrammerProject'),
DropColumn('name', onTable: 'ProgrammerProject'),
DropColumn('description', onTable: 'ProgrammerProject'),
DropColumn('status', onTable: 'ProgrammerProject'),
DropColumn('creator_id', onTable: 'ProgrammerProject'),
DropColumn('created_at', onTable: 'ProgrammerProject'),
DropColumn('updated_at', onTable: 'ProgrammerProject'),
DropColumn('is_archived', onTable: 'ProgrammerProject'),
DropColumn('project_id', onTable: 'ProgrammerTask')
];
//
// DO NOT EDIT BELOW THIS LINE
//
@Migratable(
version: '20260927030348',
up: _migration_20260927030348_up,
down: _migration_20260927030348_down,
)
class Migration20260927030348 extends Migration {
const Migration20260927030348()
: super(
version: 20260927030348,
up: _migration_20260927030348_up,
down: _migration_20260927030348_down,
);
}
@@ -0,0 +1,36 @@
// GENERATED CODE EDIT WITH CAUTION
// THIS FILE **WILL NOT** BE REGENERATED
// This file should be version controlled and can be manually edited.
part of 'schema.g.dart';
// While migrations are intelligently created, the difference between some commands, such as
// DropTable vs. RenameTable, cannot be determined. For this reason, please review migrations after
// they are created to ensure the correct inference was made.
// The migration version must **always** mirror the file name
const List<MigrationCommand> _migration_20260928065256_up = [
InsertColumn('day_sheet_id', Column.varchar, onTable: 'NotificationItem')
];
const List<MigrationCommand> _migration_20260928065256_down = [
DropColumn('day_sheet_id', onTable: 'NotificationItem')
];
//
// DO NOT EDIT BELOW THIS LINE
//
@Migratable(
version: '20260928065256',
up: _migration_20260928065256_up,
down: _migration_20260928065256_down,
)
class Migration20260928065256 extends Migration {
const Migration20260928065256()
: super(
version: 20260928065256,
up: _migration_20260928065256_up,
down: _migration_20260928065256_down,
);
}
+77 -1
View File
@@ -1,18 +1,24 @@
// GENERATED CODE DO NOT EDIT
// This file should be version controlled
import 'package:brick_sqlite/db.dart';
part '20260928065256.migration.dart';
part '20260411041808.migration.dart';
part '20260411042152.migration.dart';
part '20260926162926.migration.dart';
part '20260927030348.migration.dart';
/// All intelligently-generated migrations from all `@Migratable` classes on disk
final migrations = <Migration>{
const Migration20260928065256(),
const Migration20260411041808(),
const Migration20260411042152(),
const Migration20260926162926(),
const Migration20260927030348(),
};
/// A consumable database structure including the latest generated migration.
final schema = Schema(
20260411042152,
20260927030348,
generatorVersion: 1,
tables: <SchemaTable>{
SchemaTable(
@@ -267,6 +273,7 @@ final schema = Schema(
SchemaColumn('it_service_request_id', Column.varchar),
SchemaColumn('announcement_id', Column.varchar),
SchemaColumn('message_id', Column.integer),
SchemaColumn('day_sheet_id', Column.varchar),
SchemaColumn('type', Column.varchar),
SchemaColumn('created_at', Column.datetime),
SchemaColumn('read_at', Column.datetime),
@@ -334,6 +341,75 @@ final schema = Schema(
},
indices: <SchemaIndex>{},
),
SchemaTable(
'ProgrammerProject',
columns: <SchemaColumn>{
SchemaColumn(
'_brick_id',
Column.integer,
autoincrement: true,
nullable: false,
isPrimaryKey: true,
),
SchemaColumn('id', Column.varchar),
SchemaColumn('name', Column.varchar),
SchemaColumn('description', Column.varchar),
SchemaColumn('status', Column.varchar),
SchemaColumn('creator_id', Column.varchar),
SchemaColumn('created_at', Column.datetime),
SchemaColumn('updated_at', Column.datetime),
SchemaColumn('is_archived', Column.boolean),
},
indices: <SchemaIndex>{},
),
SchemaTable(
'ProgrammerTask',
columns: <SchemaColumn>{
SchemaColumn(
'_brick_id',
Column.integer,
autoincrement: true,
nullable: false,
isPrimaryKey: true,
),
SchemaColumn('id', Column.varchar),
SchemaColumn('title', Column.varchar),
SchemaColumn('description', Column.varchar),
SchemaColumn('category', Column.varchar),
SchemaColumn('status', Column.varchar),
SchemaColumn('priority', Column.integer),
SchemaColumn('assignee_id', Column.varchar),
SchemaColumn('creator_id', Column.varchar),
SchemaColumn('project_id', Column.varchar),
SchemaColumn('created_at', Column.datetime),
SchemaColumn('started_at', Column.datetime),
SchemaColumn('completed_at', Column.datetime),
SchemaColumn('cancelled_at', Column.datetime),
SchemaColumn('cancellation_reason', Column.varchar),
SchemaColumn('updated_at', Column.datetime),
SchemaColumn('is_terminal', Column.boolean),
},
indices: <SchemaIndex>{},
),
SchemaTable(
'ProgrammerTaskActivityLog',
columns: <SchemaColumn>{
SchemaColumn(
'_brick_id',
Column.integer,
autoincrement: true,
nullable: false,
isPrimaryKey: true,
),
SchemaColumn('id', Column.varchar),
SchemaColumn('task_id', Column.varchar),
SchemaColumn('actor_id', Column.varchar),
SchemaColumn('action_type', Column.varchar),
SchemaColumn('meta', Column.varchar),
SchemaColumn('created_at', Column.datetime),
},
indices: <SchemaIndex>{},
),
SchemaTable(
'Service',
columns: <SchemaColumn>{
+4
View File
@@ -19,6 +19,8 @@ class NotificationItem extends OfflineFirstWithSupabaseModel {
final String? itServiceRequestId;
final String? announcementId;
final int? messageId;
@Sqlite(name: 'day_sheet_id')
final String? daySheetId;
final String type;
final DateTime createdAt;
final DateTime? readAt;
@@ -38,6 +40,7 @@ class NotificationItem extends OfflineFirstWithSupabaseModel {
required this.itServiceRequestId,
this.announcementId,
required this.messageId,
this.daySheetId,
required this.type,
required this.createdAt,
required this.readAt,
@@ -55,6 +58,7 @@ class NotificationItem extends OfflineFirstWithSupabaseModel {
itServiceRequestId: map['it_service_request_id'] as String?,
announcementId: map['announcement_id'] as String?,
messageId: map['message_id'] as int?,
daySheetId: map['day_sheet_id'] as String?,
type: map['type'] as String? ?? 'mention',
createdAt: AppTime.parse(map['created_at'] as String),
readAt: map['read_at'] == null
+116
View File
@@ -0,0 +1,116 @@
import 'package:tasq/utils/programmer_daily_time.dart';
import 'package:tasq/utils/programmer_day_rows.dart';
/// Status constants for a programmer day sheet.
class DaySheetStatus {
static const String draft = 'draft';
static const String pending = 'pending';
static const String disapproved = 'disapproved';
static const String approved = 'approved';
static String label(String s) {
switch (s) {
case draft:
return 'Draft';
case pending:
return 'Pending';
case disapproved:
return 'Disapproved';
case approved:
return 'Approved';
default:
return s;
}
}
}
/// Immutable snapshot of a day sheet at approval time.
class DaySheetSnapshot {
final int totalSeconds;
final List<DaySheetRow> rows;
const DaySheetSnapshot({required this.totalSeconds, required this.rows});
Map<String, dynamic> toJson() => {
'v': 1,
'total_seconds': totalSeconds,
'rows': rows.map((r) => r.toJson()).toList(),
};
/// Alias for [toJson]; used by the approval RPC payload.
Map<String, dynamic> toMap() => toJson();
factory DaySheetSnapshot.fromJson(Map<String, dynamic> j) {
final rowsList = (j['rows'] as List? ?? [])
.map((r) => DaySheetRow.fromJson(r as Map<String, dynamic>))
.toList();
return DaySheetSnapshot(
totalSeconds: (j['total_seconds'] as num).toInt(),
rows: rowsList,
);
}
factory DaySheetSnapshot.fromRows(List<DaySheetRow> rows) {
final total = rows.fold(0, (sum, r) => sum + r.seconds);
return DaySheetSnapshot(totalSeconds: total, rows: rows);
}
}
/// A programmer's daily time-sheet submission for one work day.
class ProgrammerDaySheet {
final String id;
final String programmerId;
final String status;
final DateTime workDate;
final DateTime? submittedAt;
final DateTime? reviewedAt;
final bool autoSubmitted;
final String? reviewedBy;
final int resubmissions;
final DaySheetSnapshot? approvedSnapshot;
final DateTime createdAt;
final DateTime updatedAt;
const ProgrammerDaySheet({
required this.id,
required this.programmerId,
required this.status,
required this.workDate,
this.submittedAt,
this.reviewedAt,
this.autoSubmitted = false,
this.reviewedBy,
this.resubmissions = 0,
this.approvedSnapshot,
required this.createdAt,
required this.updatedAt,
});
/// True when approved on the first submission (no resubmissions needed).
bool get isFirstPassApproved =>
status == DaySheetStatus.approved && resubmissions == 0;
factory ProgrammerDaySheet.fromMap(Map<String, dynamic> m) {
return ProgrammerDaySheet(
id: m['id'] as String,
programmerId: m['programmer_id'] as String,
status: m['status'] as String? ?? DaySheetStatus.draft,
workDate: parseDayKey(m['work_date'] as String),
submittedAt: m['submitted_at'] != null
? DateTime.parse(m['submitted_at'] as String)
: null,
reviewedAt: m['reviewed_at'] != null
? DateTime.parse(m['reviewed_at'] as String)
: null,
autoSubmitted: m['auto_submitted'] as bool? ?? false,
reviewedBy: m['reviewed_by'] as String?,
resubmissions: (m['resubmissions'] as num?)?.toInt() ?? 0,
approvedSnapshot: m['approved_snapshot'] != null
? DaySheetSnapshot.fromJson(
m['approved_snapshot'] as Map<String, dynamic>)
: null,
createdAt: DateTime.parse(m['created_at'] as String),
updatedAt: DateTime.parse(m['updated_at'] as String),
);
}
}
@@ -0,0 +1,32 @@
/// A single audit-trail event on a programmer day sheet.
class ProgrammerDaySheetEvent {
final String id;
final String sheetId;
final String kind;
final String? actorId;
final String? body;
final List<String> flaggedTaskIds;
final DateTime createdAt;
const ProgrammerDaySheetEvent({
required this.id,
required this.sheetId,
required this.kind,
this.actorId,
this.body,
required this.flaggedTaskIds,
required this.createdAt,
});
factory ProgrammerDaySheetEvent.fromMap(Map<String, dynamic> m) {
return ProgrammerDaySheetEvent(
id: m['id'] as String,
sheetId: m['sheet_id'] as String,
kind: m['kind'] as String,
actorId: m['actor_id'] as String?,
body: m['body'] as String?,
flaggedTaskIds: List<String>.from(m['flagged_task_ids'] as List? ?? []),
createdAt: DateTime.parse(m['created_at'] as String),
);
}
}
+80
View File
@@ -0,0 +1,80 @@
import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';
import 'package:brick_sqlite/brick_sqlite.dart';
import 'package:brick_supabase/brick_supabase.dart';
import '../utils/app_time.dart';
/// Status of a programmer project.
class ProgrammerProjectStatus {
static const active = 'active';
static const archived = 'archived';
static const all = [active, archived];
static String label(String status) {
switch (status) {
case active:
return 'Active';
case archived:
return 'Archived';
default:
return status;
}
}
}
@ConnectOfflineFirstWithSupabase(
supabaseConfig: SupabaseSerializable(tableName: 'programmer_projects'),
)
class ProgrammerProject extends OfflineFirstWithSupabaseModel {
final String id;
final String name;
final String? description;
final String status;
final String? creatorId;
final DateTime createdAt;
final DateTime updatedAt;
ProgrammerProject({
required this.id,
required this.name,
this.description,
required this.status,
this.creatorId,
required this.createdAt,
required this.updatedAt,
});
bool get isArchived => status == ProgrammerProjectStatus.archived;
@Supabase(ignore: true)
@Sqlite(ignore: true)
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ProgrammerProject &&
runtimeType == other.runtimeType &&
id == other.id &&
name == other.name &&
status == other.status &&
updatedAt == other.updatedAt;
@Supabase(ignore: true)
@Sqlite(ignore: true)
@override
int get hashCode => Object.hash(id, name, status, updatedAt);
factory ProgrammerProject.fromMap(Map<String, dynamic> map) {
return ProgrammerProject(
id: map['id'].toString(),
name: map['name'] as String? ?? '',
description: map['description'] as String?,
status: map['status'] as String? ?? ProgrammerProjectStatus.active,
creatorId: map['creator_id']?.toString(),
createdAt: AppTime.parse(map['created_at'] as String),
updatedAt: map['updated_at'] == null
? AppTime.parse(map['created_at'] as String)
: AppTime.parse(map['updated_at'] as String),
);
}
}
+148
View File
@@ -0,0 +1,148 @@
import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';
import 'package:brick_sqlite/brick_sqlite.dart';
import 'package:brick_supabase/brick_supabase.dart';
import '../utils/app_time.dart';
/// Work categories for a programmer task. Values are the TitleCase display
/// strings stored directly in the `programmer_task_category` Postgres enum
/// (same convention as the IT-support request_type / request_category enums).
class ProgrammerTaskCategory {
static const softwareSupport = 'Software Support';
static const softwareDevelopment = 'Software Development';
static const softwareEnhancement = 'Software Enhancement';
static const bugFix = 'Bug Fix';
static const meeting = 'Meeting';
static const facilitateEvent = 'Facilitate Event';
static const others = 'Others';
static const all = [
softwareSupport,
softwareDevelopment,
softwareEnhancement,
bugFix,
meeting,
facilitateEvent,
others,
];
/// Enum values already read as display labels, but this keeps the call site
/// symmetric with other models and guards unknown values.
static String label(String category) =>
all.contains(category) ? category : category;
}
/// Status lifecycle for a programmer task.
///
/// `queued -> in_progress -> completed | cancelled`. Pause/resume are recorded
/// as activity-log events (not statuses): a paused task keeps `inProgress`.
class ProgrammerTaskStatus {
static const queued = 'queued';
static const inProgress = 'in_progress';
static const completed = 'completed';
static const cancelled = 'cancelled';
static const all = [queued, inProgress, completed, cancelled];
static const terminal = [completed, cancelled];
static String label(String status) {
switch (status) {
case queued:
return 'Queued';
case inProgress:
return 'In Progress';
case completed:
return 'Completed';
case cancelled:
return 'Cancelled';
default:
return status;
}
}
}
@ConnectOfflineFirstWithSupabase(
supabaseConfig: SupabaseSerializable(tableName: 'programmer_tasks'),
)
class ProgrammerTask extends OfflineFirstWithSupabaseModel {
final String id;
final String title;
final String? description;
final String category;
final String status;
final int priority;
final String? assigneeId;
final String? creatorId;
final String? projectId;
final DateTime createdAt;
final DateTime? startedAt;
final DateTime? completedAt;
final DateTime? cancelledAt;
final String? cancellationReason;
final DateTime updatedAt;
ProgrammerTask({
required this.id,
required this.title,
this.description,
required this.category,
required this.status,
this.priority = 1,
this.assigneeId,
this.creatorId,
this.projectId,
required this.createdAt,
this.startedAt,
this.completedAt,
this.cancelledAt,
this.cancellationReason,
required this.updatedAt,
});
bool get isTerminal => ProgrammerTaskStatus.terminal.contains(status);
@Supabase(ignore: true)
@Sqlite(ignore: true)
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ProgrammerTask &&
runtimeType == other.runtimeType &&
id == other.id &&
status == other.status &&
updatedAt == other.updatedAt;
@Supabase(ignore: true)
@Sqlite(ignore: true)
@override
int get hashCode => Object.hash(id, status, updatedAt);
factory ProgrammerTask.fromMap(Map<String, dynamic> map) {
return ProgrammerTask(
id: map['id'].toString(),
title: map['title'] as String? ?? '',
description: map['description'] as String?,
category: map['category'] as String? ?? ProgrammerTaskCategory.others,
status: map['status'] as String? ?? ProgrammerTaskStatus.queued,
priority: (map['priority'] as num?)?.toInt() ?? 1,
assigneeId: map['assignee_id']?.toString(),
creatorId: map['creator_id']?.toString(),
projectId: map['project_id']?.toString(),
createdAt: AppTime.parse(map['created_at'] as String),
startedAt: map['started_at'] == null
? null
: AppTime.parse(map['started_at'] as String),
completedAt: map['completed_at'] == null
? null
: AppTime.parse(map['completed_at'] as String),
cancelledAt: map['cancelled_at'] == null
? null
: AppTime.parse(map['cancelled_at'] as String),
cancellationReason: map['cancellation_reason'] as String?,
updatedAt: map['updated_at'] == null
? AppTime.parse(map['created_at'] as String)
: AppTime.parse(map['updated_at'] as String),
);
}
}
@@ -0,0 +1,77 @@
import 'dart:convert';
import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';
import 'package:brick_supabase/brick_supabase.dart';
import '../utils/app_time.dart';
/// Event log for a programmer task. Mirrors [TaskActivityLog] but keyed to
/// `programmer_tasks`. `actionType` values: created, started, paused, resumed,
/// completed, cancelled, assigned, reassigned.
@ConnectOfflineFirstWithSupabase(
supabaseConfig: SupabaseSerializable(tableName: 'programmer_task_activity_logs'),
)
class ProgrammerTaskActivityLog extends OfflineFirstWithSupabaseModel {
final String id;
final String taskId;
final String? actorId;
final String actionType;
final Map<String, dynamic>? meta;
final DateTime createdAt;
ProgrammerTaskActivityLog({
required this.id,
required this.taskId,
this.actorId,
required this.actionType,
this.meta,
required this.createdAt,
});
factory ProgrammerTaskActivityLog.fromMap(Map<String, dynamic> map) {
final id = map['id'] == null ? '' : map['id'].toString();
final taskId = map['task_id'] == null ? '' : map['task_id'].toString();
final actorId = map['actor_id']?.toString();
final actionType = (map['action_type'] as String?) ?? 'unknown';
Map<String, dynamic>? meta;
final rawMeta = map['meta'];
if (rawMeta is Map<String, dynamic>) {
meta = rawMeta;
} else if (rawMeta is Map) {
meta = rawMeta.map((k, v) => MapEntry(k.toString(), v));
} else if (rawMeta is String && rawMeta.isNotEmpty) {
try {
final decoded = jsonDecode(rawMeta);
if (decoded is Map) {
meta = decoded.map((k, v) => MapEntry(k.toString(), v));
}
} catch (_) {
meta = null;
}
}
final rawCreated = map['created_at'];
DateTime createdAt;
if (rawCreated is DateTime) {
createdAt = AppTime.toAppTime(rawCreated);
} else if (rawCreated is String) {
try {
createdAt = AppTime.parse(rawCreated);
} catch (_) {
createdAt = AppTime.now();
}
} else {
createdAt = AppTime.now();
}
return ProgrammerTaskActivityLog(
id: id,
taskId: taskId,
actorId: actorId,
actionType: actionType,
meta: meta,
createdAt: createdAt,
);
}
}
@@ -0,0 +1,74 @@
import 'dart:convert';
import '../utils/app_time.dart';
/// One file attached to a comment (stored in the task_attachments bucket).
class ProgrammerCommentAttachment {
final String path;
final String name;
const ProgrammerCommentAttachment({required this.path, required this.name});
Map<String, dynamic> toJson() => {'path': path, 'name': name};
factory ProgrammerCommentAttachment.fromJson(Map<String, dynamic> json) {
return ProgrammerCommentAttachment(
path: json['path']?.toString() ?? '',
name: json['name']?.toString() ?? '',
);
}
}
/// A comment on a programmer task (plain, online-first model). Any user who can
/// see the task may comment and attach files, whether or not they are the
/// assignee.
class ProgrammerTaskComment {
final String id;
final String taskId;
final String authorId;
final String body;
final List<ProgrammerCommentAttachment> attachments;
final DateTime createdAt;
ProgrammerTaskComment({
required this.id,
required this.taskId,
required this.authorId,
required this.body,
this.attachments = const [],
required this.createdAt,
});
factory ProgrammerTaskComment.fromMap(Map<String, dynamic> map) {
return ProgrammerTaskComment(
id: map['id'].toString(),
taskId: map['task_id'].toString(),
authorId: map['author_id'].toString(),
body: map['body'] as String? ?? '',
attachments: _parseAttachments(map['attachments']),
createdAt: AppTime.parse(map['created_at'] as String),
);
}
static List<ProgrammerCommentAttachment> _parseAttachments(dynamic raw) {
List<dynamic> list;
if (raw is List) {
list = raw;
} else if (raw is String && raw.isNotEmpty) {
try {
final decoded = jsonDecode(raw);
list = decoded is List ? decoded : const [];
} catch (_) {
list = const [];
}
} else {
return const [];
}
return list
.whereType<Map>()
.map((e) => ProgrammerCommentAttachment.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList();
}
}
@@ -0,0 +1,44 @@
import '../utils/app_time.dart';
import '../utils/programmer_daily_time.dart';
/// A work-log entry on a programmer task (plain, online-first model).
///
/// The assignee logs progress notes with [minutes] == null (their time comes
/// from the running clock). A non-assignee "helper" logs with [minutes] set —
/// their contributed time, which feeds the per-contributor time ledger and can
/// be deducted from the helper's own running task.
class ProgrammerTaskWorkLog {
final String id;
final String taskId;
final String authorId;
final String description;
final int? minutes;
final DateTime createdAt;
final DateTime workDate;
ProgrammerTaskWorkLog({
required this.id,
required this.taskId,
required this.authorId,
required this.description,
this.minutes,
required this.createdAt,
DateTime? workDate,
}) : workDate = workDate ?? manilaDay(createdAt);
factory ProgrammerTaskWorkLog.fromMap(Map<String, dynamic> map) {
final createdAt = AppTime.parse(map['created_at'] as String);
final workDate = map['work_date'] != null
? parseDayKey(map['work_date'] as String)
: manilaDay(createdAt);
return ProgrammerTaskWorkLog(
id: map['id'].toString(),
taskId: map['task_id'].toString(),
authorId: map['author_id'].toString(),
description: map['description'] as String? ?? '',
minutes: (map['minutes'] as num?)?.toInt(),
createdAt: createdAt,
workDate: workDate,
);
}
}
+8
View File
@@ -310,3 +310,11 @@ final isAdminProvider = Provider<bool>((ref) {
orElse: () => false,
);
});
final isStrictAdminProvider = Provider<bool>((ref) {
final profileAsync = ref.watch(currentProfileProvider);
return profileAsync.maybeWhen(
data: (profile) => profile?.role == 'admin',
orElse: () => false,
);
});
@@ -0,0 +1,238 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/programmer_task.model.dart';
import '../models/programmer_task_activity_log.model.dart';
import '../models/programmer_task_work_log.model.dart';
import '../utils/app_time.dart';
import '../utils/programmer_daily_time.dart';
import 'programmer_tasks_provider.dart';
import 'supabase_provider.dart';
enum ProgrammerRunState { running, paused, notStarted, completed, cancelled }
typedef ProgrammerRunInfo = ({ProgrammerRunState state, DateTime? since});
/// Pure function — no providers.
Map<String, ProgrammerRunInfo> computeRunStates(
List<ProgrammerTask> tasks,
Map<String, List<ProgrammerTaskActivityLog>> logsByTask,
) {
return {for (final t in tasks) t.id: _runInfoFor(t, logsByTask[t.id] ?? [])};
}
ProgrammerRunInfo _runInfoFor(
ProgrammerTask task,
List<ProgrammerTaskActivityLog> logs,
) {
switch (task.status) {
case ProgrammerTaskStatus.queued:
return (state: ProgrammerRunState.notStarted, since: null);
case ProgrammerTaskStatus.completed:
return (state: ProgrammerRunState.completed, since: null);
case ProgrammerTaskStatus.cancelled:
return (state: ProgrammerRunState.cancelled, since: null);
default:
return _inProgressInfo(task, logs);
}
}
ProgrammerRunInfo _inProgressInfo(
ProgrammerTask task,
List<ProgrammerTaskActivityLog> logs,
) {
const types = ['started', 'paused', 'resumed'];
final relevant = logs.where((l) => types.contains(l.actionType)).toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
if (relevant.isEmpty) {
return (state: ProgrammerRunState.running, since: task.startedAt);
}
final latest = relevant.first;
if (latest.actionType == 'paused') {
return (state: ProgrammerRunState.paused, since: latest.createdAt);
}
return (state: ProgrammerRunState.running, since: latest.createdAt);
}
class DayActivityData {
const DayActivityData({
required this.day,
required this.tasks,
required this.logsByTask,
required this.workLogs,
});
final DateTime day;
final List<ProgrammerTask> tasks;
final Map<String, List<ProgrammerTaskActivityLog>> logsByTask;
final List<ProgrammerTaskWorkLog> workLogs;
}
bool _isTaskActiveOnDay(
ProgrammerTask t,
DateTime dayStart,
DateTime dayEnd,
DateTime now,
) {
if (t.status == ProgrammerTaskStatus.inProgress) return true;
if (t.startedAt == null) return false;
final terminal = t.completedAt ?? t.cancelledAt ?? now;
return t.startedAt!.isBefore(dayEnd) && !terminal.isBefore(dayStart);
}
Future<List<ProgrammerTaskWorkLog>> _fetchWorkLogs(
dynamic client,
DateTime day,
) async {
final raw = await client
.from('programmer_task_work_logs')
.select()
.eq('work_date', formatDayKey(day)) as List;
return raw
.map((r) => ProgrammerTaskWorkLog.fromMap(r as Map<String, dynamic>))
.toList();
}
Future<Map<String, List<ProgrammerTaskActivityLog>>> _fetchActivityLogs(
dynamic client,
List<String> ids,
) async {
final raw = await client
.from('programmer_task_activity_logs')
.select()
.inFilter('task_id', ids)
.order('created_at', ascending: false) as List;
final map = <String, List<ProgrammerTaskActivityLog>>{};
for (final r in raw) {
final log = ProgrammerTaskActivityLog.fromMap(r as Map<String, dynamic>);
map.putIfAbsent(log.taskId, () => []).add(log);
}
return map;
}
Future<DayActivityData> fetchDayActivityData(
dynamic client, {
required DateTime day,
required List<ProgrammerTask> tasks,
required DateTime now,
}) async {
final dayStart = manilaDayStart(day);
final dayEnd = dayStart.add(const Duration(days: 1));
final candidates =
tasks.where((t) => _isTaskActiveOnDay(t, dayStart, dayEnd, now)).toList();
final workLogs = await _fetchWorkLogs(client, day);
final cIds = {for (final t in candidates) t.id};
candidates.addAll(
tasks.where((t) => workLogs.any((l) => l.taskId == t.id) && !cIds.contains(t.id)),
);
if (candidates.isEmpty) {
return DayActivityData(day: day, tasks: [], logsByTask: {}, workLogs: workLogs);
}
final logsByTask =
await _fetchActivityLogs(client, candidates.map((t) => t.id).toList());
return DayActivityData(
day: day,
tasks: candidates,
logsByTask: logsByTask,
workLogs: workLogs,
);
}
/// Emits [AppTime.now()] immediately then every 30 seconds.
final clockTickerProvider = StreamProvider<DateTime>((ref) async* {
yield AppTime.now();
yield* Stream.periodic(const Duration(seconds: 30), (_) => AppTime.now());
});
/// Manila calendar day derived from the clock ticker.
final todayKeyProvider = Provider<DateTime>((ref) {
final tick = ref.watch(clockTickerProvider).valueOrNull;
return manilaDay(tick ?? AppTime.now());
});
/// Increments by 1 for each INSERT on either log table.
final programmerActivitySignalProvider = StreamProvider<int>((ref) {
final client = ref.watch(supabaseClientProvider);
final controller = StreamController<int>();
var count = 0;
void onInsert(PostgresChangePayload _) {
count++;
if (!controller.isClosed) controller.add(count);
}
final channel = client
.channel('programmer_activity_signal')
.onPostgresChanges(
event: PostgresChangeEvent.insert,
schema: 'public',
table: 'programmer_task_work_logs',
callback: onInsert,
)
.onPostgresChanges(
event: PostgresChangeEvent.insert,
schema: 'public',
table: 'programmer_task_activity_logs',
callback: onInsert,
);
channel.subscribe();
ref.onDispose(() {
client.removeChannel(channel);
controller.close();
});
return controller.stream;
});
/// Fetches activity data for a given day key. Invalidated by the activity signal.
final programmerDayDataProvider =
FutureProvider.family<DayActivityData, DateTime>((ref, day) async {
ref.watch(programmerActivitySignalProvider);
final tasks = ref.watch(programmerTasksProvider).valueOrNull ?? [];
final client = ref.watch(supabaseClientProvider);
return fetchDayActivityData(client, day: day, tasks: tasks, now: AppTime.now());
});
/// Total help-given seconds for [userId] in the current Manila calendar month.
///
/// Queries work logs with minutes > 0 authored by [userId] this month.
/// Re-fetches whenever [programmerActivitySignalProvider] fires.
final helpGivenThisMonthProvider =
FutureProvider.family<int, String>((ref, userId) async {
ref.watch(programmerActivitySignalProvider);
final client = ref.watch(supabaseClientProvider);
final today = ref.watch(todayKeyProvider);
final monthStart = DateTime.utc(today.year, today.month, 1);
final nextMonthStart = today.month < 12
? DateTime.utc(today.year, today.month + 1, 1)
: DateTime.utc(today.year + 1, 1, 1);
final raw = await client
.from('programmer_task_work_logs')
.select('minutes')
.eq('author_id', userId)
.gte('work_date', formatDayKey(monthStart))
.lt('work_date', formatDayKey(nextMonthStart)) as List;
int totalSec = 0;
for (final r in raw) {
final minutes = r['minutes'] as int?;
if (minutes != null && minutes > 0) totalSec += minutes * 60;
}
return totalSec;
});
/// Run states for today's tasks, derived from today's [programmerDayDataProvider].
final programmerRunStatesProvider = Provider<Map<String, ProgrammerRunInfo>>((ref) {
final today = ref.watch(todayKeyProvider);
final data = ref.watch(programmerDayDataProvider(today)).valueOrNull;
if (data == null) return {};
return computeRunStates(data.tasks, data.logsByTask);
});
@@ -0,0 +1,388 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/programmer_day_sheet.model.dart';
import '../models/programmer_day_sheet_event.model.dart';
import '../utils/app_time.dart';
import '../utils/programmer_daily_time.dart';
import 'notifications_provider.dart';
import 'profile_provider.dart';
import 'supabase_provider.dart';
// ---------------------------------------------------------------------------
// Shared types
// ---------------------------------------------------------------------------
/// Callback type for sending push notifications.
typedef PushFn = Future<void> Function({
required List<String> userIds,
required String title,
required String body,
Map<String, dynamic>? data,
});
// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------
enum DaySheetError {
alreadyReviewed,
alreadySubmitted,
notAuthorized,
remarksRequired,
bodyRequired,
workDateLocked,
invalidSnapshot,
unknown,
}
class DaySheetActionException implements Exception {
const DaySheetActionException(this.code);
final DaySheetError code;
String get message {
switch (code) {
case DaySheetError.alreadyReviewed:
return 'This sheet was already reviewed.';
case DaySheetError.alreadySubmitted:
return 'This day sheet was already submitted.';
case DaySheetError.notAuthorized:
return 'You are not allowed to do that.';
case DaySheetError.remarksRequired:
return 'Remarks are required to disapprove.';
case DaySheetError.bodyRequired:
return 'Please write a justification.';
case DaySheetError.workDateLocked:
return 'That day is locked — you can only add entries to pending or disapproved days.';
case DaySheetError.invalidSnapshot:
return 'Could not approve: the sheet data was invalid.';
case DaySheetError.unknown:
return 'Something went wrong. Please try again.';
}
}
@override
String toString() => 'DaySheetActionException(${code.name}): $message';
}
/// Maps an RPC error to a [DaySheetError] by substring-matching the lowercased
/// error string against the known database error codes.
DaySheetError mapDaySheetError(Object error) {
final s = error.toString().toLowerCase();
if (s.contains('not_authorized')) return DaySheetError.notAuthorized;
if (s.contains('already_reviewed')) return DaySheetError.alreadyReviewed;
if (s.contains('already_submitted')) return DaySheetError.alreadySubmitted;
if (s.contains('remarks_required')) return DaySheetError.remarksRequired;
if (s.contains('body_required')) return DaySheetError.bodyRequired;
if (s.contains('work_date_locked')) return DaySheetError.workDateLocked;
if (s.contains('invalid_snapshot')) return DaySheetError.invalidSnapshot;
return DaySheetError.unknown;
}
// ---------------------------------------------------------------------------
// Result type
// ---------------------------------------------------------------------------
class BulkApproveResult {
const BulkApproveResult({
required this.approvedIds,
required this.skippedIds,
});
final List<String> approvedIds;
final List<String> skippedIds;
}
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
class DaySheetsController {
DaySheetsController(this._client, {required PushFn push}) : _push = push;
final dynamic _client;
final PushFn _push;
// -------------------------------------------------------------------------
// Submit
// -------------------------------------------------------------------------
Future<String> submit(DateTime dayKey) async {
try {
final dateStr = formatDayKey(dayKey);
final result = await _client.rpc(
'submit_day_sheet',
params: {'p_date': dateStr},
) as Map<String, dynamic>;
final sheetId = result['sheet_id'] as String;
final workDate = parseDayKey(result['work_date'] as String);
final programmerName = result['programmer_name'] as String;
final notifyUserIds =
List<String>.from(result['notify_user_ids'] as List? ?? []);
if (notifyUserIds.isNotEmpty) {
try {
await _push(
userIds: notifyUserIds,
title: 'Day sheet submitted',
body:
'$programmerName submitted their day sheet for ${AppTime.formatDate(workDate)}',
data: {
'type': 'day_sheet_submitted',
'navigate_to': '/programmer-tasks/day-sheets/$sheetId',
},
);
} catch (e) {
debugPrint('DaySheetsController.submit push error: $e');
}
}
return sheetId;
} catch (e) {
if (e is DaySheetActionException) rethrow;
throw DaySheetActionException(mapDaySheetError(e));
}
}
// -------------------------------------------------------------------------
// Disapprove
// -------------------------------------------------------------------------
Future<void> disapprove({
required String sheetId,
required String remarks,
required List<String> flaggedTaskIds,
}) async {
if (remarks.trim().isEmpty) {
throw DaySheetActionException(DaySheetError.remarksRequired);
}
try {
final result = await _client.rpc(
'disapprove_day_sheet',
params: {
'p_sheet': sheetId,
'p_remarks': remarks.trim(),
'p_flagged_task_ids': flaggedTaskIds,
},
);
if (result is Map<String, dynamic>) {
final programmerId = result['programmer_id'] as String?;
final rawDate = result['work_date'] as String?;
if (programmerId != null && rawDate != null) {
final workDate = parseDayKey(rawDate);
final truncated = remarks.trim().length > 80
? '${remarks.trim().substring(0, 80)}…'
: remarks.trim();
try {
await _push(
userIds: [programmerId],
title: 'Day sheet disapproved',
body:
'Your day sheet for ${AppTime.formatDate(workDate)} needs changes: $truncated',
data: {
'type': 'day_sheet_disapproved',
'navigate_to': '/programmer-tasks/day-sheets/$sheetId',
},
);
} catch (e) {
debugPrint('DaySheetsController.disapprove push error: $e');
}
}
}
} catch (e) {
if (e is DaySheetActionException) rethrow;
throw DaySheetActionException(mapDaySheetError(e));
}
}
// -------------------------------------------------------------------------
// Justify
// -------------------------------------------------------------------------
Future<void> justify({
required String sheetId,
required String body,
}) async {
if (body.trim().isEmpty) {
throw DaySheetActionException(DaySheetError.bodyRequired);
}
try {
final result = await _client.rpc(
'justify_day_sheet',
params: {
'p_sheet': sheetId,
'p_body': body.trim(),
},
);
if (result is Map<String, dynamic>) {
final notifyUserIds =
List<String>.from(result['notify_user_ids'] as List? ?? []);
final programmerName = result['programmer_name'] as String?;
final rawDate = result['work_date'] as String?;
if (notifyUserIds.isNotEmpty &&
programmerName != null &&
rawDate != null) {
final workDate = parseDayKey(rawDate);
try {
await _push(
userIds: notifyUserIds,
title: 'Day sheet resubmitted',
body:
'$programmerName justified their day sheet for ${AppTime.formatDate(workDate)}',
data: {
'type': 'day_sheet_resubmitted',
'navigate_to': '/programmer-tasks/day-sheets/$sheetId',
},
);
} catch (e) {
debugPrint('DaySheetsController.justify push error: $e');
}
}
}
} catch (e) {
if (e is DaySheetActionException) rethrow;
throw DaySheetActionException(mapDaySheetError(e));
}
}
// -------------------------------------------------------------------------
// Approve (bulk)
// -------------------------------------------------------------------------
Future<BulkApproveResult> approve(
List<({String sheetId, DaySheetSnapshot snapshot})> items,
) async {
try {
final params = items
.map((e) => {
'sheet_id': e.sheetId,
'snapshot': e.snapshot.toMap(),
})
.toList();
final result = await _client.rpc(
'approve_day_sheets',
params: {'p_items': params},
) as Map<String, dynamic>;
final approved = List<Map<String, dynamic>>.from(
(result['approved'] as List? ?? []).cast<Map<String, dynamic>>(),
);
final skippedIds =
List<String>.from(result['skipped_ids'] as List? ?? []);
for (final item in approved) {
final approvedSheetId = item['sheet_id'] as String;
final programmerId = item['programmer_id'] as String?;
final rawDate = item['work_date'] as String?;
if (programmerId != null && rawDate != null) {
final workDate = parseDayKey(rawDate);
try {
await _push(
userIds: [programmerId],
title: 'Day sheet approved',
body:
'Your day sheet for ${AppTime.formatDate(workDate)} was approved',
data: {
'type': 'day_sheet_approved',
'navigate_to':
'/programmer-tasks/day-sheets/$approvedSheetId',
},
);
} catch (e) {
debugPrint('DaySheetsController.approve push error: $e');
}
}
}
return BulkApproveResult(
approvedIds: approved.map((e) => e['sheet_id'] as String).toList(),
skippedIds: skippedIds,
);
} catch (e) {
if (e is DaySheetActionException) rethrow;
throw DaySheetActionException(mapDaySheetError(e));
}
}
}
// ---------------------------------------------------------------------------
// Riverpod providers
// ---------------------------------------------------------------------------
final daySheetsControllerProvider = Provider<DaySheetsController>((ref) {
final client = ref.watch(supabaseClientProvider);
final notifications = ref.read(notificationsControllerProvider);
return DaySheetsController(
client,
push: notifications.sendPush,
);
});
// ---------------------------------------------------------------------------
// Stream providers
// ---------------------------------------------------------------------------
/// Day sheets for the currently authenticated programmer, newest first.
final myDaySheetsProvider =
StreamProvider<List<ProgrammerDaySheet>>((ref) {
final userId = ref.watch(currentUserIdProvider);
if (userId == null) return const Stream.empty();
final client = ref.watch(supabaseClientProvider);
return client
.from('programmer_day_sheets')
.stream(primaryKey: ['id'])
.eq('programmer_id', userId)
.order('work_date', ascending: false)
.map((rows) => rows.map(ProgrammerDaySheet.fromMap).toList());
});
/// Day sheets for a specific user (admin view), newest first.
final daySheetsForUserProvider =
StreamProvider.family<List<ProgrammerDaySheet>, String>((ref, userId) {
final client = ref.watch(supabaseClientProvider);
return client
.from('programmer_day_sheets')
.stream(primaryKey: ['id'])
.eq('programmer_id', userId)
.order('work_date', ascending: false)
.map((rows) => rows.map(ProgrammerDaySheet.fromMap).toList());
});
/// All day sheets visible to the current user (RLS-scoped), newest first.
final allDaySheetsProvider =
StreamProvider<List<ProgrammerDaySheet>>((ref) {
final client = ref.watch(supabaseClientProvider);
return client
.from('programmer_day_sheets')
.stream(primaryKey: ['id'])
.order('work_date', ascending: false)
.map((rows) => rows.map(ProgrammerDaySheet.fromMap).toList());
});
/// A single day sheet by its ID, or null if not found.
final daySheetByIdProvider =
StreamProvider.family<ProgrammerDaySheet?, String>((ref, id) {
final client = ref.watch(supabaseClientProvider);
return client
.from('programmer_day_sheets')
.stream(primaryKey: ['id'])
.eq('id', id)
.map((rows) => rows.isEmpty ? null : ProgrammerDaySheet.fromMap(rows.first));
});
/// Events for a day sheet, ordered by creation time ascending.
final daySheetEventsProvider =
StreamProvider.family<List<ProgrammerDaySheetEvent>, String>((ref, sheetId) {
final client = ref.watch(supabaseClientProvider);
return client
.from('programmer_day_sheet_events')
.stream(primaryKey: ['id'])
.eq('sheet_id', sheetId)
.order('created_at', ascending: true)
.map((rows) => rows.map(ProgrammerDaySheetEvent.fromMap).toList());
});
@@ -0,0 +1,122 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.dart';
import '../models/programmer_project.model.dart';
import 'profile_provider.dart';
import 'realtime_controller.dart';
import 'stream_recovery.dart';
import 'supabase_provider.dart';
void _sortProjects(List<ProgrammerProject> projects) {
projects.sort((a, b) {
// Active before archived, then alphabetical.
if (a.isArchived != b.isArchived) return a.isArchived ? 1 : -1;
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
});
}
/// All programmer projects (offline-readable via Brick cache).
final programmerProjectsProvider = StreamProvider<List<ProgrammerProject>>((ref) {
final userId = ref.watch(currentUserIdProvider);
if (userId == null) return const Stream.empty();
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<ProgrammerProject>(
stream: client
.from('programmer_projects')
.stream(primaryKey: ['id']).order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('programmer_projects')
.select()
.order('created_at', ascending: false);
return data.map(ProgrammerProject.fromMap).toList();
},
fromMap: ProgrammerProject.fromMap,
channelName: 'programmer_projects',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<ProgrammerProject>();
_sortProjects(all);
return all;
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<ProgrammerProject>(rows, tag: 'programmer_projects'),
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) {
final list = List<ProgrammerProject>.from(result.data);
_sortProjects(list);
return list;
});
});
/// Active projects only — used by the task form's project picker.
final activeProgrammerProjectsProvider = Provider<List<ProgrammerProject>>((ref) {
final all = ref.watch(programmerProjectsProvider).valueOrNull ?? const [];
return all.where((p) => !p.isArchived).toList();
});
final programmerProjectByIdProvider =
Provider.family<ProgrammerProject?, String>((ref, id) {
final projects = ref.watch(programmerProjectsProvider).valueOrNull;
if (projects == null) return null;
try {
return projects.firstWhere((p) => p.id == id);
} catch (_) {
return null;
}
});
final programmerProjectsControllerProvider =
Provider<ProgrammerProjectsController>((ref) {
return ProgrammerProjectsController(ref.watch(supabaseClientProvider));
});
/// Projects are created/edited online (inline from the task form or the
/// projects screen). Reads fall back to the Brick cache when offline.
class ProgrammerProjectsController {
ProgrammerProjectsController(this._client);
final dynamic _client;
Future<String> createProject({
required String name,
String? description,
}) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
final id = const Uuid().v4();
await _client.from('programmer_projects').insert({
'id': id,
'name': name,
'description': ?description,
'creator_id': userId,
});
return id;
}
Future<void> updateProject({
required String projectId,
String? name,
String? description,
}) async {
final updates = <String, dynamic>{};
if (name != null) updates['name'] = name;
if (description != null) updates['description'] = description;
if (updates.isEmpty) return;
await _client.from('programmer_projects').update(updates).eq('id', projectId);
}
Future<void> setArchived({
required String projectId,
required bool archived,
}) async {
await _client.from('programmer_projects').update({
'status': archived
? ProgrammerProjectStatus.archived
: ProgrammerProjectStatus.active,
}).eq('id', projectId);
}
}
@@ -0,0 +1,71 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uuid/uuid.dart';
import '../models/programmer_task_comment.model.dart';
import 'supabase_provider.dart';
/// Bucket reused for programmer-task comment attachments (public read;
/// authenticated write; owner/privileged delete — already hardened by
/// 20260926120000_task_attachment_write_hardening.sql).
const _attachmentBucket = 'task_attachments';
/// A file selected for upload with a comment.
typedef CommentUpload = ({String name, Uint8List bytes});
/// Comments for a task (newest first), streamed live.
final programmerTaskCommentsProvider =
StreamProvider.family<List<ProgrammerTaskComment>, String>((ref, taskId) {
final client = ref.watch(supabaseClientProvider);
return client
.from('programmer_task_comments')
.stream(primaryKey: ['id'])
.eq('task_id', taskId)
.order('created_at', ascending: false)
.map((rows) => rows.map(ProgrammerTaskComment.fromMap).toList());
});
final programmerTaskCommentsControllerProvider =
Provider<ProgrammerTaskCommentsController>((ref) {
return ProgrammerTaskCommentsController(ref.watch(supabaseClientProvider));
});
class ProgrammerTaskCommentsController {
ProgrammerTaskCommentsController(this._client);
final dynamic _client;
/// Public URL for a stored attachment path.
String attachmentUrl(String path) =>
_client.storage.from(_attachmentBucket).getPublicUrl(path);
/// Adds a comment, uploading any [files] to the attachment bucket first.
/// Requires either a non-empty [body] or at least one file.
Future<void> addComment({
required String taskId,
required String body,
List<CommentUpload> files = const [],
}) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
final attachments = <Map<String, dynamic>>[];
for (final file in files) {
final path =
'programmer_tasks/$taskId/comments/${const Uuid().v4()}_${file.name}';
await _client.storage.from(_attachmentBucket).uploadBinary(
path,
file.bytes,
fileOptions: const FileOptions(upsert: true),
);
attachments.add({'path': path, 'name': file.name});
}
await _client.from('programmer_task_comments').insert({
'task_id': taskId,
'author_id': userId,
'body': body,
'attachments': attachments,
});
}
}
@@ -0,0 +1,54 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/programmer_task_work_log.model.dart';
import '../utils/programmer_daily_time.dart';
import 'programmer_day_sheets_provider.dart';
import 'supabase_provider.dart';
/// Work-log entries for a task (newest first), streamed live.
final programmerTaskWorkLogsProvider =
StreamProvider.family<List<ProgrammerTaskWorkLog>, String>((ref, taskId) {
final client = ref.watch(supabaseClientProvider);
return client
.from('programmer_task_work_logs')
.stream(primaryKey: ['id'])
.eq('task_id', taskId)
.order('created_at', ascending: false)
.map((rows) => rows.map(ProgrammerTaskWorkLog.fromMap).toList());
});
final programmerTaskWorkLogsControllerProvider =
Provider<ProgrammerTaskWorkLogsController>((ref) {
return ProgrammerTaskWorkLogsController(ref.watch(supabaseClientProvider));
});
class ProgrammerTaskWorkLogsController {
ProgrammerTaskWorkLogsController(this._client);
final dynamic _client;
/// Adds a work-log entry. [minutes] is null for the assignee's progress notes
/// (their time comes from the running clock) and set for a helper's
/// Time Consumed. [workDate] overrides the date the entry is attributed to;
/// when null the DB default applies (today's Manila date).
Future<void> addWorkLog({
required String taskId,
required String description,
int? minutes,
DateTime? workDate,
}) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
try {
await _client.from('programmer_task_work_logs').insert({
'task_id': taskId,
'author_id': userId,
'description': description,
'minutes': ?minutes,
if (workDate != null) 'work_date': formatDayKey(workDate),
});
} catch (e) {
if (e is DaySheetActionException) rethrow;
throw DaySheetActionException(mapDaySheetError(e));
}
}
}
@@ -0,0 +1,624 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.dart';
import '../models/programmer_task.model.dart';
import '../models/programmer_task_activity_log.model.dart';
import '../utils/snackbar.dart' show isOfflineSaveError;
import 'connectivity_provider.dart';
import 'profile_provider.dart';
import 'realtime_controller.dart';
import 'stream_recovery.dart';
import 'supabase_provider.dart';
/// Lifecycle action types that determine whether a task is currently paused.
const _pauseStateActions = ['started', 'paused', 'resumed', 'completed', 'cancelled'];
int _statusRank(String status) {
switch (status) {
case ProgrammerTaskStatus.inProgress:
return 0;
case ProgrammerTaskStatus.queued:
return 1;
case ProgrammerTaskStatus.completed:
return 2;
case ProgrammerTaskStatus.cancelled:
return 3;
default:
return 4;
}
}
void _sortTasks(List<ProgrammerTask> tasks) {
tasks.sort((a, b) {
final r = _statusRank(a.status).compareTo(_statusRank(b.status));
if (r != 0) return r;
return b.createdAt.compareTo(a.createdAt);
});
}
// ---------------------------------------------------------------------------
// Offline-pending state (merged into the live stream; replayed on reconnect)
// ---------------------------------------------------------------------------
final offlinePendingProgrammerTasksProvider =
StateProvider<List<ProgrammerTask>>((ref) => const []);
final offlinePendingProgrammerTaskRawProvider =
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
final offlinePendingProgrammerTaskUpdatesProvider =
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
final offlinePendingProgrammerTaskLogsProvider =
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
// ---------------------------------------------------------------------------
// Stream providers
// ---------------------------------------------------------------------------
final programmerTasksProvider = StreamProvider<List<ProgrammerTask>>((ref) {
final userId = ref.watch(currentUserIdProvider);
if (userId == null) return const Stream.empty();
final client = ref.watch(supabaseClientProvider);
final pendingNew = ref.watch(offlinePendingProgrammerTasksProvider);
final pendingUpdates = ref.watch(offlinePendingProgrammerTaskUpdatesProvider);
List<ProgrammerTask> applyPending(List<ProgrammerTask> rows) {
final byId = {for (final r in rows) r.id: r};
pendingUpdates.forEach((id, fields) {
final existing = byId[id];
if (existing == null) return;
byId[id] = _applyUpdateFields(existing, fields);
});
for (final p in pendingNew) {
byId.putIfAbsent(p.id, () => p);
}
final result = byId.values.toList();
_sortTasks(result);
return result;
}
final wrapper = StreamRecoveryWrapper<ProgrammerTask>(
stream: client
.from('programmer_tasks')
.stream(primaryKey: ['id'])
.order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('programmer_tasks')
.select()
.order('created_at', ascending: false)
.range(0, 199);
return data.map(ProgrammerTask.fromMap).toList();
},
fromMap: ProgrammerTask.fromMap,
channelName: 'programmer_tasks',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<ProgrammerTask>();
return applyPending(all);
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<ProgrammerTask>(rows, tag: 'programmer_tasks'),
);
ref.onDispose(wrapper.dispose);
// Reconnect-replay: POST queued items directly, bypassing Brick's queue.
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
// 1. Replay offline-created tasks.
final pendingRaw = List<Map<String, dynamic>>.from(
ref.read(offlinePendingProgrammerTaskRawProvider),
);
if (pendingRaw.isNotEmpty) {
final syncedIds = <String>[];
for (final payload in pendingRaw) {
final id = payload['id'] as String;
try {
await client.from('programmer_tasks').insert(payload);
try {
await client.from('programmer_task_activity_logs').insert({
'task_id': id,
'actor_id': payload['creator_id'],
'action_type': 'created',
});
} catch (_) {}
syncedIds.add(id);
} catch (e) {
final msg = e.toString();
if (msg.contains('23505') || msg.contains('duplicate key')) {
syncedIds.add(id);
} else {
debugPrint('[programmerTasksProvider] failed to sync id=$id: $e');
}
}
}
if (syncedIds.isNotEmpty) {
ref.read(offlinePendingProgrammerTaskRawProvider.notifier).state =
List.unmodifiable(
ref
.read(offlinePendingProgrammerTaskRawProvider)
.where((p) => !syncedIds.contains(p['id'] as String)),
);
ref.read(offlinePendingProgrammerTasksProvider.notifier).state =
List.unmodifiable(
ref
.read(offlinePendingProgrammerTasksProvider)
.where((t) => !syncedIds.contains(t.id)),
);
}
}
// 2. Replay offline field/status updates.
final pendingUpdatesMap = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingProgrammerTaskUpdatesProvider),
);
if (pendingUpdatesMap.isNotEmpty) {
final syncedIds = <String>[];
for (final entry in pendingUpdatesMap.entries) {
try {
await client
.from('programmer_tasks')
.update(entry.value)
.eq('id', entry.key);
syncedIds.add(entry.key);
} catch (e) {
debugPrint(
'[programmerTasksProvider] failed to sync update id=${entry.key}: $e',
);
}
}
if (syncedIds.isNotEmpty) {
final remaining = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingProgrammerTaskUpdatesProvider),
);
for (final id in syncedIds) {
remaining.remove(id);
}
ref.read(offlinePendingProgrammerTaskUpdatesProvider.notifier).state =
remaining;
}
}
// 3. Replay queued activity logs (started/paused/resumed/etc.).
final pendingLogs = List<Map<String, dynamic>>.from(
ref.read(offlinePendingProgrammerTaskLogsProvider),
);
if (pendingLogs.isNotEmpty) {
final synced = <Map<String, dynamic>>[];
for (final log in pendingLogs) {
try {
await client.from('programmer_task_activity_logs').insert(log);
synced.add(log);
} catch (e) {
debugPrint('[programmerTasksProvider] failed to sync log: $e');
}
}
if (synced.isNotEmpty) {
ref.read(offlinePendingProgrammerTaskLogsProvider.notifier).state =
List.unmodifiable(
ref
.read(offlinePendingProgrammerTaskLogsProvider)
.where((l) => !synced.contains(l)),
);
}
}
});
return wrapper.stream.map((result) => applyPending(result.data));
});
final programmerTaskByIdProvider = Provider.family<ProgrammerTask?, String>((
ref,
id,
) {
final tasks = ref.watch(programmerTasksProvider).valueOrNull;
if (tasks == null) return null;
try {
return tasks.firstWhere((t) => t.id == id);
} catch (_) {
return null;
}
});
/// The current user's actively-running task id (status in_progress AND latest
/// activity log is not a pause), or null. Lets the list distinguish a *running*
/// task from a *paused* one (both share the `in_progress` status). Re-queries
/// whenever the task list changes. Given the single-active invariant, at most
/// one task is running per user.
final myRunningProgrammerTaskIdProvider = FutureProvider<String?>((ref) async {
// Re-run whenever tasks change (a start/pause/resume mutates a row).
ref.watch(programmerTasksProvider);
final running =
await ref.watch(programmerTasksControllerProvider).findRunningTaskForCurrentUser();
return running?.id;
});
final programmerTaskActivityLogsProvider =
StreamProvider.family<List<ProgrammerTaskActivityLog>, String>((ref, taskId) {
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<ProgrammerTaskActivityLog>(
stream: client
.from('programmer_task_activity_logs')
.stream(primaryKey: ['id'])
.eq('task_id', taskId)
.order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('programmer_task_activity_logs')
.select()
.eq('task_id', taskId)
.order('created_at', ascending: false);
return data.map(ProgrammerTaskActivityLog.fromMap).toList();
},
fromMap: ProgrammerTaskActivityLog.fromMap,
channelName: 'programmer_task_activity_logs_$taskId',
onStatusChanged: ref
.read(realtimeControllerProvider)
.handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<ProgrammerTaskActivityLog>();
return all.where((l) => l.taskId == taskId).toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
},
onCacheMirror: (rows) => mirrorBatchToBrick<ProgrammerTaskActivityLog>(
rows,
tag: 'programmer_task_activity_logs',
),
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
final programmerTasksControllerProvider = Provider<ProgrammerTasksController>((
ref,
) {
final client = ref.watch(supabaseClientProvider);
return ProgrammerTasksController(client, ref);
});
class ProgrammerTasksController {
ProgrammerTasksController(this._client, [this._ref]);
final dynamic _client;
final Ref? _ref;
bool get _isOnline => _ref?.read(isOnlineProvider) ?? true;
/// Whether [taskId]'s most recent lifecycle event is a pause (i.e. the task
/// is in_progress but not actively running).
Future<bool> _isCurrentlyPaused(String taskId) async {
try {
final rows = await _client
.from('programmer_task_activity_logs')
.select('action_type, created_at')
.eq('task_id', taskId)
.inFilter('action_type', _pauseStateActions)
.order('created_at', ascending: false)
.limit(1);
if (rows is List && rows.isNotEmpty) {
return (rows.first['action_type']?.toString() ?? '') == 'paused';
}
} catch (_) {}
return false;
}
Future<void> _insertLog(
String taskId,
String actionType, {
Map<String, dynamic>? meta,
}) async {
final row = <String, dynamic>{
'task_id': taskId,
'actor_id': _client.auth.currentUser?.id,
'action_type': actionType,
'meta': ?meta,
};
if (!_isOnline) {
_queueLog(row);
return;
}
try {
await _client.from('programmer_task_activity_logs').insert(row);
} catch (e) {
if (isOfflineSaveError(e)) {
_queueLog(row);
} else {
rethrow;
}
}
}
/// Creates a task. [assigneeId] defaults to the current user (self-logged);
/// pass another programmer's id to assign it to them. Returns the task id.
Future<String> createTask({
required String title,
required String category,
String? description,
String? assigneeId,
String? projectId,
int priority = 1,
}) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
final id = const Uuid().v4();
final assignee = assigneeId ?? userId;
final payload = <String, dynamic>{
'id': id,
'title': title,
'description': description,
'category': category,
'status': ProgrammerTaskStatus.queued,
'priority': priority,
'assignee_id': assignee,
'creator_id': userId,
'project_id': ?projectId,
};
try {
await _client.from('programmer_tasks').insert(payload);
await _insertLog(id, 'created');
if (assignee != userId) {
await _insertLog(id, 'assigned', meta: {'assignee_id': assignee});
}
return id;
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
final now = DateTime.now().toUtc();
final local = ProgrammerTask(
id: id,
title: title,
description: description,
category: category,
status: ProgrammerTaskStatus.queued,
priority: priority,
assigneeId: assignee,
creatorId: userId,
projectId: projectId,
createdAt: now,
updatedAt: now,
);
_queueNewTask(local, payload);
return id;
}
}
/// The current user's actively-running task (status in_progress, latest event
/// not a pause), or null. Used to drive the pause-on-switch prompt. Online
/// only — returns null offline (the prompt is a best-effort UX aid).
Future<ProgrammerTask?> findRunningTaskForCurrentUser() async {
final userId = _client.auth.currentUser?.id;
if (userId == null || !_isOnline) return null;
try {
final rows = await _client
.from('programmer_tasks')
.select()
.eq('assignee_id', userId)
.eq('status', ProgrammerTaskStatus.inProgress);
if (rows is! List) return null;
for (final row in rows) {
final task = ProgrammerTask.fromMap(row as Map<String, dynamic>);
if (!await _isCurrentlyPaused(task.id)) return task;
}
} catch (_) {}
return null;
}
/// Moves a queued task to in_progress (sets started_at on first start) and
/// logs a `started` event.
Future<void> startTask({required String taskId}) async {
final updates = <String, dynamic>{
'status': ProgrammerTaskStatus.inProgress,
'started_at': DateTime.now().toUtc().toIso8601String(),
};
await _updateTaskRow(taskId, updates, onlyIfStartNull: true);
await _insertLog(taskId, 'started');
}
/// Logs a `paused` event for a running task (idempotent).
Future<void> pauseTask({required String taskId}) async {
if (_isOnline && await _isCurrentlyPaused(taskId)) return;
await _insertLog(taskId, 'paused');
}
/// Logs a `resumed` event for a paused task (idempotent).
Future<void> resumeTask({required String taskId}) async {
if (_isOnline && !await _isCurrentlyPaused(taskId)) return;
await _insertLog(taskId, 'resumed');
}
Future<void> completeTask({required String taskId}) async {
await _updateTaskRow(taskId, {
'status': ProgrammerTaskStatus.completed,
'completed_at': DateTime.now().toUtc().toIso8601String(),
});
await _insertLog(taskId, 'completed');
}
Future<void> cancelTask({
required String taskId,
required String reason,
}) async {
await _updateTaskRow(taskId, {
'status': ProgrammerTaskStatus.cancelled,
'cancelled_at': DateTime.now().toUtc().toIso8601String(),
'cancellation_reason': reason,
});
await _insertLog(taskId, 'cancelled', meta: {'reason': reason});
}
Future<void> reassign({
required String taskId,
required String newAssigneeId,
}) async {
await _updateTaskRow(taskId, {'assignee_id': newAssigneeId});
await _insertLog(taskId, 'reassigned', meta: {'assignee_id': newAssigneeId});
}
Future<void> updateTask({
required String taskId,
String? title,
String? description,
String? category,
int? priority,
}) async {
final updates = <String, dynamic>{};
if (title != null) updates['title'] = title;
if (description != null) updates['description'] = description;
if (category != null) updates['category'] = category;
if (priority != null) updates['priority'] = priority;
if (updates.isEmpty) return;
await _updateTaskRow(taskId, updates);
await _insertLog(taskId, 'updated', meta: {'fields': updates.keys.toList()});
}
/// Assigns the task to a project (pass null to clear it).
Future<void> setProject({
required String taskId,
required String? projectId,
}) async {
await _updateTaskRow(taskId, {'project_id': projectId});
await _insertLog(taskId, 'updated', meta: {
'fields': ['project_id'],
});
}
/// Records a time adjustment (negative [seconds]) against a task's worked
/// duration — e.g. when the current user helped on another task and chose to
/// deduct that time from their own running task.
Future<void> addAdjustment({
required String taskId,
required int seconds,
String? reason,
String? sourceTaskId,
}) async {
await _insertLog(taskId, 'adjustment', meta: {
'seconds': seconds,
'reason': ?reason,
'source_task_id': ?sourceTaskId,
});
}
/// Applies a field patch to a task row. When [onlyIfStartNull] is set,
/// `started_at` is dropped if the task already has one (preserves the first
/// execution start across pause/resume cycles).
Future<void> _updateTaskRow(
String taskId,
Map<String, dynamic> updates, {
bool onlyIfStartNull = false,
}) async {
var payload = updates;
if (!_isOnline) {
_queueUpdate(taskId, payload);
return;
}
try {
if (onlyIfStartNull && payload.containsKey('started_at')) {
final existing = await _client
.from('programmer_tasks')
.select('started_at')
.eq('id', taskId)
.maybeSingle();
if (existing is Map && existing['started_at'] != null) {
payload = Map<String, dynamic>.from(payload)..remove('started_at');
}
}
await _client.from('programmer_tasks').update(payload).eq('id', taskId);
} catch (e) {
if (isOfflineSaveError(e)) {
_queueUpdate(taskId, payload);
} else {
rethrow;
}
}
}
// ------------------------------------------------------------------ queues
void _queueNewTask(ProgrammerTask model, Map<String, dynamic> payload) {
final ref = _ref;
if (ref == null) return;
ref.read(offlinePendingProgrammerTasksProvider.notifier).state =
List.unmodifiable([
...ref.read(offlinePendingProgrammerTasksProvider),
model,
]);
ref.read(offlinePendingProgrammerTaskRawProvider.notifier).state =
List.unmodifiable([
...ref.read(offlinePendingProgrammerTaskRawProvider),
payload,
]);
mirrorBatchToBrick<ProgrammerTask>([model], tag: 'offline_programmer_task');
}
void _queueUpdate(String taskId, Map<String, dynamic> fields) {
final ref = _ref;
if (ref == null) return;
final current = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingProgrammerTaskUpdatesProvider),
);
current[taskId] = {...?current[taskId], ...fields};
ref.read(offlinePendingProgrammerTaskUpdatesProvider.notifier).state =
current;
}
void _queueLog(Map<String, dynamic> row) {
final ref = _ref;
if (ref == null) return;
ref.read(offlinePendingProgrammerTaskLogsProvider.notifier).state =
List.unmodifiable([
...ref.read(offlinePendingProgrammerTaskLogsProvider),
row,
]);
}
}
// ---------------------------------------------------------------------------
// Field-patch helper for optimistic offline updates
// ---------------------------------------------------------------------------
ProgrammerTask _applyUpdateFields(ProgrammerTask t, Map<String, dynamic> f) {
DateTime? parseOpt(String key) {
if (!f.containsKey(key)) return null;
final v = f[key];
return v == null ? null : DateTime.parse(v as String).toUtc();
}
return ProgrammerTask(
id: t.id,
title: (f['title'] as String?) ?? t.title,
description: f.containsKey('description')
? f['description'] as String?
: t.description,
category: (f['category'] as String?) ?? t.category,
status: (f['status'] as String?) ?? t.status,
priority: (f['priority'] as num?)?.toInt() ?? t.priority,
assigneeId:
f.containsKey('assignee_id') ? f['assignee_id'] as String? : t.assigneeId,
creatorId: t.creatorId,
projectId:
f.containsKey('project_id') ? f['project_id'] as String? : t.projectId,
createdAt: t.createdAt,
startedAt: f.containsKey('started_at') ? parseOpt('started_at') : t.startedAt,
completedAt:
f.containsKey('completed_at') ? parseOpt('completed_at') : t.completedAt,
cancelledAt:
f.containsKey('cancelled_at') ? parseOpt('cancelled_at') : t.cancelledAt,
cancellationReason: f.containsKey('cancellation_reason')
? f['cancellation_reason'] as String?
: t.cancellationReason,
updatedAt: DateTime.now().toUtc(),
);
}
+67
View File
@@ -30,6 +30,12 @@ import '../widgets/app_shell.dart';
import '../screens/teams/teams_screen.dart';
import '../screens/it_service_requests/it_service_requests_list_screen.dart';
import '../screens/it_service_requests/it_service_request_detail_screen.dart';
import '../screens/programmer_tasks/day_sheets/day_sheet_detail_screen.dart';
import '../screens/programmer_tasks/programmer_drill_down_screen.dart';
import '../screens/programmer_tasks/programmer_tasks_list_screen.dart';
import '../screens/programmer_tasks/programmer_task_detail_screen.dart';
import '../screens/programmer_projects/programmer_projects_list_screen.dart';
import '../screens/programmer_projects/programmer_project_detail_screen.dart';
import '../screens/network_map/network_map_overview_screen.dart';
import '../screens/network_map/network_map_site_screen.dart';
import '../screens/network_map/network_map_device_screen.dart';
@@ -93,6 +99,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
role == 'it_staff' ||
role == 'dispatcher' ||
role == 'programmer';
final isProgrammerTasksRoute =
state.matchedLocation.startsWith('/programmer-');
final hasProgrammerTasksAccess =
role == 'admin' || role == 'programmer';
if (!isSignedIn && !isAuthRoute) {
return '/login';
@@ -125,6 +135,9 @@ final appRouterProvider = Provider<GoRouter>((ref) {
if (isNetworkMapRoute && !hasNetworkMapAccess) {
return '/tickets';
}
if (isProgrammerTasksRoute && !hasProgrammerTasksAccess) {
return '/tickets';
}
// Attendance & Whereabouts: not accessible to standard users
final isStandardOnly = role == 'standard';
final isAttendanceRoute =
@@ -207,6 +220,60 @@ final appRouterProvider = Provider<GoRouter>((ref) {
),
],
),
GoRoute(
path: '/programmer-tasks',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: ProgrammerTasksListScreen(
initialTab: state.uri.queryParameters['tab'],
),
),
routes: [
GoRoute(
path: 'programmers/:userId',
pageBuilder: (context, state) => M3ContainerTransformPage(
key: state.pageKey,
child: ProgrammerDrillDownScreen(
userId: state.pathParameters['userId'] ?? '',
),
),
),
GoRoute(
name: 'daySheetDetail',
path: 'day-sheets/:id',
pageBuilder: (context, state) => M3ContainerTransformPage(
key: state.pageKey,
child: DaySheetDetailScreen(
sheetId: state.pathParameters['id'] ?? '',
),
),
),
GoRoute(
path: ':id',
pageBuilder: (context, state) => M3ContainerTransformPage(
key: state.pageKey,
child: ProgrammerTaskDetailScreen(
taskId: state.pathParameters['id'] ?? '',
),
),
),
],
),
GoRoute(
path: '/programmer-projects',
pageBuilder: _page(const ProgrammerProjectsListScreen()),
routes: [
GoRoute(
path: ':id',
pageBuilder: (context, state) => M3ContainerTransformPage(
key: state.pageKey,
child: ProgrammerProjectDetailScreen(
projectId: state.pathParameters['id'] ?? '',
),
),
),
],
),
GoRoute(
path: '/announcements',
pageBuilder: _page(const AnnouncementsScreen()),
+30 -19
View File
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import '../../theme/app_status_colors.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_map/flutter_map.dart';
@@ -111,8 +113,8 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
.check_circle
: Icons.cancel,
color: inside
? Colors.green
: Colors.red,
? AppStatusColors.of(context).success.color
: AppStatusColors.of(context).danger.color,
),
const SizedBox(
width: 8,
@@ -166,8 +168,8 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
? Icons.check_circle
: Icons.cancel,
color: inside
? Colors.green
: Colors.red,
? AppStatusColors.of(context).success.color
: AppStatusColors.of(context).danger.color,
),
const SizedBox(width: 8),
Text(
@@ -275,10 +277,10 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
width: 40,
height: 40,
// `child` is used by newer flutter_map Marker API.
child: const Icon(
child: Icon(
Icons.person_pin_circle,
size: 36,
color: Colors.blue,
color: Theme.of(context).colorScheme.primary,
),
),
);
@@ -289,10 +291,12 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
polygons: [
Polygon(
points: polygonPoints,
color: Colors.green.withValues(
alpha: 0.15,
),
borderColor: Colors.green,
color: AppStatusColors.of(context)
.success
.color
.withValues(alpha: 0.15),
borderColor:
AppStatusColors.of(context).success.color,
borderStrokeWidth: 2,
),
],
@@ -304,9 +308,10 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
circles: [
CircleMarker(
point: LatLng(cfg!.lat!, cfg.lng!),
color: Colors.green.withValues(
alpha: 0.10,
),
color: AppStatusColors.of(context)
.success
.color
.withValues(alpha: 0.10),
borderStrokeWidth: 2,
useRadiusInMeter: true,
radius: cfg.radiusMeters ?? 100,
@@ -387,11 +392,15 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
width: 12,
height: 12,
decoration: BoxDecoration(
color: Colors.green.withValues(
color: AppStatusColors.of(
context,
).success.color.withValues(
alpha: 0.25,
),
border: Border.all(
color: Colors.green,
color: AppStatusColors.of(
context,
).success.color,
),
),
),
@@ -420,14 +429,16 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
top: 6.0,
),
child: Row(
children: const [
children: [
Icon(
Icons.person_pin_circle,
size: 14,
color: Colors.blue,
color: Theme.of(
context,
).colorScheme.primary,
),
SizedBox(width: 8),
Text('You'),
const SizedBox(width: 8),
const Text('You'),
],
),
),
+31 -11
View File
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import '../../theme/app_status_colors.dart';
import '../../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
@@ -197,10 +199,12 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
),
TasQColumn<Profile>(
header: 'Role',
cellBuilder: (context, profile) => Text(profile.role),
minWidth: 120,
cellBuilder: (context, profile) => Text(_roleLabel(profile.role)),
),
TasQColumn<Profile>(
header: 'Offices',
minWidth: 100,
cellBuilder: (context, profile) {
final officesAssigned = officeCountByUser[profile.id] ?? 0;
return Text(officesAssigned == 0 ? 'None' : '$officesAssigned');
@@ -208,6 +212,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
),
TasQColumn<Profile>(
header: 'Status',
minWidth: 120,
cellBuilder: (context, profile) {
final statusAsync = ref.watch(adminUserStatusProvider(profile.id));
return statusAsync.when(
@@ -222,6 +227,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
),
TasQColumn<Profile>(
header: 'Last active',
minWidth: 150,
cellBuilder: (context, profile) {
final lastActive = lastActiveByUser[profile.id];
return Text(_formatLastActiveLabel(lastActive));
@@ -245,7 +251,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 2),
Text('Role: ${profile.role}'),
Text('Role: ${_roleLabel(profile.role)}'),
Text('Offices: $officesAssigned'),
Text('Last active: ${_formatLastActiveLabel(lastActive)}'),
const SizedBox(height: 4),
@@ -386,7 +392,10 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
key: ValueKey('role_${_selectedUserId ?? 'none'}'),
initialValue: _selectedRole,
items: _roles
.map((role) => DropdownMenuItem(value: role, child: Text(role)))
.map(
(role) =>
DropdownMenuItem(value: role, child: Text(_roleLabel(role))),
)
.toList(),
onChanged: (value) => setDialogState(() => _selectedRole = value),
decoration: const InputDecoration(labelText: 'Role'),
@@ -717,6 +726,16 @@ String _formatLastActiveLabel(DateTime? value) {
return DateFormat('yyyy-MM-dd').format(value);
}
/// Human-friendly display label for a stored role value.
String _roleLabel(String role) => switch (role) {
'it_staff' => 'IT Staff',
'standard' => 'Standard',
'dispatcher' => 'Dispatcher',
'programmer' => 'Programmer',
'admin' => 'Admin',
_ => role.replaceAll('_', ' '),
};
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.label});
@@ -724,14 +743,15 @@ class _StatusBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final isError = label.toLowerCase().contains('error');
final background = isError
? scheme.errorContainer
: scheme.secondaryContainer;
final foreground = isError
? scheme.onErrorContainer
: scheme.onSecondaryContainer;
final status = AppStatusColors.of(context);
final swatch = switch (label.toLowerCase()) {
'active' => status.success,
'locked' => status.danger,
final l when l.contains('error') => status.danger,
_ => status.neutral,
};
final background = swatch.container;
final foreground = swatch.onContainer;
return Badge(
backgroundColor: background,
+36 -80
View File
@@ -35,6 +35,8 @@ import 'logbook_day_activity.dart';
import 'work_log_tab.dart';
import '../../theme/m3_motion.dart';
import '../../theme/app_surfaces.dart';
import '../../theme/app_status_colors.dart';
import '../../widgets/app_section_header.dart';
import '../../utils/app_time.dart';
import '../../utils/device_security.dart';
import '../../utils/location_permission.dart';
@@ -787,7 +789,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
} else if (diff.inMinutes <= 5) {
timeColor = colors.error;
} else if (diff.inMinutes <= 15) {
timeColor = Colors.orange;
timeColor = AppStatusColors.of(context).warning.color;
} else if (diff.inMinutes <= 30) {
timeColor = colors.tertiary;
}
@@ -847,7 +849,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
: Icons.cancel,
size: 16,
color: _insideGeofence || hasGeofenceOverride
? Colors.green
? AppStatusColors.of(context).success.color
: colors.error,
),
const SizedBox(width: 6),
@@ -859,7 +861,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
: 'Outside geofence',
style: theme.textTheme.bodySmall?.copyWith(
color: _insideGeofence || hasGeofenceOverride
? Colors.green
? AppStatusColors.of(context).success.color
: colors.error,
fontWeight: FontWeight.w500,
),
@@ -884,11 +886,9 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
const SizedBox(height: 24),
// Today's schedule
Text(
const AppSectionHeader(
"Today's Schedule",
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
padding: EdgeInsets.zero,
),
const SizedBox(height: 8),
@@ -1863,11 +1863,11 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
bg = colors.secondaryContainer;
fg = colors.onSecondaryContainer;
case 'Early Out':
bg = Colors.orange.shade100;
fg = Colors.orange.shade900;
bg = AppStatusColors.of(context).warning.container;
fg = AppStatusColors.of(context).warning.onContainer;
case 'Noon Break':
bg = Colors.blue.shade100;
fg = Colors.blue.shade900;
bg = AppStatusColors.of(context).info.container;
fg = AppStatusColors.of(context).info.onContainer;
case 'Absent':
bg = colors.errorContainer;
fg = colors.onErrorContainer;
@@ -2841,13 +2841,14 @@ class _DateGroupTile extends StatelessWidget {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final status = AppStatusColors.of(context);
final statusColor = group.status == 'Absent'
? Colors.red
? status.danger.color
: group.status == 'On duty'
? Colors.orange
? status.warning.color
: group.status == 'On Leave'
? Colors.teal
: Colors.green;
? status.info.color
: status.success.color;
return Card(
margin: const EdgeInsets.fromLTRB(12, 8, 12, 0),
@@ -2960,10 +2961,10 @@ class _DateGroupTile extends StatelessWidget {
String statusLabel;
switch (slip.status) {
case 'approved':
statusColor = Colors.green;
statusColor = AppStatusColors.of(context).success.color;
statusLabel = 'Approved';
case 'pending':
statusColor = Colors.orange;
statusColor = AppStatusColors.of(context).warning.color;
statusLabel = 'Pending';
case 'rejected':
statusColor = colors.error;
@@ -3081,7 +3082,7 @@ class _DateGroupTile extends StatelessWidget {
? Chip(
label: Text(entry.isLeave ? 'On Leave' : 'Absent'),
backgroundColor: entry.isLeave
? Colors.teal.withValues(alpha: 0.15)
? AppStatusColors.of(context).info.container
: colors.errorContainer,
)
: entry.status == 'On duty'
@@ -3107,7 +3108,7 @@ class _DateGroupTile extends StatelessWidget {
switch (entry.verificationStatus) {
case 'verified':
icon = Icons.verified;
color = Colors.green;
color = AppStatusColors.of(context).success.color;
tooltip = 'Verified';
case 'unverified' || 'skipped':
icon = Icons.warning_amber_rounded;
@@ -3115,7 +3116,7 @@ class _DateGroupTile extends StatelessWidget {
tooltip = 'Unverified';
default:
icon = Icons.hourglass_bottom;
color = Colors.orange;
color = AppStatusColors.of(context).warning.color;
tooltip = 'Pending';
}
final badge = Tooltip(
@@ -3526,7 +3527,7 @@ class _CheckInOutMapTab extends StatelessWidget {
title: 'Check In',
lat: entry.checkInLat,
lng: entry.checkInLng,
markerColor: Colors.green,
markerColor: AppStatusColors.of(context).success.color,
),
),
const SizedBox(width: 12),
@@ -3536,7 +3537,7 @@ class _CheckInOutMapTab extends StatelessWidget {
title: 'Check Out',
lat: entry.checkOutLat,
lng: entry.checkOutLng,
markerColor: Colors.red,
markerColor: AppStatusColors.of(context).danger.color,
),
),
],
@@ -5702,24 +5703,9 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
// Pending slips for admin approval
if (isAdmin) ...[
Row(
children: [
Container(
width: 4,
height: 20,
decoration: BoxDecoration(
color: colors.tertiary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 10),
Text(
'Pending Approvals',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
const AppSectionHeader(
'Pending Approvals',
padding: EdgeInsets.zero,
),
const SizedBox(height: 8),
for (int i = 0; i < pendingSlipList.length; i++)
@@ -5753,24 +5739,9 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
],
// History
Row(
children: [
Container(
width: 4,
height: 20,
decoration: BoxDecoration(
color: colors.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 10),
Text(
'Pass Slip History',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
const AppSectionHeader(
'Pass Slip History',
padding: EdgeInsets.zero,
),
const SizedBox(height: 8),
for (int i = 0; i < historySlipList.length; i++)
@@ -5830,7 +5801,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
IconData statusIcon;
switch (slip.status) {
case 'approved':
statusColor = Colors.green;
statusColor = AppStatusColors.of(context).success.color;
statusIcon = Icons.check_circle;
case 'rejected':
statusColor = colors.error;
@@ -5839,7 +5810,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
statusColor = colors.primary;
statusIcon = Icons.task_alt;
default:
statusColor = Colors.orange;
statusColor = AppStatusColors.of(context).warning.color;
statusIcon = Icons.hourglass_top;
}
@@ -6067,24 +6038,9 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
children: [
// ── Pending Approvals (admin only) ──
if (isAdmin) ...[
Row(
children: [
Container(
width: 4,
height: 20,
decoration: BoxDecoration(
color: colors.tertiary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 10),
Text(
'Pending Approvals',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
const AppSectionHeader(
'Pending Approvals',
padding: EdgeInsets.zero,
),
const SizedBox(height: 8),
if (pendingApprovals.isEmpty)
@@ -6248,7 +6204,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
IconData statusIcon;
switch (leave.status) {
case 'approved':
statusColor = Colors.teal;
statusColor = AppStatusColors.of(context).success.color;
statusIcon = Icons.check_circle;
case 'rejected':
statusColor = colors.error;
@@ -6257,7 +6213,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
statusColor = colors.onSurfaceVariant;
statusIcon = Icons.remove_circle_outline;
default:
statusColor = Colors.orange;
statusColor = AppStatusColors.of(context).warning.color;
statusIcon = Icons.hourglass_top;
}
@@ -7,6 +7,7 @@ import '../../models/it_service_request.model.dart';
import '../../models/pass_slip.model.dart';
import '../../models/task.model.dart';
import '../../models/ticket.model.dart';
import '../../theme/app_surfaces.dart';
import '../../theme/m3_motion.dart';
import '../../utils/app_time.dart';
@@ -427,7 +428,7 @@ class _SummaryChip extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
),
child: Row(
mainAxisSize: MainAxisSize.min,
+7 -5
View File
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../providers/auth_provider.dart';
import '../../providers/tickets_provider.dart';
import '../../theme/app_status_colors.dart';
import '../../theme/m3_motion.dart';
import '../../utils/snackbar.dart';
@@ -563,23 +564,24 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen>
}
final normalized = (score / 6).clamp(0.0, 1.0);
final s = AppStatusColors.of(context);
String label;
Color color;
if (normalized <= 0.2) {
label = 'Very weak';
color = Colors.red;
color = s.danger.color;
} else if (normalized <= 0.4) {
label = 'Weak';
color = Colors.deepOrange;
color = s.danger.color;
} else if (normalized <= 0.6) {
label = 'Fair';
color = Colors.orange;
color = s.warning.color;
} else if (normalized <= 0.8) {
label = 'Strong';
color = Colors.green;
color = s.info.color;
} else {
label = 'Excellent';
color = Colors.teal;
color = s.success.color;
}
setState(() {
+101 -118
View File
@@ -38,9 +38,13 @@ import '../../widgets/app_breakpoints.dart';
import '../../providers/realtime_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
import '../../theme/app_surfaces.dart';
import '../../theme/app_status_colors.dart';
import '../../widgets/mono_text.dart';
import '../../widgets/app_metric_card.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_section_header.dart';
import '../../utils/app_time.dart';
import '../../utils/staff_presence.dart' as staff_presence;
class DashboardMetrics {
DashboardMetrics({
@@ -366,22 +370,10 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
}
// Index today's leaves by user.
final todayLeaveByUser = <String, LeaveOfAbsence>{};
for (final l in allLeaves) {
if (l.status == 'approved' &&
!l.startTime.isAfter(now) &&
l.endTime.isAfter(now)) {
todayLeaveByUser[l.userId] = l;
}
}
final todayLeaveByUser = staff_presence.activeLeaveByUser(allLeaves, now);
// Index active pass slips by user.
final activePassSlipByUser = <String, PassSlip>{};
for (final slip in allPassSlips) {
if (slip.isActive) {
activePassSlipByUser[slip.userId] = slip;
}
}
final activePassSlipByUser = staff_presence.activePassSlipByUser(allPassSlips);
final noon = DateTime(now.year, now.month, now.day, 12, 0);
final onePM = DateTime(now.year, now.month, now.day, 13, 0);
@@ -672,13 +664,13 @@ class _DashboardScreenState extends State<DashboardScreen> {
builder: (context, constraints) {
final sections = <Widget>[
const SizedBox(height: 16),
_sectionTitle(context, 'IT Staff Pulse'),
const AppSectionHeader('IT Staff Pulse'),
const _StaffTable(),
const SizedBox(height: 12),
_sectionTitle(context, 'Team Activity'),
const AppSectionHeader('Team Activity'),
const _StaffActivityChart(),
const SizedBox(height: 20),
_sectionTitle(context, 'Core Daily KPIs'),
const AppSectionHeader('Core Daily KPIs'),
_cardGrid(context, [
_MetricCard(
title: 'New tickets today',
@@ -694,7 +686,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
),
]),
const SizedBox(height: 20),
_sectionTitle(context, 'Task Flow'),
const AppSectionHeader('Task Flow'),
_cardGrid(context, [
_MetricCard(
title: 'Tasks created',
@@ -712,7 +704,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
),
]),
const SizedBox(height: 20),
_sectionTitle(context, 'TAT / Response'),
const AppSectionHeader('TAT / Response'),
_cardGrid(context, [
_MetricCard(
title: 'Avg response',
@@ -775,18 +767,6 @@ class _DashboardScreenState extends State<DashboardScreen> {
);
}
Widget _sectionTitle(BuildContext context, String title) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
Widget _cardGrid(BuildContext context, List<Widget> cards) {
return LayoutBuilder(
@@ -905,7 +885,6 @@ class _MetricCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final cs = Theme.of(context).colorScheme;
// Only watch the single string value for this card so unrelated metric
// updates don't rebuild the whole card. This makes updates feel much
// smoother and avoids full-page refreshes.
@@ -918,46 +897,7 @@ class _MetricCard extends ConsumerWidget {
),
),
);
return AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: cs.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
color: cs.onSurfaceVariant,
),
),
const SizedBox(height: 12),
// Animate only the metric text (not the whole card) for a
// subtle, smooth update.
AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, anim) =>
FadeTransition(opacity: anim, child: child),
child: MonoText(
value,
key: ValueKey(value),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
color: cs.onSurface,
),
),
),
],
),
);
return AppMetricCard(title: title, value: value);
}
}
@@ -967,14 +907,16 @@ class _StaffTable extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
// M3 Expressive: tonal surface container, 28 dp radius for large containers.
final isDark = cs.brightness == Brightness.dark;
// Cyber/IT panel: flat tonal fill + hairline outline.
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cs.surfaceContainerLow,
color: isDark ? cs.surfaceContainer : cs.surfaceContainerLowest,
borderRadius: BorderRadius.circular(
AppSurfaces.of(context).containerRadius,
),
border: Border.all(color: cs.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1059,18 +1001,23 @@ class _StaffTableBody extends ConsumerWidget {
);
if (providerState == 'loading') {
return const Text('Loading staff...');
return _panelHint(context, Icons.hourglass_empty_rounded, 'Loading staff…');
}
if (providerState.startsWith('error:')) {
final err = providerState.substring(6);
return Text('Failed to load staff: $err');
return _panelHint(
context,
Icons.error_outline_rounded,
'Failed to load staff: $err',
);
}
if (staffRows.isEmpty) {
return Text(
return _panelHint(
context,
Icons.groups_outlined,
'No IT staff available.',
style: Theme.of(context).textTheme.bodySmall,
);
}
@@ -1142,20 +1089,24 @@ class _StaffRow extends StatelessWidget {
Expanded(
flex: 4,
child: Center(
child: Text(
row.whereabouts,
style: valueStyle?.copyWith(
color: row.whereabouts == 'In premise'
? Colors.green
: row.whereabouts == 'Outside premise'
? Colors.grey
: row.whereabouts == 'Tracking off'
? Colors.grey
: row.whereabouts.startsWith('Last seen')
? Colors.grey
: null,
fontWeight: FontWeight.w600,
),
child: Builder(
builder: (context) {
final status = AppStatusColors.of(context);
final Color? color = row.whereabouts == 'In premise'
? status.success.color
: (row.whereabouts == 'Outside premise' ||
row.whereabouts == 'Tracking off' ||
row.whereabouts.startsWith('Last seen'))
? status.neutral.color
: null;
return Text(
row.whereabouts,
style: valueStyle?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
);
},
),
),
),
@@ -1202,21 +1153,24 @@ class _StaffActivityChart extends ConsumerWidget {
final cs = Theme.of(context).colorScheme;
final isDark = cs.brightness == Brightness.dark;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cs.surfaceContainerLow,
color: isDark ? cs.surfaceContainer : cs.surfaceContainerLowest,
borderRadius: BorderRadius.circular(
AppSurfaces.of(context).containerRadius,
),
border: Border.all(color: cs.outlineVariant),
),
child: metricsAsync.when(
data: (metrics) {
final staffRows = metrics.staffRows;
if (staffRows.isEmpty) {
return Text(
return _panelHint(
context,
Icons.bar_chart_outlined,
'No staff activity data available.',
style: Theme.of(context).textTheme.bodySmall,
);
}
@@ -1393,16 +1347,16 @@ class _StaffActivityChart extends ConsumerWidget {
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.black.withAlpha(
(0.35 * 255).round(),
color: cs.inverseSurface.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(
AppSurfaces.of(context).chipRadius,
),
borderRadius: BorderRadius.circular(8),
),
child: Text(
child: MonoText(
total.toString(),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: Colors.white,
color: cs.onInverseSurface,
fontWeight: FontWeight.w700,
),
),
@@ -1534,8 +1488,13 @@ class _StaffActivityChart extends ConsumerWidget {
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Text('Failed to load activity: $error'),
loading: () =>
_panelHint(context, Icons.hourglass_empty_rounded, 'Loading activity…'),
error: (error, stack) => _panelHint(
context,
Icons.error_outline_rounded,
'Failed to load activity: $error',
),
),
);
}
@@ -1556,29 +1515,28 @@ class _PulseStatusPill extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final s = AppStatusColors.of(context);
final (Color bg, Color fg) = switch (label.toLowerCase()) {
'arrival' => (Colors.amber.shade100, Colors.amber.shade900),
'late' => (Colors.red.shade100, Colors.red.shade900),
'noon break' => (Colors.blue.shade100, Colors.blue.shade900),
'vacant' => (Colors.green.shade100, Colors.green.shade900),
'on task' => (Colors.purple.shade100, Colors.purple.shade900),
'on event' => (Colors.purple.shade100, Colors.purple.shade900),
'in triage' => (Colors.orange.shade100, Colors.orange.shade900),
'early out' => (Colors.deepOrange.shade100, Colors.deepOrange.shade900),
'on leave' => (Colors.teal.shade100, Colors.teal.shade900),
'absent' => (Colors.red.shade200, Colors.red.shade900),
'off duty' => (Colors.grey.shade200, Colors.grey.shade700),
_ => (
Theme.of(context).colorScheme.tertiaryContainer,
Theme.of(context).colorScheme.onTertiaryContainer,
),
'arrival' => (s.success.container, s.success.onContainer),
'late' => (s.warning.container, s.warning.onContainer),
'noon break' => (s.info.container, s.info.onContainer),
'vacant' => (s.neutral.container, s.neutral.onContainer),
'on task' => (cs.primaryContainer, cs.onPrimaryContainer),
'on event' => (cs.tertiaryContainer, cs.onTertiaryContainer),
'in triage' => (s.warning.container, s.warning.onContainer),
'early out' => (s.warning.container, s.warning.onContainer),
'on leave' => (s.info.container, s.info.onContainer),
'absent' => (s.danger.container, s.danger.onContainer),
'off duty' => (s.neutral.container, s.neutral.onContainer),
_ => (cs.tertiaryContainer, cs.onTertiaryContainer),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
),
child: Text(
label,
@@ -1591,6 +1549,31 @@ class _PulseStatusPill extends StatelessWidget {
}
}
/// Compact, muted, centered hint for empty/loading/error states inside a
/// bounded dashboard panel (too small for the full-screen AppEmptyView).
Widget _panelHint(BuildContext context, IconData icon, String message) {
final cs = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 18, color: cs.onSurfaceVariant),
const SizedBox(width: 8),
Flexible(
child: Text(
message,
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
),
],
),
);
}
Duration? _averageDuration(List<Duration> durations) {
if (durations.isEmpty) {
return null;
@@ -0,0 +1,16 @@
import '../../models/it_service_request.model.dart';
import '../../theme/app_status_colors.dart';
/// Maps an IT service request status to a semantic [StatusKind] so its color
/// carries meaning consistently across the list and detail screens
/// (amber=pending approval, grey=scheduled, blue=in progress, green=completed,
/// grey=cancelled).
StatusKind isrStatusKind(String status) => switch (status) {
ItServiceRequestStatus.pendingApproval => StatusKind.warning,
ItServiceRequestStatus.scheduled => StatusKind.neutral,
ItServiceRequestStatus.inProgress => StatusKind.info,
ItServiceRequestStatus.inProgressDryRun => StatusKind.info,
ItServiceRequestStatus.completed => StatusKind.success,
ItServiceRequestStatus.cancelled => StatusKind.neutral,
_ => StatusKind.neutral,
};
@@ -29,7 +29,9 @@ import '../../widgets/gemini_animated_text_field.dart';
import '../../widgets/m3_card.dart';
import '../../widgets/mono_text.dart';
import '../../widgets/responsive_body.dart';
import '../../theme/app_status_colors.dart';
import '../../widgets/status_pill.dart';
import 'isr_status_kind.dart';
import 'it_service_request_pdf.dart';
class ItServiceRequestDetailScreen extends ConsumerStatefulWidget {
@@ -411,13 +413,13 @@ class _ItServiceRequestDetailScreenState
),
)
: _hasRecentSave
? const Padding(
key: ValueKey('isr_saved'),
padding: EdgeInsets.symmetric(horizontal: 8),
? Padding(
key: const ValueKey('isr_saved'),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Icon(
Icons.check_circle,
size: 18,
color: Colors.green,
color: AppStatusColors.of(context).success.color,
),
)
: const SizedBox(key: ValueKey('isr_idle')),
@@ -555,7 +557,10 @@ class _ItServiceRequestDetailScreenState
if (request.requestNumber != null)
MonoText(request.requestNumber!),
const SizedBox(width: 12),
StatusPill(label: ItServiceRequestStatus.label(request.status)),
StatusPill.kind(
isrStatusKind(request.status),
label: ItServiceRequestStatus.label(request.status),
),
const Spacer(),
Text(
'Created ${AppTime.formatDate(request.createdAt)}',
@@ -1231,10 +1236,10 @@ class _ItServiceRequestDetailScreenState
child: const Icon(Icons.save, size: 14),
)
: isSaved
? const Icon(
? Icon(
Icons.check_circle,
key: ValueKey('label_saved'),
color: Colors.green,
key: const ValueKey('label_saved'),
color: AppStatusColors.of(context).success.color,
size: 14,
)
: const SizedBox(key: ValueKey('label_idle')),
@@ -1584,9 +1589,6 @@ class _ItServiceRequestDetailScreenState
final controller = TextEditingController();
return AlertDialog(
title: const Text('Cancellation Reason'),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
content: TextField(
controller: controller,
decoration: const InputDecoration(
@@ -1836,7 +1838,6 @@ class _StaffPickerDialogState extends State<_StaffPickerDialog> {
.toList();
return AlertDialog(
title: const Text('Select IT Staff'),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
content: SizedBox(
width: 350,
height: 400,
@@ -2245,3 +2246,4 @@ class _EvidenceSectionState extends ConsumerState<_EvidenceSection> {
);
}
}
@@ -17,6 +17,9 @@ import '../../providers/tickets_provider.dart';
import '../../theme/m3_motion.dart';
import '../../utils/app_time.dart';
import '../../utils/snackbar.dart';
import '../../theme/app_status_colors.dart';
import '../../widgets/app_status_summary.dart';
import 'isr_status_kind.dart';
import '../../widgets/m3_card.dart';
import '../../widgets/mono_text.dart';
import '../../widgets/reconnect_overlay.dart';
@@ -381,9 +384,6 @@ class _ItServiceRequestsListScreenState
builder: (ctx, setDialogState) {
return AlertDialog(
title: const Text('New IT Service Request'),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
content: SizedBox(
width: 400,
child: SingleChildScrollView(
@@ -537,7 +537,6 @@ class _StatusSummaryRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final pending = requests
.where((r) => r.status == ItServiceRequestStatus.pendingApproval)
.length;
@@ -555,88 +554,38 @@ class _StatusSummaryRow extends StatelessWidget {
.where((r) => r.status == ItServiceRequestStatus.completed)
.length;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
_SummaryChip(
label: 'Pending',
count: pending,
color: cs.tertiary,
selected: selectedStatus == ItServiceRequestStatus.pendingApproval,
onTap: () => onStatusTap(ItServiceRequestStatus.pendingApproval),
),
const SizedBox(width: 8),
_SummaryChip(
label: 'Scheduled',
count: scheduled,
color: cs.primary,
selected: selectedStatus == ItServiceRequestStatus.scheduled,
onTap: () => onStatusTap(ItServiceRequestStatus.scheduled),
),
const SizedBox(width: 8),
_SummaryChip(
label: 'In Progress',
count: inProgress,
color: cs.secondary,
selected: selectedStatus == ItServiceRequestStatus.inProgress,
onTap: () => onStatusTap(ItServiceRequestStatus.inProgress),
),
const SizedBox(width: 8),
_SummaryChip(
label: 'Completed',
count: completed,
color: Colors.green,
selected: selectedStatus == ItServiceRequestStatus.completed,
onTap: () => onStatusTap(ItServiceRequestStatus.completed),
),
],
),
);
}
}
class _SummaryChip extends StatelessWidget {
const _SummaryChip({
required this.label,
required this.count,
required this.color,
required this.selected,
required this.onTap,
});
final String label;
final int count;
final Color color;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return M3Card.filled(
onTap: onTap,
color: selected
? color.withValues(alpha: 0.2)
: Theme.of(context).colorScheme.surfaceContainerHighest,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
count.toString(),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(width: 8),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
return AppStatusSummaryRow(
padding: const EdgeInsets.symmetric(vertical: 8),
items: [
AppStatusSummaryItem(
label: 'Pending',
count: pending,
kind: StatusKind.warning,
selected: selectedStatus == ItServiceRequestStatus.pendingApproval,
onTap: () => onStatusTap(ItServiceRequestStatus.pendingApproval),
),
),
AppStatusSummaryItem(
label: 'Scheduled',
count: scheduled,
kind: StatusKind.neutral,
selected: selectedStatus == ItServiceRequestStatus.scheduled,
onTap: () => onStatusTap(ItServiceRequestStatus.scheduled),
),
AppStatusSummaryItem(
label: 'In Progress',
count: inProgress,
kind: StatusKind.info,
selected: selectedStatus == ItServiceRequestStatus.inProgress,
onTap: () => onStatusTap(ItServiceRequestStatus.inProgress),
),
AppStatusSummaryItem(
label: 'Completed',
count: completed,
kind: StatusKind.success,
selected: selectedStatus == ItServiceRequestStatus.completed,
onTap: () => onStatusTap(ItServiceRequestStatus.completed),
),
],
);
}
}
@@ -721,7 +670,10 @@ class _RequestTile extends StatelessWidget {
if (request.requestNumber != null)
MonoText(request.requestNumber!),
const Spacer(),
StatusPill(label: ItServiceRequestStatus.label(request.status)),
StatusPill.kind(
isrStatusKind(request.status),
label: ItServiceRequestStatus.label(request.status),
),
],
),
const SizedBox(height: 8),
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../models/network/network_device.dart';
import '../../models/network/network_location.dart';
import '../../theme/app_status_colors.dart';
import '../../providers/network_map/network_devices_provider.dart';
import '../../providers/network_map/network_sites_provider.dart';
import '../../widgets/app_state_view.dart';
@@ -231,11 +232,19 @@ class _NetworkMapDeviceEditScreenState
final cs = Theme.of(context).colorScheme;
switch (_status) {
case NetworkDeviceStatus.online:
return const Icon(Icons.circle, size: 14, color: Color(0xFF388E3C));
return Icon(
Icons.circle,
size: 14,
color: AppStatusColors.of(context).success.color,
);
case NetworkDeviceStatus.offline:
return Icon(Icons.circle, size: 14, color: cs.error);
case NetworkDeviceStatus.warning:
return const Icon(Icons.circle, size: 14, color: Color(0xFFF57C00));
return Icon(
Icons.circle,
size: 14,
color: AppStatusColors.of(context).warning.color,
);
case NetworkDeviceStatus.unknown:
return Icon(Icons.circle_outlined, size: 14, color: cs.onSurfaceVariant);
}
@@ -7,6 +7,7 @@ import '../../providers/network_map/network_devices_provider.dart';
import '../../providers/network_map/network_sites_provider.dart';
import '../../providers/profile_provider.dart';
import '../../utils/snackbar.dart';
import '../../widgets/app_metric_card.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/responsive_body.dart';
@@ -167,41 +168,24 @@ class _SummaryRow extends StatelessWidget {
Widget build(BuildContext context) {
return Row(
children: [
Expanded(child: _SummaryCard(label: 'Sites', value: siteCount.toString(), icon: Icons.location_city_outlined)),
const SizedBox(width: 12),
Expanded(child: _SummaryCard(label: 'Devices', value: deviceCount.toString(), icon: Icons.devices_outlined)),
],
);
}
}
class _SummaryCard extends StatelessWidget {
const _SummaryCard({required this.label, required this.value, required this.icon});
final String label;
final String value;
final IconData icon;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Icon(icon, color: cs.primary),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant)),
Text(value, style: tt.headlineSmall?.copyWith(fontWeight: FontWeight.w700)),
],
),
],
Expanded(
child: AppMetricCard(
title: 'Sites',
value: siteCount.toString(),
icon: Icons.location_city_outlined,
animateValue: false,
),
),
),
const SizedBox(width: 12),
Expanded(
child: AppMetricCard(
title: 'Devices',
value: deviceCount.toString(),
icon: Icons.devices_outlined,
animateValue: false,
),
),
],
);
}
}
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../../models/network/network_device.dart';
import '../../../theme/app_status_colors.dart';
import 'network_device_icon.dart';
/// A styled node representing one network device on the topology canvas.
@@ -41,14 +42,15 @@ class DeviceNode extends StatelessWidget {
}
}
Color? _statusColor(ColorScheme cs, NetworkDeviceStatus status) {
Color? _statusColor(BuildContext context, NetworkDeviceStatus status) {
final s = AppStatusColors.of(context);
switch (status) {
case NetworkDeviceStatus.online:
return const Color(0xFF34A853); // Google green — universally understood
return s.success.color;
case NetworkDeviceStatus.offline:
return cs.error;
return Theme.of(context).colorScheme.error;
case NetworkDeviceStatus.warning:
return const Color(0xFFFBBC04); // Amber
return s.warning.color;
case NetworkDeviceStatus.unknown:
return null; // No dot shown
}
@@ -59,7 +61,7 @@ class DeviceNode extends StatelessWidget {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
final accent = _accentForRole(cs, device.role);
final statusColor = _statusColor(cs, device.status);
final statusColor = _statusColor(context, device.status);
final bgColor = isSelected
? cs.primaryContainer
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../../models/network/network_device.dart';
import '../../../models/network/network_link.dart';
import '../../../theme/app_surfaces.dart';
/// Collapsible legend overlay explaining device role border colours and link
/// type colours. Positioned at the bottom-left of the topology canvas view.
@@ -85,11 +86,15 @@ class _TopologyLegendState extends State<TopologyLegend>
// Expand/collapse toggle chip
Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(
AppSurfaces.of(context).containerRadius,
),
elevation: 2,
child: InkWell(
onTap: _toggle,
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(
AppSurfaces.of(context).containerRadius,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
@@ -16,6 +16,48 @@ import '../../widgets/responsive_body.dart';
import '../../theme/app_surfaces.dart';
import '../../theme/m3_motion.dart';
String notificationTitle(String type, String actorName) {
return switch (type) {
'assignment' => '$actorName assigned you',
'created' => '$actorName created a new item',
'swap_request' => '$actorName requested a shift swap',
'swap_update' => '$actorName updated a swap request',
'announcement' => '$actorName posted an announcement',
'announcement_comment' => '$actorName commented on an announcement',
'it_job_reminder' => 'IT Job submission reminder',
'isr_approved' => 'IT Service Request approved',
'isr_status_changed' => 'IT Service Request status updated',
'isr_assigned' => '$actorName assigned you an IT Service Request',
'day_sheet_submitted' => '$actorName submitted a day sheet',
'day_sheet_disapproved' => 'Day sheet disapproved',
'day_sheet_justified' => '$actorName justified a day sheet',
'day_sheet_approved' => 'Day sheet approved',
'day_sheet_digest' => 'Day sheets awaiting review',
_ => '$actorName mentioned you',
};
}
IconData notificationIcon(String type) {
return switch (type) {
'assignment' => Icons.assignment_ind_outlined,
'created' => Icons.campaign_outlined,
'swap_request' => Icons.swap_horiz,
'swap_update' => Icons.update,
'announcement' => Icons.campaign,
'announcement_comment' => Icons.comment_outlined,
'it_job_reminder' => Icons.print,
'isr_approved' => Icons.check_circle_outline,
'isr_status_changed' => Icons.sync_alt_outlined,
'isr_assigned' => Icons.engineering_outlined,
'day_sheet_submitted' => Icons.assignment_turned_in_outlined,
'day_sheet_disapproved' => Icons.report_outlined,
'day_sheet_justified' => Icons.reply_outlined,
'day_sheet_approved' => Icons.verified_outlined,
'day_sheet_digest' => Icons.inbox_outlined,
_ => Icons.alternate_email,
};
}
class NotificationsScreen extends ConsumerStatefulWidget {
const NotificationsScreen({super.key});
@@ -80,42 +122,11 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
};
}
String _notificationTitle(String type, String actorName) {
return switch (type) {
'assignment' => '$actorName assigned you',
'created' => '$actorName created a new item',
'swap_request' => '$actorName requested a shift swap',
'swap_update' => '$actorName updated a swap request',
'announcement' => '$actorName posted an announcement',
'announcement_comment' => '$actorName commented on an announcement',
'it_job_reminder' => 'IT Job submission reminder',
'isr_approved' => 'IT Service Request approved',
'isr_status_changed' => 'IT Service Request status updated',
'isr_assigned' => '$actorName assigned you an IT Service Request',
_ => '$actorName mentioned you',
};
}
IconData _notificationIcon(String type) {
return switch (type) {
'assignment' => Icons.assignment_ind_outlined,
'created' => Icons.campaign_outlined,
'swap_request' => Icons.swap_horiz,
'swap_update' => Icons.update,
'announcement' => Icons.campaign,
'announcement_comment' => Icons.comment_outlined,
'it_job_reminder' => Icons.print,
'isr_approved' => Icons.check_circle_outline,
'isr_status_changed' => Icons.sync_alt_outlined,
'isr_assigned' => Icons.engineering_outlined,
_ => Icons.alternate_email,
};
}
Future<void> _handleTap(BuildContext context, NotificationItem item) async {
final ticketId = item.ticketId;
final taskId = item.taskId;
final isrId = item.itServiceRequestId;
final daySheetId = item.daySheetId;
if (ticketId != null) {
await ref
@@ -136,6 +147,10 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
context.go('/tickets/$ticketId');
} else if (isrId != null) {
context.go('/it-service-requests/$isrId');
} else if (item.type == 'day_sheet_digest') {
context.go('/programmer-tasks?tab=approvals');
} else if (daySheetId != null) {
context.go('/programmer-tasks/day-sheets/$daySheetId');
}
}
@@ -248,8 +263,8 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
animDelay: Duration(
milliseconds: animIndexes[index].clamp(0, 6) * 50,
),
title: _notificationTitle(item.type, actorName),
icon: _notificationIcon(item.type),
title: notificationTitle(item.type, actorName),
icon: notificationIcon(item.type),
iconContainerColor:
_iconContainerColor(context, item.type),
iconForegroundColor:
@@ -331,6 +346,7 @@ class _NotificationCard extends StatelessWidget {
if (item.announcementId != null) return 'Announcement';
if (item.taskId != null) return taskTitle ?? 'Task';
if (item.itServiceRequestId != null) return 'IT Service Request';
if (item.daySheetId != null) return 'Day Sheet';
return ticketSubject ?? 'Ticket';
}
+65 -58
View File
@@ -14,6 +14,7 @@ import '../../widgets/face_verification_overlay.dart';
import '../../widgets/multi_select_picker.dart';
import '../../widgets/qr_verification_dialog.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_section_header.dart';
import '../../widgets/responsive_body.dart';
import '../../utils/snackbar.dart';
@@ -102,10 +103,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Account details',
style: Theme.of(context).textTheme.titleMedium,
),
const AppSectionHeader('Account details', padding: EdgeInsets.zero),
const SizedBox(height: 12),
// Email (read-only)
TextFormField(
@@ -151,10 +149,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Password',
style: Theme.of(context).textTheme.titleMedium,
),
const AppSectionHeader('Password', padding: EdgeInsets.zero),
const SizedBox(height: 8),
const Text(
'Set or change your password. OAuth users (Google/Meta) can set a password here.',
@@ -224,10 +219,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Offices',
style: Theme.of(context).textTheme.titleMedium,
),
const AppSectionHeader('Offices', padding: EdgeInsets.zero),
const SizedBox(height: 12),
officesAsync.when(
data: (offices) {
@@ -286,56 +278,71 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Profile Photo', style: theme.textTheme.titleMedium),
const AppSectionHeader('Profile Photo', padding: EdgeInsets.zero),
const SizedBox(height: 16),
Center(
child: Stack(
children: [
Hero(
tag: 'profile-avatar',
child: CircleAvatar(
radius: 56,
backgroundColor: colors.surfaceContainerHighest,
backgroundImage: avatarUrl != null
? NetworkImage(avatarUrl)
: null,
child: avatarUrl == null
? Icon(
Icons.person,
size: 48,
color: colors.onSurfaceVariant,
)
: null,
),
),
if (_uploadingAvatar)
const Positioned.fill(
child: Center(child: CircularProgressIndicator()),
),
],
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FilledButton.tonalIcon(
onPressed: _uploadingAvatar
? null
: () => _pickAvatar(ImageSource.gallery),
icon: const Icon(Icons.photo_library),
label: const Text('Upload'),
Stack(
children: [
Hero(
tag: 'profile-avatar',
child: CircleAvatar(
radius: 44,
backgroundColor: colors.surfaceContainerHighest,
backgroundImage: avatarUrl != null
? NetworkImage(avatarUrl)
: null,
child: avatarUrl == null
? Icon(
Icons.person,
size: 40,
color: colors.onSurfaceVariant,
)
: null,
),
),
if (_uploadingAvatar)
const Positioned.fill(
child: Center(child: CircularProgressIndicator()),
),
],
),
if (!kIsWeb) ...[
const SizedBox(width: 12),
FilledButton.tonalIcon(
onPressed: _uploadingAvatar
? null
: () => _pickAvatar(ImageSource.camera),
icon: const Icon(Icons.camera_alt),
label: const Text('Camera'),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'A clear headshot helps teammates recognize you.',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 8,
children: [
FilledButton.tonalIcon(
onPressed: _uploadingAvatar
? null
: () => _pickAvatar(ImageSource.gallery),
icon: const Icon(Icons.photo_library),
label: const Text('Upload'),
),
if (!kIsWeb)
FilledButton.tonalIcon(
onPressed: _uploadingAvatar
? null
: () => _pickAvatar(ImageSource.camera),
icon: const Icon(Icons.camera_alt),
label: const Text('Camera'),
),
],
),
],
),
],
),
],
),
],
@@ -359,7 +366,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Face Verification', style: theme.textTheme.titleMedium),
const AppSectionHeader('Face Verification', padding: EdgeInsets.zero),
const SizedBox(height: 8),
Text(
'Enroll your face for attendance verification. '
@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/programmer_project.model.dart';
import '../../models/programmer_task.model.dart';
import '../../providers/programmer_projects_provider.dart';
import '../../providers/programmer_tasks_provider.dart';
import '../../theme/app_status_colors.dart';
import '../../widgets/app_section_header.dart';
import '../../widgets/m3_card.dart';
import '../../widgets/responsive_body.dart';
import '../../widgets/status_pill.dart';
import '../programmer_tasks/programmer_task_create.dart';
import '../programmer_tasks/programmer_task_status_kind.dart';
class ProgrammerProjectDetailScreen extends ConsumerWidget {
const ProgrammerProjectDetailScreen({super.key, required this.projectId});
final String projectId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final project = ref.watch(programmerProjectByIdProvider(projectId));
final tasks = (ref.watch(programmerTasksProvider).valueOrNull ?? const [])
.where((t) => t.projectId == projectId)
.toList();
return Scaffold(
appBar: AppBar(
title: Text(project?.name ?? 'Project'),
actions: [
if (project != null)
IconButton(
tooltip: project.isArchived ? 'Unarchive' : 'Archive',
icon: Icon(project.isArchived
? Icons.unarchive_outlined
: Icons.archive_outlined),
onPressed: () async {
final messenger = ScaffoldMessenger.of(context);
await ref.read(programmerProjectsControllerProvider).setArchived(
projectId: projectId,
archived: !project.isArchived,
);
messenger.showSnackBar(SnackBar(
content: Text(
project.isArchived ? 'Project unarchived' : 'Project archived'),
));
},
),
],
),
floatingActionButton: project == null
? null
: FloatingActionButton.extended(
heroTag: 'project_new_task',
onPressed: () => showCreateProgrammerTaskDialog(
context,
ref,
initialProjectId: projectId,
),
icon: const Icon(Icons.add),
label: const Text('New Task'),
),
body: project == null
? const Center(child: CircularProgressIndicator())
: ResponsiveBody(
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 16),
children: [
M3Card.elevated(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(project.name,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.w700)),
),
StatusPill.kind(
project.isArchived
? StatusKind.neutral
: StatusKind.success,
label: ProgrammerProjectStatus.label(
project.status),
),
],
),
if (project.description != null &&
project.description!.trim().isNotEmpty) ...[
const SizedBox(height: 12),
Text(project.description!,
style: Theme.of(context).textTheme.bodyMedium),
],
],
),
),
),
const SizedBox(height: 24),
AppSectionHeader('Tasks (${tasks.length})'),
if (tasks.isEmpty)
Text('No tasks in this project yet.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color:
Theme.of(context).colorScheme.onSurfaceVariant))
else
for (final t in tasks) _ProjectTaskTile(task: t),
const SizedBox(height: 80),
],
),
),
);
}
}
class _ProjectTaskTile extends StatelessWidget {
const _ProjectTaskTile({required this.task});
final ProgrammerTask task;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: M3Card.outlined(
onTap: () => context.go('/programmer-tasks/${task.id}'),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(
child: Text(task.title,
style: Theme.of(context).textTheme.bodyLarge,
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 8),
StatusPill.kind(
programmerTaskStatusKind(task.status),
label: ProgrammerTaskStatus.label(task.status),
),
],
),
),
),
);
}
}
@@ -0,0 +1,207 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/programmer_project.model.dart';
import '../../providers/programmer_projects_provider.dart';
import '../../providers/programmer_tasks_provider.dart';
import '../../theme/app_status_colors.dart';
import '../../theme/m3_motion.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/m3_card.dart';
import '../../widgets/reconnect_overlay.dart';
import '../../widgets/responsive_body.dart';
import '../../widgets/status_pill.dart';
class ProgrammerProjectsListScreen extends ConsumerWidget {
const ProgrammerProjectsListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final projectsAsync = ref.watch(programmerProjectsProvider);
final tasks = ref.watch(programmerTasksProvider).valueOrNull ?? const [];
final taskCountByProject = <String, int>{};
for (final t in tasks) {
if (t.projectId != null) {
taskCountByProject[t.projectId!] =
(taskCountByProject[t.projectId!] ?? 0) + 1;
}
}
return Stack(
children: [
ResponsiveBody(
maxWidth: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const AppPageHeader(
title: 'Projects',
subtitle: 'Group programmer tasks by project',
),
Expanded(
child: Builder(
builder: (context) {
if (projectsAsync.hasError && !projectsAsync.hasValue) {
return AppErrorView(
error: projectsAsync.error!,
onRetry: () =>
ref.invalidate(programmerProjectsProvider),
);
}
if (!projectsAsync.hasValue && projectsAsync.isLoading) {
return const Center(child: CircularProgressIndicator());
}
final projects = projectsAsync.valueOrNull ?? const [];
if (projects.isEmpty) {
return const AppEmptyView(
icon: Icons.folder_outlined,
title: 'No projects yet',
subtitle: 'Create one with the + button, or add a '
'project on the fly from a task.',
);
}
return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: projects.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, i) {
final p = projects[i];
return _ProjectCard(
project: p,
taskCount: taskCountByProject[p.id] ?? 0,
);
},
);
},
),
),
],
),
),
Positioned(
right: 16,
bottom: 16,
child: M3ExpandedFab(
heroTag: 'create_programmer_project',
onPressed: () => _showCreateDialog(context, ref),
icon: const Icon(Icons.add),
label: const Text('New Project'),
),
),
const ReconnectIndicator(),
],
);
}
Future<void> _showCreateDialog(BuildContext context, WidgetRef ref) async {
final nameCtrl = TextEditingController();
final descCtrl = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
semanticLabel: 'New project',
title: const Text('New Project'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameCtrl,
autofocus: true,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(labelText: 'Name'),
),
const SizedBox(height: 12),
TextField(
controller: descCtrl,
minLines: 2,
maxLines: 4,
textCapitalization: TextCapitalization.sentences,
decoration:
const InputDecoration(labelText: 'Description (optional)'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Create'),
),
],
),
);
final name = nameCtrl.text.trim();
final description = descCtrl.text.trim();
nameCtrl.dispose();
descCtrl.dispose();
if (ok != true || name.isEmpty || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
final id = await ref.read(programmerProjectsControllerProvider).createProject(
name: name,
description: description.isEmpty ? null : description,
);
if (!context.mounted) return;
context.go('/programmer-projects/$id');
} catch (e) {
messenger
.showSnackBar(SnackBar(content: Text('Could not create project: $e')));
}
}
}
class _ProjectCard extends StatelessWidget {
const _ProjectCard({required this.project, required this.taskCount});
final ProgrammerProject project;
final int taskCount;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
return M3Card.outlined(
onTap: () => context.go('/programmer-projects/${project.id}'),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(project.name,
style: tt.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 8),
StatusPill.kind(
project.isArchived ? StatusKind.neutral : StatusKind.success,
label: ProgrammerProjectStatus.label(project.status),
),
],
),
if (project.description != null &&
project.description!.trim().isNotEmpty) ...[
const SizedBox(height: 6),
Text(project.description!,
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
maxLines: 2,
overflow: TextOverflow.ellipsis),
],
const SizedBox(height: 8),
Text('$taskCount task${taskCount == 1 ? '' : 's'}',
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant)),
],
),
),
);
}
}
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import '../../../theme/app_status_colors.dart';
import '../../../utils/programmer_stats.dart';
/// A horizontal wrap of badge chips for the programmer dashboard.
///
/// Earned badges use the success container colour; locked badges show neutral
/// styling and display `current/target` as their label. Tapping any chip opens
/// an [AlertDialog] with the badge title and description.
class BadgesStrip extends StatelessWidget {
const BadgesStrip({
super.key,
required this.badges,
});
final List<BadgeProgress> badges;
@override
Widget build(BuildContext context) {
if (badges.isEmpty) return const SizedBox.shrink();
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final badge in badges) _BadgeChip(badge: badge),
],
);
}
}
class _BadgeChip extends StatelessWidget {
const _BadgeChip({required this.badge});
final BadgeProgress badge;
@override
Widget build(BuildContext context) {
final statusColors = AppStatusColors.of(context);
final earned = badge.earned;
return ActionChip(
avatar: Icon(badge.icon, size: 16),
label: Text(earned ? badge.title : '${badge.current}/${badge.target}'),
backgroundColor: earned ? statusColors.success.container : null,
onPressed: () {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(badge.title),
content: Text(badge.description),
),
);
},
);
}
}
@@ -0,0 +1,152 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/programmer_task.model.dart';
import '../../../providers/programmer_tasks_provider.dart';
import '../../../utils/programmer_task_ledger.dart';
import '../../../widgets/m3_card.dart';
import '../../../widgets/tech_chip.dart';
import '../programmer_task_actions.dart';
/// Hero card at the top of the programmer dashboard showing the currently
/// running task (or an idle state when nothing is running).
class NowWorkingCard extends ConsumerWidget {
const NowWorkingCard({
super.key,
required this.running,
required this.nextUp,
required this.todayOnTask,
required this.totalOnTask,
required this.readOnly,
});
/// The currently running task, or null when nothing is running.
final ProgrammerTask? running;
/// First queued (not-started) task, shown as a start target when idle.
final ProgrammerTask? nextUp;
/// How long the user has worked on [running] today.
final Duration todayOnTask;
/// Total worked time on [running] across all days.
final Duration totalOnTask;
final bool readOnly;
@override
Widget build(BuildContext context, WidgetRef ref) {
return M3Card.elevated(
child: Padding(
padding: const EdgeInsets.all(16),
child: running != null
? _RunningView(
task: running!,
todayOnTask: todayOnTask,
totalOnTask: totalOnTask,
readOnly: readOnly,
)
: _IdleView(nextUp: nextUp, readOnly: readOnly),
),
);
}
}
class _RunningView extends ConsumerWidget {
const _RunningView({
required this.task,
required this.todayOnTask,
required this.totalOnTask,
required this.readOnly,
});
final ProgrammerTask task;
final Duration todayOnTask;
final Duration totalOnTask;
final bool readOnly;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.title,
style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
TechChip(task.category, icon: Icons.category_outlined, dense: true),
Text(
'Today ${formatDurationHm(todayOnTask)} · '
'Total ${formatDurationHm(totalOnTask)}',
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
],
),
if (!readOnly) ...[
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton.icon(
onPressed: () => ref
.read(programmerTasksControllerProvider)
.pauseTask(taskId: task.id),
icon: const Icon(Icons.pause_rounded, size: 18),
label: const Text('Pause'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: () =>
completeProgrammerTaskWithConfirm(context, ref, task),
icon: const Icon(Icons.check_rounded, size: 18),
label: const Text('Complete'),
),
],
),
],
],
);
}
}
class _IdleView extends ConsumerWidget {
const _IdleView({required this.nextUp, required this.readOnly});
final ProgrammerTask? nextUp;
final bool readOnly;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
return Row(
children: [
Expanded(
child: Text(
'Nothing running',
style: tt.bodyLarge?.copyWith(color: cs.onSurfaceVariant),
),
),
if (nextUp != null && !readOnly)
FilledButton.icon(
onPressed: () =>
startProgrammerTaskWithPausePrompt(context, ref, nextUp!),
icon: const Icon(Icons.play_arrow_rounded, size: 18),
label: const Text('Start'),
),
],
);
}
}
@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/profile.model.dart';
import '../../../models/programmer_day_sheet.model.dart';
import '../../../models/programmer_task.model.dart';
import '../../../providers/leave_provider.dart';
import '../../../providers/profile_provider.dart';
import '../../../providers/programmer_day_data_provider.dart';
import '../../../providers/programmer_day_sheets_provider.dart';
import '../../../providers/programmer_tasks_provider.dart';
import '../../../utils/app_time.dart';
import '../../../utils/programmer_daily_time.dart';
import '../../../utils/programmer_day_rows.dart';
import '../../../utils/programmer_stats.dart';
import '../../../utils/staff_presence.dart';
import 'badges_strip.dart';
import 'now_working_card.dart';
import 'stat_tiles.dart';
import 'task_sections.dart';
/// The programmer's own work dashboard — hero card, stat tiles (Task 8), and
/// task sections. Drop it into the Mine tab with `userId: me`.
class ProgrammerDashboard extends ConsumerWidget {
const ProgrammerDashboard({
super.key,
required this.userId,
this.readOnly = false,
});
final String userId;
final bool readOnly;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Drive 30-second rebuilds so duration values stay fresh.
ref.watch(clockTickerProvider);
final tasksAsync = ref.watch(programmerTasksProvider);
final runStates = ref.watch(programmerRunStatesProvider);
final todayKey = ref.watch(todayKeyProvider);
final dayDataAsync = ref.watch(programmerDayDataProvider(todayKey));
final profilesAsync = ref.watch(profilesProvider);
final now = ref.watch(clockTickerProvider).valueOrNull ?? AppTime.now();
final sheetsAsync = ref.watch(daySheetsForUserProvider(userId));
final sheets = sheetsAsync.valueOrNull ?? const <ProgrammerDaySheet>[];
final liveHelpThisMonthSec =
ref.watch(helpGivenThisMonthProvider(userId)).valueOrNull;
final tasks = tasksAsync.valueOrNull ?? const [];
final profileById = <String, Profile>{
for (final p in profilesAsync.valueOrNull ?? <Profile>[]) p.id: p,
};
final profileNames = <String, String>{
for (final p in profilesAsync.valueOrNull ?? <Profile>[]) p.id: p.fullName,
};
final dayData = dayDataAsync.valueOrNull;
final leaveDays = approvedLeaveDays(
ref.watch(leavesProvider).valueOrNull ?? [],
userId,
);
final todayRows = dayData == null
? const <DaySheetRow>[]
: buildDayRows(
userId: userId,
day: todayKey,
tasks: tasks,
logsByTask: dayData.logsByTask,
workLogs: dayData.workLogs,
projectNames: const {},
profileNames: profileNames,
now: now,
);
final todayFocus = Duration(seconds: totalSeconds(todayRows));
final stats = computeProgrammerStats(
userId: userId,
now: now,
tasks: tasks,
sheets: sheets,
leaveDays: leaveDays,
todayFocus: todayFocus,
liveHelpThisMonthSec: liveHelpThisMonthSec,
);
final showSheetStats =
ref.watch(currentProfileProvider).valueOrNull?.role == 'programmer';
final badges = computeBadges(stats, includeSheetBadges: showSheetStats);
final groups = groupTasksForDashboard(tasks, runStates, userId);
final runningTask =
groups.running.isNotEmpty ? groups.running.first : null;
final nextUp =
groups.notStarted.isNotEmpty ? groups.notStarted.first : null;
final todayOnTask = _computeTodayDuration(
runningTask: runningTask,
dayDataAsync: dayDataAsync,
userId: userId,
todayKey: todayKey,
);
final totalOnTask = _computeTotalDuration(
runningTask: runningTask,
dayDataAsync: dayDataAsync,
userId: userId,
);
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
NowWorkingCard(
running: runningTask,
nextUp: nextUp,
todayOnTask: todayOnTask,
totalOnTask: totalOnTask,
readOnly: readOnly,
),
const SizedBox(height: 16),
StatTiles(stats: stats, showSheetStats: showSheetStats),
const SizedBox(height: 8),
BadgesStrip(badges: badges),
const SizedBox(height: 16),
TaskSections(
groups: groups,
profileById: profileById,
readOnly: readOnly,
),
],
);
}
Duration _computeTodayDuration({
required ProgrammerTask? runningTask,
required AsyncValue<dynamic> dayDataAsync,
required String userId,
required DateTime todayKey,
}) {
if (runningTask == null) return Duration.zero;
final dayData = dayDataAsync.valueOrNull;
if (dayData == null) return Duration.zero;
final logs = dayData.logsByTask[runningTask.id] ?? const [];
final dailyByAssignee = dailySecondsByAssignee(
task: runningTask,
logs: logs,
now: DateTime.now().toUtc(),
);
final secs = dailyByAssignee[userId]?[todayKey] ?? 0;
return Duration(seconds: secs);
}
Duration _computeTotalDuration({
required ProgrammerTask? runningTask,
required AsyncValue<dynamic> dayDataAsync,
required String userId,
}) {
if (runningTask == null) return Duration.zero;
final dayData = dayDataAsync.valueOrNull;
if (dayData == null) return Duration.zero;
final logs = dayData.logsByTask[runningTask.id] ?? const [];
final dailyByAssignee = dailySecondsByAssignee(
task: runningTask,
logs: logs,
now: DateTime.now().toUtc(),
);
final totalSecs =
(dailyByAssignee[userId] ?? const {}).values.fold<int>(0, (s, v) => s + v);
return Duration(seconds: totalSecs);
}
}
@@ -0,0 +1,148 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../utils/programmer_stats.dart';
import '../../../utils/programmer_task_ledger.dart';
import '../../../widgets/app_metric_card.dart';
/// A vertical list of KPI stat tiles for the programmer dashboard.
///
/// When [showSheetStats] is false only the first two tiles (today's focus and
/// completed-this-week) are shown; sheet-based tiles (streak, first-pass
/// approval, help given) are hidden and badges are computed without sheet data.
class StatTiles extends StatelessWidget {
const StatTiles({
super.key,
required this.stats,
required this.showSheetStats,
});
final ProgrammerStats stats;
final bool showSheetStats;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tiles = <Widget>[
_buildFocusTile(context, cs),
const SizedBox(height: 8),
_buildCompletedTile(context),
];
if (showSheetStats) {
tiles.addAll([
const SizedBox(height: 8),
_buildStreakTile(context),
const SizedBox(height: 8),
_buildFirstPassTile(context),
const SizedBox(height: 8),
_buildHelpGivenTile(context),
]);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: tiles,
);
}
Widget _buildFocusTile(BuildContext context, ColorScheme cs) {
final progressValue =
math.min(1.0, stats.todayFocus.inSeconds / (8 * 3600));
final trailing = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(
value: progressValue,
strokeWidth: 6,
backgroundColor: cs.surfaceContainerHighest,
color: cs.primary,
strokeCap: StrokeCap.round,
),
),
const SizedBox(height: 4),
Text(
'/ 8h',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: cs.onSurfaceVariant,
),
),
],
);
return AppMetricCard(
title: "TODAY'S FOCUS",
value: formatDurationHm(stats.todayFocus),
trailing: trailing,
);
}
Widget _buildCompletedTile(BuildContext context) {
final diff = stats.completedThisWeek - stats.completedLastWeek;
final String trailingText;
if (diff > 0) {
trailingText = '↑$diff vs last week';
} else if (diff < 0) {
trailingText = '↓${-diff} vs last week';
} else {
trailingText = 'Same as last week';
}
return AppMetricCard(
title: 'COMPLETED THIS WEEK',
value: stats.completedThisWeek.toString(),
trailing: Text(
trailingText,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
Widget _buildStreakTile(BuildContext context) {
final n = stats.currentStreak;
final dayLabel = n == 1 ? '1 day' : '$n days';
return AppMetricCard(
title: 'STREAK',
value: dayLabel,
icon: Icons.local_fire_department_outlined,
trailing: Text(
'Best ${stats.bestStreak}',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
Widget _buildFirstPassTile(BuildContext context) {
final value = stats.firstPassRate != null
? '${(stats.firstPassRate! * 100).round()}%'
: '—';
return AppMetricCard(
title: 'FIRST-PASS APPROVAL',
value: value,
trailing: Text(
'Last 30 days',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
Widget _buildHelpGivenTile(BuildContext context) {
return AppMetricCard(
title: 'HELP GIVEN',
value: formatDurationHm(stats.helpGivenThisMonth),
trailing: Text(
'This month',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
}
@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import '../../../models/profile.model.dart';
import '../../../models/programmer_task.model.dart';
import '../../../providers/programmer_day_data_provider.dart';
import '../../../widgets/app_section_header.dart';
import '../widgets/programmer_task_card.dart';
typedef TaskSectionGroups = ({
List<ProgrammerTask> running,
List<ProgrammerTask> paused,
List<ProgrammerTask> notStarted,
List<ProgrammerTask> completed,
List<ProgrammerTask> cancelled,
});
/// Groups [tasks] into dashboard buckets for [userId], using [runStates] to
/// distinguish running from paused from not-started. Only tasks assigned to
/// [userId] are included.
TaskSectionGroups groupTasksForDashboard(
List<ProgrammerTask> tasks,
Map<String, ProgrammerRunInfo> runStates,
String userId,
) {
final running = <ProgrammerTask>[];
final paused = <ProgrammerTask>[];
final notStarted = <ProgrammerTask>[];
final completed = <ProgrammerTask>[];
final cancelled = <ProgrammerTask>[];
for (final task in tasks) {
if (task.assigneeId != userId) continue;
final state = runStates[task.id]?.state;
if (state == ProgrammerRunState.running) {
running.add(task);
} else if (state == ProgrammerRunState.paused) {
paused.add(task);
} else if (state == ProgrammerRunState.notStarted) {
notStarted.add(task);
} else if (task.status == ProgrammerTaskStatus.completed) {
completed.add(task);
} else if (task.status == ProgrammerTaskStatus.cancelled) {
cancelled.add(task);
}
}
completed.sort(
(a, b) =>
(b.completedAt ?? DateTime(0)).compareTo(a.completedAt ?? DateTime(0)),
);
cancelled.sort(
(a, b) =>
(b.cancelledAt ?? DateTime(0)).compareTo(a.cancelledAt ?? DateTime(0)),
);
return (
running: running,
paused: paused,
notStarted: notStarted,
completed: completed,
cancelled: cancelled,
);
}
/// Renders the five task groups as collapsible / always-visible sections.
///
/// - In progress, Paused, Not started: always visible when non-empty.
/// - Completed, Cancelled: start collapsed; tap header to expand; shows first
/// 10 + "Show all (N)" when N > 10; tap "Show all" to see all.
/// - Empty sections produce no header.
class TaskSections extends StatefulWidget {
const TaskSections({
super.key,
required this.groups,
required this.profileById,
required this.readOnly,
});
final TaskSectionGroups groups;
final Map<String, Profile> profileById;
final bool readOnly;
@override
State<TaskSections> createState() => _TaskSectionsState();
}
class _TaskSectionsState extends State<TaskSections> {
bool _completedExpanded = false;
bool _completedShowAll = false;
bool _cancelledExpanded = false;
bool _cancelledShowAll = false;
@override
Widget build(BuildContext context) {
final g = widget.groups;
final isEmpty = g.running.isEmpty &&
g.paused.isEmpty &&
g.notStarted.isEmpty &&
g.completed.isEmpty &&
g.cancelled.isEmpty;
if (isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: Text('No tasks assigned to you')),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
..._alwaysSection('In progress', g.running),
..._alwaysSection('Paused', g.paused),
..._alwaysSection('Not started', g.notStarted),
..._collapsibleSection(
'Completed',
g.completed,
_completedExpanded,
_completedShowAll,
(v) => setState(() => _completedExpanded = v),
(v) => setState(() => _completedShowAll = v),
),
..._collapsibleSection(
'Cancelled',
g.cancelled,
_cancelledExpanded,
_cancelledShowAll,
(v) => setState(() => _cancelledExpanded = v),
(v) => setState(() => _cancelledShowAll = v),
),
],
);
}
List<Widget> _alwaysSection(String name, List<ProgrammerTask> tasks) {
if (tasks.isEmpty) return const [];
return [
AppSectionHeader(name, trailing: Text('${tasks.length}')),
...tasks.map(_taskCard),
const SizedBox(height: 8),
];
}
List<Widget> _collapsibleSection(
String name,
List<ProgrammerTask> tasks,
bool expanded,
bool showAll,
void Function(bool) onToggle,
void Function(bool) onShowAll,
) {
if (tasks.isEmpty) return const [];
List<ProgrammerTask> visible;
if (!expanded) {
visible = const [];
} else if (showAll || tasks.length <= 10) {
visible = tasks;
} else {
visible = tasks.take(10).toList();
}
return [
GestureDetector(
onTap: () => onToggle(!expanded),
behavior: HitTestBehavior.opaque,
child: AppSectionHeader(name, trailing: Text('${tasks.length}')),
),
if (expanded) ...[
...visible.map(_taskCard),
if (tasks.length > 10 && !showAll)
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => onShowAll(true),
child: Text('Show all (${tasks.length})'),
),
),
const SizedBox(height: 8),
],
];
}
Widget _taskCard(ProgrammerTask t) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ProgrammerTaskCard(
task: t,
assignee: t.assigneeId != null
? widget.profileById[t.assigneeId!]
: null,
isMine: true,
readOnly: widget.readOnly,
),
);
}
@@ -0,0 +1,423 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../models/profile.model.dart';
import '../../../models/programmer_day_sheet.model.dart';
import '../../../providers/profile_provider.dart';
import '../../../providers/programmer_day_sheets_provider.dart';
import '../../../providers/reports_provider.dart';
import '../../../theme/app_status_colors.dart';
import '../../../utils/app_time.dart';
import '../../../utils/programmer_task_ledger.dart';
import '../../../widgets/app_state_view.dart';
import '../../../widgets/app_status_summary.dart';
import '../../../widgets/tech_chip.dart';
import '../../reports/report_date_filter.dart';
import 'day_sheet_snapshot_builder.dart';
import '../report/accomplishment_report_dialog.dart';
/// Admin tab for reviewing and bulk-approving programmer day sheets.
///
/// Features:
/// * Status filter chips: Pending (default) / Awaiting justification / Approved.
/// * Programmer dropdown: All programmers or a specific person.
/// * Controlled date range filter (default: this month).
/// * Cards grouped by work-date; pending cards have selection checkboxes.
/// * "Approve selected (n)" button that calls [buildSnapshotForSheet] for each
/// selected sheet before calling the bulk-approve RPC.
class ApprovalsTab extends ConsumerStatefulWidget {
const ApprovalsTab({super.key});
@override
ConsumerState<ApprovalsTab> createState() => _ApprovalsTabState();
}
class _ApprovalsTabState extends ConsumerState<ApprovalsTab> {
/// Active status filter — one of the [DaySheetStatus] constants.
String _filter = DaySheetStatus.pending;
/// Selected programmer ID, or null for "all programmers".
String? _programmerFilter;
late ReportDateRange _dateRange;
/// IDs of pending sheets selected for bulk approval.
final Set<String> _selected = {};
bool _approving = false;
@override
void initState() {
super.initState();
_dateRange = thisMonthRange(AppTime.now());
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
bool _inDateRange(DateTime workDate) =>
!workDate.isBefore(_dateRange.start) && workDate.isBefore(_dateRange.end);
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
Future<void> _onApprove(List<ProgrammerDaySheet> allSheets) async {
setState(() => _approving = true);
try {
final toApprove = allSheets
.where((s) =>
_selected.contains(s.id) &&
s.status == DaySheetStatus.pending)
.toList();
final items = <({String sheetId, DaySheetSnapshot snapshot})>[];
for (final sheet in toApprove) {
final snapshot = await buildSnapshotForSheet(ref, sheet);
items.add((sheetId: sheet.id, snapshot: snapshot));
}
final result = await ref.read(daySheetsControllerProvider).approve(items);
if (!mounted) return;
setState(() {
_selected.clear();
_approving = false;
});
final count = result.approvedIds.length;
final skipped = result.skippedIds.length;
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
icon: const Icon(Icons.check_circle_outline, size: 48),
title: Text('Approved $count ${count == 1 ? 'Sheet' : 'Sheets'}'),
content: skipped > 0
? Text(
'$skipped ${skipped == 1 ? 'sheet was' : 'sheets were'} '
'already reviewed and skipped.',
)
: null,
actions: [
FilledButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Done'),
),
],
),
);
} on DaySheetActionException catch (e) {
if (!mounted) return;
setState(() => _approving = false);
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.message)));
} catch (e) {
if (!mounted) return;
setState(() => _approving = false);
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.toString())));
}
}
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
final allSheets = ref.watch(allDaySheetsProvider).valueOrNull ?? const [];
final profiles = ref.watch(profilesProvider).valueOrNull ?? const [];
final profileById = {for (final p in profiles) p.id: p};
final programmerProfiles =
profiles.where((p) => p.role == 'programmer').toList()
..sort((a, b) => a.fullName.compareTo(b.fullName));
// Sheets passing the date + programmer filters (used for chip counts).
final dateAndProgFiltered = allSheets.where((s) {
return _inDateRange(s.workDate) &&
(_programmerFilter == null || s.programmerId == _programmerFilter);
}).toList();
final counts = approvalCounts(dateAndProgFiltered);
// Sheets also matching the status filter (displayed in the list).
final filtered =
dateAndProgFiltered.where((s) => s.status == _filter).toList();
final grouped = groupSheetsByDate(filtered);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── Status filter chips ────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: AppStatusSummaryRow(
items: [
AppStatusSummaryItem(
label: 'Pending',
count: counts.pending,
kind: StatusKind.info,
selected: _filter == DaySheetStatus.pending,
onTap: () => setState(() {
_filter = DaySheetStatus.pending;
_selected.clear();
}),
),
AppStatusSummaryItem(
label: 'Awaiting justification',
count: counts.awaiting,
kind: StatusKind.warning,
selected: _filter == DaySheetStatus.disapproved,
onTap: () => setState(() {
_filter = DaySheetStatus.disapproved;
_selected.clear();
}),
),
AppStatusSummaryItem(
label: 'Approved',
count: counts.approved,
kind: StatusKind.success,
selected: _filter == DaySheetStatus.approved,
onTap: () => setState(() {
_filter = DaySheetStatus.approved;
_selected.clear();
}),
),
],
),
),
const SizedBox(height: 8),
// ── Programmer dropdown + date filter ──────────────────────────────
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
DropdownButton<String?>(
value: _programmerFilter,
hint: const Text('All programmers'),
underline: const SizedBox.shrink(),
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('All programmers'),
),
for (final p in programmerProfiles)
DropdownMenuItem<String?>(
value: p.id,
child: Text(p.fullName),
),
],
onChanged: (v) => setState(() {
_programmerFilter = v;
_selected.clear();
}),
),
const SizedBox(width: 8),
Expanded(
child: ReportDateFilter.controlled(
value: _dateRange,
onChanged: (r) => setState(() {
_dateRange = r;
_selected.clear();
}),
),
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.description_outlined),
tooltip: 'Accomplishment Report',
onPressed: () => showAccomplishmentReportDialog(
context,
ref,
programmerId: _programmerFilter,
),
),
],
),
),
const SizedBox(height: 8),
// ── Card list ──────────────────────────────────────────────────────
Expanded(
child: filtered.isEmpty
? AppEmptyView(
icon: Icons.check_circle_outline,
title: _filter == DaySheetStatus.pending
? 'No pending sheets'
: 'Nothing here',
subtitle: 'All caught up!',
)
: ListView.builder(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: grouped.length,
itemBuilder: (context, groupIdx) {
final dateKey = grouped.keys.elementAt(groupIdx);
final groupSheets = grouped[dateKey]!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Text(
AppTime.formatDate(dateKey),
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
),
for (final sheet in groupSheets)
_SheetApprovalCard(
sheet: sheet,
profile: profileById[sheet.programmerId],
showCheckbox: _filter == DaySheetStatus.pending,
selected: _selected.contains(sheet.id),
totalSecs: sheet.approvedSnapshot?.totalSeconds,
taskCount: sheet.approvedSnapshot?.rows.length,
onToggle: _filter == DaySheetStatus.pending
? (v) => setState(() {
if (v == true) {
_selected.add(sheet.id);
} else {
_selected.remove(sheet.id);
}
})
: null,
onTap: () => context.pushNamed(
'daySheetDetail',
pathParameters: {'id': sheet.id},
),
),
],
);
},
),
),
// ── Approve button ─────────────────────────────────────────────────
if (_selected.isNotEmpty)
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: FilledButton(
onPressed: _approving ? null : () => _onApprove(allSheets),
child: _approving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text('Approve selected (${_selected.length})'),
),
),
),
],
);
}
}
// ---------------------------------------------------------------------------
// Card widget
// ---------------------------------------------------------------------------
class _SheetApprovalCard extends StatelessWidget {
const _SheetApprovalCard({
required this.sheet,
required this.profile,
required this.showCheckbox,
required this.selected,
required this.onToggle,
required this.onTap,
this.totalSecs,
this.taskCount,
});
final ProgrammerDaySheet sheet;
final Profile? profile;
final bool showCheckbox;
final bool selected;
final ValueChanged<bool?>? onToggle;
final VoidCallback onTap;
/// Null when no approved snapshot exists yet (pending/disapproved sheets).
final int? totalSecs;
final int? taskCount;
@override
Widget build(BuildContext context) {
final roundNum = sheet.resubmissions + 1;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (showCheckbox && onToggle != null) ...[
Checkbox(value: selected, onChanged: onToggle),
const SizedBox(width: 4),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
profile?.fullName ?? sheet.programmerId,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
TechChip(
totalSecs != null
? formatDurationHm(Duration(seconds: totalSecs!))
: '—',
),
TechChip(
taskCount != null ? '$taskCount tasks' : '—',
),
if (sheet.autoSubmitted)
const TechChip('Auto', status: StatusKind.info)
else
const TechChip('Early', status: StatusKind.neutral),
if (roundNum > 1)
TechChip(
'Round $roundNum',
status: StatusKind.warning,
),
],
),
],
),
),
Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
),
);
}
}
@@ -0,0 +1,527 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/programmer_day_sheet.model.dart';
import '../../../models/programmer_day_sheet_event.model.dart';
import '../../../models/programmer_task.model.dart';
import '../../../providers/connectivity_provider.dart';
import '../../../providers/programmer_day_data_provider.dart';
import '../../../providers/programmer_day_sheets_provider.dart';
import '../../../providers/programmer_tasks_provider.dart';
import '../../../providers/profile_provider.dart';
import '../../../theme/app_status_colors.dart';
import '../../../utils/app_time.dart';
import '../../../utils/programmer_day_rows.dart';
import '../../../utils/programmer_task_ledger.dart';
import '../../../widgets/app_section_header.dart';
import '../../../widgets/app_state_view.dart';
import '../../../widgets/offline_banner.dart';
import '../../../widgets/status_pill.dart';
import '../widgets/work_log_section.dart';
import 'day_sheet_dialogs.dart';
import 'day_sheet_rows_table.dart';
import 'day_sheet_thread.dart';
/// The full detail view for a single programmer day sheet.
class DaySheetDetailScreen extends ConsumerStatefulWidget {
const DaySheetDetailScreen({super.key, required this.sheetId});
final String sheetId;
@override
ConsumerState<DaySheetDetailScreen> createState() =>
_DaySheetDetailScreenState();
}
class _DaySheetDetailScreenState extends ConsumerState<DaySheetDetailScreen> {
Set<String> _selectedTaskIds = {};
bool _approvingSheet = false;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
StatusKind _kindForStatus(String s) => switch (s) {
DaySheetStatus.approved => StatusKind.success,
DaySheetStatus.pending => StatusKind.info,
DaySheetStatus.disapproved => StatusKind.warning,
_ => StatusKind.neutral,
};
Set<String> _flaggedTaskIdsFromEvents(List<ProgrammerDaySheetEvent> events) {
final disapproved = events
.where((e) => e.kind == 'disapproved')
.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
if (disapproved.isEmpty) return const {};
return Set<String>.from(disapproved.first.flaggedTaskIds);
}
void _showError(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
// ---------------------------------------------------------------------------
// Actions — Owner
// ---------------------------------------------------------------------------
Future<void> _onAddCorrection(
BuildContext ctx,
ProgrammerTask task,
String currentUserId,
DateTime workDate,
) async {
await showModalBottomSheet<void>(
context: ctx,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom,
left: 16,
right: 16,
top: 16,
),
child: WorkLogSection(
task: task,
isAssignee: task.assigneeId == currentUserId,
workDate: workDate,
),
),
);
}
Future<void> _onJustify(String sheetId) async {
final done = await showJustifyDialog(
context,
onConfirm: (body) => ref
.read(daySheetsControllerProvider)
.justify(sheetId: sheetId, body: body),
);
if (done == true && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Justification submitted')),
);
}
}
// ---------------------------------------------------------------------------
// Actions — Admin
// ---------------------------------------------------------------------------
Future<void> _onDisapprove(
String sheetId,
List<DaySheetRow> rows,
) async {
final flaggedRows =
rows.where((r) => _selectedTaskIds.contains(r.taskId)).toList();
final done = await showDisapproveDialog(
context,
flaggedRows: flaggedRows,
onConfirm: (remarks, flaggedTaskIds) =>
ref.read(daySheetsControllerProvider).disapprove(
sheetId: sheetId,
remarks: remarks,
flaggedTaskIds: flaggedTaskIds,
),
);
if (done == true && mounted) {
setState(() => _selectedTaskIds = {});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sheet disapproved')),
);
}
}
Future<void> _onApprove(
ProgrammerDaySheet sheet,
List<DaySheetRow> rows,
String programmerName,
) async {
final today = ref.read(todayKeyProvider);
final isToday = sheet.workDate == today;
if (isToday) {
final allTasks =
ref.read(programmerTasksProvider).valueOrNull ?? const [];
final programmerTaskIds = allTasks
.where((t) => t.assigneeId == sheet.programmerId)
.map((t) => t.id)
.toSet();
final runStates = ref.read(programmerRunStatesProvider);
final hasRunning = runStates.entries.any((e) =>
programmerTaskIds.contains(e.key) &&
e.value.state == ProgrammerRunState.running);
if (hasRunning) {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Timer still running'),
content: Text(
'$programmerName has a timer running. '
'Work logged after approval will reopen this sheet. Approve anyway?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Approve'),
),
],
),
);
if (confirm != true || !mounted) return;
}
}
setState(() => _approvingSheet = true);
final snapshot = DaySheetSnapshot.fromRows(rows);
try {
await ref.read(daySheetsControllerProvider).approve(
[(sheetId: sheet.id, snapshot: snapshot)],
);
if (!mounted) return;
setState(() { _approvingSheet = false; _selectedTaskIds = {}; });
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
icon: const Icon(Icons.check_circle_outline, size: 48),
title: const Text('Sheet Approved'),
content: Text("$programmerName's day sheet has been approved."),
actions: [
FilledButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Done'),
),
],
),
);
} on DaySheetActionException catch (e) {
if (mounted) setState(() => _approvingSheet = false);
_showError(e.message);
} catch (e) {
if (mounted) setState(() => _approvingSheet = false);
_showError(e.toString());
}
}
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
final sheetAsync = ref.watch(daySheetByIdProvider(widget.sheetId));
final eventsAsync = ref.watch(daySheetEventsProvider(widget.sheetId));
final profilesAsync = ref.watch(profilesProvider);
final currentUserId = ref.watch(currentUserIdProvider);
final isAdmin = ref.watch(isStrictAdminProvider);
final isOnline = ref.watch(isOnlineProvider);
return Scaffold(
appBar: AppBar(title: const Text('Day Sheet')),
body: OfflineBanner(
child: sheetAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) =>
AppErrorView(error: e, onRetry: () => ref.invalidate(daySheetByIdProvider(widget.sheetId))),
data: (sheet) {
if (sheet == null) {
return const AppEmptyView(
icon: Icons.receipt_long_outlined,
title: 'Sheet not found',
subtitle: 'This day sheet may have been removed.',
);
}
final events = eventsAsync.valueOrNull ?? const [];
final profiles = profilesAsync.valueOrNull ?? const [];
final profileById = {for (final p in profiles) p.id: p};
final profileNames = {for (final p in profiles) p.id: p.fullName};
final programmerName =
profileById[sheet.programmerId]?.fullName ?? 'Programmer';
final isOwner = currentUserId == sheet.programmerId;
final flaggedIds = _flaggedTaskIdsFromEvents(events);
// Build live rows from tasks provider.
final allTasks =
ref.watch(programmerTasksProvider).valueOrNull ?? const [];
final sheetTasks = allTasks
.where((t) => t.assigneeId == sheet.programmerId)
.toList();
// For approved sheets use the stored snapshot. For all other
// statuses fetch live row data from the day-activity provider.
final dayDataForSheet = ref
.watch(programmerDayDataProvider(sheet.workDate))
.valueOrNull;
final liveRows = sheet.status == DaySheetStatus.approved &&
sheet.approvedSnapshot != null
? sheet.approvedSnapshot!.rows
: dayDataForSheet != null
? buildDayRows(
userId: sheet.programmerId,
day: sheet.workDate,
tasks: allTasks,
logsByTask: dayDataForSheet.logsByTask,
workLogs: dayDataForSheet.workLogs,
projectNames: const {},
profileNames: profileNames,
now: AppTime.now(),
)
: const <DaySheetRow>[];
final rows = rowsForSheet(sheet, liveRows);
final totalSecs = rows.fold<int>(0, (s, r) => s + r.seconds);
final totalDuration = Duration(seconds: totalSecs);
final canAdminAct = isAdmin &&
(sheet.status == DaySheetStatus.pending ||
sheet.status == DaySheetStatus.disapproved);
final canOwnerJustify = isOwner &&
sheet.status == DaySheetStatus.disapproved;
final canOwnerCorrect = isOwner &&
(sheet.status == DaySheetStatus.disapproved ||
(sheet.status == DaySheetStatus.pending &&
sheet.workDate.isBefore(ref.read(todayKeyProvider))));
return ListView(
padding: const EdgeInsets.all(16),
children: [
// ── Offline indicator chip ──────────────────────────────────
if (!isOnline)
Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.wifi_off_rounded,
size: 16,
color: Theme.of(context)
.colorScheme
.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
'You are offline. Actions are unavailable.',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context)
.colorScheme
.onErrorContainer,
),
),
),
],
),
),
// ── Header ────────────────────────────────────────────────
_Header(
sheet: sheet,
programmerName: programmerName,
totalDuration: totalDuration,
statusKind: _kindForStatus(sheet.status),
),
const SizedBox(height: 20),
// ── Rows table ────────────────────────────────────────────
AppSectionHeader(
'Work Entries',
trailing: canAdminAct && rows.isNotEmpty
? TextButton(
onPressed: () {
if (_selectedTaskIds.length == rows.length) {
setState(() => _selectedTaskIds = {});
} else {
setState(() => _selectedTaskIds = {
for (final r in rows) r.taskId,
});
}
},
child: Text(
_selectedTaskIds.length == rows.length
? 'Deselect all'
: 'Select all',
),
)
: null,
),
DaySheetRowsTable(
rows: rows,
flaggedTaskIds: sheet.status == DaySheetStatus.approved
? const {}
: flaggedIds,
selectedTaskIds: canAdminAct ? _selectedTaskIds : null,
onSelectionChanged: canAdminAct
? (ids) => setState(() => _selectedTaskIds = ids)
: null,
onAddCorrection: canOwnerCorrect
? (row) {
final task = sheetTasks
.where((t) => t.id == row.taskId)
.firstOrNull;
if (task != null && currentUserId != null) {
_onAddCorrection(
context, task, currentUserId, sheet.workDate);
}
}
: null,
),
const SizedBox(height: 20),
// ── Admin action bar ─────────────────────────────────────
if (canAdminAct) ...[
Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.thumb_down_alt_outlined),
label: const Text('Disapprove'),
onPressed: isOnline
? () => _onDisapprove(sheet.id, rows)
: null,
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
icon: _approvingSheet
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Icon(Icons.check_circle_outline),
label: const Text('Approve'),
onPressed: (_approvingSheet || !isOnline)
? null
: () => _onApprove(
sheet,
rows,
programmerName,
),
),
),
],
),
const SizedBox(height: 20),
],
// ── Owner justify bar ─────────────────────────────────────
if (canOwnerJustify) ...[
SizedBox(
width: double.infinity,
child: FilledButton.icon(
icon: const Icon(Icons.chat_bubble_outline),
label: const Text('Justify & Resubmit'),
onPressed: isOnline
? () => _onJustify(sheet.id)
: null,
),
),
const SizedBox(height: 20),
],
// ── Event thread ──────────────────────────────────────────
DaySheetThread(
events: events,
profileNames: profileNames,
),
],
);
},
),
),
);
}
}
// ---------------------------------------------------------------------------
// Header widget
// ---------------------------------------------------------------------------
class _Header extends StatelessWidget {
const _Header({
required this.sheet,
required this.programmerName,
required this.totalDuration,
required this.statusKind,
});
final ProgrammerDaySheet sheet;
final String programmerName;
final Duration totalDuration;
final StatusKind statusKind;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
AppTime.formatDate(sheet.workDate),
style: tt.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
),
StatusPill.kind(
statusKind,
label: DaySheetStatus.label(sheet.status),
),
],
),
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.person_outline, size: 16, color: cs.onSurfaceVariant),
const SizedBox(width: 4),
Expanded(
child: Text(
programmerName,
style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant),
),
),
Text(
formatDurationHm(totalDuration),
style: tt.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
color: cs.primary,
),
),
],
),
if (sheet.resubmissions > 0) ...[
const SizedBox(height: 4),
Text(
'Round ${sheet.resubmissions + 1}',
style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant),
),
],
],
);
}
}
@@ -0,0 +1,256 @@
import 'package:flutter/material.dart';
import '../../../models/programmer_day_sheet.model.dart';
import '../../../utils/programmer_day_rows.dart';
/// Pure helper: if the sheet is approved with a snapshot, returns the snapshot
/// rows; otherwise returns the live rows.
List<DaySheetRow> rowsForSheet(
ProgrammerDaySheet sheet,
List<DaySheetRow> liveRows,
) {
if (sheet.status == DaySheetStatus.approved &&
sheet.approvedSnapshot != null) {
return sheet.approvedSnapshot!.rows;
}
return liveRows;
}
// ---------------------------------------------------------------------------
// showDisapproveDialog
// ---------------------------------------------------------------------------
/// Shows an AlertDialog collecting disapproval remarks and optionally
/// toggling which rows are flagged. Executes [onConfirm] while showing a
/// loading state; handles errors inline. Returns true if the action
/// completed, or null if cancelled.
Future<bool?> showDisapproveDialog(
BuildContext context, {
required List<DaySheetRow> flaggedRows,
required Future<void> Function(
String remarks, List<String> flaggedTaskIds)
onConfirm,
}) {
return showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) =>
_DisapproveDialog(flaggedRows: flaggedRows, onConfirm: onConfirm),
);
}
class _DisapproveDialog extends StatefulWidget {
const _DisapproveDialog({required this.flaggedRows, required this.onConfirm});
final List<DaySheetRow> flaggedRows;
final Future<void> Function(String remarks, List<String> flaggedTaskIds)
onConfirm;
@override
State<_DisapproveDialog> createState() => _DisapproveDialogState();
}
class _DisapproveDialogState extends State<_DisapproveDialog> {
final _remarksCtrl = TextEditingController();
String? _error;
bool _loading = false;
late Set<String> _selected;
@override
void initState() {
super.initState();
_selected = {for (final r in widget.flaggedRows) r.taskId};
}
@override
void dispose() {
_remarksCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
final remarks = _remarksCtrl.text.trim();
if (remarks.isEmpty) {
setState(() => _error = 'Remarks are required');
return;
}
setState(() { _loading = true; _error = null; });
try {
await widget.onConfirm(remarks, _selected.toList());
if (mounted) Navigator.of(context).pop(true);
} catch (e) {
if (mounted) setState(() { _loading = false; _error = e.toString(); });
}
}
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
return AlertDialog(
title: const Text('Disapprove'),
content: SizedBox(
width: 360,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _remarksCtrl,
maxLines: 3,
decoration: InputDecoration(
labelText: 'Remarks',
hintText: 'Explain what needs to change',
errorText: _error,
border: const OutlineInputBorder(),
),
onChanged: (_) {
if (_error != null) setState(() => _error = null);
},
),
if (widget.flaggedRows.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
'Flagged tasks',
style: tt.labelMedium?.copyWith(
color: cs.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
for (final row in widget.flaggedRows)
CheckboxListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(row.title, style: tt.bodySmall),
value: _selected.contains(row.taskId),
onChanged: (v) {
setState(() {
if (v == true) {
_selected.add(row.taskId);
} else {
_selected.remove(row.taskId);
}
});
},
),
],
],
),
),
),
actions: [
TextButton(
onPressed: _loading ? null : () => Navigator.of(context).pop(null),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Disapprove'),
),
],
);
}
}
// ---------------------------------------------------------------------------
// showJustifyDialog
// ---------------------------------------------------------------------------
/// Shows an AlertDialog for the programmer to write a justification before
/// resubmitting. Executes [onConfirm] while showing a loading state; handles
/// errors inline. Returns true if the action completed, or null if cancelled.
Future<bool?> showJustifyDialog(
BuildContext context, {
required Future<void> Function(String body) onConfirm,
}) {
return showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => _JustifyDialog(onConfirm: onConfirm),
);
}
class _JustifyDialog extends StatefulWidget {
const _JustifyDialog({required this.onConfirm});
final Future<void> Function(String body) onConfirm;
@override
State<_JustifyDialog> createState() => _JustifyDialogState();
}
class _JustifyDialogState extends State<_JustifyDialog> {
final _bodyCtrl = TextEditingController();
String? _error;
bool _loading = false;
@override
void dispose() {
_bodyCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
final body = _bodyCtrl.text.trim();
if (body.isEmpty) {
setState(() => _error = 'Please write a justification');
return;
}
setState(() { _loading = true; _error = null; });
try {
await widget.onConfirm(body);
if (mounted) Navigator.of(context).pop(true);
} catch (e) {
if (mounted) setState(() { _loading = false; _error = e.toString(); });
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Justify & Resubmit'),
content: SizedBox(
width: 360,
child: TextField(
controller: _bodyCtrl,
maxLines: 4,
enabled: !_loading,
decoration: InputDecoration(
labelText: 'Justification',
hintText: 'Explain what changed or why the sheet is correct',
errorText: _error,
border: const OutlineInputBorder(),
),
onChanged: (_) {
if (_error != null) setState(() => _error = null);
},
),
),
actions: [
TextButton(
onPressed: _loading ? null : () => Navigator.of(context).pop(null),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Submit'),
),
],
);
}
}
@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import '../../../theme/app_status_colors.dart';
import '../../../utils/programmer_day_rows.dart';
import '../../../utils/programmer_task_ledger.dart';
import '../../../widgets/tech_chip.dart';
/// Table of day-sheet rows with optional selection checkboxes and a per-row
/// "add correction" action icon.
class DaySheetRowsTable extends StatelessWidget {
const DaySheetRowsTable({
super.key,
required this.rows,
required this.flaggedTaskIds,
this.selectedTaskIds,
this.onSelectionChanged,
this.onAddCorrection,
});
/// The rows to render (assignee + helper tasks for this sheet).
final List<DaySheetRow> rows;
/// Task IDs flagged in the most recent disapproved event. These are
/// highlighted while the sheet is not yet approved.
final Set<String> flaggedTaskIds;
/// When non-null, admin selection checkboxes are shown and changes are
/// forwarded to this callback.
final Set<String>? selectedTaskIds;
final ValueChanged<Set<String>>? onSelectionChanged;
/// When non-null, a correction icon is shown on each row and tapping it
/// invokes this callback with the affected row.
final ValueChanged<DaySheetRow>? onAddCorrection;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
final status = AppStatusColors.of(context);
if (rows.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'No rows yet.',
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
);
}
return Column(
children: [
for (final row in rows)
_RowTile(
key: ValueKey('${row.taskId}:${row.kind}'),
row: row,
isFlagged: flaggedTaskIds.contains(row.taskId),
isSelected: selectedTaskIds?.contains(row.taskId) ?? false,
showCheckbox: selectedTaskIds != null,
showCorrection: onAddCorrection != null,
statusColors: status,
onToggle: selectedTaskIds != null && onSelectionChanged != null
? (checked) {
final next = Set<String>.from(selectedTaskIds!);
if (checked) {
next.add(row.taskId);
} else {
next.remove(row.taskId);
}
onSelectionChanged!(next);
}
: null,
onAddCorrection:
onAddCorrection != null ? () => onAddCorrection!(row) : null,
),
],
);
}
}
class _RowTile extends StatelessWidget {
const _RowTile({
super.key,
required this.row,
required this.isFlagged,
required this.isSelected,
required this.showCheckbox,
required this.showCorrection,
required this.statusColors,
this.onToggle,
this.onAddCorrection,
});
final DaySheetRow row;
final bool isFlagged;
final bool isSelected;
final bool showCheckbox;
final bool showCorrection;
final AppStatusColors statusColors;
final ValueChanged<bool>? onToggle;
final VoidCallback? onAddCorrection;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
final flaggedColor = statusColors.warning.container;
final bgColor = isFlagged ? flaggedColor : Colors.transparent;
return AnimatedContainer(
duration: const Duration(milliseconds: 250),
color: bgColor,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showCheckbox)
Checkbox(
value: isSelected,
onChanged: onToggle != null ? (v) => onToggle!(v ?? false) : null,
visualDensity: VisualDensity.compact,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
row.title,
style: tt.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
Text(
formatDurationHm(Duration(seconds: row.seconds)),
style: tt.bodySmall?.copyWith(
color: cs.primary,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
TechChip(row.category, dense: true),
if (row.kind == 'helper')
TechChip(
'Helper${row.helpedName != null ? ' → ${row.helpedName}' : ''}',
dense: true,
status: StatusKind.info,
),
if (row.projectName != null)
TechChip(row.projectName!, dense: true),
if (isFlagged)
TechChip(
'Flagged',
dense: true,
status: StatusKind.warning,
icon: Icons.flag_outlined,
),
],
),
if (row.notes.isNotEmpty) ...[
const SizedBox(height: 4),
for (final note in row.notes)
Text(
'• $note',
style: tt.bodySmall
?.copyWith(color: cs.onSurfaceVariant),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
if (showCorrection)
IconButton(
icon: const Icon(Icons.add_comment_outlined, size: 18),
tooltip: 'Add correction',
onPressed: onAddCorrection,
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
],
),
),
);
}
}
@@ -0,0 +1,85 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/programmer_day_sheet.model.dart';
import '../../../providers/programmer_day_data_provider.dart';
import '../../../utils/app_time.dart';
import '../../../utils/programmer_day_rows.dart';
/// Builds a [DaySheetSnapshot] for [sheet].
///
/// * **Approved** sheets: the stored [ProgrammerDaySheet.approvedSnapshot] is
/// returned directly — no live fetch required.
/// * **All other statuses** (pending / disapproved / draft): live activity data
/// for the sheet's work-date is fetched via [programmerDayDataProvider] and
/// rows are computed with [buildDayRows].
///
/// This is the canonical snapshot source for the bulk-approve flow in
/// [ApprovalsTab]. Pass `ref` from a [ConsumerStatefulWidget]'s state.
Future<DaySheetSnapshot> buildSnapshotForSheet(
WidgetRef ref,
ProgrammerDaySheet sheet,
) async {
if (sheet.status == DaySheetStatus.approved &&
sheet.approvedSnapshot != null) {
return sheet.approvedSnapshot!;
}
final data =
await ref.read(programmerDayDataProvider(sheet.workDate).future);
final rows = buildDayRows(
userId: sheet.programmerId,
day: sheet.workDate,
tasks: data.tasks,
logsByTask: data.logsByTask,
workLogs: data.workLogs,
// Project / profile names are display metadata only — pass empty maps so
// that approval RPCs are not blocked on a separate name lookup.
projectNames: const {},
profileNames: const {},
now: AppTime.now(),
);
return DaySheetSnapshot.fromRows(rows);
}
/// Groups [sheets] by [ProgrammerDaySheet.workDate].
///
/// Returns a [LinkedHashMap]-backed map whose **keys are in descending date
/// order** (most recent date first). Insertion order within each date group
/// is preserved.
Map<DateTime, List<ProgrammerDaySheet>> groupSheetsByDate(
List<ProgrammerDaySheet> sheets,
) {
final map = <DateTime, List<ProgrammerDaySheet>>{};
for (final sheet in sheets) {
map.putIfAbsent(sheet.workDate, () => []).add(sheet);
}
final keys = map.keys.toList()..sort((a, b) => b.compareTo(a));
return {for (final k in keys) k: map[k]!};
}
/// Counts the approval-relevant statuses across [sheets].
///
/// * [pending] — submitted by the programmer, awaiting admin review.
/// * [awaiting] — disapproved; programmer needs to justify / resubmit.
/// * [approved] — fully reviewed and approved.
({int pending, int awaiting, int approved}) approvalCounts(
List<ProgrammerDaySheet> sheets,
) {
int pending = 0, awaiting = 0, approved = 0;
for (final s in sheets) {
switch (s.status) {
case DaySheetStatus.pending:
pending++;
break;
case DaySheetStatus.disapproved:
awaiting++;
break;
case DaySheetStatus.approved:
approved++;
break;
}
}
return (pending: pending, awaiting: awaiting, approved: approved);
}
@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import '../../../models/programmer_day_sheet_event.model.dart';
import '../../../theme/app_status_colors.dart';
import '../../../utils/app_time.dart';
import '../../../widgets/app_section_header.dart';
/// Maps an event kind to a human-readable label.
String _threadLabel(String kind) => switch (kind) {
'submitted' => 'Submitted',
'auto_submitted' => 'Auto-submitted at end of day',
'disapproved' => 'Disapproved',
'justified' => 'Justified',
'approved' => 'Approved',
'amended' => 'Reopened — new work after approval',
_ => kind,
};
IconData _threadIcon(String kind) => switch (kind) {
'submitted' => Icons.send_outlined,
'auto_submitted' => Icons.schedule_send_outlined,
'disapproved' => Icons.thumb_down_alt_outlined,
'justified' => Icons.chat_bubble_outline,
'approved' => Icons.check_circle_outline,
'amended' => Icons.redo_outlined,
_ => Icons.circle_outlined,
};
StatusKind _threadKind(String kind) => switch (kind) {
'approved' => StatusKind.success,
'disapproved' => StatusKind.warning,
'justified' => StatusKind.info,
_ => StatusKind.neutral,
};
/// Renders day-sheet events as a vertical timeline, oldest first.
class DaySheetThread extends StatelessWidget {
const DaySheetThread({
super.key,
required this.events,
required this.profileNames,
});
final List<ProgrammerDaySheetEvent> events;
/// Map of userId → display name used to show actor names.
final Map<String, String> profileNames;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const AppSectionHeader('Activity'),
if (events.isEmpty)
Text(
'No activity yet.',
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
)
else
for (final event in events)
_ThreadRow(
event: event,
actorName: event.actorId != null
? profileNames[event.actorId] ?? 'Someone'
: null,
),
],
);
}
}
class _ThreadRow extends StatelessWidget {
const _ThreadRow({required this.event, this.actorName});
final ProgrammerDaySheetEvent event;
final String? actorName;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
final status = AppStatusColors.of(context);
final kind = _threadKind(event.kind);
final swatch = status.byKind(kind);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: swatch.container,
shape: BoxShape.circle,
),
child: Icon(
_threadIcon(event.kind),
size: 14,
color: swatch.onContainer,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
actorName != null
? '$actorName · ${_threadLabel(event.kind)}'
: _threadLabel(event.kind),
style: tt.bodyMedium,
),
),
Text(
AppTime.relative(event.createdAt),
style: tt.bodySmall
?.copyWith(color: cs.onSurfaceVariant),
),
],
),
if (event.body != null && event.body!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
event.body!,
style: tt.bodySmall
?.copyWith(color: cs.onSurfaceVariant),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
],
if (event.flaggedTaskIds.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
'Flagged: ${event.flaggedTaskIds.length} task(s)',
style: tt.bodySmall?.copyWith(
color: cs.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
],
],
),
),
],
),
);
}
}
@@ -0,0 +1,384 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../models/programmer_day_sheet.model.dart';
import '../../../providers/profile_provider.dart';
import '../../../providers/programmer_day_data_provider.dart';
import '../../../providers/programmer_day_sheets_provider.dart';
import '../../../theme/app_status_colors.dart';
import '../../../utils/app_time.dart';
import '../../../utils/programmer_day_rows.dart';
import '../../../utils/programmer_task_ledger.dart';
import '../../../widgets/app_state_view.dart';
import '../../../widgets/app_status_summary.dart';
import '../../../widgets/status_pill.dart';
import '../report/accomplishment_report_dialog.dart';
/// Programmer-facing tab for managing personal day sheets.
///
/// Sections:
/// 1. **Today** — live work rows, total duration, sheet status and a
/// "Submit day" button (enabled when the sheet is missing/draft and there
/// is at least one logged row).
/// 2. **Needs your attention** — disapproved sheets waiting for justification.
/// 3. **History** — filterable list of past sheets
/// (All / Pending / Disapproved / Approved); tapping a card opens the
/// [DaySheetDetailScreen].
class DaySheetsTab extends ConsumerStatefulWidget {
const DaySheetsTab({super.key});
@override
ConsumerState<DaySheetsTab> createState() => _DaySheetsTabState();
}
class _DaySheetsTabState extends ConsumerState<DaySheetsTab> {
/// Null → show all history; or a [DaySheetStatus] constant to filter.
String? _historyFilter;
bool _submitting = false;
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
Future<void> _submit(DateTime today) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Submit today's work?"),
content: const Text(
"This will send your day sheet to admin for review."),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Submit'),
),
],
),
);
if (ok != true) return;
setState(() => _submitting = true);
try {
await ref.read(daySheetsControllerProvider).submit(today);
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Day submitted')));
} on DaySheetActionException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.message)));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.toString())));
} finally {
if (mounted) setState(() => _submitting = false);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
StatusKind _kindFor(String status) => switch (status) {
DaySheetStatus.approved => StatusKind.success,
DaySheetStatus.pending => StatusKind.info,
DaySheetStatus.disapproved => StatusKind.warning,
_ => StatusKind.neutral,
};
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
final userId = ref.watch(currentUserIdProvider);
if (userId == null) {
return const Center(child: CircularProgressIndicator());
}
final today = ref.watch(todayKeyProvider);
final dayDataAsync = ref.watch(programmerDayDataProvider(today));
final mySheetsAsync = ref.watch(myDaySheetsProvider);
final profiles = ref.watch(profilesProvider).valueOrNull ?? const [];
final profileNames = {for (final p in profiles) p.id: p.fullName};
final mySheets = mySheetsAsync.valueOrNull ?? const [];
// Today's sheet (if any) — compare UTC date keys.
final todaySheet =
mySheets.where((s) => s.workDate == today).firstOrNull;
// Build live rows for today.
final todayRows = dayDataAsync.when(
data: (data) => buildDayRows(
userId: userId,
day: today,
tasks: data.tasks,
logsByTask: data.logsByTask,
workLogs: data.workLogs,
projectNames: const {},
profileNames: profileNames,
now: AppTime.now(),
),
loading: () => const <DaySheetRow>[],
error: (_, _) => const <DaySheetRow>[],
);
final todayTotal = Duration(seconds: totalSeconds(todayRows));
final todayStatus = todaySheet?.status ?? DaySheetStatus.draft;
final canSubmit =
(todaySheet == null || todayStatus == DaySheetStatus.draft) &&
todayRows.isNotEmpty;
// Attention: disapproved sheets.
final disapproved =
mySheets.where((s) => s.status == DaySheetStatus.disapproved).toList();
// History: past sheets (exclude today), optionally filtered by status.
final history = mySheets.where((s) {
if (s.workDate == today) return false;
if (_historyFilter == null) return true;
return s.status == _historyFilter;
}).toList();
// Counts for the history filter chips.
final historyAll = mySheets.where((s) => s.workDate != today).toList();
final countPending =
historyAll.where((s) => s.status == DaySheetStatus.pending).length;
final countDisapproved =
historyAll.where((s) => s.status == DaySheetStatus.disapproved).length;
final countApproved =
historyAll.where((s) => s.status == DaySheetStatus.approved).length;
return ListView(
padding: const EdgeInsets.all(16),
children: [
// ── Report action ──────────────────────────────────────────────────
Align(
alignment: Alignment.centerRight,
child: IconButton(
icon: const Icon(Icons.description_outlined),
tooltip: 'Accomplishment Report',
onPressed: () => showAccomplishmentReportDialog(context, ref),
),
),
// ── Today card ─────────────────────────────────────────────────────
_TodayCard(
total: todayTotal,
status: todayStatus,
statusKind: _kindFor(todayStatus),
canSubmit: canSubmit,
submitting: _submitting,
onSubmit: () => _submit(today),
onTap: todaySheet != null
? () => context.pushNamed(
'daySheetDetail',
pathParameters: {'id': todaySheet.id},
)
: null,
),
const SizedBox(height: 20),
// ── Needs your attention ──────────────────────────────────────────
if (disapproved.isNotEmpty) ...[
Text(
'Needs your attention',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
for (final sheet in disapproved)
_SheetHistoryTile(
sheet: sheet,
statusKind: _kindFor(sheet.status),
onTap: () => context.pushNamed(
'daySheetDetail',
pathParameters: {'id': sheet.id},
),
),
const SizedBox(height: 16),
],
// ── History ────────────────────────────────────────────────────────
Text('History', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
AppStatusSummaryRow(
items: [
AppStatusSummaryItem(
label: 'All',
count: historyAll.length,
kind: StatusKind.neutral,
selected: _historyFilter == null,
onTap: () => setState(() => _historyFilter = null),
),
AppStatusSummaryItem(
label: 'Pending',
count: countPending,
kind: StatusKind.info,
selected: _historyFilter == DaySheetStatus.pending,
onTap: () => setState(() => _historyFilter = DaySheetStatus.pending),
),
AppStatusSummaryItem(
label: 'Disapproved',
count: countDisapproved,
kind: StatusKind.warning,
selected: _historyFilter == DaySheetStatus.disapproved,
onTap: () =>
setState(() => _historyFilter = DaySheetStatus.disapproved),
),
AppStatusSummaryItem(
label: 'Approved',
count: countApproved,
kind: StatusKind.success,
selected: _historyFilter == DaySheetStatus.approved,
onTap: () =>
setState(() => _historyFilter = DaySheetStatus.approved),
),
],
),
const SizedBox(height: 8),
if (history.isEmpty)
const AppEmptyView(
icon: Icons.history,
title: 'No sheets yet',
subtitle: 'Submit your first day sheet above.',
)
else
for (final sheet in history)
_SheetHistoryTile(
sheet: sheet,
statusKind: _kindFor(sheet.status),
onTap: () => context.pushNamed(
'daySheetDetail',
pathParameters: {'id': sheet.id},
),
),
],
);
}
}
// ---------------------------------------------------------------------------
// Today card
// ---------------------------------------------------------------------------
class _TodayCard extends StatelessWidget {
const _TodayCard({
required this.total,
required this.status,
required this.statusKind,
required this.canSubmit,
required this.submitting,
required this.onSubmit,
this.onTap,
});
final Duration total;
final String status;
final StatusKind statusKind;
final bool canSubmit;
final bool submitting;
final VoidCallback onSubmit;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text('Today', style: tt.titleMedium),
),
StatusPill.kind(
statusKind,
label: DaySheetStatus.label(status),
),
],
),
const SizedBox(height: 8),
Text(
formatDurationHm(total),
style: tt.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
color: cs.primary,
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.tonal(
onPressed: canSubmit && !submitting ? onSubmit : null,
child: submitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Submit day'),
),
),
],
),
),
),
);
}
}
// ---------------------------------------------------------------------------
// History tile
// ---------------------------------------------------------------------------
class _SheetHistoryTile extends StatelessWidget {
const _SheetHistoryTile({
required this.sheet,
required this.statusKind,
required this.onTap,
});
final ProgrammerDaySheet sheet;
final StatusKind statusKind;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
title: Text(AppTime.formatDate(sheet.workDate)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
StatusPill.kind(
statusKind,
label: DaySheetStatus.label(sheet.status),
),
const SizedBox(width: 4),
const Icon(Icons.chevron_right),
],
),
onTap: onTap,
),
);
}
}
@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../providers/programmer_day_data_provider.dart';
import '../../../providers/programmer_tasks_provider.dart';
import '../../../widgets/app_section_header.dart';
import 'monitor_model.dart';
final _dateFmt = DateFormat('EEE, MMM d');
/// Side panel that shows "who's helping whom" for a selected day, with a
/// ← / → stepper that cannot advance past today.
///
/// Uses a [ValueNotifier<DateTime>] initialized to [todayKeyProvider] so the
/// displayed day is independent of the parent; the family provider is
/// re-watched whenever the day changes.
class HelpPanel extends ConsumerStatefulWidget {
const HelpPanel({super.key, this.profileNames = const {}});
final Map<String, String> profileNames;
@override
ConsumerState<HelpPanel> createState() => _HelpPanelState();
}
class _HelpPanelState extends ConsumerState<HelpPanel> {
late final ValueNotifier<DateTime> _day;
@override
void initState() {
super.initState();
final today = ref.read(todayKeyProvider);
_day = ValueNotifier(today);
}
@override
void dispose() {
_day.dispose();
super.dispose();
}
void _stepDay(int delta) {
final today = ref.read(todayKeyProvider);
final next = _day.value.add(Duration(days: delta));
if (delta > 0 && !next.isBefore(today) && next != today) return;
if (delta > 0 && next.isAfter(today)) return;
setState(() {
_day.value = next;
});
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
final today = ref.watch(todayKeyProvider);
final tasksAsync = ref.watch(programmerTasksProvider);
final dayDataAsync = ref.watch(programmerDayDataProvider(_day.value));
final isAtToday = !_day.value.isBefore(today);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const AppSectionHeader("Who's helping whom"),
// Day stepper
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () => _stepDay(-1),
tooltip: 'Previous day',
),
Text(
_dateFmt.format(_day.value),
style: tt.labelMedium?.copyWith(color: cs.onSurface),
),
IconButton(
icon: Icon(
Icons.chevron_right,
color: isAtToday ? cs.onSurface.withValues(alpha: 0.3) : null,
),
onPressed: isAtToday ? null : () => _stepDay(1),
tooltip: 'Next day',
),
],
),
const SizedBox(height: 8),
// Help edges
Expanded(
child: Builder(
builder: (context) {
if (dayDataAsync.isLoading && !dayDataAsync.hasValue) {
return const Center(child: CircularProgressIndicator());
}
final dayData = dayDataAsync.valueOrNull;
if (dayData == null) {
return _emptyText(
'No data for this day.',
cs: cs,
tt: tt,
);
}
final tasks = tasksAsync.valueOrNull ?? [];
final taskById = {for (final t in tasks) t.id: t};
final edges = buildHelpEdges(
workLogs: dayData.workLogs,
taskById: taskById,
day: _day.value,
);
if (edges.isEmpty) {
return _emptyText(
'No help logged this day.',
cs: cs,
tt: tt,
);
}
return ListView.separated(
itemCount: edges.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) {
final e = edges[i];
final names = widget.profileNames;
final helperName = names[e.helperId] ?? e.helperId;
final assigneeName = names[e.assigneeId] ?? e.assigneeId;
final timeStr = TimeOfDay.fromDateTime(e.at).format(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
'$helperName → $assigneeName · ${e.taskTitle} · ${e.minutes}m · $timeStr',
style: tt.bodySmall?.copyWith(color: cs.onSurface),
),
);
},
);
},
),
),
],
);
}
Widget _emptyText(
String msg, {
required ColorScheme cs,
required TextTheme tt,
}) {
return Center(
child: Text(
msg,
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
);
}
}
@@ -0,0 +1,394 @@
import 'package:tasq/models/leave_of_absence.model.dart';
import 'package:tasq/models/pass_slip.model.dart';
import 'package:tasq/models/profile.model.dart';
import 'package:tasq/models/programmer_task.model.dart';
import 'package:tasq/models/programmer_task_work_log.model.dart';
import 'package:tasq/providers/programmer_day_data_provider.dart';
import 'package:tasq/utils/programmer_day_rows.dart';
enum MonitorState { working, paused, idle, onLeave, passSlip }
class HelpEdge {
final String helperId;
final String assigneeId;
final String taskId;
final String taskTitle;
final int minutes;
final DateTime at;
HelpEdge({
required this.helperId,
required this.assigneeId,
required this.taskId,
required this.taskTitle,
required this.minutes,
required this.at,
});
}
/// Build help edges from work logs for a given day.
///
/// Returns edges where:
/// - author != assignee (author is helping)
/// - minutes > 0
/// - workDate == day
///
/// Sorted by createdAt newest first.
List<HelpEdge> buildHelpEdges({
required List<ProgrammerTaskWorkLog> workLogs,
required Map<String, ProgrammerTask> taskById,
required DateTime day,
}) {
final edges = <HelpEdge>[];
for (final log in workLogs) {
final task = taskById[log.taskId];
if (task == null) continue;
// Filter: author != assignee AND minutes > 0 AND workDate == day
if (log.authorId == task.assigneeId) continue;
if (log.minutes == null || log.minutes! <= 0) continue;
if (log.workDate != day) continue;
edges.add(HelpEdge(
helperId: log.authorId,
assigneeId: task.assigneeId ?? '',
taskId: log.taskId,
taskTitle: task.title,
minutes: log.minutes!,
at: log.createdAt,
));
}
// Sort by createdAt newest first
edges.sort((a, b) => b.at.compareTo(a.at));
return edges;
}
class ProgrammerMonitorEntry {
final Profile programmer;
final MonitorState state;
final ProgrammerTask? currentTask;
final Duration currentTaskToday;
final Duration todayTotal;
final int inProgress;
final int paused;
final int notStarted;
final String? sheetStatus;
final List<HelpEdge> helped;
final List<HelpEdge> helpedBy;
ProgrammerMonitorEntry({
required this.programmer,
required this.state,
required this.currentTask,
required this.currentTaskToday,
required this.todayTotal,
required this.inProgress,
required this.paused,
required this.notStarted,
this.sheetStatus,
required this.helped,
required this.helpedBy,
});
}
/// Build monitor entries for all programmers for a given day.
///
/// Sort order: working -> paused -> idle -> passSlip -> onLeave,
/// then alphabetical by programmer.fullName within each state group.
///
/// Parameters [leaves] and [passSlips] should be pre-computed maps from
/// activeLeaveByUser() and activePassSlipByUser() respectively.
List<ProgrammerMonitorEntry> buildMonitorEntries({
required List<Profile> programmers,
required DayActivityData today,
required Map<String, ProgrammerRunInfo> runStates,
required Map<String, LeaveOfAbsence> leaves,
required Map<String, PassSlip> passSlips,
required Map<String, String> sheetStatusByUser,
required Map<String, String> projectNames,
required Map<String, String> profileNames,
required DateTime now,
}) {
final taskById = {for (final t in today.tasks) t.id: t};
final entries = <ProgrammerMonitorEntry>[];
for (final programmer in programmers) {
final programmerId = programmer.id;
// Step 1: Determine state
// Priority: onLeave > passSlip > working/paused/idle
MonitorState state;
if (leaves.containsKey(programmerId)) {
state = MonitorState.onLeave;
} else if (passSlips.containsKey(programmerId)) {
state = MonitorState.passSlip;
} else {
// Determine from task states
state = _determineState(programmerId, today.tasks, runStates);
}
// Step 2: Find current task (oldest running task)
ProgrammerTask? currentTask;
if (state == MonitorState.working) {
currentTask = _findCurrentTask(programmerId, today.tasks, runStates);
}
// Step 3: Count tasks by state
final (inProgress, paused, notStarted) =
_countTasksByState(programmerId, today.tasks, runStates);
// Step 4: Calculate durations
final allTasksRows = buildDayRows(
userId: programmerId,
day: today.day,
tasks: today.tasks,
logsByTask: today.logsByTask,
workLogs: today.workLogs,
projectNames: projectNames,
profileNames: profileNames,
now: now,
);
final todayTotal = Duration(seconds: totalSeconds(allTasksRows));
// For current task, filter to only that task's work logs
final currentTaskToday = _calculateCurrentTaskDuration(
programmerId,
currentTask,
today,
projectNames,
profileNames,
now,
);
// Step 5: Build helped and helpedBy edges
final helped = _buildHelpedEdges(programmerId, today.workLogs, taskById, today.day);
final helpedBy = _buildHelpedByEdges(programmerId, today.workLogs, taskById, today.day);
entries.add(ProgrammerMonitorEntry(
programmer: programmer,
state: state,
currentTask: currentTask,
currentTaskToday: currentTaskToday,
todayTotal: todayTotal,
inProgress: inProgress,
paused: paused,
notStarted: notStarted,
sheetStatus: sheetStatusByUser[programmerId],
helped: helped,
helpedBy: helpedBy,
));
}
// Sort by state then by name
entries.sort((a, b) {
final stateOrder = [
MonitorState.working,
MonitorState.paused,
MonitorState.idle,
MonitorState.passSlip,
MonitorState.onLeave,
];
final aStateIdx = stateOrder.indexOf(a.state);
final bStateIdx = stateOrder.indexOf(b.state);
if (aStateIdx != bStateIdx) {
return aStateIdx.compareTo(bStateIdx);
}
return a.programmer.fullName.compareTo(b.programmer.fullName);
});
return entries;
}
/// Determine state from task states
MonitorState _determineState(
String programmerId,
List<ProgrammerTask> tasks,
Map<String, ProgrammerRunInfo> runStates,
) {
final programmerTasks = tasks.where((t) => t.assigneeId == programmerId).toList();
var hasRunning = false;
var hasPaused = false;
for (final task in programmerTasks) {
final runInfo = runStates[task.id];
if (runInfo == null) continue;
if (runInfo.state == ProgrammerRunState.running) {
hasRunning = true;
} else if (runInfo.state == ProgrammerRunState.paused) {
hasPaused = true;
}
}
if (hasRunning) return MonitorState.working;
if (hasPaused) return MonitorState.paused;
return MonitorState.idle;
}
/// Find the current task (oldest running task by since)
ProgrammerTask? _findCurrentTask(
String programmerId,
List<ProgrammerTask> tasks,
Map<String, ProgrammerRunInfo> runStates,
) {
final runningTasks = tasks
.where((t) =>
t.assigneeId == programmerId &&
runStates[t.id]?.state == ProgrammerRunState.running)
.toList();
if (runningTasks.isEmpty) return null;
// Sort by since (earliest first)
runningTasks.sort((a, b) {
final aSince = runStates[a.id]?.since ?? DateTime(0);
final bSince = runStates[b.id]?.since ?? DateTime(0);
return aSince.compareTo(bSince);
});
return runningTasks.first;
}
/// Count tasks by state
(int inProgress, int paused, int notStarted) _countTasksByState(
String programmerId,
List<ProgrammerTask> tasks,
Map<String, ProgrammerRunInfo> runStates,
) {
var inProgress = 0;
var paused = 0;
var notStarted = 0;
final programmerTasks = tasks.where((t) => t.assigneeId == programmerId).toList();
for (final task in programmerTasks) {
final runInfo = runStates[task.id];
if (runInfo == null) continue;
if (runInfo.state == ProgrammerRunState.running ||
runInfo.state == ProgrammerRunState.paused) {
// This is still "in progress" (running or paused)
// We count these separately
if (runInfo.state == ProgrammerRunState.running) {
inProgress++;
} else {
paused++;
}
} else if (runInfo.state == ProgrammerRunState.notStarted) {
notStarted++;
}
}
return (inProgress, paused, notStarted);
}
/// Calculate duration for current task
Duration _calculateCurrentTaskDuration(
String programmerId,
ProgrammerTask? currentTask,
DayActivityData today,
Map<String, String> projectNames,
Map<String, String> profileNames,
DateTime now,
) {
if (currentTask == null) return Duration.zero;
// Filter work logs to only this task
final currentTaskLogs =
today.workLogs.where((w) => w.taskId == currentTask.id).toList();
// Filter activity logs to only this task
final currentTaskActivityLogs = {
currentTask.id: today.logsByTask[currentTask.id] ?? []
};
final rows = buildDayRows(
userId: programmerId,
day: today.day,
tasks: [currentTask],
logsByTask: currentTaskActivityLogs,
workLogs: currentTaskLogs,
projectNames: projectNames,
profileNames: profileNames,
now: now,
);
return Duration(seconds: totalSeconds(rows));
}
/// Build helped edges (where programmer helped others)
List<HelpEdge> _buildHelpedEdges(
String programmerId,
List<ProgrammerTaskWorkLog> workLogs,
Map<String, ProgrammerTask> taskById,
DateTime day,
) {
final edges = <HelpEdge>[];
for (final log in workLogs) {
// Filter: author == programmer AND author != assignee AND minutes > 0 AND workDate == day
if (log.authorId != programmerId) continue;
final task = taskById[log.taskId];
if (task == null) continue;
if (log.authorId == task.assigneeId) continue;
if (log.minutes == null || log.minutes! <= 0) continue;
if (log.workDate != day) continue;
edges.add(HelpEdge(
helperId: log.authorId,
assigneeId: task.assigneeId ?? '',
taskId: log.taskId,
taskTitle: task.title,
minutes: log.minutes!,
at: log.createdAt,
));
}
// Sort by createdAt newest first
edges.sort((a, b) => b.at.compareTo(a.at));
return edges;
}
/// Build helpedBy edges (where others helped programmer)
List<HelpEdge> _buildHelpedByEdges(
String programmerId,
List<ProgrammerTaskWorkLog> workLogs,
Map<String, ProgrammerTask> taskById,
DateTime day,
) {
final edges = <HelpEdge>[];
for (final log in workLogs) {
final task = taskById[log.taskId];
if (task == null) continue;
// Filter: task assigned to programmer AND author != programmer AND minutes > 0 AND workDate == day
if (task.assigneeId != programmerId) continue;
if (log.authorId == programmerId) continue;
if (log.minutes == null || log.minutes! <= 0) continue;
if (log.workDate != day) continue;
edges.add(HelpEdge(
helperId: log.authorId,
assigneeId: programmerId,
taskId: log.taskId,
taskTitle: task.title,
minutes: log.minutes!,
at: log.createdAt,
));
}
// Sort by createdAt newest first
edges.sort((a, b) => b.at.compareTo(a.at));
return edges;
}
@@ -0,0 +1,234 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../models/programmer_day_sheet.model.dart';
import '../../../providers/leave_provider.dart';
import '../../../providers/pass_slip_provider.dart';
import '../../../providers/profile_provider.dart';
import '../../../providers/programmer_day_data_provider.dart';
import '../../../providers/programmer_day_sheets_provider.dart';
import '../../../utils/app_time.dart';
import '../../../utils/programmer_daily_time.dart';
import '../../../utils/staff_presence.dart';
import '../../../widgets/app_status_summary.dart';
import '../../../theme/app_status_colors.dart';
import 'help_panel.dart';
import 'monitor_model.dart';
import 'programmer_monitor_card.dart';
/// Summary row + programmer grid + help panel for the Monitor tab.
class MonitorTab extends ConsumerWidget {
const MonitorTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Tick every 30 s to keep durations fresh.
final now = ref.watch(clockTickerProvider).valueOrNull ?? AppTime.now();
final todayKey = ref.watch(todayKeyProvider);
final profilesAsync = ref.watch(profilesProvider);
final dayDataAsync = ref.watch(programmerDayDataProvider(todayKey));
final runStates = ref.watch(programmerRunStatesProvider);
final leavesAsync = ref.watch(leavesProvider);
final passSlipsAsync = ref.watch(passSlipsProvider);
final allSheetsAsync = ref.watch(allDaySheetsProvider);
final profiles = profilesAsync.valueOrNull ?? [];
final programmerProfiles =
profiles.where((p) => p.role == 'programmer').toList();
final profileNames = <String, String>{
for (final p in profiles) p.id: p.fullName,
};
final leaves = leavesAsync.valueOrNull ?? [];
final passSlips = passSlipsAsync.valueOrNull ?? [];
final activeLeaves = activeLeaveByUser(leaves, now);
final activePassSlips = activePassSlipByUser(passSlips);
// Build sheetStatusByUser for today: programmerId → status.
final allSheets = allSheetsAsync.valueOrNull ?? const <ProgrammerDaySheet>[];
final sheetStatusByUser = <String, String>{};
var sheetsPending = 0;
for (final sheet in allSheets) {
if (sheet.workDate == manilaDay(now)) {
sheetStatusByUser[sheet.programmerId] = sheet.status;
if (sheet.status == DaySheetStatus.pending ||
sheet.status == DaySheetStatus.disapproved) {
sheetsPending++;
}
}
}
if (dayDataAsync.isLoading && !dayDataAsync.hasValue) {
return const Center(child: CircularProgressIndicator());
}
final dayData = dayDataAsync.valueOrNull;
List<ProgrammerMonitorEntry> entries = [];
if (dayData != null) {
entries = buildMonitorEntries(
programmers: programmerProfiles,
today: dayData,
runStates: runStates,
leaves: activeLeaves,
passSlips: activePassSlips,
sheetStatusByUser: sheetStatusByUser,
projectNames: const {},
profileNames: profileNames,
now: now,
);
}
// Compute summary counts.
var working = 0;
var paused = 0;
var idle = 0;
var away = 0;
for (final e in entries) {
switch (e.state) {
case MonitorState.working:
working++;
case MonitorState.paused:
paused++;
case MonitorState.idle:
idle++;
case MonitorState.onLeave:
case MonitorState.passSlip:
away++;
}
}
final summaryItems = [
AppStatusSummaryItem(
label: 'Working',
count: working,
kind: StatusKind.success,
),
AppStatusSummaryItem(
label: 'Paused',
count: paused,
kind: StatusKind.warning,
),
AppStatusSummaryItem(
label: 'Idle',
count: idle,
kind: StatusKind.neutral,
),
AppStatusSummaryItem(
label: 'Away',
count: away,
kind: StatusKind.info,
),
AppStatusSummaryItem(
label: 'Sheets pending',
count: sheetsPending,
kind: StatusKind.warning,
),
];
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
// Help panel breakpoint: show side-by-side at >= 900.
final sideBySide = width >= 900;
final grid = _ProgrammerGrid(
entries: entries,
profileNames: profileNames,
width: sideBySide ? width - 320 : width,
);
final body = sideBySide
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: grid),
const SizedBox(width: 12),
SizedBox(
width: 300,
child: HelpPanel(profileNames: profileNames),
),
],
)
: Column(
children: [
grid,
const SizedBox(height: 16),
SizedBox(
height: 340,
child: HelpPanel(profileNames: profileNames),
),
],
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AppStatusSummaryRow(
items: summaryItems,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: body,
),
),
],
);
},
);
}
}
class _ProgrammerGrid extends StatelessWidget {
const _ProgrammerGrid({
required this.entries,
required this.profileNames,
required this.width,
});
final List<ProgrammerMonitorEntry> entries;
final Map<String, String> profileNames;
final double width;
@override
Widget build(BuildContext context) {
final crossAxisCount = width < 700
? 1
: width < 1100
? 2
: 3;
if (entries.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text('No programmers found.'),
),
);
}
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 2.4,
),
itemCount: entries.length,
itemBuilder: (context, i) {
final entry = entries[i];
return ProgrammerMonitorCard(
entry: entry,
profileNames: profileNames,
onTap: () =>
context.go('/programmer-tasks/programmers/${entry.programmer.id}'),
);
},
);
}
}
@@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
import '../../../theme/app_status_colors.dart';
import '../../../theme/app_surfaces.dart';
import '../../../widgets/tech_chip.dart';
import 'monitor_model.dart';
/// State pill label and colour for a [MonitorState].
({String label, StatusKind kind}) _statePill(MonitorState state) {
switch (state) {
case MonitorState.working:
return (label: 'Working', kind: StatusKind.success);
case MonitorState.paused:
return (label: 'Paused', kind: StatusKind.warning);
case MonitorState.idle:
return (label: 'Idle', kind: StatusKind.neutral);
case MonitorState.onLeave:
return (label: 'On leave', kind: StatusKind.info);
case MonitorState.passSlip:
return (label: 'Pass slip', kind: StatusKind.info);
}
}
String _fmtDuration(Duration d) {
final h = d.inHours;
final m = d.inMinutes.remainder(60);
if (h > 0) return '${h}h ${m}m';
return '${m}m';
}
/// Card displayed in the Monitor tab for a single programmer.
///
/// Shows:
/// - programmer name + state pill
/// - current task title (if working)
/// - running time on current task + total today
/// - task counts (in-progress / paused / not-started)
/// - help edges (Helped → / Helped by ←)
class ProgrammerMonitorCard extends StatelessWidget {
const ProgrammerMonitorCard({
super.key,
required this.entry,
required this.onTap,
this.profileNames = const {},
});
final ProgrammerMonitorEntry entry;
final VoidCallback onTap;
final Map<String, String> profileNames;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
final surfaces = AppSurfaces.of(context);
final pill = _statePill(entry.state);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(surfaces.cardRadius),
child: Container(
decoration: BoxDecoration(
color: cs.surfaceContainerLow,
borderRadius: BorderRadius.circular(surfaces.cardRadius),
border: Border.all(color: cs.outlineVariant),
),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: name + state pill
Row(
children: [
Expanded(
child: Text(
entry.programmer.fullName,
style: tt.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: cs.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
TechChip(
pill.label,
status: pill.kind,
),
],
),
// Current task
if (entry.currentTask != null) ...[
const SizedBox(height: 6),
Text(
entry.currentTask!.title,
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 8),
// Time row
Wrap(
spacing: 12,
runSpacing: 4,
children: [
if (entry.state == MonitorState.working &&
entry.currentTaskToday > Duration.zero)
_InfoChip(
label:
'running ${_fmtDuration(entry.currentTaskToday)} today',
cs: cs,
tt: tt,
),
_InfoChip(
label: 'Today ${_fmtDuration(entry.todayTotal)}',
cs: cs,
tt: tt,
),
],
),
// Task counts
const SizedBox(height: 6),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
if (entry.inProgress > 0)
_InfoChip(
label: '${entry.inProgress} in-progress',
cs: cs,
tt: tt,
),
if (entry.paused > 0)
_InfoChip(
label: '${entry.paused} paused',
cs: cs,
tt: tt,
),
if (entry.notStarted > 0)
_InfoChip(
label: '${entry.notStarted} not started',
cs: cs,
tt: tt,
),
],
),
// Help edges
if (entry.helped.isNotEmpty || entry.helpedBy.isNotEmpty) ...[
const SizedBox(height: 8),
for (final e in entry.helped)
_HelpRow(
prefix: 'Helped →',
name: profileNames[e.assigneeId] ?? e.assigneeId,
task: e.taskTitle,
minutes: e.minutes,
cs: cs,
tt: tt,
),
for (final e in entry.helpedBy)
_HelpRow(
prefix: 'Helped by ←',
name: profileNames[e.helperId] ?? e.helperId,
task: e.taskTitle,
minutes: e.minutes,
cs: cs,
tt: tt,
),
],
],
),
),
);
}
}
class _InfoChip extends StatelessWidget {
const _InfoChip({
required this.label,
required this.cs,
required this.tt,
});
final String label;
final ColorScheme cs;
final TextTheme tt;
@override
Widget build(BuildContext context) {
return Text(
label,
style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant),
);
}
}
class _HelpRow extends StatelessWidget {
const _HelpRow({
required this.prefix,
required this.name,
required this.task,
required this.minutes,
required this.cs,
required this.tt,
});
final String prefix;
final String name;
final String task;
final int minutes;
final ColorScheme cs;
final TextTheme tt;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'$prefix $name · $task · ${minutes}m',
style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
);
}
}
@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/profile.model.dart';
import '../../models/programmer_day_sheet.model.dart';
import '../../providers/profile_provider.dart';
import '../../providers/programmer_day_sheets_provider.dart';
import '../../theme/app_status_colors.dart';
import '../../utils/app_time.dart';
import '../../widgets/app_section_header.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/status_pill.dart';
import 'dashboard/programmer_dashboard.dart';
import 'report/accomplishment_report_dialog.dart';
/// Admin-only drill-down screen that shows [ProgrammerDashboard] for a
/// specific user in read-only mode, plus their recent day sheets history.
///
/// Route: /programmer-tasks/programmers/:userId
class ProgrammerDrillDownScreen extends ConsumerWidget {
const ProgrammerDrillDownScreen({
super.key,
required this.userId,
});
final String userId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isStrictAdmin = ref.watch(isStrictAdminProvider);
final profilesAsync = ref.watch(profilesProvider);
if (!isStrictAdmin) {
return Scaffold(
appBar: AppBar(title: const Text('Programmer')),
body: const AppEmptyView(
icon: Icons.lock_outline,
title: 'Admins only',
),
);
}
// Resolve the programmer's name for the AppBar title.
final profiles = profilesAsync.valueOrNull ?? <Profile>[];
Profile? profile;
for (final p in profiles) {
if (p.id == userId) {
profile = p;
break;
}
}
final name = profile?.fullName ?? 'Programmer';
return Scaffold(
appBar: AppBar(
title: Text(name),
actions: [
OutlinedButton(
onPressed: () => showAccomplishmentReportDialog(
context,
ref,
programmerId: userId,
),
child: const Text('Generate Accomplishment Report'),
),
const SizedBox(width: 8),
],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Dashboard fills the upper portion of the screen.
Expanded(
child: ProgrammerDashboard(userId: userId, readOnly: true),
),
const Divider(height: 1),
// Recent day sheets section — fixed-height scrollable panel.
_RecentDaySheetsPanel(userId: userId),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Recent sheets panel
// ---------------------------------------------------------------------------
class _RecentDaySheetsPanel extends ConsumerWidget {
const _RecentDaySheetsPanel({required this.userId});
final String userId;
StatusKind _kindFor(String status) => switch (status) {
DaySheetStatus.approved => StatusKind.success,
DaySheetStatus.pending => StatusKind.info,
DaySheetStatus.disapproved => StatusKind.warning,
_ => StatusKind.neutral,
};
@override
Widget build(BuildContext context, WidgetRef ref) {
final sheetsAsync = ref.watch(daySheetsForUserProvider(userId));
final allSheets = sheetsAsync.valueOrNull ?? const [];
// Show the most-recent 10 sheets (provider already orders newest-first).
final recent = allSheets.length > 10 ? allSheets.sublist(0, 10) : allSheets;
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 320),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: AppSectionHeader('Recent day sheets'),
),
if (recent.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'No day sheets yet.',
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
)
else
Flexible(
child: ListView.builder(
padding: const EdgeInsets.only(bottom: 12),
itemCount: recent.length,
itemBuilder: (context, i) {
final sheet = recent[i];
return InkWell(
onTap: () => context.pushNamed(
'daySheetDetail',
pathParameters: {'id': sheet.id},
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
child: Row(
children: [
Expanded(
child: Text(
AppTime.formatDate(sheet.workDate),
style: tt.bodyMedium,
),
),
StatusPill.kind(
_kindFor(sheet.status),
label: DaySheetStatus.label(sheet.status),
),
],
),
),
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/programmer_task.model.dart';
import '../../providers/programmer_tasks_provider.dart';
/// Confirms with the user and completes [task]. Body moved verbatim from the
/// detail screen's `_complete` so it can be called from both the card and the
/// detail action bar.
Future<void> completeProgrammerTaskWithConfirm(
BuildContext context,
WidgetRef ref,
ProgrammerTask task,
) async {
final messenger = ScaffoldMessenger.of(context);
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
semanticLabel: 'Complete task',
title: const Text('Complete task?'),
content: Text('Mark "${task.title}" as completed?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Complete'),
),
],
),
);
if (ok != true) return;
await ref
.read(programmerTasksControllerProvider)
.completeTask(taskId: task.id);
messenger.showSnackBar(const SnackBar(content: Text('Task completed')));
}
/// Starts (or resumes) [task] for the current user, enforcing the single-active
/// task discipline: if the user already has a *running* task, they are prompted
/// to pause it first. This is the headline "proper time management" behavior and
/// is shared by the list and detail screens.
///
/// Returns true if the task was started/resumed, false if the user cancelled.
Future<bool> startProgrammerTaskWithPausePrompt(
BuildContext context,
WidgetRef ref,
ProgrammerTask task,
) async {
final controller = ref.read(programmerTasksControllerProvider);
final messenger = ScaffoldMessenger.of(context);
final running = await controller.findRunningTaskForCurrentUser();
if (running != null && running.id != task.id) {
if (!context.mounted) return false;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
semanticLabel: 'Pause current task',
title: const Text('Pause current task?'),
content: Text(
"You're currently working on \"${running.title}\". "
'Pause it and start "${task.title}"?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Pause & Start'),
),
],
),
);
if (confirmed != true) return false;
await controller.pauseTask(taskId: running.id);
}
try {
if (task.status == ProgrammerTaskStatus.queued) {
await controller.startTask(taskId: task.id);
} else if (task.status == ProgrammerTaskStatus.inProgress) {
await controller.resumeTask(taskId: task.id);
}
messenger.showSnackBar(
SnackBar(content: Text('Started "${task.title}"')),
);
return true;
} catch (e) {
messenger.showSnackBar(SnackBar(content: Text('Could not start task: $e')));
return false;
}
}
@@ -0,0 +1,238 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/profile.model.dart';
import '../../models/programmer_project.model.dart';
import '../../models/programmer_task.model.dart';
import '../../providers/profile_provider.dart';
import '../../providers/programmer_projects_provider.dart';
import '../../providers/programmer_tasks_provider.dart';
import '../../widgets/quill_field.dart';
/// Shows the "New Programmer Task" dialog and, on success, navigates to the new
/// task's detail. Shared by the tasks list FAB and the project detail screen
/// (pass [initialProjectId] to pre-select a project).
Future<void> showCreateProgrammerTaskDialog(
BuildContext context,
WidgetRef ref, {
String? initialProjectId,
}) async {
final userId = ref.read(currentUserIdProvider);
final profiles = ref.read(profilesProvider).valueOrNull ?? const <Profile>[];
final eligible = profiles
.where((p) => p.role == 'programmer' || p.role == 'admin')
.toList()
..sort((a, b) => a.fullName.compareTo(b.fullName));
final projectOptions =
List<ProgrammerProject>.from(ref.read(activeProgrammerProjectsProvider));
final titleCtrl = TextEditingController();
final descQuill = quillControllerFromDelta(null);
var category = ProgrammerTaskCategory.softwareDevelopment;
var assigneeId = userId;
var projectId = initialProjectId;
final created = await showDialog<bool>(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setDialogState) {
final dialogWidth =
math.min(480.0, MediaQuery.of(context).size.width - 48);
return AlertDialog(
semanticLabel: 'New programmer task',
title: const Text('New Programmer Task'),
content: SizedBox(
width: dialogWidth,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: titleCtrl,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Title',
hintText: 'What are you working on?',
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: Text('Description (optional)',
style: Theme.of(context).textTheme.bodySmall),
),
const SizedBox(height: 4),
QuillFieldEditor(
controller: descQuill,
placeholder: 'Describe the task…',
height: 140,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: category,
decoration: const InputDecoration(labelText: 'Category'),
items: [
for (final c in ProgrammerTaskCategory.all)
DropdownMenuItem(value: c, child: Text(c)),
],
onChanged: (v) =>
setDialogState(() => category = v ?? category),
),
if (eligible.isNotEmpty) ...[
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: assigneeId,
decoration: const InputDecoration(labelText: 'Assignee'),
items: [
for (final p in eligible)
DropdownMenuItem(
value: p.id,
child: Text(
p.id == userId ? '${p.fullName} (me)' : p.fullName,
),
),
],
onChanged: (v) =>
setDialogState(() => assigneeId = v ?? assigneeId),
),
],
const SizedBox(height: 12),
DropdownButtonFormField<String?>(
initialValue: projectOptions.any((p) => p.id == projectId)
? projectId
: null,
decoration:
const InputDecoration(labelText: 'Project (optional)'),
items: [
const DropdownMenuItem<String?>(
value: null, child: Text('No project')),
for (final p in projectOptions)
DropdownMenuItem<String?>(
value: p.id, child: Text(p.name)),
],
onChanged: (v) => setDialogState(() => projectId = v),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () async {
final proj = await _promptNewProject(context, ref);
if (proj != null) {
setDialogState(() {
projectOptions.add(proj);
projectId = proj.id;
});
}
},
icon: const Icon(Icons.add, size: 18),
label: const Text('New project'),
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Create'),
),
],
);
},
);
},
);
if (created != true) {
titleCtrl.dispose();
descQuill.dispose();
return;
}
final title = titleCtrl.text.trim();
titleCtrl.dispose();
final description =
quillPlainText(descQuill).isEmpty ? null : quillToDeltaJson(descQuill);
descQuill.dispose();
if (title.isEmpty) return;
if (!context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
final id = await ref.read(programmerTasksControllerProvider).createTask(
title: title,
category: category,
description: description,
assigneeId: assigneeId,
projectId: projectId,
);
if (!context.mounted) return;
context.go('/programmer-tasks/$id');
} catch (e) {
messenger.showSnackBar(SnackBar(content: Text('Could not create task: $e')));
}
}
/// Prompts for a project name, creates it, and returns a lightweight model for
/// the picker (the realtime insert with the same id syncs shortly after).
Future<ProgrammerProject?> _promptNewProject(
BuildContext context,
WidgetRef ref,
) async {
final messenger = ScaffoldMessenger.of(context);
final nameCtrl = TextEditingController();
final name = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
semanticLabel: 'New project',
title: const Text('New project'),
content: TextField(
controller: nameCtrl,
autofocus: true,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(labelText: 'Project name'),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(nameCtrl.text.trim()),
child: const Text('Create'),
),
],
),
);
nameCtrl.dispose();
if (name == null || name.isEmpty) return null;
try {
final id = await ref
.read(programmerProjectsControllerProvider)
.createProject(name: name);
final now = DateTime.now().toUtc();
return ProgrammerProject(
id: id,
name: name,
status: ProgrammerProjectStatus.active,
createdAt: now,
updatedAt: now,
);
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Could not create project: $e')),
);
return null;
}
}
@@ -0,0 +1,438 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/profile.model.dart';
import '../../models/programmer_task.model.dart';
import '../../models/programmer_task_activity_log.model.dart';
import '../../providers/profile_provider.dart';
import '../../providers/programmer_projects_provider.dart';
import '../../providers/programmer_task_work_logs_provider.dart';
import '../../providers/programmer_tasks_provider.dart';
import '../../utils/app_time.dart';
import '../../utils/programmer_task_ledger.dart';
import '../../utils/task_duration.dart';
import '../../widgets/m3_card.dart';
import '../../widgets/quill_field.dart';
import '../../widgets/responsive_body.dart';
import '../../widgets/status_pill.dart';
import '../../widgets/tech_chip.dart';
import 'programmer_task_actions.dart';
import 'programmer_task_edit.dart';
import 'programmer_task_status_kind.dart';
import 'widgets/activity_timeline.dart';
import 'widgets/comment_section.dart';
import 'widgets/time_ledger_card.dart';
import 'widgets/work_log_section.dart';
/// Lifecycle events, newest-first, that determine the live running/paused state.
const _lifecycleActions = ['started', 'paused', 'resumed', 'completed', 'cancelled'];
class ProgrammerTaskDetailScreen extends ConsumerWidget {
const ProgrammerTaskDetailScreen({super.key, required this.taskId});
final String taskId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final task = ref.watch(programmerTaskByIdProvider(taskId));
final logsAsync = ref.watch(programmerTaskActivityLogsProvider(taskId));
final userId = ref.watch(currentUserIdProvider);
final role = ref.watch(currentProfileProvider).valueOrNull?.role;
final profiles = ref.watch(profilesProvider).valueOrNull ?? const <Profile>[];
final profileById = {for (final p in profiles) p.id: p};
return Scaffold(
appBar: AppBar(
title: Text(task?.title ?? 'Programmer Task'),
actions: [
if (task != null && !task.isTerminal && _canAct(task, userId, role)) ...[
IconButton(
tooltip: 'Edit',
icon: const Icon(Icons.edit_outlined),
onPressed: () => showEditProgrammerTaskDialog(context, ref, task),
),
IconButton(
tooltip: 'Reassign',
icon: const Icon(Icons.person_add_alt_1_outlined),
onPressed: () => _reassign(context, ref, task, profiles, userId),
),
],
],
),
body: task == null
? const Center(child: CircularProgressIndicator())
: _DetailBody(
task: task,
logs: logsAsync.valueOrNull ?? const [],
profileById: profileById,
userId: userId,
role: role,
),
);
}
}
bool _canAct(ProgrammerTask task, String? userId, String? role) =>
task.assigneeId == userId || role == 'admin' || role == 'programmer';
class _DetailBody extends ConsumerWidget {
const _DetailBody({
required this.task,
required this.logs,
required this.profileById,
required this.userId,
required this.role,
});
final ProgrammerTask task;
final List<ProgrammerTaskActivityLog> logs;
final Map<String, Profile> profileById;
final String? userId;
final String? role;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
// Live running/paused state from the newest lifecycle event.
String latest = '';
for (final l in logs) {
if (_lifecycleActions.contains(l.actionType)) {
latest = l.actionType;
break;
}
}
final isPaused =
task.status == ProgrammerTaskStatus.inProgress && latest == 'paused';
final isRunning =
task.status == ProgrammerTaskStatus.inProgress && !isPaused;
final endAt = switch (task.status) {
ProgrammerTaskStatus.completed => task.completedAt ?? AppTime.now(),
ProgrammerTaskStatus.cancelled => task.cancelledAt ?? AppTime.now(),
_ => AppTime.now(),
};
// Sum any time deducted (helped elsewhere) from the activity log.
var adjustmentSeconds = 0;
for (final l in logs) {
if (l.actionType == 'adjustment') {
adjustmentSeconds += (l.meta?['seconds'] as num?)?.toInt() ?? 0;
}
}
final worked = computeEffectiveDuration(
fallbackStartedAt: task.startedAt,
endAt: endAt,
events: logs
.map((l) => (createdAt: l.createdAt, actionType: l.actionType))
.toList(),
adjustment: Duration(seconds: adjustmentSeconds),
);
final workLogs =
ref.watch(programmerTaskWorkLogsProvider(task.id)).valueOrNull ?? const [];
final ledger = computeTaskLedger(
assigneeId: task.assigneeId,
assigneeDuration: worked,
workLogs: workLogs,
);
final assignee =
task.assigneeId == null ? null : profileById[task.assigneeId];
final project = task.projectId == null
? null
: ref.watch(programmerProjectByIdProvider(task.projectId!));
final canAct = _canAct(task, userId, role);
final isAssignee = task.assigneeId == userId;
return DefaultTabController(
length: 3,
child: ResponsiveBody(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 16),
M3Card.elevated(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
task.title,
style: tt.titleLarge
?.copyWith(fontWeight: FontWeight.w700),
),
),
StatusPill.kind(
programmerTaskStatusKind(task.status),
label: isPaused
? 'Paused'
: ProgrammerTaskStatus.label(task.status),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
TechChip(task.category, icon: Icons.category_outlined),
if (project != null)
TechChip(project.name, icon: Icons.folder_outlined),
if (assignee != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.person_outline,
size: 15, color: cs.onSurfaceVariant),
const SizedBox(width: 4),
Text(
assignee.fullName,
style: tt.bodyMedium
?.copyWith(color: cs.onSurfaceVariant),
),
],
),
],
),
if (task.description != null &&
task.description!.trim().isNotEmpty) ...[
const SizedBox(height: 14),
QuillReadOnly(task.description!, style: tt.bodyMedium),
],
const SizedBox(height: 14),
Row(
children: [
Icon(Icons.timer_outlined, size: 18, color: cs.primary),
const SizedBox(width: 8),
Text(
'Worked: ${formatDurationHm(worked)}',
style: tt.titleSmall
?.copyWith(fontWeight: FontWeight.w600),
),
if (isRunning) ...[
const SizedBox(width: 8),
Text('· running',
style: tt.bodySmall
?.copyWith(color: cs.onSurfaceVariant)),
],
],
),
if (task.cancellationReason != null) ...[
const SizedBox(height: 10),
Text(
'Cancelled: ${task.cancellationReason}',
style: tt.bodySmall?.copyWith(color: cs.error),
),
],
],
),
),
),
if (ledger.total > Duration.zero) ...[
const SizedBox(height: 16),
TimeLedgerCard(ledger: ledger, profileById: profileById),
],
if (canAct && !task.isTerminal) ...[
const SizedBox(height: 16),
_ActionBar(
task: task,
isRunning: isRunning,
isPaused: isPaused,
),
],
const SizedBox(height: 16),
const TabBar(
tabs: [
Tab(text: 'Work Log'),
Tab(text: 'Comments'),
Tab(text: 'Activity'),
],
),
Expanded(
child: TabBarView(
children: [
SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 16),
child: WorkLogSection(task: task, isAssignee: isAssignee),
),
SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 16),
child: CommentSection(task: task),
),
SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 16),
child: ActivityTimeline(logs: logs, profileById: profileById),
),
],
),
),
],
),
),
);
}
}
class _ActionBar extends ConsumerWidget {
const _ActionBar({
required this.task,
required this.isRunning,
required this.isPaused,
});
final ProgrammerTask task;
final bool isRunning;
final bool isPaused;
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.read(programmerTasksControllerProvider);
final isQueued = task.status == ProgrammerTaskStatus.queued;
return Wrap(
spacing: 10,
runSpacing: 10,
children: [
if (isQueued)
FilledButton.icon(
onPressed: () =>
startProgrammerTaskWithPausePrompt(context, ref, task),
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Start'),
),
if (isPaused)
FilledButton.icon(
onPressed: () =>
startProgrammerTaskWithPausePrompt(context, ref, task),
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Resume'),
),
if (isRunning)
FilledButton.tonalIcon(
onPressed: () async {
final messenger = ScaffoldMessenger.of(context);
await controller.pauseTask(taskId: task.id);
messenger.showSnackBar(const SnackBar(content: Text('Paused')));
},
icon: const Icon(Icons.pause_rounded),
label: const Text('Pause'),
),
if (isRunning || isPaused)
OutlinedButton.icon(
onPressed: () =>
completeProgrammerTaskWithConfirm(context, ref, task),
icon: const Icon(Icons.check_rounded),
label: const Text('Complete'),
),
OutlinedButton.icon(
onPressed: () => _cancel(context, ref, task),
icon: const Icon(Icons.close_rounded),
label: const Text('Cancel'),
),
],
);
}
}
Future<void> _cancel(
BuildContext context,
WidgetRef ref,
ProgrammerTask task,
) async {
final reasonCtrl = TextEditingController();
final reason = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
semanticLabel: 'Cancel task',
title: const Text('Cancel task?'),
content: TextField(
controller: reasonCtrl,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Reason',
hintText: 'Why is this being cancelled?',
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Back'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(reasonCtrl.text.trim()),
child: const Text('Cancel Task'),
),
],
),
);
reasonCtrl.dispose();
if (reason == null || reason.isEmpty) return;
if (!context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
await ref
.read(programmerTasksControllerProvider)
.cancelTask(taskId: task.id, reason: reason);
messenger.showSnackBar(const SnackBar(content: Text('Task cancelled')));
}
Future<void> _reassign(
BuildContext context,
WidgetRef ref,
ProgrammerTask task,
List<Profile> profiles,
String? userId,
) async {
final eligible = profiles
.where((p) => p.role == 'programmer' || p.role == 'admin')
.toList()
..sort((a, b) => a.fullName.compareTo(b.fullName));
if (eligible.isEmpty) return;
var selected = task.assigneeId ?? userId;
final chosen = await showDialog<String>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
semanticLabel: 'Reassign task',
title: const Text('Reassign task'),
content: DropdownButtonFormField<String>(
initialValue: eligible.any((p) => p.id == selected) ? selected : null,
decoration: const InputDecoration(labelText: 'Assignee'),
items: [
for (final p in eligible)
DropdownMenuItem(
value: p.id,
child: Text(p.id == userId ? '${p.fullName} (me)' : p.fullName),
),
],
onChanged: (v) => setDialogState(() => selected = v),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(selected),
child: const Text('Reassign'),
),
],
),
),
);
if (chosen == null || chosen == task.assigneeId) return;
if (!context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
await ref
.read(programmerTasksControllerProvider)
.reassign(taskId: task.id, newAssigneeId: chosen);
messenger.showSnackBar(const SnackBar(content: Text('Task reassigned')));
}
@@ -0,0 +1,89 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/programmer_task.model.dart';
import '../../providers/programmer_tasks_provider.dart';
import '../../widgets/quill_field.dart';
/// Edit a task's Title (plain) and Description (Quill). Saves via the existing
/// [ProgrammerTasksController.updateTask].
Future<void> showEditProgrammerTaskDialog(
BuildContext context,
WidgetRef ref,
ProgrammerTask task,
) async {
final titleCtrl = TextEditingController(text: task.title);
final descQuill = quillControllerFromDelta(task.description);
final ok = await showDialog<bool>(
context: context,
builder: (context) {
final dialogWidth =
math.min(480.0, MediaQuery.of(context).size.width - 48);
return AlertDialog(
semanticLabel: 'Edit task',
title: const Text('Edit Task'),
content: SizedBox(
width: dialogWidth,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: titleCtrl,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(labelText: 'Title'),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: Text('Description',
style: Theme.of(context).textTheme.bodySmall),
),
const SizedBox(height: 4),
QuillFieldEditor(
controller: descQuill,
placeholder: 'Describe the task…',
height: 180,
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Save'),
),
],
);
},
);
final title = titleCtrl.text.trim();
titleCtrl.dispose();
final description =
quillPlainText(descQuill).isEmpty ? '' : quillToDeltaJson(descQuill);
descQuill.dispose();
if (ok != true || title.isEmpty || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
await ref.read(programmerTasksControllerProvider).updateTask(
taskId: task.id,
title: title,
description: description,
);
messenger.showSnackBar(const SnackBar(content: Text('Task updated')));
} catch (e) {
messenger.showSnackBar(SnackBar(content: Text('Could not update: $e')));
}
}
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/programmer_task.model.dart';
import '../../providers/profile_provider.dart';
import '../../providers/programmer_tasks_provider.dart';
/// After the ASSIGNEE saves a work log or comment on their own actively-running
/// task, ask whether to pause the timer. No-op for anyone else, or when the task
/// isn't the caller's currently-running one.
Future<void> maybePromptPauseTimer(
BuildContext context,
WidgetRef ref,
ProgrammerTask task,
) async {
final userId = ref.read(currentUserIdProvider);
if (task.assigneeId != userId) return;
final controller = ref.read(programmerTasksControllerProvider);
final messenger = ScaffoldMessenger.of(context);
// Fresh query — the running-task provider can lag right after a start/switch.
final running = await controller.findRunningTaskForCurrentUser();
if (running?.id != task.id) return;
if (!context.mounted) return;
final pause = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
semanticLabel: 'Pause the timer',
title: const Text('Pause the timer?'),
content: Text('You logged on "${task.title}". Pause its timer now?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Keep running'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Pause'),
),
],
),
);
if (pause == true) {
await controller.pauseTask(taskId: task.id);
messenger.showSnackBar(const SnackBar(content: Text('Timer paused')));
}
}
/// After a HELPER logs Time Consumed on a task they don't own, ask whether to
/// deduct that time from their own currently-running task. No-op if they have no
/// running (unpaused) task. [helpedTaskId] is recorded for audit.
Future<void> maybePromptDeductHelperTime(
BuildContext context,
WidgetRef ref,
int minutes,
String helpedTaskId,
) async {
if (minutes <= 0) return;
final controller = ref.read(programmerTasksControllerProvider);
final running = await controller.findRunningTaskForCurrentUser();
if (running == null) return;
if (!context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
final deduct = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
semanticLabel: 'Deduct helped time',
title: const Text('Deduct this time?'),
content: Text(
'You logged $minutes min helping. Deduct it from your active task '
'"${running.title}" so your time stays accurate?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Skip'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Deduct'),
),
],
),
);
if (deduct == true) {
await controller.addAdjustment(
taskId: running.id,
seconds: minutes * 60,
reason: 'helped',
sourceTaskId: helpedTaskId,
);
messenger.showSnackBar(
SnackBar(content: Text('Deducted $minutes min from "${running.title}"')),
);
}
}
@@ -0,0 +1,13 @@
import '../../models/programmer_task.model.dart';
import '../../theme/app_status_colors.dart';
/// Maps a programmer-task status to a semantic [StatusKind] so its color carries
/// consistent meaning across the list and detail screens
/// (grey=queued, blue=in progress, green=completed, grey=cancelled).
StatusKind programmerTaskStatusKind(String status) => switch (status) {
ProgrammerTaskStatus.queued => StatusKind.neutral,
ProgrammerTaskStatus.inProgress => StatusKind.info,
ProgrammerTaskStatus.completed => StatusKind.success,
ProgrammerTaskStatus.cancelled => StatusKind.neutral,
_ => StatusKind.neutral,
};
@@ -0,0 +1,191 @@
import 'package:flutter/foundation.dart' show listEquals;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/profile.model.dart';
import '../../providers/profile_provider.dart';
import '../../providers/programmer_tasks_provider.dart';
import '../../theme/m3_motion.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/reconnect_overlay.dart';
import '../../widgets/responsive_body.dart';
import 'dashboard/programmer_dashboard.dart';
import 'day_sheets/approvals_tab.dart';
import 'day_sheets/day_sheets_tab.dart';
import 'monitor/monitor_tab.dart';
import 'programmer_task_create.dart';
import 'programmer_tasks_tabs.dart';
import 'widgets/programmer_task_card.dart';
class ProgrammerTasksListScreen extends ConsumerStatefulWidget {
const ProgrammerTasksListScreen({super.key, this.initialTab});
final String? initialTab;
@override
ConsumerState<ProgrammerTasksListScreen> createState() =>
_ProgrammerTasksListScreenState();
}
class _ProgrammerTasksListScreenState
extends ConsumerState<ProgrammerTasksListScreen>
with TickerProviderStateMixin {
late TabController _tabController;
late List<ProgrammerTasksTab> _tabs;
bool _tabsBuilt = false;
void _buildTabs(bool isStrictAdmin) {
final tabs = programmerTasksTabsFor(isStrictAdmin: isStrictAdmin);
// Determine initial tab index from widget.initialTab.
int initialIndex = 0;
final parsed = parseProgrammerTasksTab(widget.initialTab);
if (parsed != null) {
final idx = tabs.indexOf(parsed);
if (idx >= 0) initialIndex = idx;
}
if (_tabsBuilt && listEquals(_tabs, tabs)) {
return;
}
final oldController = _tabsBuilt ? _tabController : null;
_tabs = tabs;
_tabController = TabController(
length: tabs.length,
initialIndex: initialIndex,
vsync: this,
);
_tabController.addListener(() {
if (mounted) setState(() {});
});
_tabsBuilt = true;
oldController?.dispose();
}
@override
void dispose() {
if (_tabsBuilt) _tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isStrictAdmin = ref.watch(isStrictAdminProvider);
_buildTabs(isStrictAdmin);
final tasksAsync = ref.watch(programmerTasksProvider);
final userId = ref.watch(currentUserIdProvider);
final profilesAsync = ref.watch(profilesProvider);
final profileById = <String, Profile>{
for (final p in profilesAsync.valueOrNull ?? <Profile>[]) p.id: p,
};
final currentTab = _tabs[_tabController.index];
return Stack(
children: [
ResponsiveBody(
maxWidth: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const AppPageHeader(
title: 'Programmer Tasks',
subtitle: 'Log and track development work',
),
TabBar(
controller: _tabController,
tabs: [
for (final t in _tabs)
Tab(text: programmerTasksTabLabel(t)),
],
),
const SizedBox(height: 8),
Expanded(
child: Builder(
builder: (context) {
// Monitor tab — show the monitor overview.
if (currentTab == ProgrammerTasksTab.monitor) {
return const MonitorTab();
}
// Approvals tab — admin bulk-approve day sheets.
if (currentTab == ProgrammerTasksTab.approvals) {
return const ApprovalsTab();
}
// Mine tab — delegate entirely to ProgrammerDashboard.
if (currentTab == ProgrammerTasksTab.mine) {
if (userId == null) {
return const Center(child: CircularProgressIndicator());
}
return ProgrammerDashboard(userId: userId);
}
// Day Sheets tab — programmer personal sheet management.
if (currentTab == ProgrammerTasksTab.daySheets) {
return const DaySheetsTab();
}
// All tab — flat list with error / loading / empty states.
if (tasksAsync.hasError && !tasksAsync.hasValue) {
return AppErrorView(
error: tasksAsync.error!,
onRetry: () =>
ref.invalidate(programmerTasksProvider),
);
}
if (!tasksAsync.hasValue && tasksAsync.isLoading) {
return const Center(child: CircularProgressIndicator());
}
final all = tasksAsync.valueOrNull ?? const [];
if (all.isEmpty) {
return const AppEmptyView(
icon: Icons.developer_mode_outlined,
title: 'No tasks yet',
subtitle:
'Create the first programmer task with the + button.',
);
}
return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: all.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, i) {
final task = all[i];
return ProgrammerTaskCard(
task: task,
assignee: task.assigneeId == null
? null
: profileById[task.assigneeId],
isMine: task.assigneeId == userId,
);
},
);
},
),
),
],
),
),
Positioned(
right: 16,
bottom: 16,
child: M3ExpandedFab(
heroTag: 'create_programmer_task',
onPressed: () => showCreateProgrammerTaskDialog(context, ref),
icon: const Icon(Icons.add),
label: const Text('New Task'),
),
),
const ReconnectIndicator(),
],
);
}
}
@@ -0,0 +1,66 @@
/// Tab enumeration and helpers for the Programmer Tasks list screen.
///
/// This-slice tabs (Task 10):
/// Admin: [monitor, mine, all]
/// Non-admin: [mine, all]
///
/// approvals and daySheets are deferred to later slices.
enum ProgrammerTasksTab { monitor, approvals, mine, all, daySheets }
/// Returns the ordered tab list for the current role.
///
/// Admin: [monitor, approvals, mine, all]
/// Programmer: [mine, all, daySheets]
List<ProgrammerTasksTab> programmerTasksTabsFor({required bool isStrictAdmin}) {
if (isStrictAdmin) {
return [
ProgrammerTasksTab.monitor,
ProgrammerTasksTab.approvals,
ProgrammerTasksTab.mine,
ProgrammerTasksTab.all,
];
}
return [
ProgrammerTasksTab.mine,
ProgrammerTasksTab.all,
ProgrammerTasksTab.daySheets,
];
}
/// Parses a URL query-parameter value to a [ProgrammerTasksTab].
///
/// Valid values: 'monitor', 'approvals', 'mine', 'all', 'day-sheets'.
/// Returns null for unrecognised or null input.
ProgrammerTasksTab? parseProgrammerTasksTab(String? q) {
switch (q) {
case 'monitor':
return ProgrammerTasksTab.monitor;
case 'approvals':
return ProgrammerTasksTab.approvals;
case 'mine':
return ProgrammerTasksTab.mine;
case 'all':
return ProgrammerTasksTab.all;
case 'day-sheets':
case 'daySheets':
return ProgrammerTasksTab.daySheets;
default:
return null;
}
}
/// Human-readable tab label for display in [TabBar].
String programmerTasksTabLabel(ProgrammerTasksTab t) {
switch (t) {
case ProgrammerTasksTab.monitor:
return 'Monitor';
case ProgrammerTasksTab.approvals:
return 'Approvals';
case ProgrammerTasksTab.mine:
return 'Mine';
case ProgrammerTasksTab.all:
return 'All';
case ProgrammerTasksTab.daySheets:
return 'Day Sheets';
}
}
@@ -0,0 +1,210 @@
import 'package:tasq/models/programmer_day_sheet.model.dart';
import 'package:tasq/models/programmer_task.model.dart';
import 'package:tasq/models/profile.model.dart';
import 'package:tasq/providers/reports_provider.dart';
import 'package:tasq/utils/programmer_day_rows.dart';
/// A single day's report with status and rows.
class ReportDay {
final DateTime day;
final String? statusTag; // null when approved; 'Pending' | 'Disapproved'
final List<DaySheetRow> rows;
const ReportDay({
required this.day,
required this.statusTag,
required this.rows,
});
}
/// Complete accomplishment report data for a programmer over a date range.
class AccomplishmentReportData {
final String programmerName;
final String positionLabel; // 'Programmer' | 'Administrator'
final DateTime periodStart;
final DateTime periodEndInclusive; // day keys (UTC)
final List<ReportDay> days; // ascending by day
final int excludedPending;
final int excludedDisapproved;
final int daysReported;
final int totalSeconds;
final int tasksCompleted;
final int helpSeconds;
final Map<String, int> secondsByCategory;
final String? approverName;
const AccomplishmentReportData({
required this.programmerName,
required this.positionLabel,
required this.periodStart,
required this.periodEndInclusive,
required this.days,
required this.excludedPending,
required this.excludedDisapproved,
required this.daysReported,
required this.totalSeconds,
required this.tasksCompleted,
required this.helpSeconds,
required this.secondsByCategory,
required this.approverName,
});
}
/// Build accomplishment report data for a programmer over a date range.
///
/// Rules:
/// - Only sheets with workDate in [range.start, range.end) are considered.
/// - range.end is EXCLUSIVE; periodEndInclusive = range.end.subtract(Duration(days: 1)).
/// - In approved-only mode: include only approved sheets; statusTag = null; use snapshot rows.
/// - In include-unapproved mode: include all; statusTag varies; use liveRowsByDay for
/// non-approved, snapshot for approved.
/// - Days with zero rows are omitted.
/// - helpSeconds: sum row.seconds for rows where row.kind == 'helper'.
/// - secondsByCategory: sum row.seconds keyed by row.category (skip null).
/// - tasksCompleted: count tasks where status == 'completed' AND completedAt (or updatedAt)
/// falls in [range.start, range.end) AND assigneeId == programmer.id.
/// - approverName: if all included approved days have the same reviewedBy and that set is
/// non-empty, look up from profileNames; else null.
/// - daysReported = days.length.
/// - totalSeconds: sum all row.seconds.
AccomplishmentReportData buildAccomplishmentReportData({
required Profile programmer,
required ReportDateRange range,
required bool includeUnapproved,
required List<ProgrammerDaySheet> sheets,
required Map<DateTime, List<DaySheetRow>> liveRowsByDay,
required List<ProgrammerTask> tasks,
required Map<String, String> profileNames,
}) {
// Determine position label
final positionLabel = programmer.role == 'administrator' ? 'Administrator' : 'Programmer';
// Calculate period boundaries
final periodStart = range.start;
final periodEndInclusive = range.end.subtract(const Duration(days: 1));
// Filter sheets in range and split by status
final sheetsInRange = sheets.where((s) {
final workDate = s.workDate;
return !workDate.isBefore(range.start) && workDate.isBefore(range.end);
}).toList();
int excludedPending = 0;
int excludedDisapproved = 0;
final reportDays = <ReportDay>[];
final allReviewers = <String>{};
int totalSeconds = 0;
int helpSeconds = 0;
final secondsByCategory = <String, int>{};
if (includeUnapproved) {
// Include all sheets: approved + pending + disapproved + draft
for (final sheet in sheetsInRange) {
final statusTag = sheet.status == DaySheetStatus.approved
? null
: (sheet.status == DaySheetStatus.pending || sheet.status == DaySheetStatus.draft)
? 'Pending'
: 'Disapproved';
// Get rows: use snapshot for approved, liveRowsByDay for others
final rows = sheet.status == DaySheetStatus.approved
? sheet.approvedSnapshot?.rows ?? []
: liveRowsByDay[sheet.workDate] ?? [];
if (rows.isNotEmpty) {
reportDays.add(ReportDay(
day: sheet.workDate,
statusTag: statusTag,
rows: rows,
));
// Accumulate metrics
for (final row in rows) {
totalSeconds += row.seconds;
if (row.kind == 'helper') {
helpSeconds += row.seconds;
}
secondsByCategory[row.category] =
(secondsByCategory[row.category] ?? 0) + row.seconds;
}
// Track reviewers for included approved sheets only
if (sheet.status == DaySheetStatus.approved && sheet.reviewedBy != null) {
allReviewers.add(sheet.reviewedBy!);
}
}
}
} else {
// Approved-only mode
for (final sheet in sheetsInRange) {
if (sheet.status == DaySheetStatus.approved) {
final rows = sheet.approvedSnapshot?.rows ?? [];
if (rows.isNotEmpty) {
reportDays.add(ReportDay(
day: sheet.workDate,
statusTag: null,
rows: rows,
));
// Accumulate metrics
for (final row in rows) {
totalSeconds += row.seconds;
if (row.kind == 'helper') {
helpSeconds += row.seconds;
}
secondsByCategory[row.category] =
(secondsByCategory[row.category] ?? 0) + row.seconds;
}
// Track reviewer for included sheets only
if (sheet.reviewedBy != null) {
allReviewers.add(sheet.reviewedBy!);
}
}
} else if (sheet.status == DaySheetStatus.pending) {
excludedPending++;
} else if (sheet.status == DaySheetStatus.disapproved) {
excludedDisapproved++;
}
}
}
// Sort report days by day key
reportDays.sort((a, b) => a.day.compareTo(b.day));
// Determine approver name: only if all approved days have the same reviewer
String? approverName;
if (allReviewers.isNotEmpty && allReviewers.length == 1) {
final reviewer = allReviewers.first;
approverName = profileNames[reviewer];
}
// Count completed tasks in range for this programmer
int tasksCompleted = 0;
for (final task in tasks) {
if (task.status == ProgrammerTaskStatus.completed &&
task.assigneeId == programmer.id) {
// Use completedAt if available, otherwise updatedAt
final completionDate = task.completedAt ?? task.updatedAt;
if (!completionDate.isBefore(range.start) && completionDate.isBefore(range.end)) {
tasksCompleted++;
}
}
}
return AccomplishmentReportData(
programmerName: programmer.fullName,
positionLabel: positionLabel,
periodStart: periodStart,
periodEndInclusive: periodEndInclusive,
days: reportDays,
excludedPending: excludedPending,
excludedDisapproved: excludedDisapproved,
daysReported: reportDays.length,
totalSeconds: totalSeconds,
tasksCompleted: tasksCompleted,
helpSeconds: helpSeconds,
secondsByCategory: secondsByCategory,
approverName: approverName,
);
}
@@ -0,0 +1,282 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/profile.model.dart';
import '../../../models/programmer_day_sheet.model.dart';
import '../../../providers/profile_provider.dart';
import '../../../providers/programmer_day_data_provider.dart';
import '../../../providers/programmer_day_sheets_provider.dart';
import '../../../providers/programmer_tasks_provider.dart';
import '../../../providers/reports_provider.dart';
import '../../../providers/supabase_provider.dart';
import '../../../utils/app_time.dart';
import '../../../utils/programmer_day_rows.dart';
import '../../../widgets/pdf_preview_dialog.dart';
import '../../reports/report_date_filter.dart';
import 'accomplishment_report_data.dart';
import 'accomplishment_report_pdf.dart';
/// Load accomplishment report data for [programmer] over [range].
///
/// For non-approved sheets when [includeUnapproved] is true, fetches live
/// rows via [fetchDayActivityData] + [buildDayRows]; approved sheets use
/// the stored snapshot.
Future<AccomplishmentReportData> loadAccomplishmentReport(
WidgetRef ref, {
required Profile programmer,
required ReportDateRange range,
required bool includeUnapproved,
}) async {
final sheets =
ref.read(daySheetsForUserProvider(programmer.id)).valueOrNull ?? [];
final profiles = ref.read(profilesProvider).valueOrNull ?? [];
final profileNames = {for (final p in profiles) p.id: p.fullName};
final tasks = ref.read(programmerTasksProvider).valueOrNull ?? [];
final client = ref.read(supabaseClientProvider);
final liveRowsByDay = <DateTime, List<DaySheetRow>>{};
if (includeUnapproved) {
for (final sheet in sheets) {
if (!sheet.workDate.isBefore(range.start) &&
sheet.workDate.isBefore(range.end) &&
sheet.status != DaySheetStatus.approved) {
final data = await fetchDayActivityData(
client,
day: sheet.workDate,
tasks: tasks,
now: AppTime.now(),
);
final rows = buildDayRows(
userId: programmer.id,
day: sheet.workDate,
tasks: data.tasks,
logsByTask: data.logsByTask,
workLogs: data.workLogs,
projectNames: const {},
profileNames: profileNames,
now: AppTime.now(),
);
liveRowsByDay[sheet.workDate] = rows;
}
}
}
return buildAccomplishmentReportData(
programmer: programmer,
range: range,
includeUnapproved: includeUnapproved,
sheets: sheets,
liveRowsByDay: liveRowsByDay,
tasks: tasks,
profileNames: profileNames,
);
}
/// Show the Accomplishment Report generate dialog.
///
/// - [programmerId] non-null: admin mode locked to that programmer.
/// - [programmerId] null + isStrictAdmin: admin picks from all programmers.
/// - [programmerId] null + not admin: programmer self-service (own profile).
Future<void> showAccomplishmentReportDialog(
BuildContext context,
WidgetRef ref, {
String? programmerId,
}) {
return showDialog<void>(
context: context,
builder: (_) => _AccomplishmentReportDialog(programmerId: programmerId),
);
}
// ---------------------------------------------------------------------------
// Dialog widget
// ---------------------------------------------------------------------------
class _AccomplishmentReportDialog extends ConsumerStatefulWidget {
const _AccomplishmentReportDialog({this.programmerId});
final String? programmerId;
@override
ConsumerState<_AccomplishmentReportDialog> createState() =>
_AccomplishmentReportDialogState();
}
class _AccomplishmentReportDialogState
extends ConsumerState<_AccomplishmentReportDialog> {
Profile? _selectedProgrammer;
late ReportDateRange _dateRange;
bool _includeUnapproved = false;
bool _generating = false;
@override
void initState() {
super.initState();
_dateRange = thisMonthRange(AppTime.now());
}
Future<void> _onGenerate() async {
final profiles = ref.read(profilesProvider).valueOrNull ?? [];
final isStrictAdmin = ref.read(isStrictAdminProvider);
final isAdminMode = widget.programmerId != null || isStrictAdmin;
Profile? programmer;
if (widget.programmerId != null) {
for (final p in profiles) {
if (p.id == widget.programmerId) {
programmer = p;
break;
}
}
} else if (isAdminMode) {
programmer = _selectedProgrammer;
} else {
final userId = ref.read(currentUserIdProvider);
for (final p in profiles) {
if (p.id == userId) {
programmer = p;
break;
}
}
}
if (programmer == null) return;
setState(() => _generating = true);
try {
final data = await loadAccomplishmentReport(
ref,
programmer: programmer,
range: _dateRange,
includeUnapproved: _includeUnapproved,
);
if (!mounted) return;
final name = data.programmerName;
Navigator.of(context).pop();
await showPdfPreviewDialog(
context,
title: 'Accomplishment Report – $name',
filename: 'Accomplishment Report - $name.pdf',
buildBytes: () => buildAccomplishmentReportPdf(data),
);
} catch (e) {
if (!mounted) return;
setState(() => _generating = false);
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.toString())));
}
}
@override
Widget build(BuildContext context) {
final profiles = ref.watch(profilesProvider).valueOrNull ?? [];
final isStrictAdmin = ref.watch(isStrictAdminProvider);
final isAdminMode = widget.programmerId != null || isStrictAdmin;
final programmerProfiles =
profiles.where((p) => p.role == 'programmer').toList()
..sort((a, b) => a.fullName.compareTo(b.fullName));
// Generate is disabled when in admin-picker mode and nothing chosen yet.
final canGenerate = !_generating &&
(isAdminMode && widget.programmerId == null
? _selectedProgrammer != null
: true);
return AlertDialog(
title: const Text('Accomplishment Report'),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Programmer picker — only in admin-picker mode.
if (isAdminMode && widget.programmerId == null) ...[
_ProgrammerPicker(
programmers: programmerProfiles,
value: _selectedProgrammer,
onChanged: (p) => setState(() => _selectedProgrammer = p),
),
const SizedBox(height: 12),
],
// Date range filter.
ReportDateFilter.controlled(
value: _dateRange,
onChanged: (r) => setState(() => _dateRange = r),
),
const SizedBox(height: 12),
// Include-unapproved toggle.
SwitchListTile(
title: const Text('Include unapproved days (marked)'),
value: _includeUnapproved,
onChanged: (v) => setState(() => _includeUnapproved = v),
contentPadding: EdgeInsets.zero,
dense: true,
),
],
),
),
actions: [
TextButton(
onPressed: _generating ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: canGenerate ? _onGenerate : null,
child: _generating
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Generate'),
),
],
);
}
}
// ---------------------------------------------------------------------------
// Programmer picker widget
// ---------------------------------------------------------------------------
class _ProgrammerPicker extends StatelessWidget {
const _ProgrammerPicker({
required this.programmers,
required this.value,
required this.onChanged,
});
final List<Profile> programmers;
final Profile? value;
final ValueChanged<Profile?> onChanged;
@override
Widget build(BuildContext context) {
return InputDecorator(
decoration: const InputDecoration(
labelText: 'Programmer',
border: OutlineInputBorder(),
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<Profile>(
value: value,
hint: const Text('Select programmer'),
isExpanded: true,
items: programmers
.map(
(p) => DropdownMenuItem<Profile>(
value: p,
child: Text(p.fullName),
),
)
.toList(),
onChanged: onChanged,
),
),
);
}
}
@@ -0,0 +1,287 @@
import 'dart:typed_data';
import 'package:intl/intl.dart';
import 'package:pdf/pdf.dart' as pdf;
import 'package:pdf/widgets.dart' as pw;
import 'package:tasq/screens/programmer_tasks/report/accomplishment_report_data.dart';
import 'package:tasq/utils/pdf/pdf_letterhead.dart';
import 'package:tasq/utils/programmer_task_ledger.dart';
/// Build an A4 Accomplishment Report PDF from [data].
Future<Uint8List> buildAccomplishmentReportPdf(
AccomplishmentReportData data) async {
final brand = await PdfBrand.load();
final doc = pw.Document();
doc.addPage(
pw.MultiPage(
pageFormat: pdf.PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(32),
theme: brand.theme(),
footer: brand.pageFooter,
build: (ctx) => [
brand.letterhead(),
pw.SizedBox(height: 8),
pw.Center(
child: pw.Text(
'ACCOMPLISHMENT REPORT',
style: pw.TextStyle(
font: brand.bold, fontSize: 14, letterSpacing: 1.5),
),
),
pw.SizedBox(height: 10),
_infoBox(data, brand),
pw.SizedBox(height: 10),
_reportTable(data, brand),
if (data.excludedPending + data.excludedDisapproved > 0)
_footnote(data, brand),
pw.SizedBox(height: 12),
_summary(data, brand),
pw.SizedBox(height: 16),
_signatures(data, brand),
],
),
);
return doc.save();
}
// ─── Section builders ─────────────────────────────────────────────────────────
pw.Widget _infoBox(AccomplishmentReportData data, PdfBrand brand) {
final fmt = DateFormat('MMMM d, yyyy');
final periodStr =
'${fmt.format(data.periodStart)} – ${fmt.format(data.periodEndInclusive)}';
pw.Widget infoRow(String label, String value) => pw.RichText(
text: pw.TextSpan(
children: [
pw.TextSpan(
text: '$label ',
style: pw.TextStyle(font: brand.bold, fontSize: 9),
),
pw.TextSpan(
text: value,
style: pw.TextStyle(font: brand.regular, fontSize: 9),
),
],
),
);
return pw.Container(
decoration: pw.BoxDecoration(
border: pw.Border.all(width: 0.8, color: pdf.PdfColors.grey400),
),
padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
infoRow('Name:', data.programmerName),
pw.SizedBox(height: 3),
infoRow('Position:', data.positionLabel),
pw.SizedBox(height: 3),
infoRow('Period:', periodStr),
],
),
);
}
pw.Widget _reportTable(AccomplishmentReportData data, PdfBrand brand) {
final dateFmt = DateFormat('MMM d, yyyy');
const colWidths = {
0: pw.FixedColumnWidth(80),
1: pw.FlexColumnWidth(2),
2: pw.FlexColumnWidth(3),
3: pw.FixedColumnWidth(50),
};
final border = pw.TableBorder.all(width: 0.5, color: pdf.PdfColors.grey400);
pw.Widget th(String text) => pw.Padding(
padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: pw.Text(
text,
style: pw.TextStyle(font: brand.bold, fontSize: 8),
),
);
pw.Widget td(String text) => pw.Padding(
padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 3),
child: pw.Text(
text,
style: pw.TextStyle(font: brand.regular, fontSize: 8),
),
);
final headerRow = pw.TableRow(
decoration: const pw.BoxDecoration(color: pdf.PdfColors.grey200),
children: [
th('DATE'),
th('TASK / PROJECT'),
th('ACCOMPLISHMENT'),
th('TIME'),
],
);
if (data.days.isEmpty) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
pw.Table(
border: border,
columnWidths: colWidths,
children: [headerRow],
),
pw.Container(
decoration: pw.BoxDecoration(
border: pw.Border(
left: const pw.BorderSide(width: 0.5, color: pdf.PdfColors.grey400),
right: const pw.BorderSide(width: 0.5, color: pdf.PdfColors.grey400),
bottom: const pw.BorderSide(width: 0.5, color: pdf.PdfColors.grey400),
),
),
padding: const pw.EdgeInsets.all(8),
child: pw.Text(
'No approved accomplishments in this period.',
style: pw.TextStyle(
font: brand.regular,
fontSize: 8,
color: pdf.PdfColors.grey700),
),
),
],
);
}
final rows = <pw.TableRow>[headerRow];
for (final day in data.days) {
String dateLabel = dateFmt.format(day.day);
if (day.statusTag != null) {
dateLabel += '\n[${day.statusTag}]';
}
for (final row in day.rows) {
final taskProject = row.kind == 'helper'
? 'Helped ${row.helpedName ?? ''} — ${row.title}'
: (row.projectName != null && row.projectName!.isNotEmpty
? '${row.title}\n(${row.projectName})'
: row.title);
final accomplishment =
row.notes.isEmpty ? '(no notes)' : row.notes.join('\n');
final timeStr = formatDurationHm(Duration(seconds: row.seconds));
rows.add(pw.TableRow(children: [
td(dateLabel),
td(taskProject),
td(accomplishment),
td(timeStr),
]));
}
}
return pw.Table(
border: border,
columnWidths: colWidths,
children: rows,
);
}
pw.Widget _footnote(AccomplishmentReportData data, PdfBrand brand) {
final n = data.excludedPending + data.excludedDisapproved;
return pw.Padding(
padding: const pw.EdgeInsets.only(top: 4),
child: pw.Text(
'$n day(s) in this period are not included '
'(${data.excludedPending} pending, ${data.excludedDisapproved} disapproved).',
style: pw.TextStyle(font: brand.regular, fontSize: 8,
color: pdf.PdfColors.grey700),
),
);
}
pw.Widget _summary(AccomplishmentReportData data, PdfBrand brand) {
pw.Widget statRow(String label, String value) => pw.Row(
children: [
pw.Expanded(
child: pw.Text(label,
style: pw.TextStyle(font: brand.bold, fontSize: 9)),
),
pw.Text(value,
style: pw.TextStyle(font: brand.regular, fontSize: 9)),
],
);
return pw.Container(
decoration: pw.BoxDecoration(
border: pw.Border.all(width: 0.8, color: pdf.PdfColors.grey400),
color: pdf.PdfColors.grey100,
),
padding: const pw.EdgeInsets.all(8),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'SUMMARY',
style: pw.TextStyle(font: brand.bold, fontSize: 9, letterSpacing: 1),
),
pw.SizedBox(height: 6),
statRow('Days reported:', '${data.daysReported}'),
statRow(
'Total time:', formatDurationHm(Duration(seconds: data.totalSeconds))),
statRow('Tasks completed:', '${data.tasksCompleted}'),
statRow(
'Help given:', formatDurationHm(Duration(seconds: data.helpSeconds))),
if (data.secondsByCategory.isNotEmpty) ...[
pw.SizedBox(height: 4),
pw.Text(
'Time by category:',
style: pw.TextStyle(font: brand.bold, fontSize: 9),
),
for (final entry in data.secondsByCategory.entries)
pw.Padding(
padding: const pw.EdgeInsets.only(left: 8, top: 2),
child: statRow(
'${entry.key}:',
formatDurationHm(Duration(seconds: entry.value)),
),
),
],
],
),
);
}
pw.Widget _signatures(AccomplishmentReportData data, PdfBrand brand) {
pw.Widget sig(String label, String value) => pw.RichText(
text: pw.TextSpan(
children: [
pw.TextSpan(
text: '$label ',
style: pw.TextStyle(font: brand.bold, fontSize: 9),
),
pw.TextSpan(
text: value,
style: pw.TextStyle(font: brand.regular, fontSize: 9),
),
],
),
);
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
sig(
'Prepared by:',
'${data.programmerName} (${data.positionLabel})',
),
pw.SizedBox(height: 8),
sig(
'Approved by:',
data.approverName ?? '____________________',
),
],
);
}
@@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import '../../../models/profile.model.dart';
import '../../../models/programmer_task_activity_log.model.dart';
import '../../../utils/app_time.dart';
import '../../../widgets/app_section_header.dart';
/// The task's activity timeline (created/started/paused/adjusted/…), newest first.
class ActivityTimeline extends StatelessWidget {
const ActivityTimeline({
super.key,
required this.logs,
required this.profileById,
});
final List<ProgrammerTaskActivityLog> logs;
final Map<String, Profile> profileById;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const AppSectionHeader('Activity'),
if (logs.isEmpty)
Text('No activity yet.',
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant))
else
for (final log in logs)
_ActivityRow(log: log, profileById: profileById),
],
);
}
}
class _ActivityRow extends StatelessWidget {
const _ActivityRow({required this.log, required this.profileById});
final ProgrammerTaskActivityLog log;
final Map<String, Profile> profileById;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
final actor = log.actorId == null ? null : profileById[log.actorId];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(_iconFor(log.actionType), size: 16, color: cs.onSurfaceVariant),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${actor?.fullName ?? 'Someone'} · ${_labelFor(log.actionType)}',
style: tt.bodyMedium,
),
Text(
AppTime.relative(log.createdAt),
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
],
),
),
],
),
);
}
}
String _labelFor(String action) => switch (action) {
'created' => 'created the task',
'started' => 'started work',
'paused' => 'paused',
'resumed' => 'resumed',
'completed' => 'completed the task',
'cancelled' => 'cancelled the task',
'assigned' => 'assigned the task',
'reassigned' => 'reassigned the task',
'adjusted' || 'adjustment' => 'adjusted time',
'updated' => 'updated details',
_ => action,
};
IconData _iconFor(String action) => switch (action) {
'created' => Icons.add_task,
'started' => Icons.play_arrow_rounded,
'paused' => Icons.pause_rounded,
'resumed' => Icons.play_arrow_rounded,
'completed' => Icons.check_circle_outline,
'cancelled' => Icons.cancel_outlined,
'assigned' || 'reassigned' => Icons.person_add_alt_1_outlined,
'adjustment' => Icons.timelapse_outlined,
'updated' => Icons.edit_outlined,
_ => Icons.circle_outlined,
};
@@ -0,0 +1,280 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/profile.model.dart';
import '../../../models/programmer_task.model.dart';
import '../../../models/programmer_task_comment.model.dart';
import '../../../providers/profile_provider.dart';
import '../../../providers/programmer_task_comments_provider.dart';
import '../../../utils/app_time.dart';
import '../../../widgets/app_section_header.dart';
import '../../../widgets/m3_card.dart';
import '../../../widgets/quill_field.dart';
import '../programmer_task_prompts.dart';
const _imageExts = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'};
bool _isImage(String name) =>
_imageExts.contains(name.split('.').last.toLowerCase());
/// Comment thread + composer. Any user who can see the task may comment and
/// attach files. The assignee is prompted to pause after commenting.
class CommentSection extends ConsumerStatefulWidget {
const CommentSection({super.key, required this.task});
final ProgrammerTask task;
@override
ConsumerState<CommentSection> createState() => _CommentSectionState();
}
class _CommentSectionState extends ConsumerState<CommentSection> {
final _bodyQuill = quillControllerFromDelta(null);
final List<CommentUpload> _pending = [];
bool _sending = false;
@override
void dispose() {
_bodyQuill.dispose();
super.dispose();
}
Future<void> _pickFiles() async {
final messenger = ScaffoldMessenger.of(context);
final result = await FilePicker.platform.pickFiles(
type: FileType.any,
withData: true,
allowMultiple: true,
);
if (result == null) return;
for (final f in result.files) {
if (f.bytes == null) continue;
if (f.bytes!.length > 25 * 1024 * 1024) {
messenger.showSnackBar(
SnackBar(content: Text('${f.name} exceeds 25MB — skipped')),
);
continue;
}
_pending.add((name: f.name, bytes: f.bytes!));
}
if (mounted) setState(() {});
}
Future<void> _send() async {
final hasText = quillPlainText(_bodyQuill).isNotEmpty;
if ((!hasText && _pending.isEmpty) || _sending) return;
setState(() => _sending = true);
final messenger = ScaffoldMessenger.of(context);
try {
await ref.read(programmerTaskCommentsControllerProvider).addComment(
taskId: widget.task.id,
body: hasText ? quillToDeltaJson(_bodyQuill) : '',
files: List.of(_pending),
);
_bodyQuill.clear();
_pending.clear();
if (!mounted) return;
await maybePromptPauseTimer(context, ref, widget.task);
} catch (e) {
messenger.showSnackBar(SnackBar(content: Text('Could not send: $e')));
} finally {
if (mounted) setState(() => _sending = false);
}
}
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
final commentsAsync =
ref.watch(programmerTaskCommentsProvider(widget.task.id));
final profiles = ref.watch(profilesProvider).valueOrNull ?? const <Profile>[];
final profileById = {for (final p in profiles) p.id: p};
final comments = commentsAsync.valueOrNull ?? const [];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const AppSectionHeader('Comments'),
// Composer
QuillFieldEditor(
controller: _bodyQuill,
placeholder: 'Add a comment…',
height: 110,
),
if (_pending.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (var i = 0; i < _pending.length; i++)
InputChip(
label: Text(_pending[i].name,
overflow: TextOverflow.ellipsis),
onDeleted: () => setState(() => _pending.removeAt(i)),
),
],
),
],
const SizedBox(height: 8),
Row(
children: [
IconButton(
tooltip: 'Attach files',
onPressed: _sending ? null : _pickFiles,
icon: const Icon(Icons.attach_file),
),
const Spacer(),
FilledButton.tonalIcon(
onPressed: _sending ? null : _send,
icon: _sending
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send, size: 18),
label: const Text('Send'),
),
],
),
const SizedBox(height: 12),
if (comments.isEmpty)
Text('No comments yet.',
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant))
else
for (final c in comments)
_CommentTile(comment: c, author: profileById[c.authorId]),
],
);
}
}
class _CommentTile extends ConsumerWidget {
const _CommentTile({required this.comment, required this.author});
final ProgrammerTaskComment comment;
final Profile? author;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
final controller = ref.read(programmerTaskCommentsControllerProvider);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: M3Card.outlined(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(author?.fullName ?? 'Someone',
style: tt.bodyMedium
?.copyWith(fontWeight: FontWeight.w600)),
),
Text(AppTime.relative(comment.createdAt),
style:
tt.bodySmall?.copyWith(color: cs.onSurfaceVariant)),
],
),
if (comment.body.trim().isNotEmpty) ...[
const SizedBox(height: 4),
QuillReadOnly(comment.body, style: tt.bodyMedium),
],
if (comment.attachments.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final a in comment.attachments)
_AttachmentChip(
attachment: a,
url: controller.attachmentUrl(a.path),
),
],
),
],
],
),
),
),
);
}
}
class _AttachmentChip extends StatelessWidget {
const _AttachmentChip({required this.attachment, required this.url});
final ProgrammerCommentAttachment attachment;
final String url;
@override
Widget build(BuildContext context) {
if (_isImage(attachment.name)) {
return InkWell(
onTap: () => _openImage(context),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
url,
width: 84,
height: 84,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _fileChip(context),
),
),
);
}
return _fileChip(context);
}
Widget _fileChip(BuildContext context) => ActionChip(
avatar: const Icon(Icons.insert_drive_file_outlined, size: 16),
label: Text(attachment.name, overflow: TextOverflow.ellipsis),
onPressed: () => _showUrl(context),
);
void _openImage(BuildContext context) {
showDialog<void>(
context: context,
builder: (context) => Dialog(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
InteractiveViewer(child: Image.network(url)),
Padding(
padding: const EdgeInsets.all(8),
child: Text(attachment.name,
style: Theme.of(context).textTheme.bodySmall),
),
],
),
),
);
}
void _showUrl(BuildContext context) {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
semanticLabel: 'Attachment link',
title: Text(attachment.name),
content: SelectableText(url),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
),
);
}
}
@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../models/profile.model.dart';
import '../../../models/programmer_task.model.dart';
import '../../../providers/programmer_day_data_provider.dart';
import '../../../providers/programmer_projects_provider.dart';
import '../../../theme/app_status_colors.dart';
import '../../../utils/app_time.dart';
import '../../../widgets/m3_card.dart';
import '../../../widgets/status_pill.dart';
import '../../../widgets/tech_chip.dart';
import '../programmer_task_actions.dart';
import '../programmer_task_status_kind.dart';
/// Shared task card that reflects the current run state (running / paused /
/// not started) sourced from [programmerRunStatesProvider].
///
/// Used by the task list screen (and any future surface) so the paused-task
/// pill and action buttons are consistent everywhere.
class ProgrammerTaskCard extends ConsumerWidget {
const ProgrammerTaskCard({
super.key,
required this.task,
this.assignee,
required this.isMine,
this.readOnly = false,
});
final ProgrammerTask task;
final Profile? assignee;
final bool isMine;
final bool readOnly;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
final runStates = ref.watch(programmerRunStatesProvider);
final runInfo = runStates[task.id];
final isPaused = runInfo?.state == ProgrammerRunState.paused;
final isRunning = runInfo?.state == ProgrammerRunState.running;
final canStart =
isMine && !readOnly && task.status == ProgrammerTaskStatus.queued;
final canResume = isMine && !readOnly && isPaused;
final canComplete = isMine && !readOnly && (isRunning || isPaused);
final project = task.projectId == null
? null
: ref.watch(programmerProjectByIdProvider(task.projectId!));
final pillKind =
isPaused ? StatusKind.warning : programmerTaskStatusKind(task.status);
final pillLabel =
isPaused ? 'Paused' : ProgrammerTaskStatus.label(task.status);
return M3Card.outlined(
onTap: () => context.go('/programmer-tasks/${task.id}'),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
task.title,
style:
tt.titleMedium?.copyWith(fontWeight: FontWeight.w600),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
StatusPill.kind(pillKind, label: pillLabel),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
TechChip(task.category,
icon: Icons.category_outlined, dense: true),
if (project != null)
TechChip(project.name,
icon: Icons.folder_outlined, dense: true),
if (assignee != null && !isMine)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.person_outline,
size: 14, color: cs.onSurfaceVariant),
const SizedBox(width: 4),
Text(
assignee!.fullName,
style: tt.bodySmall
?.copyWith(color: cs.onSurfaceVariant),
),
],
),
Text(
AppTime.relative(task.createdAt),
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
],
),
if (canStart || canResume) ...[
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: FilledButton.tonalIcon(
onPressed: () =>
startProgrammerTaskWithPausePrompt(context, ref, task),
icon: const Icon(Icons.play_arrow_rounded, size: 18),
label: Text(canResume ? 'Resume' : 'Start'),
),
),
],
if (canComplete) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
onPressed: () =>
completeProgrammerTaskWithConfirm(context, ref, task),
icon: const Icon(Icons.check_rounded, size: 18),
label: const Text('Complete'),
),
),
],
],
),
),
);
}
}
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import '../../../models/profile.model.dart';
import '../../../utils/programmer_task_ledger.dart';
import '../../../widgets/app_section_header.dart';
import '../../../widgets/m3_card.dart';
/// Per-contributor time breakdown for a task: the assignee's running duration
/// plus each helper's logged minutes, and the total.
class TimeLedgerCard extends StatelessWidget {
const TimeLedgerCard({
super.key,
required this.ledger,
required this.profileById,
});
final TaskTimeLedger ledger;
final Map<String, Profile> profileById;
@override
Widget build(BuildContext context) {
if (ledger.total == Duration.zero) return const SizedBox.shrink();
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
return M3Card.outlined(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppSectionHeader(
'Time ledger',
trailing: Text(
formatDurationHm(ledger.total),
style: tt.titleSmall?.copyWith(fontWeight: FontWeight.w700),
),
),
for (final entry in ledger.all)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(
entry.isAssignee
? Icons.play_circle_outline
: Icons.volunteer_activism_outlined,
size: 16,
color: cs.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
profileById[entry.userId]?.fullName ?? 'Unknown',
style: tt.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
if (entry.isAssignee)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text('assignee',
style: tt.bodySmall
?.copyWith(color: cs.onSurfaceVariant)),
),
Text(
formatDurationHm(entry.duration),
style: tt.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,202 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/profile.model.dart';
import '../../../models/programmer_task.model.dart';
import '../../../providers/profile_provider.dart';
import '../../../providers/programmer_task_work_logs_provider.dart';
import '../../../utils/app_time.dart';
import '../../../utils/programmer_task_ledger.dart';
import '../../../widgets/app_section_header.dart';
import '../../../widgets/quill_field.dart';
import '../programmer_task_prompts.dart';
/// Work-log list + composer. The assignee logs progress notes (no time field);
/// a non-assignee helper gets Hours + Minutes fields and is prompted to deduct
/// that time from their own running task. The assignee is prompted to pause
/// after logging.
///
/// When [workDate] is supplied the entry is a correction for a past day:
/// the header names that date and the pause/deduct prompts are skipped.
class WorkLogSection extends ConsumerStatefulWidget {
const WorkLogSection({super.key, required this.task, required this.isAssignee, this.workDate});
final ProgrammerTask task;
final bool isAssignee;
final DateTime? workDate;
@override
ConsumerState<WorkLogSection> createState() => _WorkLogSectionState();
}
class _WorkLogSectionState extends ConsumerState<WorkLogSection> {
final _descQuill = quillControllerFromDelta(null);
final _hoursCtrl = TextEditingController();
final _minutesCtrl = TextEditingController();
bool _saving = false;
@override
void dispose() {
_descQuill.dispose();
_hoursCtrl.dispose();
_minutesCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_saving) return;
if (quillPlainText(_descQuill).isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Describe what you worked on')),
);
return;
}
int? minutes;
if (!widget.isAssignee) {
final h = int.tryParse(_hoursCtrl.text.trim()) ?? 0;
final m = int.tryParse(_minutesCtrl.text.trim()) ?? 0;
minutes = h * 60 + m;
if (minutes <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enter the time you spent helping')),
);
return;
}
}
setState(() => _saving = true);
final messenger = ScaffoldMessenger.of(context);
try {
await ref.read(programmerTaskWorkLogsControllerProvider).addWorkLog(
taskId: widget.task.id,
description: quillToDeltaJson(_descQuill),
minutes: minutes,
workDate: widget.workDate,
);
_descQuill.clear();
_hoursCtrl.clear();
_minutesCtrl.clear();
if (!mounted) return;
if (widget.workDate == null) {
if (widget.isAssignee) {
await maybePromptPauseTimer(context, ref, widget.task);
} else if (minutes != null) {
await maybePromptDeductHelperTime(context, ref, minutes, widget.task.id);
}
}
} catch (e) {
messenger.showSnackBar(SnackBar(content: Text('Could not save: $e')));
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
final logsAsync = ref.watch(programmerTaskWorkLogsProvider(widget.task.id));
final profiles = ref.watch(profilesProvider).valueOrNull ?? const <Profile>[];
final profileById = {for (final p in profiles) p.id: p};
final logs = logsAsync.valueOrNull ?? const [];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppSectionHeader(
widget.workDate != null
? 'Correction for ${AppTime.formatDate(widget.workDate!)}'
: 'Work Log',
),
QuillFieldEditor(
controller: _descQuill,
placeholder: widget.isAssignee
? 'What did you work on?'
: 'What did you help with?',
height: 120,
),
const SizedBox(height: 8),
Row(
children: [
if (!widget.isAssignee) ...[
SizedBox(
width: 78,
child: TextField(
controller: _hoursCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
labelText: 'Hours', isDense: true),
),
),
const SizedBox(width: 8),
SizedBox(
width: 88,
child: TextField(
controller: _minutesCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
labelText: 'Minutes', isDense: true),
),
),
],
const Spacer(),
FilledButton.tonalIcon(
onPressed: _saving ? null : _submit,
icon: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.add, size: 18),
label: const Text('Log'),
),
],
),
const SizedBox(height: 12),
if (logs.isEmpty)
Text('No work logged yet.',
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant))
else
for (final log in logs)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
profileById[log.authorId]?.fullName ?? 'Someone',
style: tt.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
),
),
if (log.minutes != null)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
formatDurationHm(Duration(minutes: log.minutes!)),
style: tt.bodySmall?.copyWith(
color: cs.primary,
fontWeight: FontWeight.w700),
),
),
Text(AppTime.relative(log.createdAt),
style: tt.bodySmall
?.copyWith(color: cs.onSurfaceVariant)),
],
),
const SizedBox(height: 2),
QuillReadOnly(log.description, style: tt.bodyMedium),
],
),
),
],
);
}
}
+43 -4
View File
@@ -5,14 +5,47 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/reports_provider.dart';
import '../../utils/app_time.dart';
/// Returns a [ReportDateRange] spanning the calendar month that contains [now].
/// start = DateTime(y, m, 1), end = DateTime(y, m+1, 1), label = 'This Month'.
ReportDateRange thisMonthRange(DateTime now) {
return ReportDateRange(
start: DateTime(now.year, now.month, 1),
end: DateTime(now.year, now.month + 1, 1),
label: 'This Month',
);
}
/// A Metabase-inspired date filter with quick presets, relative offsets,
/// and a custom date-range picker.
///
/// Two construction modes:
/// - Default `ReportDateFilter({key})` — provider-bound, reads/writes
/// [reportDateRangeProvider].
/// - `ReportDateFilter.controlled({key, value, onChanged})` — controlled;
/// displays [value] and calls [onChanged] on apply without touching the
/// provider.
class ReportDateFilter extends ConsumerWidget {
const ReportDateFilter({super.key});
/// Provider-bound mode.
const ReportDateFilter({super.key})
: _value = null,
_onChanged = null;
/// Controlled mode: does NOT read or write [reportDateRangeProvider].
const ReportDateFilter.controlled({
super.key,
required ReportDateRange value,
required ValueChanged<ReportDateRange> onChanged,
}) : _value = value,
_onChanged = onChanged;
final ReportDateRange? _value;
final ValueChanged<ReportDateRange>? _onChanged;
bool get _isControlled => _value != null;
@override
Widget build(BuildContext context, WidgetRef ref) {
final range = ref.watch(reportDateRangeProvider);
final range = _isControlled ? _value! : ref.watch(reportDateRangeProvider);
final theme = Theme.of(context);
final colors = theme.colorScheme;
final text = theme.textTheme;
@@ -51,12 +84,18 @@ class ReportDateFilter extends ConsumerWidget {
}
void _showDateFilterDialog(BuildContext context, WidgetRef ref) {
final current =
_isControlled ? _value! : ref.read(reportDateRangeProvider);
m3ShowDialog(
context: context,
builder: (ctx) => _DateFilterDialog(
current: ref.read(reportDateRangeProvider),
current: current,
onApply: (newRange) {
ref.read(reportDateRangeProvider.notifier).state = newRange;
if (_isControlled) {
_onChanged!(newRange);
} else {
ref.read(reportDateRangeProvider.notifier).state = newRange;
}
},
),
);
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:skeletonizer/skeletonizer.dart';
import '../../../theme/app_surfaces.dart';
/// Wraps each report chart widget in a themed Card with a title, loading
/// skeleton and error state handling. Exposes a [GlobalKey] on the inner
/// [RepaintBoundary] so the PDF exporter can capture the rendered chart
@@ -74,15 +76,25 @@ class ReportCardWrapper extends StatelessWidget {
body = child;
}
final radius = AppSurfaces.of(context).cardRadius;
final isDark = colors.brightness == Brightness.dark;
final cardContent = Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
child: Text(title, style: text.titleSmall),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
child: Text(
title.toUpperCase(),
style: text.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: colors.onSurfaceVariant,
),
),
),
const Divider(height: 1),
Divider(height: 1, color: colors.outlineVariant),
Padding(
padding: const EdgeInsets.all(16),
child: height != null ? SizedBox(height: height, child: body) : body,
@@ -93,8 +105,12 @@ class ReportCardWrapper extends StatelessWidget {
final card = Card(
elevation: 0,
shadowColor: Colors.transparent,
color: colors.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
surfaceTintColor: Colors.transparent,
color: isDark ? colors.surfaceContainer : colors.surfaceContainerLowest,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radius),
side: BorderSide(color: colors.outlineVariant),
),
child: cardContent,
);
@@ -77,10 +77,11 @@ class _RequestTypeChartState extends ConsumerState<RequestTypeChart> {
return ReportCardWrapper(
title: 'Request Type Distribution',
repaintBoundaryKey: widget.repaintKey,
height: 220,
height: 180,
child: Row(
children: [
Expanded(
SizedBox(
width: 150,
child: PieChart(
PieChartData(
pieTouchData: PieTouchData(
@@ -109,29 +110,31 @@ class _RequestTypeChartState extends ConsumerState<RequestTypeChart> {
radius: isTouched ? 60 : 50,
color: colorFor(i, e.name),
borderSide: isTouched
? const BorderSide(color: Colors.white, width: 2)
? BorderSide(color: Theme.of(context).colorScheme.surface, width: 2)
: BorderSide.none,
);
}).toList(),
),
),
),
const SizedBox(width: 12),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: data.asMap().entries.map((entry) {
final i = entry.key;
final e = entry.value;
final isTouched = i == _touchedIndex;
return _HoverLegendItem(
color: colorFor(i, e.name),
label: e.name,
value:
'${e.count} (${(e.count / total * 100).toStringAsFixed(0)}%)',
isTouched: isTouched,
);
}).toList(),
const SizedBox(width: 16),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: data.asMap().entries.map((entry) {
final i = entry.key;
final e = entry.value;
final isTouched = i == _touchedIndex;
return _HoverLegendItem(
color: colorFor(i, e.name),
label: e.name,
value:
'${e.count} (${(e.count / total * 100).toStringAsFixed(0)}%)',
isTouched: isTouched,
);
}).toList(),
),
),
],
),
@@ -208,10 +211,11 @@ class _RequestCategoryChartState extends ConsumerState<RequestCategoryChart> {
return ReportCardWrapper(
title: 'Request Category Distribution',
repaintBoundaryKey: widget.repaintKey,
height: 220,
height: 180,
child: Row(
children: [
Expanded(
SizedBox(
width: 150,
child: PieChart(
PieChartData(
pieTouchData: PieTouchData(
@@ -240,29 +244,31 @@ class _RequestCategoryChartState extends ConsumerState<RequestCategoryChart> {
radius: isTouched ? 60 : 50,
color: colorFor(i, e.name),
borderSide: isTouched
? const BorderSide(color: Colors.white, width: 2)
? BorderSide(color: Theme.of(context).colorScheme.surface, width: 2)
: BorderSide.none,
);
}).toList(),
),
),
),
const SizedBox(width: 12),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: data.asMap().entries.map((entry) {
final i = entry.key;
final e = entry.value;
final isTouched = i == _touchedIndex;
return _HoverLegendItem(
color: colorFor(i, e.name),
label: e.name,
value:
'${e.count} (${(e.count / total * 100).toStringAsFixed(0)}%)',
isTouched: isTouched,
);
}).toList(),
const SizedBox(width: 16),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: data.asMap().entries.map((entry) {
final i = entry.key;
final e = entry.value;
final isTouched = i == _touchedIndex;
return _HoverLegendItem(
color: colorFor(i, e.name),
label: e.name,
value:
'${e.count} (${(e.count / total * 100).toStringAsFixed(0)}%)',
isTouched: isTouched,
);
}).toList(),
),
),
],
),
+60 -49
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/reports_provider.dart';
import '../../../theme/app_status_colors.dart';
import 'report_card_wrapper.dart';
/// Donut chart ticket counts per status with hover animation.
@@ -51,10 +52,11 @@ class _TicketsByStatusChartState extends ConsumerState<TicketsByStatusChart> {
return ReportCardWrapper(
title: 'Tickets by Status',
repaintBoundaryKey: widget.repaintKey,
height: 220,
height: 180,
child: Row(
children: [
Expanded(
SizedBox(
width: 150,
child: PieChart(
PieChartData(
pieTouchData: PieTouchData(
@@ -84,29 +86,31 @@ class _TicketsByStatusChartState extends ConsumerState<TicketsByStatusChart> {
radius: isTouched ? 60 : 50,
color: _ticketStatusColor(context, e.status),
borderSide: isTouched
? const BorderSide(color: Colors.white, width: 2)
? BorderSide(color: Theme.of(context).colorScheme.surface, width: 2)
: BorderSide.none,
);
}).toList(),
),
),
),
const SizedBox(width: 12),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: data.asMap().entries.map((entry) {
final i = entry.key;
final e = entry.value;
final isTouched = i == _touchedIndex;
return _LegendItem(
color: _ticketStatusColor(context, e.status),
label: _capitalize(e.status),
value:
'${e.count} (${(e.count / total * 100).toStringAsFixed(0)}%)',
isTouched: isTouched,
);
}).toList(),
const SizedBox(width: 16),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: data.asMap().entries.map((entry) {
final i = entry.key;
final e = entry.value;
final isTouched = i == _touchedIndex;
return _LegendItem(
color: _ticketStatusColor(context, e.status),
label: _capitalize(e.status),
value:
'${e.count} (${(e.count / total * 100).toStringAsFixed(0)}%)',
isTouched: isTouched,
);
}).toList(),
),
),
],
),
@@ -116,16 +120,18 @@ class _TicketsByStatusChartState extends ConsumerState<TicketsByStatusChart> {
}
Color _ticketStatusColor(BuildContext context, String status) {
final colors = Theme.of(context).colorScheme;
final s = AppStatusColors.of(context);
switch (status) {
case 'critical':
return s.danger.color;
case 'pending':
return colors.tertiary;
return s.warning.color;
case 'promoted':
return colors.secondary;
return s.info.color;
case 'closed':
return colors.primary;
return s.success.color;
default:
return colors.outlineVariant;
return s.neutral.color;
}
}
}
@@ -175,10 +181,11 @@ class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
return ReportCardWrapper(
title: 'Tasks by Status',
repaintBoundaryKey: widget.repaintKey,
height: 220,
height: 180,
child: Row(
children: [
Expanded(
SizedBox(
width: 150,
child: PieChart(
PieChartData(
pieTouchData: PieTouchData(
@@ -208,29 +215,31 @@ class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
radius: isTouched ? 60 : 50,
color: _taskStatusColor(context, e.status),
borderSide: isTouched
? const BorderSide(color: Colors.white, width: 2)
? BorderSide(color: Theme.of(context).colorScheme.surface, width: 2)
: BorderSide.none,
);
}).toList(),
),
),
),
const SizedBox(width: 12),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: data.asMap().entries.map((entry) {
final i = entry.key;
final e = entry.value;
final isTouched = i == _touchedIndex;
return _LegendItem(
color: _taskStatusColor(context, e.status),
label: _formatTaskStatus(e.status),
value:
'${e.count} (${(e.count / total * 100).toStringAsFixed(0)}%)',
isTouched: isTouched,
);
}).toList(),
const SizedBox(width: 16),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: data.asMap().entries.map((entry) {
final i = entry.key;
final e = entry.value;
final isTouched = i == _touchedIndex;
return _LegendItem(
color: _taskStatusColor(context, e.status),
label: _formatTaskStatus(e.status),
value:
'${e.count} (${(e.count / total * 100).toStringAsFixed(0)}%)',
isTouched: isTouched,
);
}).toList(),
),
),
],
),
@@ -240,18 +249,20 @@ class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
}
Color _taskStatusColor(BuildContext context, String status) {
final colors = Theme.of(context).colorScheme;
final s = AppStatusColors.of(context);
switch (status) {
case 'critical':
return s.danger.color;
case 'queued':
return colors.surfaceContainerHighest;
return s.neutral.color;
case 'in_progress':
return colors.secondary;
return s.info.color;
case 'completed':
return colors.primary;
return s.success.color;
case 'cancelled':
return colors.error;
return s.danger.color;
default:
return colors.outlineVariant;
return s.neutral.color;
}
}
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/verification_session_provider.dart';
import '../../services/face_verification.dart' as face;
import '../../theme/app_status_colors.dart';
/// Screen opened on mobile when the user scans a QR code for cross-device
/// face verification. Performs liveness detection and uploads the result.
@@ -144,7 +145,11 @@ class _MobileVerificationScreenState
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle, size: 64, color: Colors.green),
Icon(
Icons.check_circle,
size: 64,
color: AppStatusColors.of(context).success.color,
),
const SizedBox(height: 16),
Text('Verification Complete', style: theme.textTheme.headlineSmall),
const SizedBox(height: 8),
+5 -1
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../../theme/app_status_colors.dart';
import '../../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
@@ -67,7 +68,10 @@ class _PermissionsScreenState extends ConsumerState<PermissionsScreen> {
title: Text(info.label),
subtitle: Text(status?.toString() ?? 'unknown'),
trailing: granted
? const Icon(Icons.check, color: Colors.green)
? Icon(
Icons.check,
color: AppStatusColors.of(context).success.color,
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
+21 -14
View File
@@ -13,6 +13,8 @@ import '../../providers/teams_provider.dart';
import '../../utils/app_time.dart';
import '../../utils/snackbar.dart';
import '../../widgets/app_breakpoints.dart';
import '../../theme/app_status_colors.dart';
import '../../theme/app_surfaces.dart';
import '../../widgets/m3_card.dart';
import '../../widgets/mono_text.dart';
import '../../widgets/profile_avatar.dart';
@@ -231,24 +233,25 @@ class _ItJobChecklistTabState extends ConsumerState<ItJobChecklistTab> {
}
Widget _buildSkeletonTile(BuildContext context, bool isWide) {
final bone = Theme.of(context).colorScheme.surfaceContainerHighest;
return M3Card.outlined(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Container(width: 60, height: 14, color: Colors.grey),
Container(width: 60, height: 14, color: bone),
const SizedBox(width: 12),
Expanded(
child: Container(height: 14, color: Colors.grey),
child: Container(height: 14, color: bone),
),
if (isWide) ...[
const SizedBox(width: 12),
Container(width: 80, height: 22, color: Colors.grey),
Container(width: 80, height: 22, color: bone),
const SizedBox(width: 12),
Container(width: 120, height: 14, color: Colors.grey),
Container(width: 120, height: 14, color: bone),
],
const SizedBox(width: 12),
Container(width: 24, height: 24, color: Colors.grey),
Container(width: 24, height: 24, color: bone),
],
),
),
@@ -292,20 +295,21 @@ class _StatsCard extends StatelessWidget {
),
);
final status = AppStatusColors.of(context);
final submittedChip = _StatChip(
icon: Icons.check_circle_outline,
label: 'Submitted',
count: submitted,
color: cs.primary,
bgColor: cs.primaryContainer,
color: status.success.onContainer,
bgColor: status.success.container,
);
final pendingChip = _StatChip(
icon: Icons.pending_outlined,
label: 'Pending',
count: pending,
color: cs.tertiary,
bgColor: cs.tertiaryContainer,
color: status.warning.onContainer,
bgColor: status.warning.container,
);
return M3Card.filled(
@@ -399,7 +403,7 @@ class _StatChip extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -878,11 +882,14 @@ class _ItJobTileState extends ConsumerState<_ItJobTile> {
_isChecked ? cs.primaryContainer.withValues(alpha: 0.18) : null;
// Status badge
final statusSwatch = _isChecked
? AppStatusColors.of(context).success
: AppStatusColors.of(context).warning;
final statusBadge = Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: _isChecked ? cs.primaryContainer : cs.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
color: statusSwatch.container,
borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -892,13 +899,13 @@ class _ItJobTileState extends ConsumerState<_ItJobTile> {
? Icons.check_circle_outline
: Icons.hourglass_empty_outlined,
size: 12,
color: _isChecked ? cs.primary : cs.tertiary,
color: statusSwatch.onContainer,
),
const SizedBox(width: 4),
Text(
_isChecked ? 'Submitted' : 'Pending',
style: tt.labelSmall?.copyWith(
color: _isChecked ? cs.primary : cs.tertiary,
color: statusSwatch.onContainer,
fontWeight: FontWeight.w600,
),
),
+35 -38
View File
@@ -35,6 +35,7 @@ import '../../widgets/office_picker.dart';
import '../../widgets/mono_text.dart';
import '../../widgets/responsive_body.dart';
import '../../theme/app_surfaces.dart';
import '../../theme/app_status_colors.dart';
import '../../widgets/sync_pending_badge.dart';
import '../../widgets/task_assignment_section.dart';
import '../../widgets/typing_dots.dart';
@@ -621,9 +622,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
const SizedBox(height: 12),
Row(
children: [
const Icon(
Icon(
Icons.warning_amber_rounded,
color: Colors.orange,
color: AppStatusColors.of(context).warning.color,
),
const SizedBox(width: 6),
Expanded(
@@ -2686,16 +2687,24 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
final files =
_attachments ?? [];
if (files.isEmpty) {
return const Center(
return Center(
child: Padding(
padding: EdgeInsets.all(
padding:
const EdgeInsets.all(
16.0,
),
child: Text(
'No attachments yet',
style: TextStyle(
color: Colors.grey,
),
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(
context,
)
.colorScheme
.onSurfaceVariant,
),
),
),
);
@@ -2722,9 +2731,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
).textTheme.bodySmall,
),
trailing: IconButton(
icon: const Icon(
icon: Icon(
Icons.delete,
color: Colors.red,
color: Theme.of(
context,
).colorScheme.error,
),
onPressed: () =>
_deleteTaskAttachment(
@@ -2873,7 +2884,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
.colorScheme
.surfaceContainerHighest,
borderRadius:
BorderRadius.circular(16),
BorderRadius.circular(
AppSurfaces.of(
context,
).cardRadius,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -4883,39 +4898,21 @@ class _TaskStatusChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final (icon, bg, fg) = switch (status) {
'queued' => (
Icons.inbox_rounded,
cs.secondaryContainer,
cs.onSecondaryContainer,
),
'in_progress' => (
Icons.play_arrow_rounded,
cs.primaryContainer,
cs.onPrimaryContainer,
),
'completed' => (
Icons.check_circle_rounded,
cs.tertiaryContainer,
cs.onTertiaryContainer,
),
'cancelled' => (
Icons.cancel_rounded,
cs.errorContainer,
cs.onErrorContainer,
),
_ => (
Icons.help_outline_rounded,
cs.surfaceContainerHighest,
cs.onSurface,
),
final s = AppStatusColors.of(context);
final (IconData icon, StatusSwatch swatch) = switch (status) {
'queued' => (Icons.inbox_rounded, s.neutral),
'in_progress' => (Icons.play_arrow_rounded, s.info),
'completed' => (Icons.check_circle_rounded, s.success),
'cancelled' => (Icons.cancel_rounded, s.danger),
_ => (Icons.help_outline_rounded, s.neutral),
};
final bg = swatch.container;
final fg = swatch.onContainer;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
),
child: Row(
mainAxisSize: MainAxisSize.min,
+27 -87
View File
@@ -29,6 +29,8 @@ import '../../widgets/responsive_body.dart';
import '../../widgets/tasq_adaptive_list.dart';
import '../../widgets/typing_dots.dart';
import '../../theme/app_surfaces.dart';
import '../../theme/app_status_colors.dart';
import '../../widgets/app_status_summary.dart';
import '../../utils/snackbar.dart';
import '../../widgets/app_breakpoints.dart';
import '../../widgets/app_page_header.dart';
@@ -523,10 +525,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
if (task.status == 'completed' &&
task.hasIncompleteDetails) ...[
const SizedBox(width: 4),
const Icon(
Icon(
Icons.warning_amber_rounded,
size: 16,
color: Colors.orange,
color: AppStatusColors.of(context).warning.color,
),
],
],
@@ -620,10 +622,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
if (task.status == 'completed' &&
task.hasIncompleteDetails) ...[
const SizedBox(width: 4),
const Icon(
Icon(
Icons.warning_amber_rounded,
size: 16,
color: Colors.orange,
color: AppStatusColors.of(context).warning.color,
),
],
if (showTyping) ...[
@@ -1328,78 +1330,28 @@ class _StatusSummaryRow extends StatelessWidget {
final entries = counts.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (int i = 0; i < entries.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
_StatusSummaryCard(
status: entries[i].key,
count: entries[i].value,
),
],
],
),
return AppStatusSummaryRow(
items: [
for (final e in entries)
AppStatusSummaryItem(
label: e.key.replaceAll('_', ' '),
count: e.value,
kind: _taskStatusKind(e.key),
),
],
);
}
}
class _StatusSummaryCard extends StatelessWidget {
const _StatusSummaryCard({required this.status, required this.count});
final String status;
final int count;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final background = switch (status) {
'critical' => scheme.errorContainer,
'queued' => scheme.surfaceContainerHighest,
'in_progress' => scheme.secondaryContainer,
'completed' => scheme.primaryContainer,
_ => scheme.surfaceContainerHigh,
};
final foreground = switch (status) {
'critical' => scheme.onErrorContainer,
'queued' => scheme.onSurfaceVariant,
'in_progress' => scheme.onSecondaryContainer,
'completed' => scheme.onPrimaryContainer,
_ => scheme.onSurfaceVariant,
};
final label = status.replaceAll('_', ' ').toUpperCase();
return Material(
color: background,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: foreground,
fontWeight: FontWeight.w600,
letterSpacing: 0.4,
),
),
const SizedBox(width: 6),
Text(
count.toString(),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: foreground,
fontWeight: FontWeight.w700,
),
),
],
),
),
);
}
}
/// Maps a task status to a semantic [StatusKind] (red=critical, blue=in
/// progress, green=completed, grey=queued).
StatusKind _taskStatusKind(String status) => switch (status) {
'critical' => StatusKind.danger,
'in_progress' => StatusKind.info,
'completed' => StatusKind.success,
'queued' => StatusKind.neutral,
_ => StatusKind.neutral,
};
String _assignedAgent(Map<String, Profile> profileById, String? userId) {
if (userId == null || userId.isEmpty) {
@@ -1422,21 +1374,9 @@ class _StatusBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final background = switch (status) {
'critical' => scheme.errorContainer,
'queued' => scheme.surfaceContainerHighest,
'in_progress' => scheme.secondaryContainer,
'completed' => scheme.primaryContainer,
_ => scheme.surfaceContainerHighest,
};
final foreground = switch (status) {
'critical' => scheme.onErrorContainer,
'queued' => scheme.onSurfaceVariant,
'in_progress' => scheme.onSecondaryContainer,
'completed' => scheme.onPrimaryContainer,
_ => scheme.onSurfaceVariant,
};
final swatch = AppStatusColors.of(context).byKind(_taskStatusKind(status));
final background = swatch.container;
final foreground = swatch.onContainer;
return Badge(
backgroundColor: background,
+16 -25
View File
@@ -22,6 +22,7 @@ import '../../widgets/responsive_body.dart';
import '../../widgets/task_assignment_section.dart';
import '../../widgets/typing_dots.dart';
import '../../theme/app_surfaces.dart';
import '../../theme/app_status_colors.dart';
class TicketDetailScreen extends ConsumerStatefulWidget {
const TicketDetailScreen({super.key, required this.ticketId});
@@ -424,7 +425,9 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(
AppSurfaces.of(context).cardRadius,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -467,7 +470,9 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
borderRadius: BorderRadius.circular(28),
borderRadius: BorderRadius.circular(
AppSurfaces.of(context).containerRadius,
),
),
padding: const EdgeInsets.only(
left: 16,
@@ -1357,34 +1362,20 @@ class _TicketStatusChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final (icon, bg, fg) = switch (status) {
'pending' => (
Icons.hourglass_empty_rounded,
cs.secondaryContainer,
cs.onSecondaryContainer,
),
'promoted' => (
Icons.trending_up_rounded,
cs.primaryContainer,
cs.onPrimaryContainer,
),
'closed' => (
Icons.lock_rounded,
cs.tertiaryContainer,
cs.onTertiaryContainer,
),
_ => (
Icons.help_outline_rounded,
cs.surfaceContainerHighest,
cs.onSurface,
),
final s = AppStatusColors.of(context);
final (IconData icon, StatusSwatch swatch) = switch (status) {
'pending' => (Icons.hourglass_empty_rounded, s.warning),
'promoted' => (Icons.trending_up_rounded, s.info),
'closed' => (Icons.lock_rounded, s.success),
_ => (Icons.help_outline_rounded, s.neutral),
};
final bg = swatch.container;
final fg = swatch.onContainer;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
),
child: Row(
mainAxisSize: MainAxisSize.min,
+23 -83
View File
@@ -23,10 +23,12 @@ import '../../widgets/responsive_body.dart';
import '../../widgets/tasq_adaptive_list.dart';
import '../../widgets/typing_dots.dart';
import '../../theme/app_surfaces.dart';
import '../../theme/app_status_colors.dart';
import '../../utils/snackbar.dart';
import '../../widgets/app_breakpoints.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/app_status_summary.dart';
import '../../widgets/sync_pending_badge.dart';
class TicketsListScreen extends ConsumerStatefulWidget {
@@ -714,78 +716,28 @@ class _StatusSummaryRow extends StatelessWidget {
final entries = counts.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (int i = 0; i < entries.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
_StatusSummaryCard(
status: entries[i].key,
count: entries[i].value,
),
],
],
),
return AppStatusSummaryRow(
items: [
for (final e in entries)
AppStatusSummaryItem(
label: e.key.replaceAll('_', ' '),
count: e.value,
kind: _ticketStatusKind(e.key),
),
],
);
}
}
class _StatusSummaryCard extends StatelessWidget {
const _StatusSummaryCard({required this.status, required this.count});
final String status;
final int count;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final background = switch (status) {
'critical' => scheme.errorContainer,
'pending' => scheme.tertiaryContainer,
'promoted' => scheme.secondaryContainer,
'closed' => scheme.primaryContainer,
_ => scheme.surfaceContainerHigh,
};
final foreground = switch (status) {
'critical' => scheme.onErrorContainer,
'pending' => scheme.onTertiaryContainer,
'promoted' => scheme.onSecondaryContainer,
'closed' => scheme.onPrimaryContainer,
_ => scheme.onSurfaceVariant,
};
final label = status.replaceAll('_', ' ').toUpperCase();
return Material(
color: background,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: foreground,
fontWeight: FontWeight.w600,
letterSpacing: 0.4,
),
),
const SizedBox(width: 6),
Text(
count.toString(),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: foreground,
fontWeight: FontWeight.w700,
),
),
],
),
),
);
}
}
/// Maps a ticket status to a semantic [StatusKind] so its color carries meaning
/// (red=critical, amber=pending, blue=promoted/escalated, green=closed).
StatusKind _ticketStatusKind(String status) => switch (status) {
'critical' => StatusKind.danger,
'pending' => StatusKind.warning,
'promoted' => StatusKind.info,
'closed' => StatusKind.success,
_ => StatusKind.neutral,
};
/// First 8 chars of the UUID, like a short git hash; the full id is in the
/// tooltip. Tickets have no human-friendly number.
@@ -813,21 +765,9 @@ class _StatusBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final background = switch (status) {
'critical' => scheme.errorContainer,
'pending' => scheme.tertiaryContainer,
'promoted' => scheme.secondaryContainer,
'closed' => scheme.primaryContainer,
_ => scheme.surfaceContainerHighest,
};
final foreground = switch (status) {
'critical' => scheme.onErrorContainer,
'pending' => scheme.onTertiaryContainer,
'promoted' => scheme.onSecondaryContainer,
'closed' => scheme.onPrimaryContainer,
_ => scheme.onSurfaceVariant,
};
final swatch = AppStatusColors.of(context).byKind(_ticketStatusKind(status));
final background = swatch.container;
final foreground = swatch.onContainer;
return Badge(
backgroundColor: background,
+26 -13
View File
@@ -12,6 +12,7 @@ import '../../providers/profile_provider.dart';
import '../../providers/whereabouts_provider.dart';
import '../../providers/workforce_provider.dart';
import '../../theme/app_surfaces.dart';
import '../../theme/app_status_colors.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/responsive_body.dart';
import '../../utils/app_time.dart';
@@ -232,8 +233,10 @@ class _WhereaboutsMap extends StatelessWidget {
polygons: [
Polygon(
points: points,
color: Colors.blue.withValues(alpha: 0.1),
borderColor: Colors.blue,
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.12),
borderColor: Theme.of(context).colorScheme.primary,
borderStrokeWidth: 2.0,
),
],
@@ -313,11 +316,20 @@ class _StaffLegendPanel extends StatelessWidget {
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
),
const Spacer(),
_RoleBadge(color: Colors.blue.shade700, label: 'Admin'),
_RoleBadge(
color: Theme.of(context).colorScheme.primary,
label: 'Admin',
),
const SizedBox(width: 12),
_RoleBadge(color: Colors.green.shade700, label: 'IT Staff'),
_RoleBadge(
color: Theme.of(context).colorScheme.secondary,
label: 'IT Staff',
),
const SizedBox(width: 12),
_RoleBadge(color: Colors.orange.shade700, label: 'Dispatcher'),
_RoleBadge(
color: Theme.of(context).colorScheme.tertiary,
label: 'Dispatcher',
),
],
),
),
@@ -416,6 +428,7 @@ class _StaffLegendTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final status = AppStatusColors.of(context);
final roleColor = _roleColor(profile.role, cs);
final hasPosition = position != null;
@@ -445,30 +458,30 @@ class _StaffLegendTile extends StatelessWidget {
? 'In premise (Tracking off) \u00b7 $timeLabel'
: 'Outside premise (Tracking off) \u00b7 $timeLabel';
statusColor = isStale
? Colors.grey
: (isInPremise ? Colors.green : Colors.grey);
? status.neutral.color
: (isInPremise ? status.success.color : status.neutral.color);
} else if (inferredInPremise) {
statusText = 'In premise (Checked in)';
statusColor = Colors.green;
statusColor = status.success.color;
} else {
statusText = 'Tracking off';
statusColor = Colors.grey;
statusColor = status.neutral.color;
}
} else if (isStale && hasPosition) {
final timeLabel = _timeAgo(position!.updatedAt);
statusText = isInPremise
? 'Last seen in premise \u00b7 $timeLabel'
: 'Last seen outside premise \u00b7 $timeLabel';
statusColor = Colors.grey;
statusColor = status.neutral.color;
} else if (isActive) {
statusText = 'In premise \u00b7 ${_timeAgo(position!.updatedAt)}';
statusColor = Colors.green;
statusColor = status.success.color;
} else if (hasPosition) {
statusText = 'Outside premise \u00b7 ${_timeAgo(position!.updatedAt)}';
statusColor = Colors.grey;
statusColor = status.neutral.color;
} else {
statusText = 'No location data';
statusColor = Colors.grey;
statusColor = status.neutral.color;
}
final IconData statusIcon;
+10 -16
View File
@@ -16,6 +16,7 @@ import '../../providers/chat_provider.dart';
import '../../providers/ramadan_provider.dart';
import '../../providers/notifications_provider.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_section_header.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/responsive_body.dart';
import '../../theme/app_surfaces.dart';
@@ -126,22 +127,15 @@ class _SchedulePanel extends ConsumerWidget {
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
Text(
'Duty Schedules',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
const Spacer(),
FilterChip(
label: const Text('Show past'),
selected: showPast,
onSelected: (v) =>
ref.read(showPastSchedulesProvider.notifier).state = v,
),
],
child: AppSectionHeader(
'Duty Schedules',
padding: EdgeInsets.zero,
trailing: FilterChip(
label: const Text('Show past'),
selected: showPast,
onSelected: (v) =>
ref.read(showPastSchedulesProvider.notifier).state = v,
),
),
),
const SizedBox(height: 4),
+12 -1
View File
@@ -106,6 +106,11 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
'isr_approved' => 'IT Service Request approved',
'isr_status_changed' => 'IT Service Request updated',
'isr_assigned' => 'New IT Service Request assigned',
'day_sheet_submitted' => 'Day sheet submitted',
'day_sheet_disapproved' => 'Day sheet disapproved',
'day_sheet_justified' => 'Day sheet justified',
'day_sheet_approved' => 'Day sheet approved',
'day_sheet_digest' => 'Day sheets awaiting review',
_ => 'New notification',
};
@@ -121,7 +126,13 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
action: SnackBarAction(
label: 'View',
onPressed: () => _goToDetail(
route: item.announcementId != null ? '/announcements' : null,
route: item.announcementId != null
? '/announcements'
: item.type == 'day_sheet_digest'
? '/programmer-tasks?tab=approvals'
: item.daySheetId != null
? '/programmer-tasks/day-sheets/${item.daySheetId}'
: null,
taskId: item.taskId,
ticketId: item.ticketId,
itServiceRequestId: item.itServiceRequestId,
+183
View File
@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
/// Semantic status categories shared across status pills, chips and badges.
enum StatusKind { success, warning, danger, info, neutral }
/// A single semantic status swatch: a vivid [color] for icons/dots/text, plus a
/// tonal [container] / [onContainer] pair for filled pills and badges.
@immutable
class StatusSwatch {
const StatusSwatch({
required this.color,
required this.container,
required this.onContainer,
});
/// Vivid accent — use for status dots, icons, borders and emphasized text.
final Color color;
/// Tonal fill for pills/badges/callout surfaces.
final Color container;
/// Foreground (text/icon) drawn on top of [container].
final Color onContainer;
StatusSwatch copyWith({Color? color, Color? container, Color? onContainer}) {
return StatusSwatch(
color: color ?? this.color,
container: container ?? this.container,
onContainer: onContainer ?? this.onContainer,
);
}
static StatusSwatch lerp(StatusSwatch a, StatusSwatch b, double t) {
return StatusSwatch(
color: Color.lerp(a.color, b.color, t) ?? a.color,
container: Color.lerp(a.container, b.container, t) ?? a.container,
onContainer: Color.lerp(a.onContainer, b.onContainer, t) ?? a.onContainer,
);
}
}
/// Cyber/IT semantic status tokens.
///
/// These replace scattered raw `Colors.green/orange/red/grey` usages so status
/// meaning is consistent and legible in **both** light and dark themes. Access
/// via [AppStatusColors.of] and pick a swatch by meaning:
///
/// ```dart
/// final status = AppStatusColors.of(context);
/// Icon(Icons.circle, size: 10, color: status.success.color); // online / up
/// ```
@immutable
class AppStatusColors extends ThemeExtension<AppStatusColors> {
const AppStatusColors({
required this.success,
required this.warning,
required this.danger,
required this.info,
required this.neutral,
});
/// Resolved, present, online, device-up, healthy.
final StatusSwatch success;
/// In-progress, pending, warning, degraded.
final StatusSwatch warning;
/// Urgent, critical, absent, error, device-down.
final StatusSwatch danger;
/// Informational / low-priority highlight.
final StatusSwatch info;
/// Idle, offline, disabled, muted.
final StatusSwatch neutral;
// ── Dark (cyber-first) ──────────────────────────────────────────────────
static const AppStatusColors dark = AppStatusColors(
success: StatusSwatch(
color: Color(0xFF34D399),
container: Color(0xFF0C4A34),
onContainer: Color(0xFF8FF0C4),
),
warning: StatusSwatch(
color: Color(0xFFFBBF24),
container: Color(0xFF4A3506),
onContainer: Color(0xFFFCE199),
),
danger: StatusSwatch(
color: Color(0xFFF87171),
container: Color(0xFF5A1A1A),
onContainer: Color(0xFFFFC9C4),
),
info: StatusSwatch(
color: Color(0xFF60A5FA),
container: Color(0xFF0E3A5F),
onContainer: Color(0xFFCFE7FF),
),
neutral: StatusSwatch(
color: Color(0xFF94A3B8),
container: Color(0xFF26313D),
onContainer: Color(0xFFCBD5E1),
),
);
// ── Light ───────────────────────────────────────────────────────────────
static const AppStatusColors light = AppStatusColors(
success: StatusSwatch(
color: Color(0xFF15803D),
container: Color(0xFFCDEFD8),
onContainer: Color(0xFF052E16),
),
warning: StatusSwatch(
color: Color(0xFFB45309),
container: Color(0xFFFCEBC7),
onContainer: Color(0xFF3A2606),
),
danger: StatusSwatch(
color: Color(0xFFDC2626),
container: Color(0xFFFBD9D6),
onContainer: Color(0xFF450A0A),
),
info: StatusSwatch(
color: Color(0xFF2563EB),
container: Color(0xFFD6E4FF),
onContainer: Color(0xFF0A2A6B),
),
neutral: StatusSwatch(
color: Color(0xFF64748B),
container: Color(0xFFE2E8F0),
onContainer: Color(0xFF1E293B),
),
);
static AppStatusColors of(BuildContext context) {
return Theme.of(context).extension<AppStatusColors>() ?? dark;
}
/// Resolve the swatch for a semantic [StatusKind].
StatusSwatch byKind(StatusKind kind) {
switch (kind) {
case StatusKind.success:
return success;
case StatusKind.warning:
return warning;
case StatusKind.danger:
return danger;
case StatusKind.info:
return info;
case StatusKind.neutral:
return neutral;
}
}
@override
AppStatusColors copyWith({
StatusSwatch? success,
StatusSwatch? warning,
StatusSwatch? danger,
StatusSwatch? info,
StatusSwatch? neutral,
}) {
return AppStatusColors(
success: success ?? this.success,
warning: warning ?? this.warning,
danger: danger ?? this.danger,
info: info ?? this.info,
neutral: neutral ?? this.neutral,
);
}
@override
AppStatusColors lerp(ThemeExtension<AppStatusColors>? other, double t) {
if (other is! AppStatusColors) return this;
return AppStatusColors(
success: StatusSwatch.lerp(success, other.success, t),
warning: StatusSwatch.lerp(warning, other.warning, t),
danger: StatusSwatch.lerp(danger, other.danger, t),
info: StatusSwatch.lerp(info, other.info, t),
neutral: StatusSwatch.lerp(neutral, other.neutral, t),
);
}
}
+15 -14
View File
@@ -1,10 +1,11 @@
import 'package:flutter/material.dart';
/// M3 Expressive surface tokens.
/// Cyber/IT surface tokens.
///
/// Cards now use **tonal elevation** (color tints) instead of drop-shadows.
/// Large containers adopt the M3 standard 28 dp corner radius; compact items
/// use 16 dp; small chips/badges use 12 dp.
/// Cards use **tonal elevation** (color tints) plus a hairline outline instead
/// of drop-shadows. The shape language is tightened to a precise 12–16 dp
/// technical feel: standard cards 12 dp, compact items 10 dp, large containers
/// 16 dp, dialogs/sheets 20 dp, and chips/badges 8 dp.
@immutable
class AppSurfaces extends ThemeExtension<AppSurfaces> {
const AppSurfaces({
@@ -15,19 +16,19 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
required this.chipRadius,
});
/// Standard card radius – 16 dp (M3 medium shape).
/// Standard card radius – 12 dp (cyber/IT medium shape).
final double cardRadius;
/// Compact card radius for dense list tiles – 12 dp.
/// Compact card radius for dense list tiles – 10 dp.
final double compactCardRadius;
/// Large container radius – 28 dp (M3 Expressive).
/// Large container radius – 16 dp.
final double containerRadius;
/// Dialog / bottom-sheet radius – 28 dp.
/// Dialog / bottom-sheet radius – 20 dp.
final double dialogRadius;
/// Chip / badge radius – 12 dp.
/// Chip / badge radius – 8 dp.
final double chipRadius;
// convenience shapes
@@ -46,11 +47,11 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
final ext = Theme.of(context).extension<AppSurfaces>();
return ext ??
const AppSurfaces(
cardRadius: 16,
compactCardRadius: 12,
containerRadius: 28,
dialogRadius: 28,
chipRadius: 12,
cardRadius: 12,
compactCardRadius: 10,
containerRadius: 16,
dialogRadius: 20,
chipRadius: 8,
);
}
+187 -91
View File
@@ -3,57 +3,140 @@ import 'package:google_fonts/google_fonts.dart';
import 'app_typography.dart';
import 'app_surfaces.dart';
import 'app_status_colors.dart';
/// M3 Expressive theme for TasQ.
/// Refined enterprise **Cyber/IT** theme for TasQ, built on Material 3.
///
/// Key differences from the previous Hybrid M2/M3 theme:
/// * Cards use **tonal elevation** (surfaceTint color overlays) instead of
/// drop-shadows, giving surfaces an organic, seed-tinted look.
/// * Large containers use the M3 standard **28 dp** corner radius.
/// * Buttons follow the M3 hierarchy: FilledButton (primary), Tonal, Elevated,
/// Outlined, and Text.
/// * NavigationBar / NavigationRail use pill-shaped indicators with the
/// secondary-container tonal color.
/// * Spring-physics inspired durations: transitions default to 400 ms with an
/// emphasized easing curve.
/// Identity:
/// * Deep slate surfaces (dark) / cool near-white (light) with an electric
/// **azure** primary, **teal** secondary and **violet** tertiary.
/// * Cards use a flat tonal fill plus a hairline outline (no drop shadow) for a
/// precise, technical feel.
/// * A tightened 12–16 dp shape language (see [AppSurfaces]).
/// * Monospace ([AppMonoText]) reserved for technical data — IDs, IPs, ports,
/// timestamps — and chip labels.
/// * Semantic status tokens ([AppStatusColors]) drive all status/priority color.
class AppTheme {
/// The seed color drives M3's entire tonal palette generation.
static const Color _seed = Color(0xFF4A6FA5);
const AppTheme._();
/// Brand azure — anchors M3's neutral tonal ramp generation.
static const Color _seed = Color(0xFF2D7FF9);
static const AppSurfaces _surfaces = AppSurfaces(
cardRadius: 12,
compactCardRadius: 10,
containerRadius: 16,
dialogRadius: 20,
chipRadius: 8,
);
// ────────────────────────────────────────────────────────────
// COLOR SCHEMES (hand-tuned cyber palette)
// ────────────────────────────────────────────────────────────
static ColorScheme _darkScheme() {
return ColorScheme.fromSeed(
seedColor: _seed,
brightness: Brightness.dark,
).copyWith(
primary: const Color(0xFF4EA8FF),
onPrimary: const Color(0xFF04263F),
primaryContainer: const Color(0xFF0E3A5F),
onPrimaryContainer: const Color(0xFFCFE7FF),
secondary: const Color(0xFF2DD4BF),
onSecondary: const Color(0xFF00201C),
secondaryContainer: const Color(0xFF0C4B45),
onSecondaryContainer: const Color(0xFFB8F5EC),
tertiary: const Color(0xFFA78BFA),
onTertiary: const Color(0xFF1C0E3F),
tertiaryContainer: const Color(0xFF3A2A6B),
onTertiaryContainer: const Color(0xFFE7DDFF),
error: const Color(0xFFF87171),
onError: const Color(0xFF2A0A0A),
errorContainer: const Color(0xFF5A1A1A),
onErrorContainer: const Color(0xFFFFC9C4),
surface: const Color(0xFF0E141B),
onSurface: const Color(0xFFE3E9F0),
onSurfaceVariant: const Color(0xFFA7B4C2),
surfaceContainerLowest: const Color(0xFF080C11),
surfaceContainerLow: const Color(0xFF141B23),
surfaceContainer: const Color(0xFF17202A),
surfaceContainerHigh: const Color(0xFF1E2833),
surfaceContainerHighest: const Color(0xFF26313D),
surfaceDim: const Color(0xFF0B0F14),
surfaceBright: const Color(0xFF2A3542),
outline: const Color(0xFF3A4A5A),
outlineVariant: const Color(0xFF283440),
surfaceTint: const Color(0xFF4EA8FF),
inverseSurface: const Color(0xFFE3E9F0),
onInverseSurface: const Color(0xFF111820),
inversePrimary: const Color(0xFF0B6BCB),
);
}
static ColorScheme _lightScheme() {
return ColorScheme.fromSeed(
seedColor: _seed,
brightness: Brightness.light,
).copyWith(
primary: const Color(0xFF0B6BCB),
onPrimary: const Color(0xFFFFFFFF),
primaryContainer: const Color(0xFFD3E7FF),
onPrimaryContainer: const Color(0xFF00294A),
secondary: const Color(0xFF0F766E),
onSecondary: const Color(0xFFFFFFFF),
secondaryContainer: const Color(0xFFB8F1E8),
onSecondaryContainer: const Color(0xFF00201C),
tertiary: const Color(0xFF6D28D9),
onTertiary: const Color(0xFFFFFFFF),
tertiaryContainer: const Color(0xFFE9DDFF),
onTertiaryContainer: const Color(0xFF23005C),
error: const Color(0xFFDC2626),
onError: const Color(0xFFFFFFFF),
errorContainer: const Color(0xFFFBD9D6),
onErrorContainer: const Color(0xFF450A0A),
surface: const Color(0xFFF6F8FB),
onSurface: const Color(0xFF111820),
onSurfaceVariant: const Color(0xFF42505F),
surfaceContainerLowest: const Color(0xFFFFFFFF),
surfaceContainerLow: const Color(0xFFF0F4F9),
surfaceContainer: const Color(0xFFEDF1F6),
surfaceContainerHigh: const Color(0xFFE4EAF2),
surfaceContainerHighest: const Color(0xFFDCE4EE),
surfaceDim: const Color(0xFFD7DEE8),
surfaceBright: const Color(0xFFF6F8FB),
outline: const Color(0xFF73808F),
outlineVariant: const Color(0xFFC3CDDA),
surfaceTint: const Color(0xFF0B6BCB),
inverseSurface: const Color(0xFF111820),
onInverseSurface: const Color(0xFFEFF3F8),
inversePrimary: const Color(0xFFA6C8FF),
);
}
// ────────────────────────────────────────────────────────────
// LIGHT
// ────────────────────────────────────────────────────────────
static ThemeData light() {
final base = ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: _seed,
brightness: Brightness.light,
),
useMaterial3: true,
return _apply(
ThemeData(colorScheme: _lightScheme(), useMaterial3: true),
AppStatusColors.light,
);
return _apply(base);
}
// ────────────────────────────────────────────────────────────
// DARK
// ────────────────────────────────────────────────────────────
static ThemeData dark() {
final base = ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: _seed,
brightness: Brightness.dark,
),
useMaterial3: true,
return _apply(
ThemeData(colorScheme: _darkScheme(), useMaterial3: true),
AppStatusColors.dark,
);
return _apply(base);
}
// ────────────────────────────────────────────────────────────
// SHARED BUILDER
// ────────────────────────────────────────────────────────────
static ThemeData _apply(ThemeData base) {
static ThemeData _apply(ThemeData base, AppStatusColors status) {
final cs = base.colorScheme;
final isDark = cs.brightness == Brightness.dark;
@@ -68,18 +151,17 @@ class AppTheme {
const TextStyle(letterSpacing: 0.2),
);
const surfaces = AppSurfaces(
cardRadius: 16,
compactCardRadius: 12,
containerRadius: 28,
dialogRadius: 28,
chipRadius: 12,
);
final surfaces = _surfaces;
// Card fill: flat tonal surface + hairline outline (cyber "panel" look).
final cardColor = isDark ? cs.surfaceContainer : cs.surfaceContainerLowest;
return base.copyWith(
textTheme: textTheme,
scaffoldBackgroundColor: isDark ? cs.surface : cs.surfaceContainerLowest,
extensions: [mono, surfaces],
scaffoldBackgroundColor: isDark
? const Color(0xFF0B0F14)
: cs.surfaceContainerLowest,
extensions: [mono, surfaces, status],
// ── AppBar ──────────────────────────────────────────────
appBarTheme: AppBarTheme(
@@ -99,22 +181,27 @@ class AppTheme {
),
),
// ── Cards — M3 Elevated (tonal surface tint, no hard shadow) ──
// ── Cards — flat tonal fill + hairline outline (no hard shadow) ──
cardTheme: CardThemeData(
color: isDark ? cs.surfaceContainer : cs.surfaceContainerLow,
elevation: 1,
color: cardColor,
elevation: 0,
margin: EdgeInsets.zero,
shadowColor: Colors.transparent,
surfaceTintColor: cs.surfaceTint,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(surfaces.cardRadius),
side: BorderSide(color: cs.outlineVariant),
),
),
// ── Chips ───────────────────────────────────────────────
// ── Chips — mono label, tight radius ────────────────────
chipTheme: ChipThemeData(
backgroundColor: cs.surfaceContainerHighest,
side: BorderSide.none,
labelStyle: textTheme.labelSmall,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
side: BorderSide(color: cs.outlineVariant),
labelStyle: mono.label,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(surfaces.chipRadius),
),
),
// ── Dividers ────────────────────────────────────────────
@@ -123,17 +210,17 @@ class AppTheme {
// ── Input Fields ────────────────────────────────────────
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: cs.surfaceContainerLow,
fillColor: isDark ? cs.surfaceContainerLow : cs.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(surfaces.cardRadius),
borderSide: BorderSide(color: cs.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(surfaces.cardRadius),
borderSide: BorderSide(color: cs.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(surfaces.cardRadius),
borderSide: BorderSide(color: cs.primary, width: 2),
),
contentPadding: const EdgeInsets.symmetric(
@@ -142,87 +229,84 @@ class AppTheme {
),
),
// ── Buttons — M3 Expressive hierarchy ───────────────────
// ── Buttons — M3 hierarchy, tightened shapes ────────────
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(surfaces.cardRadius),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
textStyle: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: cs.surfaceContainerLow,
backgroundColor: cs.surfaceContainerHigh,
foregroundColor: cs.primary,
elevation: 1,
elevation: 0,
shadowColor: Colors.transparent,
surfaceTintColor: cs.surfaceTint,
surfaceTintColor: Colors.transparent,
side: BorderSide(color: cs.outlineVariant),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(surfaces.cardRadius),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
textStyle: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(surfaces.cardRadius),
),
side: BorderSide(color: cs.outline),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
textStyle: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(surfaces.cardRadius),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
textStyle: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
),
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(surfaces.compactCardRadius),
),
),
),
segmentedButtonTheme: SegmentedButtonThemeData(
style: ButtonStyle(
shape: WidgetStateProperty.all(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(surfaces.cardRadius),
),
),
),
),
// ── FAB — M3 Expressive ──────────────────────────────────
// ── FAB ──────────────────────────────────────────────────
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: cs.primaryContainer,
foregroundColor: cs.onPrimaryContainer,
elevation: 3,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(surfaces.containerRadius),
),
),
// ── Navigation — M3 Expressive pill indicators ──────────
// ── Navigation — pill indicators ────────────────────────
navigationDrawerTheme: NavigationDrawerThemeData(
backgroundColor: cs.surface,
indicatorColor: cs.secondaryContainer,
tileHeight: 56,
indicatorShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
borderRadius: BorderRadius.circular(surfaces.containerRadius),
),
),
navigationRailTheme: NavigationRailThemeData(
@@ -238,14 +322,14 @@ class AppTheme {
),
indicatorColor: cs.secondaryContainer,
indicatorShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
borderRadius: BorderRadius.circular(surfaces.containerRadius),
),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: cs.surfaceContainer,
indicatorColor: cs.secondaryContainer,
indicatorShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(surfaces.cardRadius),
),
labelTextStyle: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected);
@@ -260,15 +344,19 @@ class AppTheme {
// ── List Tiles ──────────────────────────────────────────
listTileTheme: ListTileThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(surfaces.cardRadius),
),
tileColor: Colors.transparent,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
),
// ── Dialogs ─────────────────────────────────────────────
dialogTheme: DialogThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
surfaceTintColor: cs.surfaceTint,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(surfaces.dialogRadius),
),
surfaceTintColor: Colors.transparent,
backgroundColor: isDark
? cs.surfaceContainerHigh
: cs.surfaceContainerLowest,
@@ -276,20 +364,24 @@ class AppTheme {
// ── Bottom Sheets ───────────────────────────────────────
bottomSheetTheme: BottomSheetThemeData(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(surfaces.dialogRadius),
),
),
backgroundColor: isDark
? cs.surfaceContainerHigh
: cs.surfaceContainerLowest,
surfaceTintColor: cs.surfaceTint,
surfaceTintColor: Colors.transparent,
showDragHandle: true,
),
// ── Snackbar ────────────────────────────────────────────
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(surfaces.compactCardRadius),
),
),
// ── Search Bar ──────────────────────────────────────────
@@ -297,7 +389,9 @@ class AppTheme {
elevation: WidgetStateProperty.all(0),
backgroundColor: WidgetStateProperty.all(cs.surfaceContainerHigh),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(surfaces.containerRadius),
),
),
),
@@ -305,7 +399,7 @@ class AppTheme {
tooltipTheme: TooltipThemeData(
decoration: BoxDecoration(
color: cs.inverseSurface,
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(surfaces.chipRadius),
),
textStyle: textTheme.bodySmall?.copyWith(color: cs.onInverseSurface),
),
@@ -323,8 +417,10 @@ class AppTheme {
// ── PopupMenu ───────────────────────────────────────────
popupMenuTheme: PopupMenuThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
surfaceTintColor: cs.surfaceTint,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(surfaces.compactCardRadius),
),
surfaceTintColor: Colors.transparent,
color: isDark ? cs.surfaceContainerHigh : cs.surfaceContainerLowest,
),
);
+81
View File
@@ -0,0 +1,81 @@
import 'package:flutter/services.dart' show rootBundle;
import 'package:pdf/pdf.dart' as pdf;
import 'package:pdf/widgets.dart' as pw;
/// Shared brand assets and letterhead widget for CRMC PDF documents.
class PdfBrand {
final pw.Font regular;
final pw.Font bold;
final pw.ImageProvider logo;
const PdfBrand({
required this.regular,
required this.bold,
required this.logo,
});
/// Load fonts and logo from the Flutter asset bundle.
static Future<PdfBrand> load() async {
final logoData = await rootBundle.load('assets/crmc_logo.png');
final logo = pw.MemoryImage(logoData.buffer.asUint8List());
final regular = pw.Font.ttf(
await rootBundle.load('assets/fonts/Roboto-Regular.ttf'),
);
final bold = pw.Font.ttf(
await rootBundle.load('assets/fonts/Roboto-Bold.ttf'),
);
return PdfBrand(regular: regular, bold: bold, logo: logo);
}
/// Standard CRMC letterhead: 60×60 logo + four-line organisation text.
pw.Widget letterhead() {
return pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.center,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Image(logo, width: 60, height: 60),
pw.SizedBox(width: 16),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Text(
'Republic of the Philippines',
style: pw.TextStyle(font: regular, fontSize: 9),
),
pw.Text(
'Department of Health',
style: pw.TextStyle(font: regular, fontSize: 9),
),
pw.Text(
'Cotabato Regional and Medical Center',
style: pw.TextStyle(font: regular, fontSize: 9),
),
pw.SizedBox(height: 3),
pw.Text(
'Integrated Hospital Operations and Management Program (IHOMP)',
style: pw.TextStyle(font: bold, fontSize: 9),
),
],
),
],
);
}
/// Convenience: create a ThemeData from these fonts.
pw.ThemeData theme() => pw.ThemeData.withFont(base: regular, bold: bold);
/// Footer widget: right-aligned "Page x of y".
pw.Widget pageFooter(pw.Context ctx) => pw.Align(
alignment: pw.Alignment.centerRight,
child: pw.Text(
'Page ${ctx.pageNumber} of ${ctx.pagesCount}',
style: pw.TextStyle(
font: regular,
fontSize: 8,
color: pdf.PdfColors.grey600,
),
),
);
}
+225
View File
@@ -0,0 +1,225 @@
import 'package:timezone/timezone.dart' as tz;
import 'package:tasq/models/programmer_task.model.dart';
import 'package:tasq/models/programmer_task_activity_log.model.dart';
import 'app_time.dart';
/// Get the Manila calendar date of an instant as DateTime.utc(y, m, d)
DateTime manilaDay(DateTime instant) {
final utc = instant.isUtc ? instant : instant.toUtc();
final manila = tz.TZDateTime.from(utc, tz.local);
return DateTime.utc(manila.year, manila.month, manila.day);
}
/// Get the Manila midnight of a day key as a TZDateTime
DateTime manilaDayStart(DateTime dayKey) {
return AppTime.fromComponents(
year: dayKey.year,
month: dayKey.month,
day: dayKey.day,
hour: 0,
minute: 0,
);
}
/// Parse a day key string (e.g., '2026-09-27') to DateTime.utc
DateTime parseDayKey(String yyyyMmDd) {
final parts = yyyyMmDd.split('-');
return DateTime.utc(int.parse(parts[0]), int.parse(parts[1]), int.parse(parts[2]));
}
/// Format a day key to string (e.g., DateTime.utc(2026, 9, 27) -> '2026-09-27')
String formatDayKey(DateTime dayKey) {
final y = dayKey.year.toString();
final m = dayKey.month.toString().padLeft(2, '0');
final d = dayKey.day.toString().padLeft(2, '0');
return '$y-$m-$d';
}
/// Represents a worked interval for an assignee
typedef WorkedInterval = ({String assigneeId, DateTime start, DateTime end});
/// Calculate worked intervals from task and activity logs
List<WorkedInterval> workedIntervals({
required ProgrammerTask task,
required List<ProgrammerTaskActivityLog> logs,
required DateTime now,
}) {
final sorted = List<ProgrammerTaskActivityLog>.from(logs)
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
final intervals = <WorkedInterval>[];
DateTime? segmentStart;
String currentAssignee = _initialAssignee(task, sorted);
// Check if there's a synthetic started event needed
if (!sorted.any((l) => l.actionType == 'started') && task.startedAt != null) {
// Inject synthetic started
sorted.insert(
0,
ProgrammerTaskActivityLog(
id: 'synthetic-start',
taskId: task.id,
actionType: 'started',
createdAt: task.startedAt!,
),
);
}
for (final log in sorted) {
switch (log.actionType) {
case 'started':
case 'resumed':
if (segmentStart == null) {
segmentStart = log.createdAt;
}
break;
case 'paused':
case 'completed':
case 'cancelled':
if (segmentStart != null) {
intervals.add((
assigneeId: currentAssignee,
start: segmentStart,
end: log.createdAt,
));
segmentStart = null;
}
break;
case 'reassigned':
final wasRunning = segmentStart != null;
if (segmentStart != null) {
// Close segment for old assignee
intervals.add((
assigneeId: currentAssignee,
start: segmentStart,
end: log.createdAt,
));
segmentStart = null;
}
// Update assignee and reopen segment only if it was running
if (log.meta?['assignee_id'] != null) {
currentAssignee = log.meta!['assignee_id'] as String;
if (wasRunning) {
segmentStart = log.createdAt;
}
}
break;
}
}
// Close any remaining open segment
if (segmentStart != null) {
final endTime = task.status == ProgrammerTaskStatus.inProgress
? now
: (task.completedAt ?? task.cancelledAt ?? now);
intervals.add((
assigneeId: currentAssignee,
start: segmentStart,
end: endTime,
));
}
return intervals;
}
/// Calculate daily seconds by assignee
Map<String, Map<DateTime, int>> dailySecondsByAssignee({
required ProgrammerTask task,
required List<ProgrammerTaskActivityLog> logs,
required DateTime now,
}) {
final intervals = workedIntervals(task: task, logs: logs, now: now);
final result = <String, Map<DateTime, int>>{};
// Process intervals and split at Manila midnights
for (final interval in intervals) {
var current = interval.start;
final end = interval.end;
while (current.isBefore(end)) {
final currentDay = manilaDay(current);
final dayStart = manilaDayStart(currentDay);
final nextDayStart = dayStart.add(Duration(days: 1));
final segmentEnd = end.isBefore(nextDayStart) ? end : nextDayStart;
final seconds = segmentEnd.difference(current).inSeconds;
if (!result.containsKey(interval.assigneeId)) {
result[interval.assigneeId] = {};
}
result[interval.assigneeId]![currentDay] =
(result[interval.assigneeId]![currentDay] ?? 0) + seconds;
current = segmentEnd;
}
}
// Apply adjustments
for (final log in logs) {
if (log.actionType == 'adjustment' && log.meta?['seconds'] != null) {
final adjustSeconds = (log.meta!['seconds'] as num).toInt();
final adjustDay = manilaDay(log.createdAt);
// Find the assignee at this time
final assigneeAtTime = _assigneeAtTime(task, logs, log.createdAt);
if (assigneeAtTime != null) {
if (!result.containsKey(assigneeAtTime)) {
result[assigneeAtTime] = {};
}
final current =
result[assigneeAtTime]![adjustDay] ?? 0;
result[assigneeAtTime]![adjustDay] =
(current - adjustSeconds).clamp(0, 999999999);
}
}
}
return result;
}
/// Find the initial assignee based on task and logs
String _initialAssignee(
ProgrammerTask task,
List<ProgrammerTaskActivityLog> logs,
) {
// First: check for 'assigned' event
for (final log in logs) {
if (log.actionType == 'assigned' && log.meta?['assignee_id'] != null) {
return log.meta!['assignee_id'] as String;
}
}
// Second: if any 'reassigned' event exists, use task.creatorId
if (logs.any((l) => l.actionType == 'reassigned') && task.creatorId != null) {
return task.creatorId!;
}
// Third: use task.assigneeId
return task.assigneeId ?? 'unknown';
}
/// Find the assignee at a specific time
String? _assigneeAtTime(
ProgrammerTask task,
List<ProgrammerTaskActivityLog> logs,
DateTime time,
) {
final sorted = List<ProgrammerTaskActivityLog>.from(logs)
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
var currentAssignee = _initialAssignee(task, sorted);
for (final log in sorted) {
if (log.createdAt.isAfter(time)) break;
if (log.actionType == 'reassigned' &&
log.meta?['assignee_id'] != null) {
currentAssignee = log.meta!['assignee_id'] as String;
}
}
return currentAssignee;
}
+160
View File
@@ -0,0 +1,160 @@
import 'package:tasq/models/programmer_task.model.dart';
import 'package:tasq/models/programmer_task_activity_log.model.dart';
import 'package:tasq/models/programmer_task_work_log.model.dart';
import 'package:tasq/widgets/quill_field.dart';
import 'programmer_daily_time.dart';
/// A single row in a programmer's day sheet (assignee or helper work).
class DaySheetRow {
final String taskId;
final String title;
final String category;
final String kind; // 'assignee' or 'helper'
final String? projectName;
final String? helpedName;
final int seconds;
final List<String> notes;
DaySheetRow({
required this.taskId,
required this.title,
required this.category,
required this.kind,
this.projectName,
this.helpedName,
required this.seconds,
required this.notes,
});
Map<String, dynamic> toJson() => {
'task_id': taskId,
'title': title,
'category': category,
'project_name': projectName,
'kind': kind,
'helped_name': helpedName,
'seconds': seconds,
'notes': notes,
};
factory DaySheetRow.fromJson(Map<String, dynamic> json) => DaySheetRow(
taskId: json['task_id'] as String,
title: json['title'] as String,
category: json['category'] as String,
kind: json['kind'] as String,
projectName: json['project_name'] as String?,
helpedName: json['helped_name'] as String?,
seconds: json['seconds'] as int,
notes: List<String>.from(json['notes'] as List? ?? []),
);
}
/// Build day rows for a programmer on a specific day.
/// Returns rows for assignee tasks (by seconds desc) then helper tasks (by seconds desc).
List<DaySheetRow> buildDayRows({
required String userId,
required DateTime day,
required List<ProgrammerTask> tasks,
required Map<String, List<ProgrammerTaskActivityLog>> logsByTask,
required List<ProgrammerTaskWorkLog> workLogs,
required Map<String, String> projectNames,
required Map<String, String> profileNames,
required DateTime now,
}) {
final taskMap = {for (final t in tasks) t.id: t};
final workLogsByTaskAndAuthor = <String, List<ProgrammerTaskWorkLog>>{};
// Group work logs by task and author for quick lookup
for (final log in workLogs) {
final key = '${log.taskId}:${log.authorId}';
workLogsByTaskAndAuthor.putIfAbsent(key, () => []).add(log);
}
final rows = <DaySheetRow>[];
// Process each task
for (final task in tasks) {
final assigneeId = task.assigneeId;
// Compute daily seconds for this task
final logs = logsByTask[task.id] ?? [];
final dailyByAssignee = dailySecondsByAssignee(
task: task,
logs: logs,
now: now,
);
final userDailySeconds = dailyByAssignee[userId] ?? {};
final assigneeSeconds = userDailySeconds[day] ?? 0;
// Check for assignee row: has seconds that day OR (is assignee AND authored notes-only log that day)
final assigneeLogsKey = '${task.id}:$userId';
final assigneeLogsThisDay = (userId == assigneeId)
? (workLogsByTaskAndAuthor[assigneeLogsKey]
?.where((l) => l.workDate == day && l.minutes == null)
.toList() ??
[])
: [];
if (assigneeSeconds > 0 || assigneeLogsThisDay.isNotEmpty) {
final notes = assigneeLogsThisDay
.map((l) => deltaJsonToPlainText(l.description))
.where((n) => n.isNotEmpty)
.toList();
rows.add(DaySheetRow(
taskId: task.id,
title: task.title,
category: task.category,
kind: 'assignee',
projectName: task.projectId != null ? projectNames[task.projectId] : null,
helpedName: null,
seconds: assigneeSeconds,
notes: notes,
));
}
// Check for helper row: userId != assigneeId AND has minutes logs that day
if (userId != assigneeId) {
final helperLogsKey = '${task.id}:$userId';
final helperLogs = workLogsByTaskAndAuthor[helperLogsKey]
?.where((l) => l.workDate == day && l.minutes != null && l.minutes! > 0)
.toList() ??
[];
if (helperLogs.isNotEmpty) {
final totalMinutes = helperLogs.fold<int>(0, (sum, l) => sum + (l.minutes ?? 0));
final seconds = totalMinutes * 60;
final notes = helperLogs
.map((l) => deltaJsonToPlainText(l.description))
.where((n) => n.isNotEmpty)
.toList();
rows.add(DaySheetRow(
taskId: task.id,
title: task.title,
category: task.category,
kind: 'helper',
projectName: task.projectId != null ? projectNames[task.projectId] : null,
helpedName: assigneeId != null ? profileNames[assigneeId] : null,
seconds: seconds,
notes: notes,
));
}
}
}
// Sort: assignee rows by seconds desc, then helper rows by seconds desc
rows.sort((a, b) {
if (a.kind != b.kind) {
return a.kind == 'assignee' ? -1 : 1;
}
return b.seconds.compareTo(a.seconds);
});
return rows;
}
/// Calculate total seconds from day sheet rows.
int totalSeconds(List<DaySheetRow> rows) {
return rows.fold(0, (sum, row) => sum + row.seconds);
}
+421
View File
@@ -0,0 +1,421 @@
import 'package:flutter/material.dart';
import 'package:tasq/models/programmer_day_sheet.model.dart';
import 'package:tasq/models/programmer_task.model.dart';
/// Aggregated statistics for a programmer's dashboard.
class ProgrammerStats {
final Duration todayFocus;
final Duration helpGivenThisMonth;
final Duration approvedTotal;
final Duration helpGivenTotal;
final int completedThisWeek;
final int completedLastWeek;
final int currentStreak;
final int bestStreak;
final int completedTotal;
final int bugFixCompleted;
final double? firstPassRate;
final bool hasCleanWeek;
const ProgrammerStats({
required this.todayFocus,
required this.helpGivenThisMonth,
required this.approvedTotal,
required this.helpGivenTotal,
required this.completedThisWeek,
required this.completedLastWeek,
required this.currentStreak,
required this.bestStreak,
required this.completedTotal,
required this.bugFixCompleted,
this.firstPassRate,
required this.hasCleanWeek,
});
}
DateTime _dateOnly(DateTime dt) => DateTime.utc(dt.year, dt.month, dt.day);
DateTime _weekStart(DateTime dt) {
final d = _dateOnly(dt);
return d.subtract(Duration(days: d.weekday - 1));
}
/// Compute all programmer statistics from raw data.
///
/// [liveHelpThisMonthSec] — when provided (from a live work-log query), this
/// value is used directly for [ProgrammerStats.helpGivenThisMonth] instead of
/// the approved-snapshot sum, so the stat reflects pending work too.
ProgrammerStats computeProgrammerStats({
required String userId,
required DateTime now,
required List<ProgrammerTask> tasks,
required List<ProgrammerDaySheet> sheets,
required Set<DateTime> leaveDays,
required Duration todayFocus,
int? liveHelpThisMonthSec,
}) {
final today = _dateOnly(now);
final thisWeekStart = _weekStart(today);
final thisWeekEnd = thisWeekStart.add(const Duration(days: 6)); // Sunday
final lastWeekStart = thisWeekStart.subtract(const Duration(days: 7));
final lastWeekEnd = thisWeekStart.subtract(const Duration(days: 1));
int completedThisWeek = 0;
int completedLastWeek = 0;
int completedTotal = 0;
int bugFixCompleted = 0;
for (final task in tasks) {
if (task.assigneeId != userId) continue;
if (task.status != ProgrammerTaskStatus.completed) continue;
completedTotal++;
if (task.category == ProgrammerTaskCategory.bugFix) bugFixCompleted++;
if (task.completedAt != null) {
final completedDay = _dateOnly(task.completedAt!);
if (!completedDay.isBefore(thisWeekStart) &&
!completedDay.isAfter(thisWeekEnd)) {
completedThisWeek++;
} else if (!completedDay.isBefore(lastWeekStart) &&
!completedDay.isAfter(lastWeekEnd)) {
completedLastWeek++;
}
}
}
final streakResult = computeStreak(
sheets: sheets,
leaveDays: leaveDays,
today: today,
);
// firstPassRate: last 30 days (today-29 through today inclusive)
final thirtyDaysAgo = today.subtract(const Duration(days: 29));
int reviewedCount = 0;
int firstPassApprovedCount = 0;
for (final sheet in sheets) {
final wd = sheet.workDate;
if (wd.isBefore(thirtyDaysAgo) || wd.isAfter(today)) continue;
final isReviewed = sheet.status == DaySheetStatus.approved ||
sheet.status == DaySheetStatus.disapproved ||
sheet.resubmissions > 0;
if (!isReviewed) continue;
reviewedCount++;
if (sheet.isFirstPassApproved) firstPassApprovedCount++;
}
final double? firstPassRate =
reviewedCount == 0 ? null : firstPassApprovedCount / reviewedCount;
// Approved sums from approvedSnapshot
int approvedTotalSec = 0;
int helpGivenTotalSec = 0;
int helpGivenThisMonthSec = 0;
for (final sheet in sheets) {
final snap = sheet.approvedSnapshot;
if (snap == null) continue;
approvedTotalSec += snap.totalSeconds;
for (final row in snap.rows) {
if (row.kind == 'helper') {
helpGivenTotalSec += row.seconds;
if (sheet.workDate.year == now.year &&
sheet.workDate.month == now.month) {
helpGivenThisMonthSec += row.seconds;
}
}
}
}
return ProgrammerStats(
todayFocus: todayFocus,
helpGivenThisMonth: Duration(
seconds: liveHelpThisMonthSec ?? helpGivenThisMonthSec),
approvedTotal: Duration(seconds: approvedTotalSec),
helpGivenTotal: Duration(seconds: helpGivenTotalSec),
completedThisWeek: completedThisWeek,
completedLastWeek: completedLastWeek,
currentStreak: streakResult.current,
bestStreak: streakResult.best,
completedTotal: completedTotal,
bugFixCompleted: bugFixCompleted,
firstPassRate: firstPassRate,
hasCleanWeek: _computeHasCleanWeek(sheets, leaveDays),
);
}
/// Compute current and best approval streaks.
///
/// A day qualifies if its sheet status is `pending` or `approved`.
/// Weekends and [leaveDays] are neutral (skipped, not breaks).
/// A non-qualifying `today` is also skipped (not a break).
/// Any other non-neutral weekday without a qualifying sheet breaks the current streak.
({int current, int best}) computeStreak({
required List<ProgrammerDaySheet> sheets,
required Set<DateTime> leaveDays,
required DateTime today,
}) {
final qualifyingDays = <DateTime>{};
for (final sheet in sheets) {
if (sheet.status == DaySheetStatus.pending ||
sheet.status == DaySheetStatus.approved) {
qualifyingDays.add(sheet.workDate);
}
}
return (
current: _currentStreak(qualifyingDays, leaveDays, today),
best: _bestStreak(qualifyingDays, leaveDays, today),
);
}
int _currentStreak(
Set<DateTime> qualifyingDays,
Set<DateTime> leaveDays,
DateTime today,
) {
int streak = 0;
bool isFirstDay = true;
DateTime day = today;
for (int i = 0; i < 1000; i++) {
final weekday = day.weekday; // 1=Mon..7=Sun
if (weekday == 6 || weekday == 7) {
day = day.subtract(const Duration(days: 1));
continue;
}
if (leaveDays.contains(day)) {
day = day.subtract(const Duration(days: 1));
isFirstDay = false;
continue;
}
if (qualifyingDays.contains(day)) {
streak++;
day = day.subtract(const Duration(days: 1));
isFirstDay = false;
continue;
}
if (isFirstDay) {
// today with no qualifying sheet is skipped, not a break
day = day.subtract(const Duration(days: 1));
isFirstDay = false;
continue;
}
break; // non-neutral, non-qualifying weekday that is not today
}
return streak;
}
int _bestStreak(
Set<DateTime> qualifyingDays,
Set<DateTime> leaveDays,
DateTime today,
) {
if (qualifyingDays.isEmpty) return 0;
final earliest =
qualifyingDays.reduce((a, b) => a.isBefore(b) ? a : b);
int best = 0;
int run = 0;
DateTime day = earliest;
while (!day.isAfter(today)) {
final weekday = day.weekday;
if (weekday == 6 || weekday == 7 || leaveDays.contains(day)) {
day = day.add(const Duration(days: 1));
continue;
}
if (qualifyingDays.contains(day)) {
run++;
if (run > best) best = run;
} else {
run = 0;
}
day = day.add(const Duration(days: 1));
}
return best;
}
bool _computeHasCleanWeek(
List<ProgrammerDaySheet> sheets,
Set<DateTime> leaveDays,
) {
if (sheets.isEmpty) return false;
final firstPassDays = <DateTime>{};
for (final sheet in sheets) {
if (sheet.isFirstPassApproved) firstPassDays.add(sheet.workDate);
}
// Collect unique Monday starts from all sheet work dates
final weeks = <DateTime>{};
for (final sheet in sheets) {
final wd = sheet.workDate;
final monday = wd.subtract(Duration(days: wd.weekday - 1));
weeks.add(monday);
}
for (final monday in weeks) {
bool hasApproved = false;
bool allClean = true;
for (int i = 0; i < 5; i++) {
final day = monday.add(Duration(days: i));
if (leaveDays.contains(day)) continue;
if (firstPassDays.contains(day)) {
hasApproved = true;
continue;
}
allClean = false;
break;
}
if (allClean && hasApproved) return true;
}
return false;
}
/// Progress toward a badge milestone.
class BadgeProgress {
final String id;
final String title;
final String description;
final IconData icon;
final int current;
final int target;
final bool earned;
const BadgeProgress({
required this.id,
required this.title,
required this.description,
required this.icon,
required this.current,
required this.target,
required this.earned,
});
}
/// Compute badge progress list from [stats].
///
/// When [includeSheetBadges] is false, sheet-based badges
/// (deep_work, on_a_roll, team_player, clean_week) are omitted.
List<BadgeProgress> computeBadges(
ProgrammerStats stats, {
required bool includeSheetBadges,
}) {
final badges = <BadgeProgress>[];
badges.add(_makeBadge(
id: 'first_finish',
title: 'First Finish',
description: 'Complete your first task',
icon: Icons.flag_outlined,
tiers: const [1],
current: stats.completedTotal,
));
badges.add(_makeBadge(
id: 'finisher',
title: 'Finisher',
description: 'Complete 10 / 50 / 100 tasks',
icon: Icons.task_alt,
tiers: const [10, 50, 100],
current: stats.completedTotal,
));
badges.add(_makeBadge(
id: 'bug_squasher',
title: 'Bug Squasher',
description: 'Complete 10 Bug Fix tasks',
icon: Icons.bug_report_outlined,
tiers: const [10],
current: stats.bugFixCompleted,
));
if (includeSheetBadges) {
badges.add(_makeBadge(
id: 'deep_work',
title: 'Deep Work',
description: 'Log 100 / 500 approved hours',
icon: Icons.timer_outlined,
tiers: const [100, 500],
current: stats.approvedTotal.inHours,
));
badges.add(_makeBadge(
id: 'on_a_roll',
title: 'On a Roll',
description: 'Reach a 5 / 10 / 20-day streak',
icon: Icons.local_fire_department_outlined,
tiers: const [5, 10, 20],
current: stats.bestStreak,
));
badges.add(_makeBadge(
id: 'team_player',
title: 'Team Player',
description: 'Give 10 hours of help to teammates',
icon: Icons.handshake_outlined,
tiers: const [10],
current: stats.helpGivenTotal.inHours,
));
badges.add(_makeBadge(
id: 'clean_week',
title: 'Clean Week',
description: 'A Mon–Fri week with every day approved first time',
icon: Icons.verified_outlined,
tiers: const [1],
current: stats.hasCleanWeek ? 1 : 0,
));
}
return badges;
}
BadgeProgress _makeBadge({
required String id,
required String title,
required String description,
required IconData icon,
required List<int> tiers,
required int current,
}) {
final earned = current >= tiers.first;
// Highest tier reached
int highestReached = 0;
for (final tier in tiers) {
if (current >= tier) highestReached = tier;
}
// Next unreached tier, or top tier when all reached
int target = tiers.last;
for (final tier in tiers) {
if (current < tier) {
target = tier;
break;
}
}
final displayTitle = earned ? '$title · $highestReached' : title;
return BadgeProgress(
id: id,
title: displayTitle,
description: description,
icon: icon,
current: current,
target: target,
earned: earned,
);
}
+78
View File
@@ -0,0 +1,78 @@
import '../models/programmer_task_work_log.model.dart';
/// Formats a worked duration compactly, e.g. `2h 15m` or `45m`.
String formatDurationHm(Duration d) {
final h = d.inHours;
final m = d.inMinutes.remainder(60);
if (h > 0) return '${h}h ${m}m';
return '${m}m';
}
/// One contributor's share of a task's time.
class LedgerEntry {
final String userId;
final Duration duration;
final bool isAssignee;
const LedgerEntry({
required this.userId,
required this.duration,
required this.isAssignee,
});
}
/// Per-contributor time breakdown for a task: the assignee's effective running
/// duration plus each helper's summed logged minutes.
class TaskTimeLedger {
final LedgerEntry? assignee;
final List<LedgerEntry> helpers;
const TaskTimeLedger({required this.assignee, required this.helpers});
Duration get total =>
(assignee?.duration ?? Duration.zero) +
helpers.fold(Duration.zero, (a, e) => a + e.duration);
List<LedgerEntry> get all => [?assignee, ...helpers];
}
/// Builds the per-contributor ledger for a task.
///
/// [assigneeDuration] is the assignee's effective running duration (already
/// computed from the activity log, including any adjustments). Helper entries
/// sum the `minutes` of work logs authored by someone other than the assignee;
/// assignee work logs (minutes == null progress notes) are excluded because the
/// assignee's time comes from the running clock.
TaskTimeLedger computeTaskLedger({
required String? assigneeId,
required Duration assigneeDuration,
required List<ProgrammerTaskWorkLog> workLogs,
}) {
final byHelper = <String, int>{};
for (final log in workLogs) {
final minutes = log.minutes;
if (minutes == null || minutes <= 0) continue;
if (log.authorId == assigneeId) continue;
byHelper[log.authorId] = (byHelper[log.authorId] ?? 0) + minutes;
}
final helpers = byHelper.entries
.map((e) => LedgerEntry(
userId: e.key,
duration: Duration(minutes: e.value),
isAssignee: false,
))
.toList()
..sort((a, b) => b.duration.compareTo(a.duration));
return TaskTimeLedger(
assignee: assigneeId == null
? null
: LedgerEntry(
userId: assigneeId,
duration: assigneeDuration,
isAssignee: true,
),
helpers: helpers,
);
}
+63
View File
@@ -0,0 +1,63 @@
import 'package:tasq/models/leave_of_absence.model.dart';
import 'package:tasq/models/pass_slip.model.dart';
import 'programmer_daily_time.dart';
/// Index approved leaves covering the given moment by user ID.
///
/// Filters to leaves where:
/// - status == 'approved'
/// - startTime <= now < endTime
///
/// Returns a map from userId to the leave record.
Map<String, LeaveOfAbsence> activeLeaveByUser(
List<LeaveOfAbsence> leaves,
DateTime now,
) {
final result = <String, LeaveOfAbsence>{};
for (final leave in leaves) {
if (leave.status == 'approved' &&
!leave.startTime.isAfter(now) &&
leave.endTime.isAfter(now)) {
result[leave.userId] = leave;
}
}
return result;
}
/// Index active pass slips by user ID.
///
/// Filters to slips where slip.isActive is true.
///
/// Returns a map from userId to the pass slip record.
Map<String, PassSlip> activePassSlipByUser(List<PassSlip> slips) {
final result = <String, PassSlip>{};
for (final slip in slips) {
if (slip.isActive) {
result[slip.userId] = slip;
}
}
return result;
}
/// Get the set of Manila calendar days covered by approved leaves for a user.
///
/// Iterates through all approved leaves for the given user and collects the
/// set of Manila day keys (as DateTime.utc(year, month, day)) from each
/// leave's start date through end date, inclusive.
Set<DateTime> approvedLeaveDays(
List<LeaveOfAbsence> leaves,
String userId,
) {
final result = <DateTime>{};
for (final leave in leaves) {
if (leave.userId == userId && leave.status == 'approved') {
var current = manilaDay(leave.startTime);
final endDay = manilaDay(leave.endTime);
while (!current.isAfter(endDay)) {
result.add(current);
current = current.add(Duration(days: 1));
}
}
}
return result;
}

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