Chrome: standard AppBar (title "Dashboard", elevation El-1), optional
subtitle line "Last updated Xs ago" when serving from cache; body scrolls
(CustomScrollView), SafeArea respected; horizontal padding 16 (phone),
24 (tablet+). No FAB except on (planned) customize entry (IconButton in
AppBar, shown only with dashboard.widget.manage — permissions.constants.ts:38).
Each KPI card binds to one leaf of the payload (dashboard.service.ts:50-70):
Card
Value
Source line
Format
Students
students.total
:31 (count active)
integer, NumberFormat
Teachers
teachers.total
:32
integer
Staff
staff.total
:33
integer
Attendance
attendance.rate
:57-64
% (server pre-rounds)
Dues
finance.totalDueAmount
:45-48,:68
currency, locale INR
Pending invoices
finance.pendingInvoices
:36-37,:67
integer badge on Dues card
Trend (Δ vs previous period) has no server field → render only when a
(planned) trend field ships; until then show neutral — and never fake a
delta. Trend style: success up, error down, neutral flat (00-shared/02).
loading (first load):AppSkeleton — KPI row as 5 square cards
(value line 28 dp, label line 12 dp, shimmer Mo.m-fast); charts as
AppSkeleton(chart) blocks. No partial numbers.
success: cards fade/slide in Mo-m-base staggered (30 ms apart);
charts animate draw Mo-m-base.
empty (fresh tenant): counts render as real 0 (the API returns
zeros — dashboard.service.ts:31-33,64), each zero card shows a muted
hint: "Invite your first student"; attendance card shows "No attendance
today" (rate 0 path :64); finance card "No pending invoices". A single
AppEmptyState hero ("Your dashboard is ready — add your first data") for
the all-zero case; never hide a 0 behind an error.
error (offline):AppOfflineBanner top + last-good payload rendered
with StaleBanner ("Showing data from X min ago"); retry button.
error (5xx): first-load → AppErrorState.code (INTERNAL) + Retry;
with previous data → inline AppSnackbar + keep old numbers, no flash.
stale: payload served from client cache older than the 60 s TTL
(Dashboard.md:41) but newer than 10 min → StaleBanner (info) with
"Refresh" action; older → treat as offline.
permission: no dashboard.read → route inaccessible (module hidden),
never an error screen (00-shared/06 §5).
Type: attendance trend line/bar; finance collection line. Render via shared
AppCharts (00-shared/03); no chart library decision is module-owned.
A11y chart summaries: every chart has semanticsLabel describing the
data ("Attendance 93% today, 812 of 874 present") + a bodySmall caption
text row (screen-reader-safe alternative, 00-shared/09).
Empty chart: dashed-line placeholder + "No data for this period".
PeriodSelector (planned): SegmentedButton Today/7d/30d; selection is
cubit state, triggers refetch with ?period=only when server supports
it (planned) — until then client-side slicing of the overview is
not possible (single today-only payload); selector stays disabled with
tooltip.