15 — Flutter Implementation Guide (Dashboard Module)
- 1. Feature folder
- 2. OverviewCubit skeleton
- 3. Repository + cache
- 4. Screen skeleton
- 5. WS wiring (realtime refresh)
- 6. Responsive layout
- 7. Charts
(planned) - 8. Tests (map to
14_QA_Checklist.md) - 9. Rollout order
Build guidance for the dashboard feature slice. App architecture per 00-shared/11; state per 13_State_Management.md; components per 07_Component_Library.md; API per 12_API_Mapping.md. Native mobile is excluded from Phase 1 of the PRD (web-first) — this guide targets the responsive web client and is
(forward-looking)by extension (01_Product_Overview.md §6).
1. Feature folder
lib/features/dashboard/
├── dashboard_page.dart # route scaffold + BlocProvider
├── cubit/
│ ├── overview_cubit.dart # §2
│ ├── overview_state.dart # 13_State_Management.md §1
│ └── period_cubit.dart # (planned) §5
├── widgets/
│ ├── kpi_row.dart # KpiRow
│ ├── kpi_card.dart # KpiCard
│ ├── chart_card.dart # ChartCard (planned blocks)
│ ├── stale_banner.dart # StaleBanner
│ └── widget_palette.dart # (planned) customize
├── data/
│ ├── overview_repository.dart # GET /dashboard/overview + cache
│ └── overview_model.dart # mirrors dashboard.service.ts:50-70
└── dashboard_wire.dart # DI wiring (lazy route factory)
2. OverviewCubit skeleton
class OverviewCubit extends Cubit<DashboardState> {
OverviewCubit(this._repo) : super(DashboardInitial());
final OverviewRepository _repo;
Timer? _poll;
bool _inflight = false;
bool _pending = false;
Future<void> refresh({bool force = false}) async {
if (_inflight) { _pending = true; return; } // single-flight (13 §3)
_inflight = true;
try {
final (data, age) = force
? (await _repo.fetchNetwork(), Duration.zero)
: await _repo.getOrCached(); // cache age<60s → hit
emit(DashboardLoaded(overview: data, fetchedAt: DateTime.now(),
fromCache: age > Duration.zero, cacheAge: age));
} on AppError catch (e) {
emit(DashboardError(error: e, lastGood: _lastGood()));
} finally {
_inflight = false;
if (_pending) { _pending = false; unawaited(refresh(force: force)); }
}
}
void startPolling() => _poll ??= Timer.periodic(60s, (_) => refresh());
// close() cancels _poll — timer dies with the route (13 §4)
}
3. Repository + cache
class OverviewRepository {
final ApiClient _api; // 00-shared/07 contract
final CacheService _cache; // shared cache layer
static const _key = 'sl:{tenant}:dashboard:overview';
static const _ttl = Duration(seconds: 60); // mirrors Dashboard.md:41
Future<(Overview, Duration)> getOrCached() async {
final cached = await _cache.get<Overview>(_key);
if (cached != null && _cache.age(_key) < _ttl) return (cached, _cache.age(_key));
return (await fetchNetwork(), Duration.zero);
}
Future<Overview> fetchNetwork() async {
final res = await _api.get('/dashboard/overview'); // E1
final data = Overview.fromJson(res['data']);
await _cache.set(_key, data, ttlSeconds: _ttl.inSeconds);
return data;
}
}
Client cache key mirrors the server namespace sl:{tenantId}:…
(redis-cache.service.ts:16-19); volatile, 200-only writes (13_State_Management.md §8).
4. Screen skeleton
class DashboardPage extends StatelessWidget {
@override
Widget build(BuildContext context) => BlocProvider(
create: (_) => OverviewCubit(context.read())..refresh(),
child: const DashboardView(),
);
}
class DashboardView extends StatefulWidget { /* WidgetsBindingObserver for
poll pause on background (13 §4) */ }
class DashboardViewState ... {
build → Scaffold(
appBar: AppBar(title: 'Dashboard', actions: [ /* customize IconButton,
visible only with dashboard.widget.manage — permissions.constants.ts:38 */ ]),
body: AppRefreshIndicator(
onRefresh: () => cubit.refresh(force: true), // §2 interaction spec
child: BlocBuilder<OverviewCubit, DashboardState>(
buildWhen: (p, c) => p.overview != c.overview || p.runtimeType != c.runtimeType,
builder: (context, state) => switch (state) {
DashboardLoading() => const _SkeletonGrid(), // AppSkeleton ×5
DashboardError(:final lastGood) when lastGood != null =>
_KeepLastGood(lastGood, banner: true), // 13 §9
DashboardError() => AppErrorState(...),
_ => _DashboardContent(state as DashboardLoaded),
},
),
),
);
}
5. WS wiring (realtime refresh)
// In the screen state (initState):
_socket.onEvent((event) {
const kpiEvents = {'AttendanceMarked', 'ResultPublished',
'PaymentRecorded', 'InvoiceGenerated'}; // Dashboard.md:34
if (!kpiEvents.contains(event.eventType)) return; // filter (13 §5)
_debounce ??= Timer(2s, () => cubit.refresh(force: true)); // debounce bursts
});
Client is auto-joined to tenant:{tenantId} on connect
(ws.gateway.ts:50); events arrive via the existing socket layer
(ws-bridge.service.ts:17-21). No new server channel needed today;
server-pushed KPI payloads (forward-looking).
6. Responsive layout
LayoutBuilder(builder: (context, c) {
if (c.maxWidth >= 840) return const _DesktopGrid(); // 3-col, charts side-by-side
return const _PhoneColumn(); // KPI row scroll + stacked charts
});
KPI row: horizontal ListView on phone, wrapped grid on tablet/desktop
(04_Information_Architecture.md §4). Dynamic type 200% → vertical fallback
(11_Design_System_Mapping.md §7).
7. Charts (planned)
- Use shared
AppCharts(00-shared/03) — never a module-owned chart lib. - Every chart wrapped in
ChartCardwith caption +semanticsLabel(06_Screen_Specifications.md§1.5). DisablePeriodSelectoruntil server period params exist (E2/E3(planned)).
8. Tests (map to 14_QA_Checklist.md)
| Test | What it guards |
|---|---|
overview_cubit_test.dart | single-flight coalescing; cache-hit skips network; force bypasses cache; 5xx keeps lastGood; error never writes cache |
overview_repository_test.dart | key/TTL exact (sl:{tenant}:dashboard:overview, 60 s); 200-only writes |
dashboard_widget_test.dart | values rendered verbatim (no client math — 09 B9); zero-states show hints; stale banner age text |
kpi_card_semantics_test.dart | value-first node, button semantics, trend glyphs with hidden text |
| WS test (fake socket) | filter to 4 event types; 2 s debounce; ≥1% delta announcement |
| Perm test | customize entry hidden without dashboard.widget.manage; route hidden without dashboard.read |
9. Rollout order
- OverviewCubit + repository + KPI row (E1 live today).
- Stale/offline banners + WS refresh + polling.
- Charts/periods
(planned)when E2/E3 land. - Widget customization
(planned)when E4/E5 land. - Role-scoped layouts
(planned)perIMPLEMENTATION_PLAN.md:232.