Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

15 — Flutter Implementation Guide (Dashboard Module)

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 ChartCard with caption + semanticsLabel (06_Screen_Specifications.md §1.5). Disable PeriodSelector until server period params exist (E2/E3 (planned)).

8. Tests (map to 14_QA_Checklist.md)

TestWhat it guards
overview_cubit_test.dartsingle-flight coalescing; cache-hit skips network; force bypasses cache; 5xx keeps lastGood; error never writes cache
overview_repository_test.dartkey/TTL exact (sl:{tenant}:dashboard:overview, 60 s); 200-only writes
dashboard_widget_test.dartvalues rendered verbatim (no client math — 09 B9); zero-states show hints; stale banner age text
kpi_card_semantics_test.dartvalue-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 testcustomize entry hidden without dashboard.widget.manage; route hidden without dashboard.read

9. Rollout order

  1. OverviewCubit + repository + KPI row (E1 live today).
  2. Stale/offline banners + WS refresh + polling.
  3. Charts/periods (planned) when E2/E3 land.
  4. Widget customization (planned) when E4/E5 land.
  5. Role-scoped layouts (planned) per IMPLEMENTATION_PLAN.md:232.