43 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
108 changed files with 19994 additions and 203 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()),
+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,
+12 -58
View File
@@ -36,6 +36,7 @@ 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';
@@ -885,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),
@@ -5704,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++)
@@ -5755,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++)
@@ -6069,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)
+3 -14
View File
@@ -44,6 +44,7 @@ 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({
@@ -369,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);
@@ -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;
}
},
),
);
+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,
+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;
}
+57 -21
View File
@@ -1,17 +1,25 @@
import '../models/task_activity_log.model.dart';
/// Finds the canonical execution start time for a task.
/// A minimal activity event shape: just the fields the worked-duration math
/// needs. Both [TaskActivityLog] and ProgrammerTaskActivityLog map onto this,
/// so the pause-aware duration logic is shared across task modules.
typedef DurationEvent = ({DateTime createdAt, String actionType});
// ---------------------------------------------------------------------------
// Generic core (works on any DurationEvent list)
// ---------------------------------------------------------------------------
/// Finds the canonical execution start time from [events].
///
/// [logs] is expected in DESCENDING order (newest first), matching the
/// `order('created_at', ascending: false)` used by Supabase queries.
/// The reversed iteration therefore walks chronologically forward and stops
/// at the earliest 'started' event.
DateTime? resolveTaskExecutionStart(
List<TaskActivityLog> logs,
/// [events] is expected in DESCENDING order (newest first). The reversed
/// iteration walks chronologically forward and stops at the earliest 'started'
/// event, falling back to [fallbackStartedAt].
DateTime? resolveExecutionStart(
List<DurationEvent> events,
DateTime? fallbackStartedAt,
) {
DateTime? started;
for (final entry in logs.reversed) {
for (final entry in events.reversed) {
if (entry.actionType == 'started') {
started = entry.createdAt;
break;
@@ -20,31 +28,33 @@ DateTime? resolveTaskExecutionStart(
return started ?? fallbackStartedAt;
}
/// Computes the effective worked duration for a task, subtracting all
/// paused intervals between [fallbackStartedAt] (or the first 'started'
/// log event) and [endAt].
/// Computes the effective worked duration, subtracting all paused intervals
/// between the resolved start and [endAt], then subtracting [adjustment]
/// (e.g. time reassigned to another task when the user helped elsewhere).
///
/// [logs] must be in DESCENDING order (newest first) — the same order
/// returned by `task_activity_logs` queries with `ascending: false`.
Duration computeEffectiveTaskDuration({
/// [events] must be in DESCENDING order (newest first) — the same order
/// returned by activity-log queries with `ascending: false`. The result never
/// goes below zero.
Duration computeEffectiveDuration({
required DateTime? fallbackStartedAt,
required DateTime endAt,
required List<TaskActivityLog> logs,
required List<DurationEvent> events,
Duration adjustment = Duration.zero,
}) {
final start = resolveTaskExecutionStart(logs, fallbackStartedAt);
final start = resolveExecutionStart(events, fallbackStartedAt);
if (start == null || !endAt.isAfter(start)) return Duration.zero;
Duration pausedTotal = Duration.zero;
DateTime? pausedSince;
// logs.reversed → ascending (chronological) order within [start, endAt]
final events = logs.reversed.where((e) {
// events.reversed → ascending (chronological) order within [start, endAt]
final relevant = events.reversed.where((e) {
if (e.createdAt.isBefore(start)) return false;
if (e.createdAt.isAfter(endAt)) return false;
return e.actionType == 'paused' || e.actionType == 'resumed';
});
for (final event in events) {
for (final event in relevant) {
if (event.actionType == 'paused') {
pausedSince ??= event.createdAt;
} else if (event.actionType == 'resumed' && pausedSince != null) {
@@ -55,11 +65,37 @@ Duration computeEffectiveTaskDuration({
}
}
// Task is still paused at endAt — count the open pause interval.
// Still paused at endAt — count the open pause interval.
if (pausedSince != null && endAt.isAfter(pausedSince)) {
pausedTotal += endAt.difference(pausedSince);
}
final total = endAt.difference(start) - pausedTotal;
final total = endAt.difference(start) - pausedTotal - adjustment;
return total.isNegative ? Duration.zero : total;
}
// ---------------------------------------------------------------------------
// TaskActivityLog wrappers (unchanged public API — existing call sites intact)
// ---------------------------------------------------------------------------
List<DurationEvent> _toEvents(List<TaskActivityLog> logs) =>
logs.map((l) => (createdAt: l.createdAt, actionType: l.actionType)).toList();
/// Finds the canonical execution start time for a task. See
/// [resolveExecutionStart].
DateTime? resolveTaskExecutionStart(
List<TaskActivityLog> logs,
DateTime? fallbackStartedAt,
) => resolveExecutionStart(_toEvents(logs), fallbackStartedAt);
/// Computes the effective worked duration for a task. See
/// [computeEffectiveDuration].
Duration computeEffectiveTaskDuration({
required DateTime? fallbackStartedAt,
required DateTime endAt,
required List<TaskActivityLog> logs,
}) => computeEffectiveDuration(
fallbackStartedAt: fallbackStartedAt,
endAt: endAt,
events: _toEvents(logs),
);
+16
View File
@@ -460,6 +460,20 @@ List<NavSection> _buildSections(String role) {
icon: Icons.task_outlined,
selectedIcon: Icons.task,
),
if (role == 'admin' || role == 'programmer') ...[
NavItem(
label: 'Programmer Tasks',
route: '/programmer-tasks',
icon: Icons.developer_mode_outlined,
selectedIcon: Icons.developer_mode,
),
NavItem(
label: 'Projects',
route: '/programmer-projects',
icon: Icons.folder_outlined,
selectedIcon: Icons.folder,
),
],
NavItem(
label: 'IT Service Requests',
route: '/it-service-requests',
@@ -715,6 +729,8 @@ String _routeToTitle(String location) {
'/attendance' => 'Attendance',
'/tickets' => 'Tickets',
'/tasks' => 'Tasks',
'/programmer-tasks' => 'Programmer Tasks',
'/programmer-projects' => 'Projects',
'/it-service-requests' => 'IT Service Requests',
'/network-map' => 'Network Map',
'/notifications' => 'Notifications',
+124
View File
@@ -0,0 +1,124 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:printing/printing.dart';
/// Show a PDF preview dialog with Print and Download buttons.
///
/// [buildBytes] is called once and cached; the result is shared between the
/// preview viewer and the action buttons.
Future<void> showPdfPreviewDialog(
BuildContext context, {
required String title,
required String filename,
required Future<Uint8List> Function() buildBytes,
}) {
return showDialog<void>(
context: context,
builder: (_) => _PdfPreviewDialog(
title: title,
filename: filename,
buildBytes: buildBytes,
),
);
}
class _PdfPreviewDialog extends StatefulWidget {
const _PdfPreviewDialog({
required this.title,
required this.filename,
required this.buildBytes,
});
final String title;
final String filename;
final Future<Uint8List> Function() buildBytes;
@override
State<_PdfPreviewDialog> createState() => _PdfPreviewDialogState();
}
class _PdfPreviewDialogState extends State<_PdfPreviewDialog> {
late final Future<Uint8List> _future;
@override
void initState() {
super.initState();
_future = widget.buildBytes();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
contentPadding: EdgeInsets.zero,
content: SizedBox(
width: 800,
height: 900,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Expanded(
child: Text(
widget.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
tooltip: 'Print',
icon: const Icon(Icons.print),
onPressed: () async {
final bytes = await _future;
await Printing.layoutPdf(
onLayout: (_) async => bytes,
name: widget.filename,
);
},
),
IconButton(
tooltip: 'Download',
icon: const Icon(Icons.download),
onPressed: () async {
final bytes = await _future;
await Printing.sharePdf(
bytes: bytes,
filename: widget.filename,
);
},
),
IconButton(
tooltip: 'Close',
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 1),
Expanded(
child: FutureBuilder<Uint8List>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text(snapshot.error.toString()));
}
final data = snapshot.data;
if (data == null) return const SizedBox.shrink();
return PdfViewer.data(data, sourceName: widget.filename);
},
),
),
],
),
),
);
}
}
+164
View File
@@ -0,0 +1,164 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill;
/// Convert a stored Delta JSON or plain text string to trimmed plain text.
/// - Delta JSON (starts with '[') is decoded and converted to plain text
/// - Non-JSON strings are returned trimmed as-is
/// - null returns empty string
String deltaJsonToPlainText(String? stored) {
if (stored == null) return '';
final trimmed = stored.trim();
if (trimmed.isEmpty) return '';
if (trimmed.startsWith('[')) {
try {
final decoded = jsonDecode(trimmed);
if (decoded is List) {
final doc = quill.Document.fromJson(decoded);
return doc.toPlainText().trim();
}
} catch (_) {}
}
return trimmed;
}
/// Parses a stored Delta-JSON string into a [quill.QuillController], or returns
/// an empty one. Callers own the controller and must dispose it.
quill.QuillController quillControllerFromDelta(String? deltaJson) {
if (deltaJson != null && deltaJson.isNotEmpty) {
try {
final decoded = jsonDecode(deltaJson);
if (decoded is List) {
return quill.QuillController(
document: quill.Document.fromJson(decoded),
selection: const TextSelection.collapsed(offset: 0),
);
}
} catch (_) {}
}
return quill.QuillController.basic();
}
/// Serializes a controller's document to a Delta-JSON string for storage.
String quillToDeltaJson(quill.QuillController controller) =>
jsonEncode(controller.document.toDelta().toJson());
/// The controller's plain text (trimmed) — for empty checks and previews.
String quillPlainText(quill.QuillController controller) =>
controller.document.toPlainText().trim();
/// Bordered rich-text editor with a minimal toolbar (bold/italic/underline +
/// bullet/numbered lists). The caller owns [controller] and disposes it.
class QuillFieldEditor extends StatelessWidget {
const QuillFieldEditor({
super.key,
required this.controller,
this.placeholder,
this.height = 140,
});
final quill.QuillController controller;
final String? placeholder;
final double height;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
height: height,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
border: Border.all(color: cs.outlineVariant),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
SizedBox(
height: 40,
child: quill.QuillSimpleToolbar(
controller: controller,
config: const quill.QuillSimpleToolbarConfig(
multiRowsDisplay: false,
showAlignmentButtons: false,
showBackgroundColorButton: false,
showCenterAlignment: false,
showColorButton: false,
showDividers: false,
showFontFamily: false,
showFontSize: false,
showHeaderStyle: false,
showIndent: false,
showInlineCode: false,
showLeftAlignment: false,
showLink: false,
showQuote: false,
showRightAlignment: false,
showSearchButton: false,
showCodeBlock: false,
showDirection: false,
showJustifyAlignment: false,
showListCheck: false,
showSubscript: false,
showSuperscript: false,
showStrikeThrough: false,
showSmallButton: false,
showClearFormat: false,
showRedo: false,
showUndo: false,
),
),
),
const Divider(height: 1),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: quill.QuillEditor.basic(
controller: controller,
config: quill.QuillEditorConfig(
placeholder: placeholder,
expands: true,
),
),
),
),
],
),
);
}
}
/// Renders a stored value read-only. Interprets a Delta-JSON string as rich
/// text; falls back to a plain [Text] for legacy plain-string values.
class QuillReadOnly extends StatelessWidget {
const QuillReadOnly(this.deltaOrText, {super.key, this.style});
final String deltaOrText;
final TextStyle? style;
@override
Widget build(BuildContext context) {
final trimmed = deltaOrText.trim();
if (trimmed.startsWith('[')) {
try {
final decoded = jsonDecode(trimmed);
if (decoded is List) {
final controller = quill.QuillController(
document: quill.Document.fromJson(decoded),
selection: const TextSelection.collapsed(offset: 0),
);
return IgnorePointer(
child: quill.QuillEditor.basic(
controller: controller,
config: const quill.QuillEditorConfig(
showCursor: false,
autoFocus: false,
),
),
);
}
} catch (_) {}
}
return Text(deltaOrText, style: style);
}
}
+11 -1
View File
@@ -18,6 +18,7 @@ class TasQColumn<T> {
required this.cellBuilder,
this.technical = false,
this.hideOnMedium = false,
this.minWidth,
});
/// The column header text.
@@ -33,6 +34,11 @@ class TasQColumn<T> {
/// Use this for lower-priority columns (e.g. Timestamp, Status) that
/// would otherwise force a horizontal scrollbar on tablet viewports.
final bool hideOnMedium;
/// Minimum comfortable width for this column, in logical pixels. When null a
/// default of 200 is used. Set a smaller value for short columns (a count, a
/// status pill, a short enum) so the table fits without a horizontal scroll.
final double? minWidth;
}
/// Builds a mobile tile for [TasQAdaptiveList].
@@ -380,9 +386,13 @@ class TasQAdaptiveList<T> extends StatelessWidget {
const double colSpacing = 20.0;
final actionsColumnCount = rowActions == null ? 0 : 1;
final totalCols = visibleColumns.length + actionsColumnCount;
final columnsMinWidth = visibleColumns.fold<double>(
0.0,
(sum, c) => sum + (c.minWidth ?? colMinW),
);
final minColumnsWidth =
hMarginTotal +
(visibleColumns.length * colMinW) +
columnsMinWidth +
(actionsColumnCount * actMinW) +
(math.max(0, totalCols - 1) * colSpacing);
final tableWidth = math.max(contentWidth, minColumnsWidth);
@@ -197,6 +197,33 @@ async function processBatch() {
data.navigate_to = '/announcements'
break
}
case 'day_sheet_digest': {
const { count, error: countErr } = await supabase
.from('programmer_day_sheets')
.select('id', { count: 'exact', head: true })
.eq('status', 'pending')
if (countErr) {
console.error('Failed to count pending day sheets', countErr)
throw countErr
}
const n = count ?? 0
if (n === 0) {
// No pending sheets; mark processed and skip sending
await supabase
.from('scheduled_notifications')
.update({ processed: true, processed_at: new Date().toISOString() })
.eq('id', rowId)
console.log('Day sheet digest: no pending sheets, skipped', rowId)
continue
}
title = 'Day sheets awaiting review'
body = `${n} day sheet${n === 1 ? ' is' : 's are'} awaiting your review.`
data.navigate_to = '/programmer-tasks?tab=approvals'
break
}
default:
title = 'Reminder'
body = 'You have a pending notification.'
@@ -0,0 +1,168 @@
-- Programmer Tasks — a separate work-log / time-tracking module for the
-- `programmer` role. Distinct from the IT help-desk `tasks` feature: no office/
-- ticket/it_staff coupling, its own dev-flavored category vocabulary, a single
-- assignee, and pause/resume tracked as activity-log events for time attribution.
-- Designed as the seed of a future Project Management workflow (projects/subtasks
-- can be added later without touching the help-desk `tasks` feature).
-- Category vocabulary (TitleCase display strings, matching the existing enum
-- convention used by request_type / request_category).
create type programmer_task_category as enum (
'Software Support',
'Software Development',
'Software Enhancement',
'Bug Fix',
'Meeting',
'Facilitate Event',
'Others'
);
create table if not exists programmer_tasks (
id uuid primary key default gen_random_uuid(),
title text not null,
description text,
category programmer_task_category not null,
status text not null default 'queued',
priority int not null default 1,
assignee_id uuid references profiles(id),
creator_id uuid references profiles(id),
created_at timestamptz not null default now(),
started_at timestamptz,
completed_at timestamptz,
cancelled_at timestamptz,
cancellation_reason text,
updated_at timestamptz not null default now()
);
create index if not exists idx_programmer_tasks_assignee_id on programmer_tasks(assignee_id);
create index if not exists idx_programmer_tasks_status on programmer_tasks(status);
-- Keep updated_at fresh on every write (dedicated function to avoid clashing
-- with any generic trigger function elsewhere).
create or replace function set_programmer_tasks_updated_at()
returns trigger as $$
begin
new.updated_at = now();
return new;
end;
$$ language plpgsql;
drop trigger if exists trg_programmer_tasks_updated_at on programmer_tasks;
create trigger trg_programmer_tasks_updated_at
before update on programmer_tasks
for each row execute function set_programmer_tasks_updated_at();
-- Activity log — mirrors task_activity_logs. Records created/started/paused/
-- resumed/completed/cancelled/assigned/reassigned events. Pause/resume events
-- drive the effective-worked-duration math (see lib/utils/task_duration.dart).
create table if not exists programmer_task_activity_logs (
id uuid primary key default gen_random_uuid(),
task_id uuid not null references programmer_tasks(id) on delete cascade,
actor_id uuid references profiles(id),
action_type text not null,
meta jsonb,
created_at timestamptz not null default now()
);
create index if not exists idx_programmer_task_activity_logs_task_id
on programmer_task_activity_logs(task_id);
-- ---------------------------------------------------------------------------
-- Row Level Security
-- ---------------------------------------------------------------------------
-- Programmer tasks are a programmer-team tool: admins and programmers have full
-- access; a row's assignee or creator can always see/act on their own task.
alter table programmer_tasks enable row level security;
alter table programmer_task_activity_logs enable row level security;
drop policy if exists "programmer_tasks_select" on programmer_tasks;
create policy "programmer_tasks_select" on programmer_tasks
for select to authenticated
using (
assignee_id = auth.uid()
or creator_id = auth.uid()
or exists (
select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')
)
);
drop policy if exists "programmer_tasks_insert" on programmer_tasks;
create policy "programmer_tasks_insert" on programmer_tasks
for insert to authenticated
with check (
creator_id = auth.uid()
and exists (
select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')
)
);
drop policy if exists "programmer_tasks_update" on programmer_tasks;
create policy "programmer_tasks_update" on programmer_tasks
for update to authenticated
using (
assignee_id = auth.uid()
or creator_id = auth.uid()
or exists (
select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')
)
)
with check (
assignee_id = auth.uid()
or creator_id = auth.uid()
or exists (
select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')
)
);
drop policy if exists "programmer_tasks_delete" on programmer_tasks;
create policy "programmer_tasks_delete" on programmer_tasks
for delete to authenticated
using (
creator_id = auth.uid()
or exists (
select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')
)
);
-- Activity logs: visible/writable to anyone who can see the parent task.
drop policy if exists "programmer_task_activity_logs_select" on programmer_task_activity_logs;
create policy "programmer_task_activity_logs_select" on programmer_task_activity_logs
for select to authenticated
using (
exists (
select 1 from programmer_tasks t
where t.id = task_id
and (
t.assignee_id = auth.uid()
or t.creator_id = auth.uid()
or exists (
select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')
)
)
)
);
drop policy if exists "programmer_task_activity_logs_insert" on programmer_task_activity_logs;
create policy "programmer_task_activity_logs_insert" on programmer_task_activity_logs
for insert to authenticated
with check (
exists (
select 1 from programmer_tasks t
where t.id = task_id
and (
t.assignee_id = auth.uid()
or t.creator_id = auth.uid()
or exists (
select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')
)
)
)
);
-- ---------------------------------------------------------------------------
-- Realtime — the Riverpod .stream() subscriptions require these tables in the
-- supabase_realtime publication.
-- ---------------------------------------------------------------------------
alter publication supabase_realtime add table programmer_tasks;
alter publication supabase_realtime add table programmer_task_activity_logs;
@@ -0,0 +1,51 @@
-- Hardens the programmer_tasks RLS shipped in 20260927120000, per the commit
-- security review (two MEDIUM findings). The base migration is already applied
-- remotely, so this is a corrective follow-up.
-- 1) audit-log-forgery: the activity-log INSERT policy did not bind actor_id to
-- the caller, so an authorized user could attribute a log entry to someone
-- else. Require actor_id to be the caller (or null for system rows).
drop policy if exists "programmer_task_activity_logs_insert" on programmer_task_activity_logs;
create policy "programmer_task_activity_logs_insert" on programmer_task_activity_logs
for insert to authenticated
with check (
(actor_id = auth.uid() or actor_id is null)
and exists (
select 1 from programmer_tasks t
where t.id = task_id
and (
t.assignee_id = auth.uid()
or t.creator_id = auth.uid()
or exists (
select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')
)
)
)
);
-- 2) column-immutability: the UPDATE policy allowed an assignee to mutate any
-- column, including creator_id/created_at (the DELETE policy keys off
-- creator_id). Make those columns immutable via a trigger.
create or replace function programmer_tasks_guard_immutable()
returns trigger
language plpgsql
set search_path = ''
as $$
begin
if new.creator_id is distinct from old.creator_id then
raise exception 'creator_id is immutable';
end if;
if new.created_at is distinct from old.created_at then
raise exception 'created_at is immutable';
end if;
return new;
end;
$$;
drop trigger if exists trg_programmer_tasks_guard_immutable on programmer_tasks;
create trigger trg_programmer_tasks_guard_immutable
before update on programmer_tasks
for each row execute function programmer_tasks_guard_immutable();
-- Note: activity-logs UPDATE/DELETE remain denied (no such policies exist),
-- keeping the audit trail append-only. New log/ledger tables follow the same shape.
@@ -0,0 +1,151 @@
-- Programmer Tasks PM extension: projects, work logs (per-contributor time
-- ledger), and comments with attachments. All tables start hardened (author
-- bound to auth.uid(); logs/work-logs append-only; immutable creator/created_at).
-- Visibility helper: true iff the caller can see the task. SECURITY INVOKER so
-- it runs under the caller's own programmer_tasks RLS (no SECURITY DEFINER
-- exposure); reused by work-log and comment policies.
create or replace function programmer_task_visible(t_id uuid)
returns boolean
language sql
stable
security invoker
set search_path = public
as $$
select exists (select 1 from programmer_tasks where id = t_id);
$$;
-- ---------------------------------------------------------------------------
-- Projects
-- ---------------------------------------------------------------------------
create table if not exists programmer_projects (
id uuid primary key default gen_random_uuid(),
name text not null,
description text,
status text not null default 'active', -- active | archived
creator_id uuid references profiles(id),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_programmer_projects_status on programmer_projects(status);
-- Reuse the table-agnostic updated_at + immutability trigger functions from the
-- base/harden migrations.
drop trigger if exists trg_programmer_projects_updated_at on programmer_projects;
create trigger trg_programmer_projects_updated_at
before update on programmer_projects
for each row execute function set_programmer_tasks_updated_at();
drop trigger if exists trg_programmer_projects_guard_immutable on programmer_projects;
create trigger trg_programmer_projects_guard_immutable
before update on programmer_projects
for each row execute function programmer_tasks_guard_immutable();
alter table programmer_projects enable row level security;
drop policy if exists "programmer_projects_select" on programmer_projects;
create policy "programmer_projects_select" on programmer_projects
for select to authenticated
using (
creator_id = auth.uid()
or exists (select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer'))
);
drop policy if exists "programmer_projects_insert" on programmer_projects;
create policy "programmer_projects_insert" on programmer_projects
for insert to authenticated
with check (
creator_id = auth.uid()
and exists (select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer'))
);
drop policy if exists "programmer_projects_update" on programmer_projects;
create policy "programmer_projects_update" on programmer_projects
for update to authenticated
using (
creator_id = auth.uid()
or exists (select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer'))
)
with check (
creator_id = auth.uid()
or exists (select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer'))
);
drop policy if exists "programmer_projects_delete" on programmer_projects;
create policy "programmer_projects_delete" on programmer_projects
for delete to authenticated
using (
creator_id = auth.uid()
or exists (select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer'))
);
-- Link tasks to a project (optional; unset on project delete).
alter table programmer_tasks add column if not exists project_id uuid
references programmer_projects(id) on delete set null;
create index if not exists idx_programmer_tasks_project_id on programmer_tasks(project_id);
-- ---------------------------------------------------------------------------
-- Work logs (per-contributor time ledger)
-- ---------------------------------------------------------------------------
create table if not exists programmer_task_work_logs (
id uuid primary key default gen_random_uuid(),
task_id uuid not null references programmer_tasks(id) on delete cascade,
author_id uuid not null references profiles(id),
description text not null,
minutes int check (minutes is null or minutes > 0), -- null = assignee progress note
created_at timestamptz not null default now()
);
create index if not exists idx_programmer_task_work_logs_task_id on programmer_task_work_logs(task_id);
alter table programmer_task_work_logs enable row level security;
-- Append-only: select + insert only (no update/delete policies).
drop policy if exists "programmer_task_work_logs_select" on programmer_task_work_logs;
create policy "programmer_task_work_logs_select" on programmer_task_work_logs
for select to authenticated
using (programmer_task_visible(task_id));
drop policy if exists "programmer_task_work_logs_insert" on programmer_task_work_logs;
create policy "programmer_task_work_logs_insert" on programmer_task_work_logs
for insert to authenticated
with check (author_id = auth.uid() and programmer_task_visible(task_id));
-- ---------------------------------------------------------------------------
-- Comments (+ attachments stored in the task_attachments bucket)
-- ---------------------------------------------------------------------------
create table if not exists programmer_task_comments (
id uuid primary key default gen_random_uuid(),
task_id uuid not null references programmer_tasks(id) on delete cascade,
author_id uuid not null references profiles(id),
body text not null default '',
attachments jsonb not null default '[]'::jsonb, -- [{path, name}]
created_at timestamptz not null default now()
);
create index if not exists idx_programmer_task_comments_task_id on programmer_task_comments(task_id);
alter table programmer_task_comments enable row level security;
drop policy if exists "programmer_task_comments_select" on programmer_task_comments;
create policy "programmer_task_comments_select" on programmer_task_comments
for select to authenticated
using (programmer_task_visible(task_id));
drop policy if exists "programmer_task_comments_insert" on programmer_task_comments;
create policy "programmer_task_comments_insert" on programmer_task_comments
for insert to authenticated
with check (author_id = auth.uid() and programmer_task_visible(task_id));
drop policy if exists "programmer_task_comments_delete" on programmer_task_comments;
create policy "programmer_task_comments_delete" on programmer_task_comments
for delete to authenticated
using (
author_id = auth.uid()
or exists (select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer'))
);
-- ---------------------------------------------------------------------------
-- Realtime
-- ---------------------------------------------------------------------------
alter publication supabase_realtime add table programmer_projects;
alter publication supabase_realtime add table programmer_task_work_logs;
alter publication supabase_realtime add table programmer_task_comments;
@@ -0,0 +1,311 @@
-- Programmer Day Sheets — daily approval workflow for programmer work logs.
-- Each programmer gets one sheet per calendar day (Manila timezone). Sheets
-- aggregate work-log entries and activity-log events, flow through
-- draft → pending → approved/disapproved state machine, and expose an
-- event trail (submitted, disapproved, justified, approved, amended).
--
-- Depends on:
-- 20260927120000_add_programmer_tasks.sql (programmer_tasks, programmer_task_activity_logs,
-- set_programmer_tasks_updated_at)
-- 20260927140000_add_programmer_projects_worklogs_comments.sql
-- (programmer_task_work_logs)
-- ---------------------------------------------------------------------------
-- 1. manila_today() — canonical Manila-timezone "today" used everywhere
-- ---------------------------------------------------------------------------
create or replace function manila_today()
returns date
language sql
stable
security definer
set search_path = public
as $$
select (now() at time zone 'Asia/Manila')::date;
$$;
-- ---------------------------------------------------------------------------
-- 2. programmer_day_sheets
-- ---------------------------------------------------------------------------
create table if not exists programmer_day_sheets (
id uuid primary key default gen_random_uuid(),
programmer_id uuid not null references profiles(id),
work_date date not null,
status text not null default 'draft'
check (status in ('draft','pending','disapproved','approved')),
submitted_at timestamptz,
auto_submitted bool not null default false,
reviewed_by uuid references profiles(id),
reviewed_at timestamptz,
resubmissions int not null default 0,
approved_snapshot jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (programmer_id, work_date)
);
create index if not exists idx_programmer_day_sheets_status_work_date
on programmer_day_sheets (status, work_date);
alter table programmer_day_sheets enable row level security;
-- updated_at trigger — reuse the existing function
drop trigger if exists trg_programmer_day_sheets_updated_at on programmer_day_sheets;
create trigger trg_programmer_day_sheets_updated_at
before update on programmer_day_sheets
for each row execute function set_programmer_tasks_updated_at();
-- ---------------------------------------------------------------------------
-- 3. programmer_day_sheet_events
-- ---------------------------------------------------------------------------
create table if not exists programmer_day_sheet_events (
id uuid primary key default gen_random_uuid(),
sheet_id uuid not null references programmer_day_sheets(id) on delete cascade,
actor_id uuid references profiles(id),
kind text not null
check (kind in ('submitted','auto_submitted','disapproved',
'justified','approved','amended')),
body text,
flagged_task_ids uuid[] not null default '{}',
created_at timestamptz not null default now(),
-- body must be non-empty for disapproval and justification events
check (kind not in ('disapproved','justified') or (body is not null and body <> ''))
);
alter table programmer_day_sheet_events enable row level security;
-- ---------------------------------------------------------------------------
-- 4. work_date column on programmer_task_work_logs
-- ---------------------------------------------------------------------------
alter table programmer_task_work_logs
add column if not exists work_date date;
-- Backfill: derive from created_at in Manila timezone
update programmer_task_work_logs
set work_date = (created_at at time zone 'Asia/Manila')::date
where work_date is null;
-- Now make it default + not null
alter table programmer_task_work_logs
alter column work_date set default manila_today();
alter table programmer_task_work_logs
alter column work_date set not null;
create index if not exists idx_programmer_task_work_logs_author_work_date
on programmer_task_work_logs (author_id, work_date);
-- ---------------------------------------------------------------------------
-- 5. ensure_day_sheet(p_programmer, p_date) — idempotent sheet bootstrap
-- SECURITY DEFINER; callers (trigger functions) have no direct table access.
-- ---------------------------------------------------------------------------
create or replace function ensure_day_sheet(
p_programmer uuid,
p_date date
)
returns uuid
language plpgsql
security definer
set search_path = public
as $$
declare
v_role text;
v_id uuid;
v_status text;
begin
-- Only create sheets for programmers
select role into v_role from profiles where id = p_programmer;
if v_role is distinct from 'programmer' then
return null;
end if;
-- Insert a draft sheet; conflict means the sheet already exists
insert into programmer_day_sheets (programmer_id, work_date, status)
values (p_programmer, p_date, 'draft')
on conflict (programmer_id, work_date) do nothing;
-- Fetch the (potentially pre-existing) sheet id and status
select id, status
into v_id, v_status
from programmer_day_sheets
where programmer_id = p_programmer
and work_date = p_date;
-- If the sheet was previously approved and we are touching today's date,
-- reopen it to pending and record an amended event.
if v_status = 'approved' and p_date = manila_today() then
update programmer_day_sheets
set status = 'pending',
updated_at = now()
where id = v_id;
insert into programmer_day_sheet_events (sheet_id, actor_id, kind, body)
values (v_id, null, 'amended', 'New work was logged after approval.');
end if;
return v_id;
end;
$$;
-- Restrict direct invocation — trigger functions are the only callers
revoke execute on function ensure_day_sheet(uuid, date) from public;
revoke execute on function ensure_day_sheet(uuid, date) from anon;
revoke execute on function ensure_day_sheet(uuid, date) from authenticated;
-- ---------------------------------------------------------------------------
-- 6. BEFORE INSERT trigger on programmer_task_work_logs
-- Validates and normalises work_date; raises work_date_locked when needed.
-- ---------------------------------------------------------------------------
create or replace function validate_work_log_date()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
declare
v_sheet_status text;
begin
-- Default work_date to today (Manila) when the caller left it null
new.work_date := coalesce(new.work_date, manila_today());
-- Future dates are never allowed
if new.work_date > manila_today() then
raise exception 'work_date_locked' using errcode = 'P0001';
end if;
-- Past dates are allowed only when the author already has an open sheet
-- (status pending or disapproved) for that date — i.e. the window is still
-- editable. No sheet or any other status → locked.
if new.work_date < manila_today() then
select status
into v_sheet_status
from programmer_day_sheets
where programmer_id = new.author_id
and work_date = new.work_date;
if v_sheet_status is null or v_sheet_status not in ('pending', 'disapproved') then
raise exception 'work_date_locked' using errcode = 'P0001';
end if;
end if;
return new;
end;
$$;
drop trigger if exists trg_work_logs_validate_date on programmer_task_work_logs;
create trigger trg_work_logs_validate_date
before insert on programmer_task_work_logs
for each row execute function validate_work_log_date();
-- ---------------------------------------------------------------------------
-- 7. AFTER INSERT trigger on programmer_task_work_logs
-- Ensures a day sheet exists for the author on the work_date.
-- ---------------------------------------------------------------------------
create or replace function after_work_log_ensure_sheet()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
perform ensure_day_sheet(new.author_id, new.work_date);
return null;
end;
$$;
drop trigger if exists trg_work_logs_ensure_sheet on programmer_task_work_logs;
create trigger trg_work_logs_ensure_sheet
after insert on programmer_task_work_logs
for each row execute function after_work_log_ensure_sheet();
-- ---------------------------------------------------------------------------
-- 8. AFTER INSERT trigger on programmer_task_activity_logs
-- Ensures a day sheet for the task's assignee on relevant action types.
-- ---------------------------------------------------------------------------
create or replace function after_activity_log_ensure_sheet()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
declare
v_assignee uuid;
begin
if new.action_type not in ('started','paused','resumed','completed','cancelled','adjustment') then
return null;
end if;
select assignee_id into v_assignee
from programmer_tasks
where id = new.task_id;
if v_assignee is not null then
perform ensure_day_sheet(
v_assignee,
(new.created_at at time zone 'Asia/Manila')::date
);
end if;
return null;
end;
$$;
drop trigger if exists trg_activity_logs_ensure_sheet on programmer_task_activity_logs;
create trigger trg_activity_logs_ensure_sheet
after insert on programmer_task_activity_logs
for each row execute function after_activity_log_ensure_sheet();
-- ---------------------------------------------------------------------------
-- 9. RLS policies
-- ---------------------------------------------------------------------------
-- programmer_day_sheets: SELECT — own sheet or admin
drop policy if exists "programmer_day_sheets_select" on programmer_day_sheets;
create policy "programmer_day_sheets_select" on programmer_day_sheets
for select to authenticated
using (
programmer_id = auth.uid()
or exists (
select 1 from profiles p
where p.id = auth.uid()
and p.role = 'admin'
)
);
-- programmer_day_sheet_events: SELECT — must be able to see the parent sheet
drop policy if exists "programmer_day_sheet_events_select" on programmer_day_sheet_events;
create policy "programmer_day_sheet_events_select" on programmer_day_sheet_events
for select to authenticated
using (
exists (
select 1 from programmer_day_sheets s
where s.id = sheet_id
and (
s.programmer_id = auth.uid()
or exists (
select 1 from profiles p
where p.id = auth.uid()
and p.role = 'admin'
)
)
)
);
-- No client INSERT/UPDATE/DELETE policies on either table — all writes go
-- through SECURITY DEFINER functions or server-side triggers.
-- ---------------------------------------------------------------------------
-- 10. notifications.day_sheet_id FK
-- ---------------------------------------------------------------------------
alter table notifications
add column if not exists day_sheet_id uuid
references programmer_day_sheets(id) on delete cascade;
create index if not exists idx_notifications_day_sheet_id
on notifications (day_sheet_id)
where day_sheet_id is not null;
-- ---------------------------------------------------------------------------
-- 11. Realtime publication
-- ---------------------------------------------------------------------------
alter publication supabase_realtime add table programmer_day_sheets;
alter publication supabase_realtime add table programmer_day_sheet_events;
@@ -0,0 +1,482 @@
-- Day sheet RPCs, auto-submit cron, and digest queue.
--
-- Depends on:
-- 20260927150000_add_programmer_day_sheets.sql (programmer_day_sheets,
-- programmer_day_sheet_events, ensure_day_sheet, manila_today)
--
-- Provides (SECURITY DEFINER, set search_path = public):
-- submit_day_sheet(p_date date) returns jsonb
-- disapprove_day_sheet(p_sheet uuid, p_remarks text,
-- p_flagged_task_ids uuid[]) returns jsonb
-- justify_day_sheet(p_sheet uuid, p_body text) returns jsonb
-- approve_day_sheets(p_items jsonb) returns jsonb
-- auto_submit_day_sheets() returns void
-- (revoked from public/anon/authenticated)
-- ---------------------------------------------------------------------------
-- 1. submit_day_sheet
-- ---------------------------------------------------------------------------
create or replace function public.submit_day_sheet(p_date date)
returns jsonb
language plpgsql
security definer
set search_path = public
as $$
declare
v_caller_id uuid := auth.uid();
v_caller_role text;
v_sheet programmer_day_sheets%rowtype;
v_admin record;
v_notify_ids uuid[] := '{}';
v_prog_name text;
begin
-- Caller must be a programmer
select role into v_caller_role
from profiles
where id = v_caller_id;
if v_caller_role is distinct from 'programmer' then
raise exception 'not_authorized';
end if;
-- Future dates are not allowed
if p_date > manila_today() then
raise exception 'future_date';
end if;
-- Upsert draft row (idempotent bootstrap)
insert into programmer_day_sheets (programmer_id, work_date, status)
values (v_caller_id, p_date, 'draft')
on conflict (programmer_id, work_date) do nothing;
-- Lock the sheet row before status check
select * into v_sheet
from programmer_day_sheets
where programmer_id = v_caller_id
and work_date = p_date
for update;
if v_sheet.status <> 'draft' then
raise exception 'already_submitted';
end if;
-- Submit
update programmer_day_sheets
set status = 'pending',
submitted_at = now(),
auto_submitted = false
where id = v_sheet.id;
insert into programmer_day_sheet_events (sheet_id, actor_id, kind)
values (v_sheet.id, v_caller_id, 'submitted');
-- Notify every admin
for v_admin in
select id from profiles where role = 'admin'
loop
insert into notifications (user_id, actor_id, type, day_sheet_id)
values (v_admin.id, v_caller_id, 'day_sheet_submitted', v_sheet.id);
v_notify_ids := v_notify_ids || v_admin.id;
end loop;
select full_name into v_prog_name
from profiles
where id = v_caller_id;
return jsonb_build_object(
'sheet_id', v_sheet.id,
'work_date', v_sheet.work_date,
'programmer_id', v_caller_id,
'programmer_name', v_prog_name,
'notify_user_ids', v_notify_ids
);
end;
$$;
grant execute on function public.submit_day_sheet(date) to authenticated;
-- ---------------------------------------------------------------------------
-- 2. disapprove_day_sheet
-- ---------------------------------------------------------------------------
create or replace function public.disapprove_day_sheet(
p_sheet uuid,
p_remarks text,
p_flagged_task_ids uuid[] default '{}'
)
returns jsonb
language plpgsql
security definer
set search_path = public
as $$
declare
v_caller_id uuid := auth.uid();
v_caller_role text;
v_sheet programmer_day_sheets%rowtype;
v_prog_name text;
begin
-- Caller must be admin
select role into v_caller_role
from profiles
where id = v_caller_id;
if v_caller_role is distinct from 'admin' then
raise exception 'not_authorized';
end if;
if btrim(p_remarks) = '' then
raise exception 'remarks_required';
end if;
-- Lock the sheet row before status check
select * into v_sheet
from programmer_day_sheets
where id = p_sheet
for update;
if v_sheet.status <> 'pending' then
raise exception 'already_reviewed';
end if;
update programmer_day_sheets
set status = 'disapproved',
reviewed_by = v_caller_id,
reviewed_at = now()
where id = p_sheet;
insert into programmer_day_sheet_events
(sheet_id, actor_id, kind, body, flagged_task_ids)
values
(p_sheet, v_caller_id, 'disapproved', p_remarks, p_flagged_task_ids);
insert into notifications (user_id, actor_id, type, day_sheet_id)
values (v_sheet.programmer_id, v_caller_id, 'day_sheet_disapproved', p_sheet);
select full_name into v_prog_name
from profiles
where id = v_sheet.programmer_id;
return jsonb_build_object(
'sheet_id', p_sheet,
'work_date', v_sheet.work_date,
'programmer_id', v_sheet.programmer_id,
'programmer_name', v_prog_name,
'notify_user_ids', array[v_sheet.programmer_id]
);
end;
$$;
grant execute on function public.disapprove_day_sheet(uuid, text, uuid[]) to authenticated;
-- ---------------------------------------------------------------------------
-- 3. justify_day_sheet
-- ---------------------------------------------------------------------------
create or replace function public.justify_day_sheet(
p_sheet uuid,
p_body text
)
returns jsonb
language plpgsql
security definer
set search_path = public
as $$
declare
v_caller_id uuid := auth.uid();
v_sheet programmer_day_sheets%rowtype;
v_notify_ids uuid[] := '{}';
v_prog_name text;
begin
if btrim(p_body) = '' then
raise exception 'body_required';
end if;
-- Lock the sheet row before status check
select * into v_sheet
from programmer_day_sheets
where id = p_sheet
for update;
-- Caller must be the sheet owner
if v_sheet.programmer_id is distinct from v_caller_id then
raise exception 'not_authorized';
end if;
if v_sheet.status <> 'disapproved' then
raise exception 'already_reviewed';
end if;
update programmer_day_sheets
set status = 'pending',
resubmissions = resubmissions + 1,
submitted_at = now()
where id = p_sheet;
insert into programmer_day_sheet_events (sheet_id, actor_id, kind, body)
values (p_sheet, v_caller_id, 'justified', p_body);
-- Notify the previous reviewer if set
if v_sheet.reviewed_by is not null then
insert into notifications (user_id, actor_id, type, day_sheet_id)
values (v_sheet.reviewed_by, v_caller_id, 'day_sheet_justified', p_sheet);
v_notify_ids := array[v_sheet.reviewed_by];
end if;
select full_name into v_prog_name
from profiles
where id = v_caller_id;
return jsonb_build_object(
'sheet_id', p_sheet,
'work_date', v_sheet.work_date,
'programmer_id', v_caller_id,
'programmer_name', v_prog_name,
'notify_user_ids', v_notify_ids
);
end;
$$;
grant execute on function public.justify_day_sheet(uuid, text) to authenticated;
-- ---------------------------------------------------------------------------
-- 4. approve_day_sheets
-- ---------------------------------------------------------------------------
create or replace function public.approve_day_sheets(p_items jsonb)
returns jsonb
language plpgsql
security definer
set search_path = public
as $$
declare
v_caller_id uuid := auth.uid();
v_caller_role text;
v_item jsonb;
v_sheet_id uuid;
v_snapshot jsonb;
v_sheet programmer_day_sheets%rowtype;
v_approved jsonb[] := '{}';
v_skipped uuid[] := '{}';
v_prog_name text;
begin
-- Caller must be admin
select role into v_caller_role
from profiles
where id = v_caller_id;
if v_caller_role is distinct from 'admin' then
raise exception 'not_authorized';
end if;
for v_item in select * from jsonb_array_elements(p_items)
loop
v_sheet_id := (v_item->>'sheet_id')::uuid;
v_snapshot := v_item->'snapshot';
-- Validate snapshot: must be an object with a 'rows' array
if jsonb_typeof(v_snapshot) <> 'object'
or jsonb_typeof(v_snapshot->'rows') <> 'array' then
raise exception 'invalid_snapshot';
end if;
-- Lock the sheet row before status check
select * into v_sheet
from programmer_day_sheets
where id = v_sheet_id
for update;
if v_sheet.status not in ('pending', 'disapproved') then
v_skipped := v_skipped || v_sheet_id;
continue;
end if;
update programmer_day_sheets
set status = 'approved',
reviewed_by = v_caller_id,
reviewed_at = now(),
approved_snapshot = v_snapshot
where id = v_sheet_id;
insert into programmer_day_sheet_events (sheet_id, actor_id, kind)
values (v_sheet_id, v_caller_id, 'approved');
insert into notifications (user_id, actor_id, type, day_sheet_id)
values (v_sheet.programmer_id, v_caller_id, 'day_sheet_approved', v_sheet_id);
select full_name into v_prog_name
from profiles
where id = v_sheet.programmer_id;
v_approved := v_approved || jsonb_build_object(
'sheet_id', v_sheet_id,
'programmer_id', v_sheet.programmer_id,
'work_date', v_sheet.work_date
);
end loop;
return jsonb_build_object(
'approved', to_jsonb(v_approved),
'skipped_ids', to_jsonb(v_skipped)
);
end;
$$;
grant execute on function public.approve_day_sheets(jsonb) to authenticated;
-- ---------------------------------------------------------------------------
-- 5. auto_submit_day_sheets
-- ---------------------------------------------------------------------------
create or replace function public.auto_submit_day_sheets()
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
v_rec record;
v_sheet_id uuid;
v_sheet_status text;
v_any_pending boolean := false;
v_admin record;
begin
-- ── Step 1: tasks whose timer is still running at end of day ───────────────
-- For each in_progress task where the latest started/paused/resumed event is
-- not 'paused', ensure a day sheet exists for the programmer assignee on
-- yesterday's date. If that sheet is approved, reopen it to pending.
for v_rec in
select distinct pt.assignee_id
from programmer_tasks pt
where pt.status = 'in_progress'
and pt.assignee_id is not null
and exists (
select 1 from profiles p
where p.id = pt.assignee_id
and p.role = 'programmer'
)
and exists (
select 1 from programmer_task_activity_logs pal
where pal.task_id = pt.id
and pal.action_type in ('started', 'paused', 'resumed')
)
and (
select pal2.action_type
from programmer_task_activity_logs pal2
where pal2.task_id = pt.id
and pal2.action_type in ('started', 'paused', 'resumed')
order by pal2.created_at desc
limit 1
) <> 'paused'
loop
v_sheet_id := ensure_day_sheet(v_rec.assignee_id, manila_today() - 1);
if v_sheet_id is null then
continue;
end if;
-- Lock and check the sheet status
select status into v_sheet_status
from programmer_day_sheets
where id = v_sheet_id
for update;
if v_sheet_status = 'approved' then
update programmer_day_sheets
set status = 'pending',
updated_at = now()
where id = v_sheet_id;
insert into programmer_day_sheet_events (sheet_id, actor_id, kind, body)
values (v_sheet_id, null, 'amended',
'Timer was still running at the end of the day.');
v_any_pending := true;
end if;
end loop;
-- ── Step 2: auto-submit stale draft sheets ─────────────────────────────────
-- Every draft sheet with work_date < today is promoted to pending.
for v_rec in
select id
from programmer_day_sheets
where status = 'draft'
and work_date < manila_today()
for update
loop
update programmer_day_sheets
set status = 'pending',
submitted_at = now(),
auto_submitted = true
where id = v_rec.id;
insert into programmer_day_sheet_events (sheet_id, actor_id, kind)
values (v_rec.id, null, 'auto_submitted');
v_any_pending := true;
end loop;
-- ── Step 3: digest notification for admins ─────────────────────────────────
-- If any sheet transitioned to pending, insert a notification and a
-- scheduled digest row (one per admin, idempotent via ON CONFLICT DO NOTHING).
if v_any_pending then
for v_admin in
select id from profiles where role = 'admin'
loop
insert into notifications (user_id, actor_id, type)
values (v_admin.id, null, 'day_sheet_digest');
insert into scheduled_notifications (
user_id,
notify_type,
scheduled_for,
epoch
) values (
v_admin.id,
'day_sheet_digest',
(manila_today() + time '08:00') at time zone 'Asia/Manila',
to_char(manila_today(), 'YYYYMMDD')::int
) on conflict do nothing;
end loop;
end if;
end;
$$;
-- auto_submit_day_sheets is a cron-only function; revoke from all client roles
revoke execute on function public.auto_submit_day_sheets() from public;
revoke execute on function public.auto_submit_day_sheets() from anon;
revoke execute on function public.auto_submit_day_sheets() from authenticated;
-- ---------------------------------------------------------------------------
-- 6. Extend chk_at_least_one_ref to allow day_sheet_digest rows
-- (which have no FK reference, identified solely by notify_type)
-- ---------------------------------------------------------------------------
alter table public.scheduled_notifications
drop constraint if exists chk_at_least_one_ref;
alter table public.scheduled_notifications
add constraint chk_at_least_one_ref check (
schedule_id is not null
or task_id is not null
or it_service_request_id is not null
or pass_slip_id is not null
or announcement_id is not null
or notify_type = 'day_sheet_digest'
);
-- ---------------------------------------------------------------------------
-- 7. pg_cron: auto-submit at 00:05 Manila time (16:05 UTC)
-- ---------------------------------------------------------------------------
do $$
begin
begin
perform cron.unschedule('programmer_day_sheet_autosubmit');
exception when others then null;
end;
perform cron.schedule(
'programmer_day_sheet_autosubmit',
'5 16 * * *',
'SELECT public.auto_submit_day_sheets();'
);
exception when others then
raise notice 'pg_cron scheduling failed: %. Set up cron jobs manually or use external cron.', sqlerrm;
end $$;
+692
View File
@@ -0,0 +1,692 @@
import 'package:flutter_test/flutter_test.dart';
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/screens/programmer_tasks/report/accomplishment_report_data.dart';
import 'package:tasq/utils/programmer_day_rows.dart';
void main() {
group('buildAccomplishmentReportData', () {
final programmer = Profile(
id: 'prog1',
role: 'programmer',
fullName: 'John Doe',
);
final admin = Profile(
id: 'admin1',
role: 'administrator',
fullName: 'Admin User',
);
final reviewer = Profile(
id: 'reviewer1',
role: 'reviewer',
fullName: 'Jane Reviewer',
);
test('approved-only uses snapshots and counts excluded days', () {
final day1 = DateTime.utc(2026, 9, 1);
final day2 = DateTime.utc(2026, 9, 2);
final day3 = DateTime.utc(2026, 9, 3);
final day4 = DateTime.utc(2026, 9, 4);
final row1 = DaySheetRow(
taskId: 't1',
title: 'Task 1',
category: 'Software Development',
kind: 'assignee',
seconds: 3600,
notes: [],
);
final row2 = DaySheetRow(
taskId: 't2',
title: 'Task 2',
category: 'Bug Fix',
kind: 'assignee',
seconds: 1800,
notes: [],
);
// 2 approved sheets
final sheets = [
ProgrammerDaySheet(
id: 'sheet1',
programmerId: 'prog1',
status: 'approved',
workDate: day1,
autoSubmitted: false,
resubmissions: 0,
reviewedBy: 'reviewer1',
approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row1]),
createdAt: DateTime.utc(2026, 9, 1),
updatedAt: DateTime.utc(2026, 9, 1),
),
ProgrammerDaySheet(
id: 'sheet2',
programmerId: 'prog1',
status: 'approved',
workDate: day2,
autoSubmitted: false,
resubmissions: 0,
reviewedBy: 'reviewer1',
approvedSnapshot: DaySheetSnapshot(totalSeconds: 1800, rows: [row2]),
createdAt: DateTime.utc(2026, 9, 2),
updatedAt: DateTime.utc(2026, 9, 2),
),
// 1 pending
ProgrammerDaySheet(
id: 'sheet3',
programmerId: 'prog1',
status: 'pending',
workDate: day3,
autoSubmitted: false,
resubmissions: 0,
createdAt: DateTime.utc(2026, 9, 3),
updatedAt: DateTime.utc(2026, 9, 3),
),
// 1 disapproved
ProgrammerDaySheet(
id: 'sheet4',
programmerId: 'prog1',
status: 'disapproved',
workDate: day4,
autoSubmitted: false,
resubmissions: 1,
reviewedBy: 'reviewer1',
createdAt: DateTime.utc(2026, 9, 4),
updatedAt: DateTime.utc(2026, 9, 4),
),
];
final range = ReportDateRange(
start: DateTime.utc(2026, 9, 1),
end: DateTime.utc(2026, 9, 5),
label: 'Test Range',
);
final data = buildAccomplishmentReportData(
programmer: programmer,
range: range,
includeUnapproved: false,
sheets: sheets,
liveRowsByDay: {},
tasks: [],
profileNames: {'reviewer1': 'Jane Reviewer'},
);
expect(data.days.length, 2);
expect(data.excludedPending, 1);
expect(data.excludedDisapproved, 1);
expect(data.days[0].statusTag, null);
expect(data.days[1].statusTag, null);
expect(data.totalSeconds, 5400);
expect(data.daysReported, 2);
});
test('include unapproved tags days and uses live rows', () {
final day1 = DateTime.utc(2026, 9, 1);
final day2 = DateTime.utc(2026, 9, 2);
final day3 = DateTime.utc(2026, 9, 3);
final day4 = DateTime.utc(2026, 9, 4);
final approvedRow = DaySheetRow(
taskId: 't1',
title: 'Task 1',
category: 'Software Development',
kind: 'assignee',
seconds: 3600,
notes: [],
);
final pendingRow = DaySheetRow(
taskId: 't2',
title: 'Task 2',
category: 'Bug Fix',
kind: 'assignee',
seconds: 1800,
notes: [],
);
final disapprovedRow = DaySheetRow(
taskId: 't3',
title: 'Task 3',
category: 'Meeting',
kind: 'assignee',
seconds: 900,
notes: [],
);
final draftRow = DaySheetRow(
taskId: 't4',
title: 'Task 4',
category: 'Software Support',
kind: 'assignee',
seconds: 600,
notes: [],
);
final sheets = [
// approved
ProgrammerDaySheet(
id: 'sheet1',
programmerId: 'prog1',
status: 'approved',
workDate: day1,
autoSubmitted: false,
resubmissions: 0,
reviewedBy: 'reviewer1',
approvedSnapshot:
DaySheetSnapshot(totalSeconds: 3600, rows: [approvedRow]),
createdAt: DateTime.utc(2026, 9, 1),
updatedAt: DateTime.utc(2026, 9, 1),
),
// pending
ProgrammerDaySheet(
id: 'sheet2',
programmerId: 'prog1',
status: 'pending',
workDate: day2,
autoSubmitted: false,
resubmissions: 0,
createdAt: DateTime.utc(2026, 9, 2),
updatedAt: DateTime.utc(2026, 9, 2),
),
// disapproved
ProgrammerDaySheet(
id: 'sheet3',
programmerId: 'prog1',
status: 'disapproved',
workDate: day3,
autoSubmitted: false,
resubmissions: 1,
reviewedBy: 'reviewer1',
createdAt: DateTime.utc(2026, 9, 3),
updatedAt: DateTime.utc(2026, 9, 3),
),
// draft
ProgrammerDaySheet(
id: 'sheet4',
programmerId: 'prog1',
status: 'draft',
workDate: day4,
autoSubmitted: false,
resubmissions: 0,
createdAt: DateTime.utc(2026, 9, 4),
updatedAt: DateTime.utc(2026, 9, 4),
),
];
final range = ReportDateRange(
start: DateTime.utc(2026, 9, 1),
end: DateTime.utc(2026, 9, 5),
label: 'Test Range',
);
final liveRowsByDay = {
day2: [pendingRow],
day3: [disapprovedRow],
day4: [draftRow],
};
final data = buildAccomplishmentReportData(
programmer: programmer,
range: range,
includeUnapproved: true,
sheets: sheets,
liveRowsByDay: liveRowsByDay,
tasks: [],
profileNames: {'reviewer1': 'Jane Reviewer'},
);
expect(data.days.length, 4);
expect(data.excludedPending, 0);
expect(data.excludedDisapproved, 0);
expect(data.days[0].statusTag, null); // approved
expect(data.days[1].statusTag, 'Pending'); // pending
expect(data.days[2].statusTag, 'Disapproved'); // disapproved
expect(data.days[3].statusTag, 'Pending'); // draft
});
test('period end is inclusive', () {
final day1 = DateTime.utc(2026, 9, 1);
final row = DaySheetRow(
taskId: 't1',
title: 'Task 1',
category: 'Software Development',
kind: 'assignee',
seconds: 3600,
notes: [],
);
final sheets = [
ProgrammerDaySheet(
id: 'sheet1',
programmerId: 'prog1',
status: 'approved',
workDate: day1,
autoSubmitted: false,
resubmissions: 0,
reviewedBy: 'reviewer1',
approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row]),
createdAt: DateTime.utc(2026, 9, 1),
updatedAt: DateTime.utc(2026, 9, 1),
),
];
final range = ReportDateRange(
start: DateTime.utc(2026, 9, 1),
end: DateTime.utc(2026, 10, 1), // exclusive
label: 'Test Range',
);
final data = buildAccomplishmentReportData(
programmer: programmer,
range: range,
includeUnapproved: false,
sheets: sheets,
liveRowsByDay: {},
tasks: [],
profileNames: {'reviewer1': 'Jane Reviewer'},
);
expect(data.periodStart, DateTime.utc(2026, 9, 1));
expect(data.periodEndInclusive, DateTime.utc(2026, 9, 30));
});
test('approver name only when one reviewer approved every included day', () {
final day1 = DateTime.utc(2026, 9, 1);
final day2 = DateTime.utc(2026, 9, 2);
final day3 = DateTime.utc(2026, 9, 3);
final row = DaySheetRow(
taskId: 't1',
title: 'Task 1',
category: 'Software Development',
kind: 'assignee',
seconds: 3600,
notes: [],
);
// Test: same reviewer for all approved days
final sheets1 = [
ProgrammerDaySheet(
id: 'sheet1',
programmerId: 'prog1',
status: 'approved',
workDate: day1,
autoSubmitted: false,
resubmissions: 0,
reviewedBy: 'reviewer1',
approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row]),
createdAt: DateTime.utc(2026, 9, 1),
updatedAt: DateTime.utc(2026, 9, 1),
),
ProgrammerDaySheet(
id: 'sheet2',
programmerId: 'prog1',
status: 'approved',
workDate: day2,
autoSubmitted: false,
resubmissions: 0,
reviewedBy: 'reviewer1',
approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row]),
createdAt: DateTime.utc(2026, 9, 2),
updatedAt: DateTime.utc(2026, 9, 2),
),
];
final range = ReportDateRange(
start: DateTime.utc(2026, 9, 1),
end: DateTime.utc(2026, 9, 4),
label: 'Test Range',
);
final data1 = buildAccomplishmentReportData(
programmer: programmer,
range: range,
includeUnapproved: false,
sheets: sheets1,
liveRowsByDay: {},
tasks: [],
profileNames: {'reviewer1': 'Jane Reviewer'},
);
expect(data1.approverName, 'Jane Reviewer');
// Test: mixed reviewers
final sheets2 = [
ProgrammerDaySheet(
id: 'sheet1',
programmerId: 'prog1',
status: 'approved',
workDate: day1,
autoSubmitted: false,
resubmissions: 0,
reviewedBy: 'reviewer1',
approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row]),
createdAt: DateTime.utc(2026, 9, 1),
updatedAt: DateTime.utc(2026, 9, 1),
),
ProgrammerDaySheet(
id: 'sheet2',
programmerId: 'prog1',
status: 'approved',
workDate: day2,
autoSubmitted: false,
resubmissions: 0,
reviewedBy: 'reviewer2',
approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row]),
createdAt: DateTime.utc(2026, 9, 2),
updatedAt: DateTime.utc(2026, 9, 2),
),
];
final data2 = buildAccomplishmentReportData(
programmer: programmer,
range: range,
includeUnapproved: false,
sheets: sheets2,
liveRowsByDay: {},
tasks: [],
profileNames: {'reviewer1': 'Jane Reviewer', 'reviewer2': 'Bob Reviewer'},
);
expect(data2.approverName, null);
});
test('tasksCompleted counts tasks completed in range for the programmer', () {
final range = ReportDateRange(
start: DateTime.utc(2026, 9, 1),
end: DateTime.utc(2026, 9, 5),
label: 'Test Range',
);
final task1 = ProgrammerTask(
id: 't1',
title: 'Task 1',
category: 'Software Development',
status: 'completed',
assigneeId: 'prog1',
createdAt: DateTime.utc(2026, 9, 1),
completedAt: DateTime.utc(2026, 9, 3), // in range
updatedAt: DateTime.utc(2026, 9, 3),
);
final task2 = ProgrammerTask(
id: 't2',
title: 'Task 2',
category: 'Bug Fix',
status: 'completed',
assigneeId: 'prog1',
createdAt: DateTime.utc(2026, 9, 1),
completedAt: DateTime.utc(2026, 8, 31), // before range
updatedAt: DateTime.utc(2026, 8, 31),
);
final task3 = ProgrammerTask(
id: 't3',
title: 'Task 3',
category: 'Meeting',
status: 'completed',
assigneeId: 'prog2', // different assignee
createdAt: DateTime.utc(2026, 9, 1),
completedAt: DateTime.utc(2026, 9, 3),
updatedAt: DateTime.utc(2026, 9, 3),
);
final task4 = ProgrammerTask(
id: 't4',
title: 'Task 4',
category: 'Software Support',
status: 'in_progress', // not completed
assigneeId: 'prog1',
createdAt: DateTime.utc(2026, 9, 1),
updatedAt: DateTime.utc(2026, 9, 3),
);
final task5 = ProgrammerTask(
id: 't5',
title: 'Task 5',
category: 'Software Development',
status: 'completed',
assigneeId: 'prog1',
createdAt: DateTime.utc(2026, 9, 1),
completedAt: DateTime.utc(2026, 9, 5), // at end boundary (exclusive)
updatedAt: DateTime.utc(2026, 9, 5),
);
final data = buildAccomplishmentReportData(
programmer: programmer,
range: range,
includeUnapproved: false,
sheets: [],
liveRowsByDay: {},
tasks: [task1, task2, task3, task4, task5],
profileNames: {},
);
expect(data.tasksCompleted, 1); // only task1
});
test('empty period', () {
final range = ReportDateRange(
start: DateTime.utc(2026, 9, 1),
end: DateTime.utc(2026, 9, 2),
label: 'Test Range',
);
final data = buildAccomplishmentReportData(
programmer: programmer,
range: range,
includeUnapproved: false,
sheets: [],
liveRowsByDay: {},
tasks: [],
profileNames: {},
);
expect(data.days, isEmpty);
expect(data.daysReported, 0);
expect(data.totalSeconds, 0);
expect(data.excludedPending, 0);
expect(data.excludedDisapproved, 0);
});
test('positionLabel based on programmer role', () {
final range = ReportDateRange(
start: DateTime.utc(2026, 9, 1),
end: DateTime.utc(2026, 9, 2),
label: 'Test Range',
);
final dataProgrammer = buildAccomplishmentReportData(
programmer: programmer,
range: range,
includeUnapproved: false,
sheets: [],
liveRowsByDay: {},
tasks: [],
profileNames: {},
);
final dataAdmin = buildAccomplishmentReportData(
programmer: admin,
range: range,
includeUnapproved: false,
sheets: [],
liveRowsByDay: {},
tasks: [],
profileNames: {},
);
expect(dataProgrammer.positionLabel, 'Programmer');
expect(dataAdmin.positionLabel, 'Administrator');
});
test('helpSeconds and secondsByCategory calculations', () {
final day1 = DateTime.utc(2026, 9, 1);
final assigneeRow = DaySheetRow(
taskId: 't1',
title: 'Task 1',
category: 'Software Development',
kind: 'assignee',
seconds: 3600,
notes: [],
);
final helperRow = DaySheetRow(
taskId: 't2',
title: 'Task 2',
category: 'Bug Fix',
kind: 'helper',
seconds: 1800,
notes: [],
);
final anotherHelperRow = DaySheetRow(
taskId: 't3',
title: 'Task 3',
category: 'Software Development',
kind: 'helper',
seconds: 900,
notes: [],
);
final row = DaySheetRow(
taskId: 't4',
title: 'Task 4',
category: 'Meeting',
kind: 'assignee',
seconds: 600,
notes: [],
);
final sheets = [
ProgrammerDaySheet(
id: 'sheet1',
programmerId: 'prog1',
status: 'approved',
workDate: day1,
autoSubmitted: false,
resubmissions: 0,
reviewedBy: 'reviewer1',
approvedSnapshot: DaySheetSnapshot(
totalSeconds: 6900,
rows: [assigneeRow, helperRow, anotherHelperRow, row],
),
createdAt: DateTime.utc(2026, 9, 1),
updatedAt: DateTime.utc(2026, 9, 1),
),
];
final range = ReportDateRange(
start: DateTime.utc(2026, 9, 1),
end: DateTime.utc(2026, 9, 2),
label: 'Test Range',
);
final data = buildAccomplishmentReportData(
programmer: programmer,
range: range,
includeUnapproved: false,
sheets: sheets,
liveRowsByDay: {},
tasks: [],
profileNames: {'reviewer1': 'Jane Reviewer'},
);
expect(data.helpSeconds, 2700); // 1800 + 900
expect(data.secondsByCategory['Software Development'], 4500); // 3600 + 900
expect(data.secondsByCategory['Bug Fix'], 1800);
expect(data.secondsByCategory['Meeting'], 600);
expect(data.totalSeconds, 6900);
});
test('approved sheet with zero rows and different reviewer does not affect approverName',
() {
final day1 = DateTime.utc(2026, 9, 1);
final day2 = DateTime.utc(2026, 9, 2);
final day3 = DateTime.utc(2026, 9, 3);
final row = DaySheetRow(
taskId: 't1',
title: 'Task 1',
category: 'Software Development',
kind: 'assignee',
seconds: 3600,
notes: [],
);
// Two included approved sheets with same reviewer
// One zero-row approved sheet with different reviewer
final sheets = [
ProgrammerDaySheet(
id: 'sheet1',
programmerId: 'prog1',
status: 'approved',
workDate: day1,
autoSubmitted: false,
resubmissions: 0,
reviewedBy: 'reviewer1',
approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row]),
createdAt: DateTime.utc(2026, 9, 1),
updatedAt: DateTime.utc(2026, 9, 1),
),
// Zero-row approved sheet with different reviewer (should be ignored)
ProgrammerDaySheet(
id: 'sheet2',
programmerId: 'prog1',
status: 'approved',
workDate: day2,
autoSubmitted: false,
resubmissions: 0,
reviewedBy: 'reviewer2',
approvedSnapshot: DaySheetSnapshot(totalSeconds: 0, rows: []),
createdAt: DateTime.utc(2026, 9, 2),
updatedAt: DateTime.utc(2026, 9, 2),
),
ProgrammerDaySheet(
id: 'sheet3',
programmerId: 'prog1',
status: 'approved',
workDate: day3,
autoSubmitted: false,
resubmissions: 0,
reviewedBy: 'reviewer1',
approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row]),
createdAt: DateTime.utc(2026, 9, 3),
updatedAt: DateTime.utc(2026, 9, 3),
),
];
final range = ReportDateRange(
start: DateTime.utc(2026, 9, 1),
end: DateTime.utc(2026, 9, 4),
label: 'Test Range',
);
final data = buildAccomplishmentReportData(
programmer: programmer,
range: range,
includeUnapproved: false,
sheets: sheets,
liveRowsByDay: {},
tasks: [],
profileNames: {
'reviewer1': 'Jane Reviewer',
'reviewer2': 'Bob Reviewer',
},
);
// Only 2 days included (day2 has zero rows)
expect(data.days.length, 2);
// Both included days were reviewed by reviewer1
expect(data.approverName, 'Jane Reviewer');
});
});
}
+170
View File
@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tasq/models/profile.model.dart';
import 'package:tasq/providers/profile_provider.dart';
import 'package:tasq/screens/programmer_tasks/report/accomplishment_report_dialog.dart';
import 'package:tasq/theme/app_theme.dart';
import 'package:tasq/utils/app_time.dart';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Widget _buildApp(Widget child, {List<Override> overrides = const []}) {
return ProviderScope(
overrides: overrides,
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(body: child),
),
);
}
/// Opener widget that shows the dialog on tap.
class _DialogOpener extends ConsumerWidget {
const _DialogOpener({this.programmerId});
final String? programmerId;
@override
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton(
onPressed: () => showAccomplishmentReportDialog(
context,
ref,
programmerId: programmerId,
),
child: const Text('Open'),
);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
setUpAll(AppTime.initialize);
// ── Programmer mode (not admin, no programmerId) ─────────────────────────
group('as programmer (not admin)', () {
late List<Override> overrides;
setUp(() {
overrides = [
isStrictAdminProvider.overrideWithValue(false),
profilesProvider.overrideWith(
(ref) => Stream.value([
Profile(id: 'prog-1', role: 'programmer', fullName: 'Alice'),
]),
),
currentUserIdProvider.overrideWithValue('prog-1'),
];
});
testWidgets('shows Accomplishment Report title', (tester) async {
await tester
.pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides));
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.text('Accomplishment Report'), findsOneWidget);
});
testWidgets('does not show programmer picker', (tester) async {
await tester
.pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides));
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.byType(DropdownButton<Profile>), findsNothing);
});
testWidgets('toggle Include unapproved days is present and off by default',
(tester) async {
await tester
.pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides));
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.text('Include unapproved days (marked)'), findsOneWidget);
final sw = tester.widget<Switch>(find.byType(Switch).first);
expect(sw.value, isFalse);
});
testWidgets('date filter defaults to This Month', (tester) async {
await tester
.pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides));
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.text('This Month'), findsOneWidget);
});
});
// ── Admin picker mode (isStrictAdmin, no programmerId passed) ────────────
group('as admin (no programmerId, strict admin)', () {
final prog1 = Profile(id: 'prog-1', role: 'programmer', fullName: 'Alice');
final prog2 = Profile(id: 'prog-2', role: 'programmer', fullName: 'Bob');
final admin1 =
Profile(id: 'admin-1', role: 'admin', fullName: 'Admin User');
late List<Override> overrides;
setUp(() {
overrides = [
isStrictAdminProvider.overrideWithValue(true),
profilesProvider.overrideWith(
(ref) => Stream.value([prog1, prog2, admin1]),
),
currentUserIdProvider.overrideWithValue('admin-1'),
];
});
testWidgets('shows programmer picker', (tester) async {
await tester
.pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides));
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.byType(DropdownButton<Profile>), findsOneWidget);
});
testWidgets('Generate is disabled until a programmer is chosen',
(tester) async {
await tester
.pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides));
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
final button = tester.widget<FilledButton>(
find.ancestor(
of: find.text('Generate'),
matching: find.byType(FilledButton),
),
);
expect(button.onPressed, isNull);
});
testWidgets('picker shows only programmer-role profiles', (tester) async {
await tester
.pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides));
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
// Open the dropdown to expose its items.
await tester.tap(find.byType(DropdownButton<Profile>));
await tester.pumpAndSettle();
// Programmer names should appear in the dropdown overlay.
expect(find.text('Alice'), findsWidgets);
expect(find.text('Bob'), findsWidgets);
// Admin-role profile must not appear.
expect(find.text('Admin User'), findsNothing);
});
});
}
+87
View File
@@ -0,0 +1,87 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:tasq/screens/programmer_tasks/report/accomplishment_report_data.dart';
import 'package:tasq/screens/programmer_tasks/report/accomplishment_report_pdf.dart';
import 'package:tasq/utils/programmer_day_rows.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
AccomplishmentReportData populated() => AccomplishmentReportData(
programmerName: 'John Doe',
positionLabel: 'Programmer',
periodStart: DateTime.utc(2026, 9, 1),
periodEndInclusive: DateTime.utc(2026, 9, 30),
days: [
ReportDay(
day: DateTime.utc(2026, 9, 1),
statusTag: null,
rows: [
DaySheetRow(
taskId: 't1',
title: 'Fix login bug',
category: 'Backend',
kind: 'assignee',
projectName: 'AuthService',
helpedName: null,
seconds: 3600,
notes: ['Resolved null pointer exception'],
),
],
),
ReportDay(
day: DateTime.utc(2026, 9, 2),
statusTag: 'Pending',
rows: [
DaySheetRow(
taskId: 't2',
title: 'Add unit tests',
category: 'Testing',
kind: 'helper',
projectName: null,
helpedName: 'Jane Smith',
seconds: 1800,
notes: [],
),
],
),
],
excludedPending: 1,
excludedDisapproved: 0,
daysReported: 2,
totalSeconds: 5400,
tasksCompleted: 1,
helpSeconds: 1800,
secondsByCategory: {'Backend': 3600, 'Testing': 1800},
approverName: 'Jane Reviewer',
);
AccomplishmentReportData emptyPeriod() => AccomplishmentReportData(
programmerName: 'John Doe',
positionLabel: 'Programmer',
periodStart: DateTime.utc(2026, 9, 1),
periodEndInclusive: DateTime.utc(2026, 9, 30),
days: [],
excludedPending: 0,
excludedDisapproved: 0,
daysReported: 0,
totalSeconds: 0,
tasksCompleted: 0,
helpSeconds: 0,
secondsByCategory: {},
approverName: null,
);
test('populated report builds non-empty PDF bytes starting with %PDF',
() async {
final bytes = await buildAccomplishmentReportPdf(populated());
expect(bytes.isNotEmpty, isTrue);
expect(utf8.decode(bytes.sublist(0, 4)), equals('%PDF'));
});
test('empty period builds a valid PDF', () async {
final bytes = await buildAccomplishmentReportPdf(emptyPeriod());
expect(bytes.isNotEmpty, isTrue);
expect(utf8.decode(bytes.sublist(0, 4)), equals('%PDF'));
});
}
+207
View File
@@ -0,0 +1,207 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tasq/models/programmer_day_sheet.model.dart';
import 'package:tasq/screens/programmer_tasks/day_sheets/day_sheet_dialogs.dart';
import 'package:tasq/utils/programmer_day_rows.dart';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Widget _wrap(Widget child) {
return MaterialApp(home: Scaffold(body: child));
}
// Minimal tappable button that opens the dialog under test.
class _DisapproveOpener extends StatelessWidget {
const _DisapproveOpener({required this.rows, this.result});
final List<DaySheetRow> rows;
final ValueNotifier<String?>? result;
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () async {
final r = await showDisapproveDialog(context, flaggedRows: rows);
result?.value = r;
},
child: const Text('Open'),
);
}
}
class _JustifyOpener extends StatelessWidget {
const _JustifyOpener({this.result});
final ValueNotifier<String?>? result;
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () async {
final r = await showJustifyDialog(context);
result?.value = r;
},
child: const Text('Open'),
);
}
}
// ---------------------------------------------------------------------------
// DaySheetRow fixture
// ---------------------------------------------------------------------------
DaySheetRow _row(String taskId) => DaySheetRow(
taskId: taskId,
title: 'Task $taskId',
category: 'Software Development',
kind: 'assignee',
seconds: 3600,
notes: const [],
);
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
group('showDisapproveDialog', () {
testWidgets('stays open and shows error when remarks are empty',
(tester) async {
final result = ValueNotifier<String?>(null);
await tester.pumpWidget(_wrap(_DisapproveOpener(
rows: [_row('t1')],
result: result,
)));
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
// Dialog should be visible.
expect(find.text('Disapprove'), findsWidgets);
// Tap the Disapprove action button with empty remarks.
final disapproveButtons = find.text('Disapprove');
await tester.tap(disapproveButtons.last);
await tester.pumpAndSettle();
// Error message should appear and dialog should still be open.
expect(find.text('Remarks are required'), findsOneWidget);
expect(find.text('Disapprove'), findsWidgets);
expect(result.value, isNull);
});
testWidgets('returns the entered remarks when non-empty', (tester) async {
final result = ValueNotifier<String?>(null);
await tester.pumpWidget(_wrap(_DisapproveOpener(
rows: [_row('t1')],
result: result,
)));
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
await tester.enterText(
find.byType(TextField).first, 'Missing notes');
await tester.pumpAndSettle();
final disapproveButtons = find.text('Disapprove');
await tester.tap(disapproveButtons.last);
await tester.pumpAndSettle();
expect(result.value, 'Missing notes');
});
});
group('showJustifyDialog', () {
testWidgets('stays open and shows error when justification is empty',
(tester) async {
final result = ValueNotifier<String?>(null);
await tester.pumpWidget(_wrap(_JustifyOpener(result: result)));
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
// Tap submit with no text.
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
expect(find.text('Please write a justification'), findsOneWidget);
expect(result.value, isNull);
});
testWidgets('returns body when non-empty', (tester) async {
final result = ValueNotifier<String?>(null);
await tester.pumpWidget(_wrap(_JustifyOpener(result: result)));
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).first, 'My justification');
await tester.pumpAndSettle();
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
expect(result.value, 'My justification');
});
});
group('rowsForSheet', () {
test('returns snapshot rows when approved and snapshot exists', () {
final snapshotRow = _row('snap-1');
final snapshot = DaySheetSnapshot(
totalSeconds: 3600,
rows: [snapshotRow],
);
final sheet = ProgrammerDaySheet(
id: 'sheet-1',
programmerId: 'user-1',
status: DaySheetStatus.approved,
workDate: DateTime.utc(2026, 9, 27),
approvedSnapshot: snapshot,
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
);
final liveRow = _row('live-1');
final result = rowsForSheet(sheet, [liveRow]);
expect(result, [snapshotRow]);
});
test('returns live rows when not approved', () {
final sheet = ProgrammerDaySheet(
id: 'sheet-2',
programmerId: 'user-1',
status: DaySheetStatus.pending,
workDate: DateTime.utc(2026, 9, 27),
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
);
final liveRow = _row('live-1');
final result = rowsForSheet(sheet, [liveRow]);
expect(result, [liveRow]);
});
test('returns live rows when approved but snapshot is null', () {
final sheet = ProgrammerDaySheet(
id: 'sheet-3',
programmerId: 'user-1',
status: DaySheetStatus.approved,
workDate: DateTime.utc(2026, 9, 27),
approvedSnapshot: null,
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
);
final liveRow = _row('live-1');
final result = rowsForSheet(sheet, [liveRow]);
expect(result, [liveRow]);
});
});
}
+180
View File
@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tasq/models/programmer_day_sheet.model.dart';
import 'package:tasq/providers/profile_provider.dart';
import 'package:tasq/providers/programmer_day_sheets_provider.dart';
import 'package:tasq/screens/programmer_tasks/day_sheets/approvals_tab.dart';
import 'package:tasq/screens/programmer_tasks/day_sheets/day_sheet_snapshot_builder.dart';
import 'package:tasq/theme/app_theme.dart';
import 'package:tasq/utils/app_time.dart';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
ProgrammerDaySheet _sheet({
required String id,
required String status,
DateTime? workDate,
bool autoSubmitted = false,
int resubmissions = 0,
}) {
final now = AppTime.now();
final date = workDate ?? DateTime.utc(now.year, now.month, 15);
return ProgrammerDaySheet(
id: id,
programmerId: 'prog-1',
status: status,
workDate: date,
autoSubmitted: autoSubmitted,
resubmissions: resubmissions,
createdAt: now,
updatedAt: now,
);
}
Widget _buildApp(Widget child, {List<Override> overrides = const []}) {
return ProviderScope(
overrides: overrides,
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(body: child),
),
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
setUpAll(AppTime.initialize);
// ── groupSheetsByDate ────────────────────────────────────────────────────
group('groupSheetsByDate', () {
test('groups by workDate, keys descending', () {
final d1 = DateTime.utc(2024, 1, 3);
final d2 = DateTime.utc(2024, 1, 2);
final d3 = DateTime.utc(2024, 1, 1);
final sheets = [
_sheet(id: 'a', status: DaySheetStatus.pending, workDate: d2),
_sheet(id: 'b', status: DaySheetStatus.approved, workDate: d1),
_sheet(id: 'c', status: DaySheetStatus.draft, workDate: d3),
_sheet(id: 'd', status: DaySheetStatus.pending, workDate: d2),
];
final grouped = groupSheetsByDate(sheets);
final keys = grouped.keys.toList();
expect(keys, [d1, d2, d3]);
expect(grouped[d1]!.map((s) => s.id).toList(), ['b']);
expect(grouped[d2]!.map((s) => s.id).toList(), ['a', 'd']);
expect(grouped[d3]!.map((s) => s.id).toList(), ['c']);
});
test('single sheet returns single-entry map', () {
final d = DateTime.utc(2024, 6, 1);
final result = groupSheetsByDate([_sheet(id: 'x', status: DaySheetStatus.pending, workDate: d)]);
expect(result.keys.toList(), [d]);
expect(result[d]!.length, 1);
});
test('empty list returns empty map', () {
expect(groupSheetsByDate([]), isEmpty);
});
});
// ── approvalCounts ───────────────────────────────────────────────────────
group('approvalCounts', () {
test('counts pending, awaiting (disapproved) and approved', () {
final sheets = [
_sheet(id: '1', status: DaySheetStatus.pending),
_sheet(id: '2', status: DaySheetStatus.pending),
_sheet(id: '3', status: DaySheetStatus.disapproved),
_sheet(id: '4', status: DaySheetStatus.approved),
_sheet(id: '5', status: DaySheetStatus.draft),
];
final counts = approvalCounts(sheets);
expect(counts.pending, 2);
expect(counts.awaiting, 1);
expect(counts.approved, 1);
});
test('all zero for empty list', () {
final counts = approvalCounts([]);
expect(counts.pending, 0);
expect(counts.awaiting, 0);
expect(counts.approved, 0);
});
test('draft sheets are not counted', () {
final counts = approvalCounts([
_sheet(id: '1', status: DaySheetStatus.draft),
_sheet(id: '2', status: DaySheetStatus.draft),
]);
expect(counts.pending, 0);
expect(counts.awaiting, 0);
expect(counts.approved, 0);
});
});
// ── ApprovalsTab widget ──────────────────────────────────────────────────
group('ApprovalsTab', () {
List<ProgrammerDaySheet> _pendingSheets(int n) {
final now = AppTime.now();
return List.generate(
n,
(i) => _sheet(
id: 'sheet-$i',
status: DaySheetStatus.pending,
workDate: DateTime.utc(now.year, now.month, 15),
),
);
}
testWidgets(
'selecting 2 of 3 pending cards shows Approve selected (2)',
(tester) async {
final sheets = _pendingSheets(3);
await tester.pumpWidget(
_buildApp(
const ApprovalsTab(),
overrides: [
allDaySheetsProvider.overrideWith(
(ref) => Stream.value(sheets),
),
profilesProvider.overrideWith(
(ref) => Stream.value([]),
),
isStrictAdminProvider.overrideWith((ref) => true),
currentUserIdProvider.overrideWith((ref) => 'admin-1'),
],
),
);
// Let the stream settle.
await tester.pump();
await tester.pump();
// All 3 pending cards should be visible with checkboxes.
final checkboxes = find.byType(Checkbox);
expect(checkboxes, findsAtLeastNWidgets(2));
// Select two cards.
await tester.tap(checkboxes.at(0));
await tester.pump();
await tester.tap(checkboxes.at(1));
await tester.pump();
// The approve button must reflect the selection count.
expect(find.text('Approve selected (2)'), findsOneWidget);
});
});
}
+87
View File
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tasq/screens/notifications/notifications_screen.dart';
void main() {
group('notificationTitle', () {
test('assignment guard (existing case unchanged)', () {
expect(notificationTitle('assignment', 'Alice'), 'Alice assigned you');
});
test('day_sheet_submitted', () {
expect(
notificationTitle('day_sheet_submitted', 'Bob'),
'Bob submitted a day sheet',
);
});
test('day_sheet_disapproved', () {
expect(
notificationTitle('day_sheet_disapproved', 'Alice'),
'Day sheet disapproved',
);
});
test('day_sheet_justified', () {
expect(
notificationTitle('day_sheet_justified', 'Bob'),
'Bob justified a day sheet',
);
});
test('day_sheet_approved', () {
expect(
notificationTitle('day_sheet_approved', 'Alice'),
'Day sheet approved',
);
});
test('day_sheet_digest', () {
expect(
notificationTitle('day_sheet_digest', 'System'),
'Day sheets awaiting review',
);
});
});
group('notificationIcon', () {
test('assignment guard (existing case unchanged)', () {
expect(notificationIcon('assignment'), Icons.assignment_ind_outlined);
});
test('day_sheet_submitted', () {
expect(
notificationIcon('day_sheet_submitted'),
Icons.assignment_turned_in_outlined,
);
});
test('day_sheet_disapproved', () {
expect(
notificationIcon('day_sheet_disapproved'),
Icons.report_outlined,
);
});
test('day_sheet_justified', () {
expect(
notificationIcon('day_sheet_justified'),
Icons.reply_outlined,
);
});
test('day_sheet_approved', () {
expect(
notificationIcon('day_sheet_approved'),
Icons.verified_outlined,
);
});
test('day_sheet_digest', () {
expect(
notificationIcon('day_sheet_digest'),
Icons.inbox_outlined,
);
});
});
}
+267
View File
@@ -0,0 +1,267 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tasq/models/programmer_task.model.dart';
import 'package:tasq/models/programmer_task_activity_log.model.dart';
import 'package:tasq/utils/app_time.dart';
import 'package:tasq/utils/programmer_daily_time.dart';
void main() {
setUpAll(AppTime.initialize);
/// Helper to create a ProgrammerTaskActivityLog at Manila time d h:m
ProgrammerTaskActivityLog _ev(
String action,
int d,
int h, [
int m = 0,
Map<String, dynamic>? meta,
]) {
return ProgrammerTaskActivityLog(
id: 'ev-$d-$h-$m',
taskId: 'task-1',
actorId: 'actor',
actionType: action,
meta: meta,
createdAt: AppTime.fromComponents(
year: 2026,
month: 9,
day: d,
hour: h,
minute: m,
),
);
}
/// Helper to create a ProgrammerTask
ProgrammerTask _task({
String status = ProgrammerTaskStatus.inProgress,
String? creatorId = 'a',
String? assigneeId = 'a',
DateTime? startedAt,
DateTime? completedAt,
DateTime? cancelledAt,
}) {
return ProgrammerTask(
id: 'task-1',
title: 'Test Task',
category: ProgrammerTaskCategory.softwareDevelopment,
status: status,
creatorId: creatorId,
assigneeId: assigneeId,
createdAt: AppTime.fromComponents(year: 2026, month: 9, day: 27),
startedAt: startedAt,
completedAt: completedAt,
cancelledAt: cancelledAt,
updatedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27),
);
}
test('manilaDay ignores device time zone', () {
// Sep 26 4:30 PM UTC = Sep 27 12:30 AM Manila time
expect(
manilaDay(DateTime.parse('2026-09-26T16:30:00Z')),
DateTime.utc(2026, 9, 27),
);
// Sep 26 3:59 PM UTC = Sep 26 11:59 PM Manila time
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');
expect(formatDayKey(parseDayKey('2025-01-05')), '2025-01-05');
});
test('single day with a pause', () {
final task = _task(
status: ProgrammerTaskStatus.completed,
creatorId: 'a',
assigneeId: 'a',
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
completedAt:
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 15),
);
final logs = [
_ev('started', 27, 9),
_ev('paused', 27, 11),
_ev('resumed', 27, 13),
_ev('completed', 27, 15),
];
final now =
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20);
final result = dailySecondsByAssignee(task: task, logs: logs, now: now);
expect(result['a'], {DateTime.utc(2026, 9, 27): 4 * 3600});
});
test('running across Manila midnight splits by day', () {
final task = _task(
status: ProgrammerTaskStatus.inProgress,
creatorId: 'a',
assigneeId: 'a',
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 26, hour: 22),
);
final logs = [
_ev('started', 26, 22),
];
final now =
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 2);
final result = dailySecondsByAssignee(task: task, logs: logs, now: now);
expect(result['a']![DateTime.utc(2026, 9, 26)], 2 * 3600);
expect(result['a']![DateTime.utc(2026, 9, 27)], 2 * 3600);
});
test('reassignment credits each assignee for their own span', () {
final task = _task(
status: ProgrammerTaskStatus.completed,
creatorId: 'a',
assigneeId: 'b',
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
completedAt:
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 12),
);
final logs = [
_ev('started', 27, 9),
_ev('reassigned', 27, 10, 0, {'assignee_id': 'b'}),
_ev('completed', 27, 12),
];
final now =
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20);
final result = dailySecondsByAssignee(task: task, logs: logs, now: now);
expect(result['a']![DateTime.utc(2026, 9, 27)], 3600);
expect(result['b']![DateTime.utc(2026, 9, 27)], 2 * 3600);
});
test('initial assignee comes from the first assigned event', () {
final task = _task(
status: ProgrammerTaskStatus.completed,
creatorId: 'boss',
assigneeId: 'a',
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
completedAt:
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 10),
);
final logs = [
_ev('assigned', 27, 8, 0, {'assignee_id': 'a'}),
_ev('started', 27, 9),
_ev('completed', 27, 10),
];
final now =
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20);
final result = dailySecondsByAssignee(task: task, logs: logs, now: now);
expect(result.keys.toList(), ['a']);
expect(result['a']![DateTime.utc(2026, 9, 27)], 3600);
});
test('adjustment subtracts on the day created and clamps at zero', () {
// Case 1: adjustment {seconds:1800} at 10:00
final task1 = _task(
status: ProgrammerTaskStatus.completed,
creatorId: 'a',
assigneeId: 'a',
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
completedAt:
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 11),
);
final logs1 = [
_ev('started', 27, 9),
_ev('adjustment', 27, 10, 0, {'seconds': 1800}),
_ev('completed', 27, 11),
];
final now =
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20);
final result1 =
dailySecondsByAssignee(task: task1, logs: logs1, now: now);
expect(result1['a']![DateTime.utc(2026, 9, 27)], 5400); // 7200 - 1800
// Case 2: adjustment {seconds: 99999} -> clamps at 0
final task2 = _task(
status: ProgrammerTaskStatus.completed,
creatorId: 'a',
assigneeId: 'a',
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
completedAt:
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 11),
);
final logs2 = [
_ev('started', 27, 9),
_ev('adjustment', 27, 10, 0, {'seconds': 99999}),
_ev('completed', 27, 11),
];
final result2 =
dailySecondsByAssignee(task: task2, logs: logs2, now: now);
expect(result2['a']![DateTime.utc(2026, 9, 27)], 0); // clamped
});
test('falls back to task.startedAt when there is no started log', () {
final task = _task(
status: ProgrammerTaskStatus.completed,
creatorId: 'a',
assigneeId: 'a',
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
completedAt:
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 10),
);
final logs = [
_ev('completed', 27, 10),
];
final now =
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20);
final result = dailySecondsByAssignee(task: task, logs: logs, now: now);
expect(result['a']![DateTime.utc(2026, 9, 27)], 3600);
});
test('a paused in-progress task stops accruing at the pause', () {
final task = _task(
status: ProgrammerTaskStatus.inProgress,
creatorId: 'a',
assigneeId: 'a',
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
);
final logs = [
_ev('started', 27, 9),
_ev('paused', 27, 10),
];
final now =
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 15);
final result = dailySecondsByAssignee(task: task, logs: logs, now: now);
expect(result['a']![DateTime.utc(2026, 9, 27)], 3600); // not 6 hours
});
test('reassignment while paused does not open a new segment for the new assignee', () {
// task: started 09:00, paused 10:00, reassigned {assignee_id:'b'} 11:00, completed 12:00 (all Sep 27)
// expected: a -> 3600s (09-10), b -> 0 (no interval opened after paused reassignment)
final task = _task(
status: ProgrammerTaskStatus.completed,
creatorId: 'a',
assigneeId: 'b',
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
completedAt:
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 12),
);
final logs = [
_ev('started', 27, 9),
_ev('paused', 27, 10),
_ev('reassigned', 27, 11, 0, {'assignee_id': 'b'}),
_ev('completed', 27, 12),
];
final now =
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20);
final result = dailySecondsByAssignee(task: task, logs: logs, now: now);
expect(result['a']![DateTime.utc(2026, 9, 27)], 3600); // a worked 09-10
// b should have no time, or null entry
expect(
result['b']?[DateTime.utc(2026, 9, 27)] ?? 0,
0,
);
});
}
@@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tasq/models/profile.model.dart';
import 'package:tasq/models/programmer_task.model.dart';
import 'package:tasq/providers/programmer_day_data_provider.dart';
import 'package:tasq/screens/programmer_tasks/dashboard/task_sections.dart';
import 'package:tasq/utils/app_time.dart';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
ProgrammerTask _makeTask({
required String id,
required String status,
String assigneeId = 'user1',
DateTime? completedAt,
DateTime? cancelledAt,
}) {
final now = DateTime(2026, 1, 1);
return ProgrammerTask(
id: id,
title: 'Task $id',
category: ProgrammerTaskCategory.bugFix,
status: status,
assigneeId: assigneeId,
createdAt: now,
updatedAt: now,
completedAt: completedAt,
cancelledAt: cancelledAt,
);
}
Widget _buildSections(TaskSectionGroups groups, {bool readOnly = false}) {
return ProviderScope(
overrides: [
programmerRunStatesProvider.overrideWithValue({}),
],
child: MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: TaskSections(
groups: groups,
profileById: const {},
readOnly: readOnly,
),
),
),
),
);
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
void main() {
setUpAll(AppTime.initialize);
test('groupTasksForDashboard splits by run state and excludes other assignees', () {
final tasks = [
_makeTask(id: 't1', status: ProgrammerTaskStatus.inProgress, assigneeId: 'user1'),
_makeTask(id: 't2', status: ProgrammerTaskStatus.inProgress, assigneeId: 'user1'),
_makeTask(id: 't3', status: ProgrammerTaskStatus.queued, assigneeId: 'user1'),
_makeTask(id: 't4', status: ProgrammerTaskStatus.queued, assigneeId: 'other'),
];
final runStates = <String, ProgrammerRunInfo>{
't1': (state: ProgrammerRunState.running, since: null),
't2': (state: ProgrammerRunState.paused, since: null),
't3': (state: ProgrammerRunState.notStarted, since: null),
't4': (state: ProgrammerRunState.notStarted, since: null),
};
final groups = groupTasksForDashboard(tasks, runStates, 'user1');
expect(groups.running.map((t) => t.id), ['t1']);
expect(groups.paused.map((t) => t.id), ['t2']);
expect(groups.notStarted.map((t) => t.id), ['t3']);
// t4 belongs to 'other' — must not appear
expect(groups.running.any((t) => t.id == 't4'), isFalse);
expect(groups.paused.any((t) => t.id == 't4'), isFalse);
expect(groups.notStarted.any((t) => t.id == 't4'), isFalse);
});
test('completed sorted by completedAt desc, cancelled by cancelledAt desc', () {
final base = DateTime(2026, 1, 1);
final tasks = [
_makeTask(
id: 'c1',
status: ProgrammerTaskStatus.completed,
completedAt: base.add(const Duration(hours: 1)),
),
_makeTask(
id: 'c2',
status: ProgrammerTaskStatus.completed,
completedAt: base.add(const Duration(hours: 3)),
),
_makeTask(
id: 'x1',
status: ProgrammerTaskStatus.cancelled,
cancelledAt: base.add(const Duration(hours: 2)),
),
_makeTask(
id: 'x2',
status: ProgrammerTaskStatus.cancelled,
cancelledAt: base.add(const Duration(hours: 5)),
),
];
final runStates = <String, ProgrammerRunInfo>{
'c1': (state: ProgrammerRunState.completed, since: null),
'c2': (state: ProgrammerRunState.completed, since: null),
'x1': (state: ProgrammerRunState.cancelled, since: null),
'x2': (state: ProgrammerRunState.cancelled, since: null),
};
final groups = groupTasksForDashboard(tasks, runStates, 'user1');
expect(groups.completed.map((t) => t.id).toList(), ['c2', 'c1']);
expect(groups.cancelled.map((t) => t.id).toList(), ['x2', 'x1']);
});
// ---------------------------------------------------------------------------
// Widget tests
// ---------------------------------------------------------------------------
testWidgets('Completed and Cancelled start collapsed', (tester) async {
final base = DateTime(2026, 1, 1);
final completed = List.generate(
12,
(i) => _makeTask(
id: 'c$i',
status: ProgrammerTaskStatus.completed,
completedAt: base.add(Duration(hours: i)),
),
);
final runStates = <String, ProgrammerRunInfo>{
for (final t in completed)
t.id: (state: ProgrammerRunState.completed, since: null),
};
final groups = groupTasksForDashboard(completed, runStates, 'user1');
await tester.pumpWidget(_buildSections(groups));
await tester.pump();
// Header is visible
expect(find.textContaining('COMPLETED'), findsOneWidget);
// First task title is NOT visible (collapsed)
expect(find.text('Task c0'), findsNothing);
// Tap the header to expand
await tester.tap(find.textContaining('COMPLETED'));
await tester.pump();
// 10 titles visible (first 10 of 12)
int visibleCount = 0;
for (int i = 0; i < 12; i++) {
if (tester.any(find.text('Task c${11 - i}'))) visibleCount++;
}
expect(visibleCount, 10);
// "Show all (12)" link is visible
expect(find.text('Show all (12)'), findsOneWidget);
});
testWidgets('empty sections are hidden', (tester) async {
final tasks = [
_makeTask(id: 'r1', status: ProgrammerTaskStatus.inProgress),
];
final runStates = <String, ProgrammerRunInfo>{
'r1': (state: ProgrammerRunState.running, since: null),
};
final groups = groupTasksForDashboard(tasks, runStates, 'user1');
await tester.pumpWidget(_buildSections(groups));
await tester.pump();
// 'In progress' header present (at least one widget contains the text)
expect(find.textContaining('IN PROGRESS'), findsAtLeastNWidgets(1));
// 'Cancelled' header not rendered (no cancelled tasks)
expect(find.textContaining('CANCELLED'), findsNothing);
// 'Paused' header not rendered
expect(find.textContaining('PAUSED'), findsNothing);
});
}
+359
View File
@@ -0,0 +1,359 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:tasq/models/programmer_task.model.dart';
import 'package:tasq/models/programmer_task_activity_log.model.dart';
import 'package:tasq/providers/programmer_day_data_provider.dart';
import 'package:tasq/utils/app_time.dart';
// ---------------------------------------------------------------------------
// Minimal fake Supabase client — only the surface fetchDayActivityData uses.
// ---------------------------------------------------------------------------
class _FakeClient {
_FakeClient({required this.tables});
final Map<String, List<Map<String, dynamic>>> tables;
_FakeQuery from(String table) => _FakeQuery(this, table);
}
class _FakeQuery implements Future<List<Map<String, dynamic>>> {
_FakeQuery(this._client, this._table);
final _FakeClient _client;
final String _table;
final List<MapEntry<String, dynamic>> _eqs = [];
String? _inField;
List<String>? _inValues;
String? _orderField;
bool _orderAsc = true;
String? _gteField;
dynamic _gteValue;
String? _ltField;
dynamic _ltValue;
List<Map<String, dynamic>> get _rows {
var rows = List<Map<String, dynamic>>.from(_client.tables[_table] ?? []);
for (final eq in _eqs) {
rows = rows.where((r) => r[eq.key] == eq.value).toList();
}
if (_inField != null && _inValues != null) {
rows = rows
.where((r) => _inValues!.contains(r[_inField]?.toString()))
.toList();
}
if (_gteField != null) {
rows = rows.where((r) {
final v = r[_gteField];
return v != null && v.toString().compareTo(_gteValue.toString()) >= 0;
}).toList();
}
if (_ltField != null) {
rows = rows.where((r) {
final v = r[_ltField];
return v != null && v.toString().compareTo(_ltValue.toString()) < 0;
}).toList();
}
if (_orderField != null) {
rows.sort((a, b) {
final av = '${a[_orderField]}';
final bv = '${b[_orderField]}';
return _orderAsc ? av.compareTo(bv) : bv.compareTo(av);
});
}
return rows;
}
Future<List<Map<String, dynamic>>> get _future async => _rows;
_FakeQuery select([String? _]) => this;
_FakeQuery eq(String field, dynamic value) {
_eqs.add(MapEntry(field, value));
return this;
}
_FakeQuery inFilter(String field, List<dynamic> values) {
_inField = field;
_inValues = values.map((v) => v.toString()).toList();
return this;
}
_FakeQuery gte(String field, dynamic value) {
_gteField = field;
_gteValue = value;
return this;
}
_FakeQuery lt(String field, dynamic value) {
_ltField = field;
_ltValue = value;
return this;
}
_FakeQuery order(String field, {bool ascending = true}) {
_orderField = field;
_orderAsc = ascending;
return this;
}
@override
Stream<List<Map<String, dynamic>>> asStream() => _future.asStream();
@override
Future<List<Map<String, dynamic>>> catchError(Function onError,
{bool Function(Object error)? test}) =>
_future.catchError(onError, test: test);
@override
Future<R> then<R>(
FutureOr<R> Function(List<Map<String, dynamic>>) onValue,
{Function? onError}) =>
_future.then(onValue, onError: onError);
@override
Future<List<Map<String, dynamic>>> timeout(Duration timeLimit,
{FutureOr<List<Map<String, dynamic>>> Function()? onTimeout}) =>
_future.timeout(timeLimit, onTimeout: onTimeout);
@override
Future<List<Map<String, dynamic>>> whenComplete(
FutureOr<void> Function() action) =>
_future.whenComplete(action);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
ProgrammerTask _task({
required String id,
required String status,
DateTime? startedAt,
DateTime? completedAt,
DateTime? cancelledAt,
}) {
return ProgrammerTask(
id: id,
title: 'Task $id',
category: ProgrammerTaskCategory.others,
status: status,
createdAt: DateTime.utc(2026, 9, 1),
updatedAt: DateTime.utc(2026, 9, 1),
startedAt: startedAt,
completedAt: completedAt,
cancelledAt: cancelledAt,
);
}
ProgrammerTaskActivityLog _log({
required String taskId,
required String actionType,
required DateTime createdAt,
}) {
return ProgrammerTaskActivityLog(
id: '${taskId}_${actionType}_${createdAt.millisecondsSinceEpoch}',
taskId: taskId,
actionType: actionType,
createdAt: createdAt,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
setUpAll(AppTime.initialize);
group('computeRunStates', () {
test(
'queued->notStarted; latest paused->paused since pause time; '
'resumed->running; completed', () {
final startedAt = DateTime.utc(2026, 9, 27, 8, 0);
final pauseAt = DateTime.utc(2026, 9, 27, 10, 0);
final resumeAt = DateTime.utc(2026, 9, 27, 11, 0);
final tasks = [
_task(id: 'q', status: ProgrammerTaskStatus.queued),
_task(
id: 'p',
status: ProgrammerTaskStatus.inProgress,
startedAt: startedAt,
),
_task(
id: 'r',
status: ProgrammerTaskStatus.inProgress,
startedAt: DateTime.utc(2026, 9, 27, 9, 0),
),
_task(
id: 'c',
status: ProgrammerTaskStatus.completed,
startedAt: startedAt,
completedAt: DateTime.utc(2026, 9, 27, 12, 0),
),
];
final logsByTask = {
'p': [
_log(taskId: 'p', actionType: 'started', createdAt: startedAt),
_log(taskId: 'p', actionType: 'paused', createdAt: pauseAt),
],
'r': [
_log(taskId: 'r', actionType: 'started', createdAt: DateTime.utc(2026, 9, 27, 9, 0)),
_log(taskId: 'r', actionType: 'paused', createdAt: DateTime.utc(2026, 9, 27, 10, 30)),
_log(taskId: 'r', actionType: 'resumed', createdAt: resumeAt),
],
};
final states = computeRunStates(tasks, logsByTask);
// queued -> notStarted
expect(states['q']!.state, ProgrammerRunState.notStarted);
expect(states['q']!.since, isNull);
// latest action is paused -> paused since pause time
expect(states['p']!.state, ProgrammerRunState.paused);
expect(states['p']!.since, pauseAt);
// latest action is resumed -> running since resume time
expect(states['r']!.state, ProgrammerRunState.running);
expect(states['r']!.since, resumeAt);
// completed -> completed
expect(states['c']!.state, ProgrammerRunState.completed);
expect(states['c']!.since, isNull);
});
});
group('fetchDayActivityData', () {
// Target day: 2026-09-27 Manila.
// dayStart (Manila midnight) = 2026-09-26T16:00:00Z UTC.
// dayEnd = 2026-09-27T16:00:00Z UTC.
final day = DateTime.utc(2026, 9, 27);
final now = DateTime.utc(2026, 9, 27, 12, 0);
test('includes in-progress tasks and tasks completed that day', () async {
final tActive = _task(
id: 'active',
status: ProgrammerTaskStatus.inProgress,
startedAt: DateTime.utc(2026, 9, 27, 0, 0),
);
// Completed at 11:00 UTC on the 27th — still within the Manila day
final tDone = _task(
id: 'done',
status: ProgrammerTaskStatus.completed,
startedAt: DateTime.utc(2026, 9, 27, 0, 0),
completedAt: DateTime.utc(2026, 9, 27, 3, 0),
);
final client = _FakeClient(tables: {
'programmer_task_work_logs': [],
'programmer_task_activity_logs': [
{
'id': 'log-1',
'task_id': 'active',
'action_type': 'started',
'created_at': '2026-09-27T00:00:00Z',
},
],
});
final result = await fetchDayActivityData(
client,
day: day,
tasks: [tActive, tDone],
now: now,
);
expect(result.tasks.map((t) => t.id), containsAll(['active', 'done']));
});
test('excludes a task completed two days earlier', () async {
// Completed well before the Manila day boundary (2026-09-26T16:00Z).
final tOld = _task(
id: 'old',
status: ProgrammerTaskStatus.completed,
startedAt: DateTime.utc(2026, 9, 24, 8, 0),
completedAt: DateTime.utc(2026, 9, 25, 10, 0),
);
final client = _FakeClient(tables: {
'programmer_task_work_logs': [],
'programmer_task_activity_logs': [],
});
final result = await fetchDayActivityData(
client,
day: day,
tasks: [tOld],
now: now,
);
expect(result.tasks, isEmpty);
});
test('includes a task referenced only by a helper work log that day',
() async {
// Task was completed long before; not active on 2026-09-27 by dates alone.
final tHelper = _task(
id: 'helper-task',
status: ProgrammerTaskStatus.completed,
startedAt: DateTime.utc(2026, 9, 1, 8, 0),
completedAt: DateTime.utc(2026, 9, 1, 17, 0),
);
final client = _FakeClient(tables: {
'programmer_task_work_logs': [
{
'id': 'wl-1',
'task_id': 'helper-task',
'author_id': 'u2',
'description': 'helped out',
'minutes': 30,
'created_at': '2026-09-27T01:00:00Z',
'work_date': '2026-09-27',
},
],
'programmer_task_activity_logs': [],
});
final result = await fetchDayActivityData(
client,
day: day,
tasks: [tHelper],
now: now,
);
expect(result.tasks.map((t) => t.id), contains('helper-task'));
expect(result.workLogs, hasLength(1));
expect(result.workLogs.first.taskId, 'helper-task');
});
test('excludes work logs with a different work_date', () async {
final tTask = _task(
id: 'some-task',
status: ProgrammerTaskStatus.inProgress,
startedAt: DateTime.utc(2026, 9, 27, 0, 0),
);
final client = _FakeClient(tables: {
'programmer_task_work_logs': [
{
'id': 'wl-old',
'task_id': 'some-task',
'author_id': 'u1',
'description': 'yesterday',
'minutes': 60,
'created_at': '2026-09-26T10:00:00Z',
'work_date': '2026-09-26',
},
],
'programmer_task_activity_logs': [],
});
final result = await fetchDayActivityData(
client,
day: day,
tasks: [tTask],
now: now,
);
expect(result.workLogs, isEmpty);
});
});
}
+405
View File
@@ -0,0 +1,405 @@
import 'package:flutter_test/flutter_test.dart';
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/utils/app_time.dart';
import 'package:tasq/utils/programmer_day_rows.dart';
import 'package:tasq/widgets/quill_field.dart';
void main() {
setUpAll(AppTime.initialize);
group('deltaJsonToPlainText', () {
test('legacy plain text notes pass through', () {
expect(deltaJsonToPlainText('fixed bug'), 'fixed bug');
expect(deltaJsonToPlainText(' spaces '), 'spaces');
expect(deltaJsonToPlainText(''), '');
});
test('null returns empty string', () {
expect(deltaJsonToPlainText(null), '');
});
test('invalid JSON falls back to returning trimmed string', () {
expect(deltaJsonToPlainText('[{invalid json'), '[{invalid json');
});
test('Delta JSON with insert is converted to plain text', () {
expect(deltaJsonToPlainText('[{"insert":"Hello\\n"}]'), 'Hello');
});
test('Delta JSON arrays starting with [ are attempted to be parsed', () {
// The function attempts to parse JSON that starts with '['
// If parsing fails, it returns the trimmed string
// This test verifies the fallback behavior
expect(deltaJsonToPlainText('[{"invalid":"json"}]'), '[{"invalid":"json"}]');
});
});
group('DaySheetRow', () {
test('json round-trip', () {
final row = DaySheetRow(
taskId: 't1',
title: 'Review PR',
category: 'Software Support',
kind: 'assignee',
projectName: 'Project A',
helpedName: null,
seconds: 3600,
notes: ['First note', 'Second note'],
);
expect(DaySheetRow.fromJson(row.toJson()).toJson(), row.toJson());
});
test('helper row json round-trip', () {
final row = DaySheetRow(
taskId: 't2',
title: 'Debug issue',
category: 'Bug Fix',
kind: 'helper',
projectName: null,
helpedName: 'Ana Cruz',
seconds: 2700,
notes: ['Helped with testing'],
);
expect(DaySheetRow.fromJson(row.toJson()).toJson(), row.toJson());
});
});
group('ProgrammerTaskWorkLog.workDate', () {
test('workDate defaults to Manila day of createdAt', () {
final base = {
'id': 'l1',
'task_id': 't1',
'author_id': 'a',
'description': 'work',
'minutes': null,
'created_at': '2026-09-26T16:30:00Z',
};
final log = ProgrammerTaskWorkLog.fromMap(base);
// 2026-09-26 16:30 UTC is 2026-09-27 in Manila (UTC+8)
expect(log.workDate, DateTime.utc(2026, 9, 27));
});
test('workDate from map when provided', () {
final base = {
'id': 'l1',
'task_id': 't1',
'author_id': 'a',
'description': 'work',
'minutes': null,
'created_at': '2026-09-26T16:30:00Z',
'work_date': '2026-09-25',
};
final log = ProgrammerTaskWorkLog.fromMap(base);
expect(log.workDate, DateTime.utc(2026, 9, 25));
});
test('workDate constructor parameter', () {
final log = ProgrammerTaskWorkLog(
id: 'l1',
taskId: 't1',
authorId: 'a',
description: 'work',
createdAt: DateTime.utc(2026, 9, 26, 16, 30),
workDate: DateTime.utc(2026, 9, 25),
);
expect(log.workDate, DateTime.utc(2026, 9, 25));
});
});
group('buildDayRows', () {
final day = DateTime.utc(2026, 9, 27);
final earlierDay = DateTime.utc(2026, 9, 26);
final now = DateTime.utc(2026, 9, 27, 12, 0);
ProgrammerTask _task(String id, String assigneeId, [String? projectId]) =>
ProgrammerTask(
id: id,
title: 'Task $id',
category: 'Software Support',
status: 'in_progress',
assigneeId: assigneeId,
creatorId: 'creator',
projectId: projectId,
createdAt: DateTime.utc(2026, 9, 1),
updatedAt: DateTime.utc(2026, 9, 27),
);
ProgrammerTaskActivityLog _log(String taskId, String type,
[Map<String, dynamic>? meta]) =>
ProgrammerTaskActivityLog(
id: '$taskId-$type',
taskId: taskId,
actionType: type,
meta: meta,
createdAt: DateTime.utc(2026, 9, 27, 10),
);
ProgrammerTaskWorkLog _workLog(
String taskId,
String authorId,
String description, {
int? minutes,
DateTime? workDate,
}) =>
ProgrammerTaskWorkLog(
id: '$taskId-$authorId-${minutes ?? "notes"}',
taskId: taskId,
authorId: authorId,
description: description,
minutes: minutes,
createdAt: DateTime.utc(2026, 9, 27, 10),
workDate: workDate,
);
test('assignee row includes only that-day seconds and notes', () {
// Task t1 assigned to 'a'
// 'a' worked 1 hour on t1 that day
// 'a' also logged a note on t1 that day
// 'a' logged a note on t1 the day before (should be excluded)
final task = _task('t1', 'a');
final workLogs = [
_workLog('t1', 'a', 'Fixed part 1', workDate: day),
_workLog('t1', 'a', 'Fixed part 2', workDate: earlierDay),
];
final rows = buildDayRows(
userId: 'a',
day: day,
tasks: [task],
logsByTask: {
't1': [
ProgrammerTaskActivityLog(
id: 't1-started',
taskId: 't1',
actionType: 'started',
createdAt: DateTime.utc(2026, 9, 27, 8, 0),
),
ProgrammerTaskActivityLog(
id: 't1-paused',
taskId: 't1',
actionType: 'paused',
createdAt: DateTime.utc(2026, 9, 27, 9, 0),
),
],
},
workLogs: workLogs,
projectNames: {},
profileNames: {'a': 'Alice Smith'},
now: now,
);
expect(rows.length, 1);
expect(rows[0].kind, 'assignee');
expect(rows[0].notes, ['Fixed part 1']);
});
test('helper row: minutes*60 and helpedName', () {
// Task t1 assigned to 'a'
// 'h' (helper) logs 45 minutes of help on t1 that day
final task = _task('t1', 'a');
final workLogs = [
_workLog('t1', 'h', 'Helped with debugging', minutes: 45, workDate: day),
];
final rows = buildDayRows(
userId: 'h',
day: day,
tasks: [task],
logsByTask: {'t1': []},
workLogs: workLogs,
projectNames: {},
profileNames: {'a': 'Ana Cruz'},
now: now,
);
expect(rows.length, 1);
expect(rows[0].kind, 'helper');
expect(rows[0].seconds, 2700);
expect(rows[0].helpedName, 'Ana Cruz');
});
test('notes-only row when assignee logged but ran no timer', () {
// Task t1 assigned to 'a'
// 'a' only logged notes (no timer activity)
final task = _task('t1', 'a');
final workLogs = [
_workLog('t1', 'a', 'Just some notes', workDate: day),
];
final rows = buildDayRows(
userId: 'a',
day: day,
tasks: [task],
logsByTask: {'t1': []},
workLogs: workLogs,
projectNames: {},
profileNames: {'a': 'Alice Smith'},
now: now,
);
expect(rows.length, 1);
expect(rows[0].kind, 'assignee');
expect(rows[0].seconds, 0);
expect(rows[0].notes, ['Just some notes']);
});
test('rows sort: assignee by seconds desc, then helpers by seconds desc', () {
// Task t1 assigned to 'a': 1 hour
// Task t2 assigned to 'a': 30 minutes
// Task t3 assigned to 'b': with 'a' as helper (20 minutes)
// Task t4 assigned to 'b': with 'a' as helper (10 minutes)
final tasks = [
_task('t1', 'a'),
_task('t2', 'a'),
_task('t3', 'b'),
_task('t4', 'b'),
];
final workLogs = [
_workLog('t3', 'a', 'helped b', minutes: 20, workDate: day),
_workLog('t4', 'a', 'helped b', minutes: 10, workDate: day),
];
final rows = buildDayRows(
userId: 'a',
day: day,
tasks: tasks,
logsByTask: {
't1': [
ProgrammerTaskActivityLog(
id: 't1-started',
taskId: 't1',
actionType: 'started',
createdAt: DateTime.utc(2026, 9, 27, 8, 0),
),
ProgrammerTaskActivityLog(
id: 't1-paused',
taskId: 't1',
actionType: 'paused',
createdAt: DateTime.utc(2026, 9, 27, 9, 0),
),
],
't2': [
ProgrammerTaskActivityLog(
id: 't2-started',
taskId: 't2',
actionType: 'started',
createdAt: DateTime.utc(2026, 9, 27, 9, 0),
),
ProgrammerTaskActivityLog(
id: 't2-paused',
taskId: 't2',
actionType: 'paused',
createdAt: DateTime.utc(2026, 9, 27, 9, 30),
),
],
't3': [],
't4': [],
},
workLogs: workLogs,
projectNames: {},
profileNames: {'a': 'Alice Smith', 'b': 'Bob Jones'},
now: now,
);
// Expect: assignee t1 (3600), assignee t2 (1800), helper t3 (1200), helper t4 (600)
expect(rows.length, 4);
expect(rows[0].kind, 'assignee');
expect(rows[0].taskId, 't1');
expect(rows[0].seconds, 3600);
expect(rows[1].kind, 'assignee');
expect(rows[1].taskId, 't2');
expect(rows[1].seconds, 1800);
expect(rows[2].kind, 'helper');
expect(rows[2].taskId, 't3');
expect(rows[2].seconds, 1200);
expect(rows[2].helpedName, 'Bob Jones');
expect(rows[3].kind, 'helper');
expect(rows[3].taskId, 't4');
expect(rows[3].seconds, 600);
expect(rows[3].helpedName, 'Bob Jones');
});
test('tasks missing from task list are skipped', () {
// Work logs reference t1, but t1 is not in tasks list
final workLogs = [
_workLog('t1', 'a', 'work', workDate: day),
];
final rows = buildDayRows(
userId: 'a',
day: day,
tasks: [],
logsByTask: {},
workLogs: workLogs,
projectNames: {},
profileNames: {},
now: now,
);
expect(rows, isEmpty);
});
test('helper logs with minutes=0 are ignored', () {
final task = _task('t1', 'a');
final workLogs = [
_workLog('t1', 'h', 'zero minutes', minutes: 0, workDate: day),
];
final rows = buildDayRows(
userId: 'h',
day: day,
tasks: [task],
logsByTask: {'t1': []},
workLogs: workLogs,
projectNames: {},
profileNames: {'a': 'Ana Cruz'},
now: now,
);
expect(rows, isEmpty);
});
test('empty notes are dropped from output', () {
final task = _task('t1', 'a');
final workLogs = [
_workLog('t1', 'a', ' ', workDate: day), // whitespace-only
_workLog('t1', 'a', 'Real note', workDate: day),
_workLog('t1', 'a', '', workDate: day), // empty
];
final rows = buildDayRows(
userId: 'a',
day: day,
tasks: [task],
logsByTask: {'t1': []},
workLogs: workLogs,
projectNames: {},
profileNames: {},
now: now,
);
expect(rows.length, 1);
expect(rows[0].notes, ['Real note']);
});
});
group('totalSeconds', () {
test('sums all row seconds', () {
final rows = [
DaySheetRow(
taskId: 't1',
title: 'Task 1',
category: 'Work',
kind: 'assignee',
seconds: 3600,
notes: [],
),
DaySheetRow(
taskId: 't2',
title: 'Task 2',
category: 'Work',
kind: 'helper',
seconds: 1800,
notes: [],
helpedName: 'Someone',
),
];
expect(totalSeconds(rows), 5400);
});
test('empty list returns 0', () {
expect(totalSeconds([]), 0);
});
});
}
@@ -0,0 +1,318 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tasq/models/programmer_day_sheet.model.dart';
import 'package:tasq/models/programmer_day_sheet_event.model.dart';
import 'package:tasq/providers/programmer_day_sheets_provider.dart';
// ---------------------------------------------------------------------------
// Fake push recorder
// ---------------------------------------------------------------------------
class _PushCall {
final List<String> userIds;
final String title;
final String body;
final Map<String, dynamic>? data;
_PushCall({
required this.userIds,
required this.title,
required this.body,
this.data,
});
}
class _RecordingPush {
final calls = <_PushCall>[];
Future<void> call({
required List<String> userIds,
required String title,
required String body,
Map<String, dynamic>? data,
}) async {
calls.add(_PushCall(userIds: userIds, title: title, body: body, data: data));
}
}
// ---------------------------------------------------------------------------
// Fake Supabase-like client — only exposes rpc()
// ---------------------------------------------------------------------------
class _RpcCall {
final String name;
final Map<String, dynamic>? params;
_RpcCall(this.name, this.params);
}
class _FakeClient {
final calls = <_RpcCall>[];
Map<String, dynamic>? _nextResult;
Object? _nextError;
void setResult(Map<String, dynamic> result) => _nextResult = result;
void setError(Object error) => _nextError = error;
Future<dynamic> rpc(String name, {Map<String, dynamic>? params}) async {
calls.add(_RpcCall(name, params));
if (_nextError != null) {
final err = _nextError!;
_nextError = null;
throw err;
}
final result = _nextResult;
_nextResult = null;
return result;
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
group('DaySheetsController', () {
late _FakeClient fake;
late _RecordingPush push;
late DaySheetsController controller;
setUp(() {
fake = _FakeClient();
push = _RecordingPush();
controller = DaySheetsController(fake, push: push.call);
});
test('submit calls submit_day_sheet with p_date and pushes admins', () async {
fake.setResult({
'sheet_id': 's1',
'work_date': '2026-09-28',
'programmer_name': 'Paola Ross',
'notify_user_ids': ['adm'],
});
final sheetId = await controller.submit(DateTime.utc(2026, 9, 28));
expect(sheetId, 's1');
expect(fake.calls.single.name, 'submit_day_sheet');
expect(fake.calls.single.params, {'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', () async {
await expectLater(
controller.disapprove(
sheetId: 'sh1',
remarks: ' ',
flaggedTaskIds: [],
),
throwsA(
isA<DaySheetActionException>().having(
(e) => e.code,
'code',
DaySheetError.remarksRequired,
),
),
);
expect(fake.calls, isEmpty);
});
test('already_reviewed maps to a friendly message', () async {
fake.setError(Exception('already_reviewed'));
DaySheetActionException? caught;
try {
await controller.submit(DateTime.utc(2026, 9, 28));
} on DaySheetActionException catch (e) {
caught = e;
}
expect(caught, isNotNull);
expect(caught!.code, DaySheetError.alreadyReviewed);
expect(caught.message, 'This sheet was already reviewed.');
});
test('approve reports approved and skipped ids and pushes per approved sheet',
() async {
fake.setResult({
'approved': [
{
'sheet_id': 'sh1',
'work_date': '2026-09-28',
'programmer_id': 'prog1',
},
{
'sheet_id': 'sh2',
'work_date': '2026-09-27',
'programmer_id': 'prog2',
},
],
'skipped_ids': ['sh3'],
});
final snapshot = DaySheetSnapshot(totalSeconds: 3600, rows: []);
final items = [
(sheetId: 'sh1', snapshot: snapshot),
(sheetId: 'sh2', snapshot: snapshot),
(sheetId: 'sh3', snapshot: snapshot),
];
final result = await controller.approve(items);
expect(result.approvedIds, ['sh1', 'sh2']);
expect(result.skippedIds, ['sh3']);
expect(push.calls.length, 2);
expect(push.calls[0].title, 'Day sheet approved');
expect(
push.calls[0].body,
'Your day sheet for Sep 28, 2026 was approved',
);
expect(
push.calls[1].body,
'Your day sheet for Sep 27, 2026 was approved',
);
});
test('a failing push does not fail the action', () async {
fake.setResult({
'sheet_id': 's1',
'work_date': '2026-09-28',
'programmer_name': 'Paola Ross',
'notify_user_ids': ['adm'],
});
final throwingController = DaySheetsController(
fake,
push: ({
required List<String> userIds,
required String title,
required String body,
Map<String, dynamic>? data,
}) async {
throw Exception('push failed');
},
);
// Should succeed even though push throws.
final sheetId = await throwingController.submit(DateTime.utc(2026, 9, 28));
expect(sheetId, 's1');
});
});
// -------------------------------------------------------------------------
group('mapDaySheetError', () {
test('maps known substrings to the correct error code', () {
expect(
mapDaySheetError(Exception('not_authorized')),
DaySheetError.notAuthorized,
);
expect(
mapDaySheetError(Exception('already_reviewed')),
DaySheetError.alreadyReviewed,
);
expect(
mapDaySheetError(Exception('already_submitted')),
DaySheetError.alreadySubmitted,
);
expect(
mapDaySheetError(Exception('remarks_required')),
DaySheetError.remarksRequired,
);
expect(
mapDaySheetError(Exception('body_required')),
DaySheetError.bodyRequired,
);
expect(
mapDaySheetError(Exception('work_date_locked')),
DaySheetError.workDateLocked,
);
expect(
mapDaySheetError(Exception('invalid_snapshot')),
DaySheetError.invalidSnapshot,
);
expect(
mapDaySheetError(Exception('something else entirely')),
DaySheetError.unknown,
);
});
});
// -------------------------------------------------------------------------
group('DaySheetActionException.message', () {
test('returns exact strings for every error code', () {
expect(
DaySheetActionException(DaySheetError.alreadyReviewed).message,
'This sheet was already reviewed.',
);
expect(
DaySheetActionException(DaySheetError.alreadySubmitted).message,
'This day sheet was already submitted.',
);
expect(
DaySheetActionException(DaySheetError.notAuthorized).message,
'You are not allowed to do that.',
);
expect(
DaySheetActionException(DaySheetError.remarksRequired).message,
'Remarks are required to disapprove.',
);
expect(
DaySheetActionException(DaySheetError.bodyRequired).message,
'Please write a justification.',
);
expect(
DaySheetActionException(DaySheetError.workDateLocked).message,
'That day is locked — you can only add entries to pending or disapproved days.',
);
expect(
DaySheetActionException(DaySheetError.invalidSnapshot).message,
'Could not approve: the sheet data was invalid.',
);
expect(
DaySheetActionException(DaySheetError.unknown).message,
'Something went wrong. Please try again.',
);
});
});
// -------------------------------------------------------------------------
group('ProgrammerDaySheetEvent', () {
test('fromMap parses all fields correctly', () {
final event = ProgrammerDaySheetEvent.fromMap({
'id': 'ev1',
'sheet_id': 'sh1',
'kind': 'submitted',
'actor_id': 'u1',
'body': 'some text',
'flagged_task_ids': ['t1', 't2'],
'created_at': '2026-09-28T10:00:00.000Z',
});
expect(event.id, 'ev1');
expect(event.sheetId, 'sh1');
expect(event.kind, 'submitted');
expect(event.actorId, 'u1');
expect(event.body, 'some text');
expect(event.flaggedTaskIds, ['t1', 't2']);
expect(event.createdAt, DateTime.utc(2026, 9, 28, 10, 0, 0));
});
test('fromMap handles null optional fields', () {
final event = ProgrammerDaySheetEvent.fromMap({
'id': 'ev2',
'sheet_id': 'sh2',
'kind': 'approved',
'created_at': '2026-09-28T10:00:00.000Z',
});
expect(event.actorId, isNull);
expect(event.body, isNull);
expect(event.flaggedTaskIds, isEmpty);
});
});
}
+128
View File
@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tasq/models/profile.model.dart';
import 'package:tasq/models/programmer_day_sheet.model.dart';
import 'package:tasq/providers/leave_provider.dart';
import 'package:tasq/providers/programmer_day_data_provider.dart';
import 'package:tasq/providers/programmer_day_sheets_provider.dart';
import 'package:tasq/providers/programmer_tasks_provider.dart';
import 'package:tasq/providers/profile_provider.dart';
import 'package:tasq/screens/programmer_tasks/programmer_drill_down_screen.dart';
import 'package:tasq/theme/app_theme.dart';
import 'package:tasq/utils/app_time.dart';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
ProgrammerDaySheet _sheet({
required String id,
required String status,
required DateTime workDate,
}) {
final now = AppTime.now();
return ProgrammerDaySheet(
id: id,
programmerId: 'u1',
status: status,
workDate: workDate,
autoSubmitted: false,
resubmissions: 0,
createdAt: now,
updatedAt: now,
);
}
Widget _buildApp(String userId, {List<Override> overrides = const []}) {
return ProviderScope(
overrides: overrides,
child: MaterialApp(
theme: AppTheme.light(),
home: ProgrammerDrillDownScreen(userId: userId),
),
);
}
List<Override> _minimalOverrides({
List<ProgrammerDaySheet> sheets = const [],
}) {
final today = DateTime.utc(2026, 9, 28);
return [
isStrictAdminProvider.overrideWith((ref) => true),
profilesProvider.overrideWith((ref) => Stream.value([])),
currentProfileProvider.overrideWith((ref) => Stream.value(null)),
programmerTasksProvider.overrideWith((ref) => Stream.value([])),
programmerRunStatesProvider.overrideWith((ref) => const {}),
clockTickerProvider.overrideWith((ref) => Stream.value(today)),
todayKeyProvider.overrideWith((ref) => today),
leavesProvider.overrideWith((ref) => Stream.value([])),
programmerDayDataProvider.overrideWith(
(ref, day) => Future.value(
DayActivityData(day: day, tasks: [], logsByTask: {}, workLogs: []),
),
),
daySheetsForUserProvider.overrideWith((ref, userId) => Stream.value(sheets)),
];
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
setUpAll(AppTime.initialize);
group('ProgrammerDrillDownScreen — recent day sheets', () {
testWidgets('shows Recent day sheets header when there are sheets',
(tester) async {
final sheets = [
_sheet(
id: 's1',
status: DaySheetStatus.pending,
workDate: DateTime.utc(2026, 9, 27),
),
_sheet(
id: 's2',
status: DaySheetStatus.approved,
workDate: DateTime.utc(2026, 9, 26),
),
];
await tester.pumpWidget(
_buildApp('u1', overrides: _minimalOverrides(sheets: sheets)),
);
await tester.pump();
// AppSectionHeader renders title in uppercase
expect(find.text('RECENT DAY SHEETS'), findsOneWidget);
});
testWidgets('shows both sheet dates', (tester) async {
final d1 = DateTime.utc(2026, 9, 27);
final d2 = DateTime.utc(2026, 9, 26);
final sheets = [
_sheet(id: 's1', status: DaySheetStatus.pending, workDate: d1),
_sheet(id: 's2', status: DaySheetStatus.approved, workDate: d2),
];
await tester.pumpWidget(
_buildApp('u1', overrides: _minimalOverrides(sheets: sheets)),
);
await tester.pump();
expect(find.text(AppTime.formatDate(d1)), findsOneWidget);
expect(find.text(AppTime.formatDate(d2)), findsOneWidget);
});
testWidgets('shows RECENT DAY SHEETS even with empty sheets list',
(tester) async {
await tester.pumpWidget(
_buildApp('u1', overrides: _minimalOverrides()),
);
await tester.pump();
expect(find.text('RECENT DAY SHEETS'), findsOneWidget);
});
});
}
+786
View File
@@ -0,0 +1,786 @@
import 'package:flutter_test/flutter_test.dart';
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_activity_log.model.dart';
import 'package:tasq/models/programmer_task_work_log.model.dart';
import 'package:tasq/providers/programmer_day_data_provider.dart';
import 'package:tasq/screens/programmer_tasks/monitor/monitor_model.dart';
import 'package:tasq/utils/app_time.dart';
import 'package:tasq/utils/staff_presence.dart';
void main() {
setUpAll(AppTime.initialize);
group('HelpEdge', () {
test('creates with all fields', () {
final edge = HelpEdge(
helperId: 'h1',
assigneeId: 'a1',
taskId: 't1',
taskTitle: 'Fix bug',
minutes: 30,
at: DateTime.utc(2026, 9, 27),
);
expect(edge.helperId, 'h1');
expect(edge.assigneeId, 'a1');
expect(edge.taskId, 't1');
expect(edge.taskTitle, 'Fix bug');
expect(edge.minutes, 30);
expect(edge.at, DateTime.utc(2026, 9, 27));
});
});
group('buildHelpEdges', () {
test('no edges when no work logs', () {
final edges = buildHelpEdges(
workLogs: [],
taskById: {},
day: DateTime.utc(2026, 9, 27),
);
expect(edges, isEmpty);
});
test('skips when author == assignee (not helping)', () {
final task = ProgrammerTask(
id: 't1',
title: 'Task',
category: 'Bug Fix',
status: 'in_progress',
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
assigneeId: 'a1',
);
final log = ProgrammerTaskWorkLog(
id: 'l1',
taskId: 't1',
authorId: 'a1', // Same as assignee
description: 'work',
minutes: 30,
createdAt: DateTime.utc(2026, 9, 27, 10, 0),
workDate: DateTime.utc(2026, 9, 27),
);
final edges = buildHelpEdges(
workLogs: [log],
taskById: {'t1': task},
day: DateTime.utc(2026, 9, 27),
);
expect(edges, isEmpty);
});
test('skips when minutes is null', () {
final task = ProgrammerTask(
id: 't1',
title: 'Task',
category: 'Bug Fix',
status: 'in_progress',
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
assigneeId: 'a1',
);
final log = ProgrammerTaskWorkLog(
id: 'l1',
taskId: 't1',
authorId: 'h1',
description: 'work',
minutes: null,
createdAt: DateTime.utc(2026, 9, 27, 10, 0),
workDate: DateTime.utc(2026, 9, 27),
);
final edges = buildHelpEdges(
workLogs: [log],
taskById: {'t1': task},
day: DateTime.utc(2026, 9, 27),
);
expect(edges, isEmpty);
});
test('skips when minutes is 0', () {
final task = ProgrammerTask(
id: 't1',
title: 'Task',
category: 'Bug Fix',
status: 'in_progress',
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
assigneeId: 'a1',
);
final log = ProgrammerTaskWorkLog(
id: 'l1',
taskId: 't1',
authorId: 'h1',
description: 'work',
minutes: 0,
createdAt: DateTime.utc(2026, 9, 27, 10, 0),
workDate: DateTime.utc(2026, 9, 27),
);
final edges = buildHelpEdges(
workLogs: [log],
taskById: {'t1': task},
day: DateTime.utc(2026, 9, 27),
);
expect(edges, isEmpty);
});
test('skips when workDate != day', () {
final task = ProgrammerTask(
id: 't1',
title: 'Task',
category: 'Bug Fix',
status: 'in_progress',
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
assigneeId: 'a1',
);
final log = ProgrammerTaskWorkLog(
id: 'l1',
taskId: 't1',
authorId: 'h1',
description: 'work',
minutes: 30,
createdAt: DateTime.utc(2026, 9, 26, 10, 0),
workDate: DateTime.utc(2026, 9, 26),
);
final edges = buildHelpEdges(
workLogs: [log],
taskById: {'t1': task},
day: DateTime.utc(2026, 9, 27),
);
expect(edges, isEmpty);
});
test('creates edge when all conditions met', () {
final task = ProgrammerTask(
id: 't1',
title: 'Fix bug',
category: 'Bug Fix',
status: 'in_progress',
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
assigneeId: 'a1',
);
final log = ProgrammerTaskWorkLog(
id: 'l1',
taskId: 't1',
authorId: 'h1',
description: 'work',
minutes: 30,
createdAt: DateTime.utc(2026, 9, 27, 10, 0),
workDate: DateTime.utc(2026, 9, 27),
);
final edges = buildHelpEdges(
workLogs: [log],
taskById: {'t1': task},
day: DateTime.utc(2026, 9, 27),
);
expect(edges, hasLength(1));
expect(edges[0].helperId, 'h1');
expect(edges[0].assigneeId, 'a1');
expect(edges[0].taskId, 't1');
expect(edges[0].taskTitle, 'Fix bug');
expect(edges[0].minutes, 30);
});
test('sorts by createdAt newest first', () {
final task = ProgrammerTask(
id: 't1',
title: 'Fix bug',
category: 'Bug Fix',
status: 'in_progress',
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
assigneeId: 'a1',
);
final log1 = ProgrammerTaskWorkLog(
id: 'l1',
taskId: 't1',
authorId: 'h1',
description: 'work',
minutes: 30,
createdAt: DateTime.utc(2026, 9, 27, 10, 0),
workDate: DateTime.utc(2026, 9, 27),
);
final log2 = ProgrammerTaskWorkLog(
id: 'l2',
taskId: 't1',
authorId: 'h2',
description: 'work',
minutes: 20,
createdAt: DateTime.utc(2026, 9, 27, 11, 0),
workDate: DateTime.utc(2026, 9, 27),
);
final edges = buildHelpEdges(
workLogs: [log1, log2],
taskById: {'t1': task},
day: DateTime.utc(2026, 9, 27),
);
expect(edges, hasLength(2));
expect(edges[0].helperId, 'h2'); // Newest first
expect(edges[1].helperId, 'h1');
});
});
group('buildMonitorEntries', () {
final now = DateTime.utc(2026, 9, 27, 12, 0);
final today = DateTime.utc(2026, 9, 27);
test('programmer on leave -> onLeave state', () {
final programmer = Profile(
id: 'p1',
role: 'programmer',
fullName: 'Alice',
);
final leave = LeaveOfAbsence(
id: 'l1',
userId: 'p1',
leaveType: 'Sick Leave',
justification: 'ill',
startTime: DateTime.utc(2026, 9, 27, 8, 0),
endTime: DateTime.utc(2026, 9, 27, 17, 0),
status: 'approved',
filedBy: 'p1',
createdAt: DateTime.utc(2026, 9, 27),
);
final data = DayActivityData(
day: today,
tasks: [],
logsByTask: {},
workLogs: [],
);
final activeLeaves = activeLeaveByUser([leave], now);
final entries = buildMonitorEntries(
programmers: [programmer],
today: data,
runStates: {},
leaves: activeLeaves,
passSlips: {},
sheetStatusByUser: {},
projectNames: {},
profileNames: {},
now: now,
);
expect(entries, hasLength(1));
expect(entries[0].state, MonitorState.onLeave);
});
test('programmer with active pass slip -> passSlip state', () {
final programmer = Profile(
id: 'p1',
role: 'programmer',
fullName: 'Alice',
);
final slip = PassSlip(
id: 's1',
userId: 'p1',
dutyScheduleId: 'd1',
reason: 'out',
status: 'approved',
requestedAt: DateTime.utc(2026, 9, 27),
approvedBy: 'admin',
approvedAt: DateTime.utc(2026, 9, 27),
slipStart: DateTime.utc(2026, 9, 27, 13, 0),
slipEnd: null,
);
final data = DayActivityData(
day: today,
tasks: [],
logsByTask: {},
workLogs: [],
);
final activePassSlips = activePassSlipByUser([slip]);
final entries = buildMonitorEntries(
programmers: [programmer],
today: data,
runStates: {},
leaves: {},
passSlips: activePassSlips,
sheetStatusByUser: {},
projectNames: {},
profileNames: {},
now: now,
);
expect(entries, hasLength(1));
expect(entries[0].state, MonitorState.passSlip);
});
test('programmer with running task -> working state with currentTask', () {
final programmer = Profile(
id: 'p1',
role: 'programmer',
fullName: 'Alice',
);
final task = ProgrammerTask(
id: 't1',
title: 'Fix bug',
category: 'Bug Fix',
status: 'in_progress',
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
assigneeId: 'p1',
);
final data = DayActivityData(
day: today,
tasks: [task],
logsByTask: {},
workLogs: [],
);
final runStates = {
't1': (state: ProgrammerRunState.running, since: DateTime.utc(2026, 9, 27, 10, 0)),
};
final entries = buildMonitorEntries(
programmers: [programmer],
today: data,
runStates: runStates,
leaves: {},
passSlips: {},
sheetStatusByUser: {},
projectNames: {},
profileNames: {},
now: now,
);
expect(entries, hasLength(1));
expect(entries[0].state, MonitorState.working);
expect(entries[0].currentTask, task);
});
test('programmer with only paused task -> paused state', () {
final programmer = Profile(
id: 'p1',
role: 'programmer',
fullName: 'Alice',
);
final task = ProgrammerTask(
id: 't1',
title: 'Fix bug',
category: 'Bug Fix',
status: 'in_progress',
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
assigneeId: 'p1',
);
final data = DayActivityData(
day: today,
tasks: [task],
logsByTask: {},
workLogs: [],
);
final runStates = {
't1': (state: ProgrammerRunState.paused, since: DateTime.utc(2026, 9, 27, 10, 0)),
};
final entries = buildMonitorEntries(
programmers: [programmer],
today: data,
runStates: runStates,
leaves: {},
passSlips: {},
sheetStatusByUser: {},
projectNames: {},
profileNames: {},
now: now,
);
expect(entries, hasLength(1));
expect(entries[0].state, MonitorState.paused);
});
test('programmer with no running tasks -> idle state', () {
final programmer = Profile(
id: 'p1',
role: 'programmer',
fullName: 'Alice',
);
final data = DayActivityData(
day: today,
tasks: [],
logsByTask: {},
workLogs: [],
);
final entries = buildMonitorEntries(
programmers: [programmer],
today: data,
runStates: {},
leaves: {},
passSlips: {},
sheetStatusByUser: {},
projectNames: {},
profileNames: {},
now: now,
);
expect(entries, hasLength(1));
expect(entries[0].state, MonitorState.idle);
expect(entries[0].currentTask, isNull);
});
test('leave overrides running task (onLeave state)', () {
final programmer = Profile(
id: 'p1',
role: 'programmer',
fullName: 'Alice',
);
final task = ProgrammerTask(
id: 't1',
title: 'Fix bug',
category: 'Bug Fix',
status: 'in_progress',
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
assigneeId: 'p1',
);
final leave = LeaveOfAbsence(
id: 'l1',
userId: 'p1',
leaveType: 'Sick Leave',
justification: 'ill',
startTime: DateTime.utc(2026, 9, 27, 8, 0),
endTime: DateTime.utc(2026, 9, 27, 17, 0),
status: 'approved',
filedBy: 'p1',
createdAt: DateTime.utc(2026, 9, 27),
);
final data = DayActivityData(
day: today,
tasks: [task],
logsByTask: {},
workLogs: [],
);
final runStates = {
't1': (state: ProgrammerRunState.running, since: DateTime.utc(2026, 9, 27, 10, 0)),
};
final activeLeaves = activeLeaveByUser([leave], now);
final entries = buildMonitorEntries(
programmers: [programmer],
today: data,
runStates: runStates,
leaves: activeLeaves,
passSlips: {},
sheetStatusByUser: {},
projectNames: {},
profileNames: {},
now: now,
);
expect(entries, hasLength(1));
expect(entries[0].state, MonitorState.onLeave);
});
test('sort order: working, paused, idle, passSlip, onLeave, then name', () {
final p1 = Profile(
id: 'p1',
role: 'programmer',
fullName: 'Zoe', // Should be last after sorting by state
);
final p2 = Profile(
id: 'p2',
role: 'programmer',
fullName: 'Alice',
);
final p3 = Profile(
id: 'p3',
role: 'programmer',
fullName: 'Bob',
);
final p4 = Profile(
id: 'p4',
role: 'programmer',
fullName: 'Charlie',
);
final p5 = Profile(
id: 'p5',
role: 'programmer',
fullName: 'Diana',
);
// p1: working
// p2: paused
// p3: idle
// p4: passSlip
// p5: onLeave
final task1 = ProgrammerTask(
id: 't1',
title: 'Task',
category: 'Bug Fix',
status: 'in_progress',
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
assigneeId: 'p1',
);
final task2 = ProgrammerTask(
id: 't2',
title: 'Task',
category: 'Bug Fix',
status: 'in_progress',
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
assigneeId: 'p2',
);
final slip = PassSlip(
id: 's1',
userId: 'p4',
dutyScheduleId: 'd1',
reason: 'out',
status: 'approved',
requestedAt: DateTime.utc(2026, 9, 27),
approvedBy: 'admin',
approvedAt: DateTime.utc(2026, 9, 27),
slipStart: DateTime.utc(2026, 9, 27, 13, 0),
slipEnd: null,
);
final leave = LeaveOfAbsence(
id: 'l1',
userId: 'p5',
leaveType: 'Sick Leave',
justification: 'ill',
startTime: DateTime.utc(2026, 9, 27, 8, 0),
endTime: DateTime.utc(2026, 9, 27, 17, 0),
status: 'approved',
filedBy: 'p5',
createdAt: DateTime.utc(2026, 9, 27),
);
final data = DayActivityData(
day: today,
tasks: [task1, task2],
logsByTask: {},
workLogs: [],
);
final runStates = {
't1': (state: ProgrammerRunState.running, since: DateTime.utc(2026, 9, 27, 10, 0)),
't2': (state: ProgrammerRunState.paused, since: DateTime.utc(2026, 9, 27, 11, 0)),
};
final activeLeaves = activeLeaveByUser([leave], now);
final activePassSlips = activePassSlipByUser([slip]);
final entries = buildMonitorEntries(
programmers: [p1, p2, p3, p4, p5],
today: data,
runStates: runStates,
leaves: activeLeaves,
passSlips: activePassSlips,
sheetStatusByUser: {},
projectNames: {},
profileNames: {},
now: now,
);
expect(entries, hasLength(5));
expect(entries[0].programmer.id, 'p1'); // working
expect(entries[1].programmer.id, 'p2'); // paused
expect(entries[2].programmer.id, 'p3'); // idle
expect(entries[3].programmer.id, 'p4'); // passSlip
expect(entries[4].programmer.id, 'p5'); // onLeave
});
test('within same state, sort alphabetically by fullName', () {
final p1 = Profile(
id: 'p1',
role: 'programmer',
fullName: 'Zoe',
);
final p2 = Profile(
id: 'p2',
role: 'programmer',
fullName: 'Alice',
);
final p3 = Profile(
id: 'p3',
role: 'programmer',
fullName: 'Bob',
);
// All idle
final data = DayActivityData(
day: today,
tasks: [],
logsByTask: {},
workLogs: [],
);
final entries = buildMonitorEntries(
programmers: [p1, p2, p3],
today: data,
runStates: {},
leaves: {},
passSlips: {},
sheetStatusByUser: {},
projectNames: {},
profileNames: {},
now: now,
);
expect(entries, hasLength(3));
expect(entries[0].programmer.fullName, 'Alice');
expect(entries[1].programmer.fullName, 'Bob');
expect(entries[2].programmer.fullName, 'Zoe');
});
test('counts inProgress and paused and notStarted tasks', () {
final programmer = Profile(
id: 'p1',
role: 'programmer',
fullName: 'Alice',
);
// 2 running, 1 paused, 1 queued
final t1 = ProgrammerTask(
id: 't1',
title: 'Task 1',
category: 'Bug Fix',
status: 'in_progress',
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
assigneeId: 'p1',
);
final t2 = ProgrammerTask(
id: 't2',
title: 'Task 2',
category: 'Bug Fix',
status: 'in_progress',
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
assigneeId: 'p1',
);
final t3 = ProgrammerTask(
id: 't3',
title: 'Task 3',
category: 'Bug Fix',
status: 'in_progress',
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
assigneeId: 'p1',
);
final t4 = ProgrammerTask(
id: 't4',
title: 'Task 4',
category: 'Bug Fix',
status: 'queued',
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
assigneeId: 'p1',
);
final data = DayActivityData(
day: today,
tasks: [t1, t2, t3, t4],
logsByTask: {},
workLogs: [],
);
final runStates = {
't1': (state: ProgrammerRunState.running, since: DateTime.utc(2026, 9, 27, 10, 0)),
't2': (state: ProgrammerRunState.running, since: DateTime.utc(2026, 9, 27, 10, 0)),
't3': (state: ProgrammerRunState.paused, since: DateTime.utc(2026, 9, 27, 11, 0)),
't4': (state: ProgrammerRunState.notStarted, since: null),
};
final entries = buildMonitorEntries(
programmers: [programmer],
today: data,
runStates: runStates,
leaves: {},
passSlips: {},
sheetStatusByUser: {},
projectNames: {},
profileNames: {},
now: now,
);
expect(entries[0].inProgress, 2);
expect(entries[0].paused, 1);
expect(entries[0].notStarted, 1);
});
test('currentTask is oldest running task', () {
final programmer = Profile(
id: 'p1',
role: 'programmer',
fullName: 'Alice',
);
final t1 = ProgrammerTask(
id: 't1',
title: 'Task 1',
category: 'Bug Fix',
status: 'in_progress',
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
assigneeId: 'p1',
);
final t2 = ProgrammerTask(
id: 't2',
title: 'Task 2',
category: 'Bug Fix',
status: 'in_progress',
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
assigneeId: 'p1',
);
final data = DayActivityData(
day: today,
tasks: [t1, t2],
logsByTask: {},
workLogs: [],
);
final runStates = {
't1': (
state: ProgrammerRunState.running,
since: DateTime.utc(2026, 9, 27, 10, 0),
), // Earlier
't2': (
state: ProgrammerRunState.running,
since: DateTime.utc(2026, 9, 27, 11, 0),
), // Later
};
final entries = buildMonitorEntries(
programmers: [programmer],
today: data,
runStates: runStates,
leaves: {},
passSlips: {},
sheetStatusByUser: {},
projectNames: {},
profileNames: {},
now: now,
);
expect(entries[0].currentTask?.id, 't1'); // Oldest running
});
test('todayTotal equals totalSeconds for all programmer tasks', () {
// This test verifies that todayTotal matches the expected value
// For now, we just verify the structure is correct
final programmer = Profile(
id: 'p1',
role: 'programmer',
fullName: 'Alice',
);
final data = DayActivityData(
day: today,
tasks: [],
logsByTask: {},
workLogs: [],
);
final entries = buildMonitorEntries(
programmers: [programmer],
today: data,
runStates: {},
leaves: {},
passSlips: {},
sheetStatusByUser: {},
projectNames: {},
profileNames: {},
now: now,
);
expect(entries[0].todayTotal, isA<Duration>());
expect(entries[0].currentTaskToday, isA<Duration>());
});
});
}
+75
View File
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tasq/screens/programmer_tasks/dashboard/badges_strip.dart';
import 'package:tasq/screens/programmer_tasks/dashboard/stat_tiles.dart';
import 'package:tasq/utils/programmer_stats.dart';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
ProgrammerStats _makeStats({
int currentStreak = 0,
int bestStreak = 0,
int completedThisWeek = 0,
int completedLastWeek = 0,
double? firstPassRate,
}) {
return ProgrammerStats(
todayFocus: Duration.zero,
helpGivenThisMonth: Duration.zero,
approvedTotal: Duration.zero,
helpGivenTotal: Duration.zero,
completedThisWeek: completedThisWeek,
completedLastWeek: completedLastWeek,
currentStreak: currentStreak,
bestStreak: bestStreak,
completedTotal: 0,
bugFixCompleted: 0,
firstPassRate: firstPassRate,
hasCleanWeek: false,
);
}
Widget _wrap(Widget child) {
return MaterialApp(home: Scaffold(body: SingleChildScrollView(child: child)));
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
testWidgets('sheet stats hidden when showSheetStats is false', (tester) async {
final stats = _makeStats(currentStreak: 6, bestStreak: 9);
await tester.pumpWidget(_wrap(
StatTiles(stats: stats, showSheetStats: false),
));
expect(find.text('STREAK'), findsNothing);
});
testWidgets('streak tile shows days and best', (tester) async {
final stats = _makeStats(currentStreak: 6, bestStreak: 9);
await tester.pumpWidget(_wrap(
StatTiles(stats: stats, showSheetStats: true),
));
expect(find.text('6 days'), findsOneWidget);
expect(find.text('Best 9'), findsOneWidget);
});
testWidgets('locked badge shows progress', (tester) async {
const badge = BadgeProgress(
id: 'finisher',
title: 'Finisher',
description: 'Complete 10 tasks',
icon: Icons.task_alt,
current: 7,
target: 10,
earned: false,
);
await tester.pumpWidget(_wrap(
const BadgesStrip(badges: [badge]),
));
expect(find.text('7/10'), findsOneWidget);
});
}

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