UNPKG

opencode-agent-kit

Version:

Multi-stack OpenCode agent toolkit — 33+ specialized AI agents, 200+ skills, 46 commands, 8 MCP servers (Nuxt, React, Node.js, Laravel, CI3, Android, Flutter, DevOps, SEO, SonarQube, and more)

1,367 lines (1,191 loc) 39.5 kB
--- name: flutter-dashboard-patterns description: >- Production dashboard UI patterns for Flutter: animation tokens system with spring physics and page transitions, glassmorphism cards with staggered entrance, gradient stat cards with animated counters, shimmer loading skeletons, error state with shake animation, empty state with animated entrance, consistent section headers, status badges, store selector with bottom sheet, multi-tenant store selection with persistence, schema-based feature visibility, state provider integration with Riverpod, and responsive metric card layouts. license: MIT metadata: author: opencode-agent-kit version: '1.0.0' target_agent: flutter-developer stack: - Dart 3 - Flutter SDK - Material Design 3 - Riverpod - GoRouter - intl --- # Flutter Dashboard Patterns **Target Agent:** @flutter-developer **Stack:** Dart 3 · Flutter SDK · Material Design 3 · Riverpod · GoRouter · intl Production patterns for building data-rich dashboard UIs: animation tokens, glass cards, gradient stat cards, shimmer loading, error/empty states, and multi-tenant store selection. --- ## Table of Contents 1. [Animation Tokens System](#1-animation-tokens-system) 2. [GoRouter Page Transitions](#2-gorouter-page-transitions) 3. [Glassmorphism Card Components](#3-glassmorphism-card-components) 4. [Gradient Stat Card & Animated Counter](#4-gradient-stat-card--animated-counter) 5. [Shimmer Loading & Skeleton](#5-shimmer-loading--skeleton) 6. [Error State with Shake Animation](#6-error-state-with-shake-animation) 7. [Empty State with Staggered Entrance](#7-empty-state-with-staggered-entrance) 8. [Supporting UI Components](#8-supporting-ui-components) 9. [Multi-Tenant Store Selection](#9-multi-tenant-store-selection) 10. [Schema-Based Feature Visibility](#10-schema-based-feature-visibility) 11. [Dashboard Data Flow with Riverpod](#11-dashboard-data-flow-with-riverpod) --- ## 1. Animation Tokens System Define animation constants in one place so every widget shares a consistent rhythm. ```dart import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class AnimConstants { AnimConstants._(); // ── Durations ── static const Duration kInstant = Duration(milliseconds: 100); static const Duration kShort = Duration(milliseconds: 200); static const Duration kMedium = Duration(milliseconds: 350); static const Duration kLong = Duration(milliseconds: 500); static const Duration kExtraLong = Duration(milliseconds: 800); // ── Curves ── static const Curve kDefaultCurve = Curves.easeOutCubic; static const Curve kDialogCurve = Curves.easeOutBack; static const Curve kSpringCurve = Curves.elasticOut; static const Curve kSmoothCurve = Curves.easeInOutCubic; static const Curve kEmphasizedCurve = Curves.easeInOutCubic; // ── Spring Physics ── static const SpringDescription kDefaultSpring = SpringDescription( mass: 1, stiffness: 200, damping: 20, ); static const SpringDescription kSnappySpring = SpringDescription( mass: 0.8, stiffness: 300, damping: 18, ); // ── Reusable Animations ── /// Staggered entrance items appear one by one with fade+slide. static List<Animation<double>> staggeredEntrance( AnimationController parent, int itemCount, { double start = 0.0, double interval = 0.12, Curve curve = Curves.easeOut, }) => List.generate( itemCount, (i) => CurvedAnimation( parent: parent, curve: Interval( start + i * interval, (start + (i + 1) * interval).clamp(0.0, 1.0), curve: curve, ), ), ); /// Slide from below entrance. static Animation<Offset> slideFromBelow( Animation<double> parent, { double distance = 0.15, }) => Tween<Offset>( begin: Offset(0, distance), end: Offset.zero, ).animate(parent); } ``` ### Key Animation Decisions | Token | Value | Why | | --------------- | ------------------------- | ----------------------------------------- | | `kShort` | 200ms | Button press, subtle feedback | | `kMedium` | 350ms | Page transitions, card entrances | | `kLong` | 500ms | Full-screen overlays, dialogs | | `kDefaultCurve` | `easeOutCubic` | Fast start, smooth settle feels natural | | `kSnappySpring` | mass: 0.8, stiffness: 300 | Quick tactile feedback on press | --- ## 2. GoRouter Page Transitions Custom page transitions for GoRouter that respect `prefers-reduced-motion`. ```dart class PageTransition { PageTransition._(); /// Subtle fade + scale (for splash/login/dialogs). static CustomTransitionPage fadeScalePage({ required Widget child, String? key, }) => CustomTransitionPage( key: key != null ? ValueKey(key) : null, child: child, transitionsBuilder: (context, animation, secondaryAnimation, child) { final reduce = ui.PlatformDispatcher.instance.accessibilityFeatures.reduceMotion; if (reduce) return child; return FadeTransition( opacity: animation, child: ScaleTransition( scale: Tween<double>(begin: 0.95, end: 1).animate( CurvedAnimation( parent: animation, curve: AnimConstants.kDefaultCurve, ), ), child: child, ), ); }, transitionDuration: AnimConstants.kMedium, ); /// Slide from right + fade (drill-down navigation). static CustomTransitionPage slidePage({ required Widget child, String? key, }) => CustomTransitionPage( key: key != null ? ValueKey(key) : null, child: child, transitionsBuilder: (context, animation, secondaryAnimation, child) { final reduce = ui.PlatformDispatcher.instance.accessibilityFeatures.reduceMotion; if (reduce) return child; return SlideTransition( position: Tween<Offset>( begin: const Offset(0.3, 0), end: Offset.zero, ).animate(CurvedAnimation( parent: animation, curve: AnimConstants.kSmoothCurve, )), child: FadeTransition(opacity: animation, child: child), ); }, transitionDuration: AnimConstants.kMedium, ); /// Fade-only (for full-screen overlays). static CustomTransitionPage fadePage({ required Widget child, String? key, }) => CustomTransitionPage( key: key != null ? ValueKey(key) : null, child: child, transitionsBuilder: (context, animation, secondaryAnimation, child) { final reduce = ui.PlatformDispatcher.instance.accessibilityFeatures.reduceMotion; if (reduce) return child; return FadeTransition(opacity: animation, child: child); }, transitionDuration: AnimConstants.kMedium, ); } ``` ### Usage in GoRouter ```dart GoRoute( path: '/login', pageBuilder: (_, _) => PageTransition.fadeScalePage(child: const LoginScreen()), ), GoRoute( path: '/profile', parentNavigatorKey: _rootNavigatorKey, pageBuilder: (_, _) => PageTransition.slidePage(child: const ProfileScreen()), ), ``` --- ## 3. Glassmorphism Card Components ### GlassCard (stateless, static) ```dart class GlassCard extends StatelessWidget { const GlassCard({ required this.child, super.key, this.margin, this.padding, this.borderRadius = 20, this.opacity = 0.85, this.tint, this.hasGlow = false, this.onTap, this.width, }); final Widget child; final EdgeInsetsGeometry? margin; final EdgeInsetsGeometry? padding; final double borderRadius; final double opacity; final Color? tint; final bool hasGlow; final VoidCallback? onTap; final double? width; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isLight = Theme.of(context).brightness == Brightness.light; final bgColor = tint ?? (isLight ? Colors.white : const Color(0xFF1C1C1E)); final decoration = BoxDecoration( color: bgColor.withValues(alpha: opacity), borderRadius: BorderRadius.circular(borderRadius), border: Border.all( color: hasGlow ? cs.primary.withValues(alpha: 0.3) : cs.outlineVariant.withValues(alpha: 0.3), width: hasGlow ? 1.0 : 0.5, ), boxShadow: [ BoxShadow( color: hasGlow ? cs.primary.withValues(alpha: 0.12) : Colors.black.withValues(alpha: 0.05), blurRadius: hasGlow ? 24 : 16, offset: const Offset(0, 4), ), BoxShadow( color: Colors.black.withValues(alpha: 0.03), blurRadius: 8, offset: const Offset(0, 2), ), if (hasGlow) BoxShadow( color: cs.primary.withValues(alpha: 0.08), blurRadius: 40, spreadRadius: -4, ), ], ); final card = Container( width: width, margin: margin, padding: padding ?? const EdgeInsets.all(16), decoration: decoration, child: child, ); if (onTap != null) return GestureDetector(onTap: onTap, child: card); return card; } } ``` ### OlposGlassCard (animated entrance) ```dart class OlposGlassCard extends StatefulWidget { const OlposGlassCard({ required this.child, super.key, this.margin, this.padding, this.borderRadius = 20, this.hasGlow = false, this.onTap, this.width, this.animationDelay = 0, }); final Widget child; final EdgeInsetsGeometry? margin, padding; final double borderRadius; final bool hasGlow; final VoidCallback? onTap; final double? width; final int animationDelay; // 0-based index for staggered entrance @override State<OlposGlassCard> createState() => _OlposGlassCardState(); } class _OlposGlassCardState extends State<OlposGlassCard> with SingleTickerProviderStateMixin { late final AnimationController _ctrl; late final Animation<double> _fadeAnim; late final Animation<Offset> _slideAnim; @override void initState() { super.initState(); _ctrl = AnimationController(vsync: this, duration: AnimConstants.kLong); _fadeAnim = CurvedAnimation( parent: _ctrl, curve: Interval( widget.animationDelay * 0.1, 0.3 + widget.animationDelay * 0.1, curve: AnimConstants.kDefaultCurve, ), ); _slideAnim = Tween<Offset>( begin: const Offset(0, 0.06), end: Offset.zero, ).animate(_fadeAnim); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _ctrl.forward(); }); } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { // ... same glass decoration as GlassCard above ... final card = FadeTransition( opacity: _fadeAnim, child: SlideTransition( position: _slideAnim, child: Container(/* decoration same as GlassCard */), ), ); if (widget.onTap != null) { return _PressScaleWrapper(onTap: widget.onTap!, child: card); } return card; } } ``` ### PressScaleWrapper (tactile feedback) ```dart class _PressScaleWrapper extends StatefulWidget { const _PressScaleWrapper({required this.child, required this.onTap}); final Widget child; final VoidCallback onTap; @override State<_PressScaleWrapper> createState() => _PressScaleWrapperState(); } class _PressScaleWrapperState extends State<_PressScaleWrapper> with SingleTickerProviderStateMixin { late final AnimationController _ctrl; late final Animation<double> _anim; @override void initState() { super.initState(); _ctrl = AnimationController( vsync: this, duration: AnimConstants.kShort, upperBound: 0.03, ); _anim = Tween(begin: 0.0, end: -0.025).animate( CurvedAnimation(parent: _ctrl, curve: AnimConstants.kDefaultCurve), ); } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) => GestureDetector( onTapDown: (_) => _ctrl.forward(), onTapUp: (_) => _ctrl.reverse(), onTapCancel: () => _ctrl.reverse(), onTap: widget.onTap, child: AnimatedBuilder( animation: _anim, builder: (context, child) => Transform.scale(scale: 1.0 + _anim.value, child: child), child: widget.child, ), ); } ``` --- ## 4. Gradient Stat Card & Animated Counter ### AnimatedCounter — number roll-up ```dart class AnimatedCounter extends StatefulWidget { const AnimatedCounter({ required this.value, super.key, this.prefix = '', this.suffix = '', this.style, this.decimals = 0, this.duration = const Duration(milliseconds: 1200), this.curve = Curves.easeOutCubic, }); final double value; final String prefix, suffix; final TextStyle? style; final int decimals; final Duration duration; final Curve curve; @override State<AnimatedCounter> createState() => _AnimatedCounterState(); } class _AnimatedCounterState extends State<AnimatedCounter> with SingleTickerProviderStateMixin { late AnimationController _ctrl; late Animation<double> _anim; @override void initState() { super.initState(); _ctrl = AnimationController(vsync: this, duration: widget.duration); _anim = CurvedAnimation(parent: _ctrl, curve: widget.curve); _ctrl.forward(); } @override void didUpdateWidget(AnimatedCounter oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.value != widget.value) { _ctrl.forward(from: 0); } } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) => AnimatedBuilder( animation: _anim, builder: (context, _) { final displayed = '${widget.prefix}${(widget.value * _anim.value).toStringAsFixed(widget.decimals)}${widget.suffix}'; return Text(displayed, style: widget.style); }, ); } ``` ### GradientStatCard — full card ```dart class GradientStatCard extends StatefulWidget { const GradientStatCard({ required this.gradient, required this.icon, required this.label, required this.value, super.key, this.prefix, this.suffix, this.onTap, }); final LinearGradient gradient; final IconData icon; final String label; final double value; final String? prefix, suffix; final VoidCallback? onTap; @override State<GradientStatCard> createState() => _GradientStatCardState(); } class _GradientStatCardState extends State<GradientStatCard> with SingleTickerProviderStateMixin { late final AnimationController _ctrl; late final Animation<double> _scaleAnim; @override void initState() { super.initState(); _ctrl = AnimationController( vsync: this, duration: AnimConstants.kShort, upperBound: 0.03, ); _scaleAnim = Tween(begin: 0.0, end: -0.025).animate( CurvedAnimation(parent: _ctrl, curve: AnimConstants.kDefaultCurve), ); } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final card = AnimatedBuilder( animation: _scaleAnim, builder: (context, _) => Transform.scale( scale: 1.0 + _scaleAnim.value, child: DecoratedBox( decoration: BoxDecoration( gradient: widget.gradient, borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( color: widget.gradient.colors.first.withValues(alpha: 0.3), blurRadius: 16, offset: const Offset(0, 6), ), ], ), child: Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 36, height: 36, decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(10), ), child: Icon(widget.icon, color: Colors.white, size: 20), ), const Spacer(), AnimatedCounter( value: widget.value, prefix: widget.prefix ?? '', suffix: widget.suffix ?? '', style: const TextStyle( fontSize: 28, fontWeight: FontWeight.w800, color: Colors.white, height: 1.1, ), ), const SizedBox(height: 4), Text( widget.label, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: Colors.white.withValues(alpha: 0.85), ), ), ], ), ), ), ), ); if (widget.onTap != null) { return GestureDetector( onTapDown: (_) => _ctrl.forward(), onTapUp: (_) => _ctrl.reverse(), onTapCancel: () => _ctrl.reverse(), onTap: widget.onTap, child: card, ); } return card; } } ``` ### Metric Card Layout Patterns ``` ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ Hero Metric Metric Pair Metric Row ┌───────────┐ ┌──────┐┌──────┐│ ┌──┐ ┌──┐ ┌──┐ Icon │Omzet ││Profit││ │R │P │S Rp 50,000 │+12% ││+8% ││ │e │r │e Total └──────┘└──────┘│ │v │r │r └───────────┘ │o │v └─────────────────┘ └─────────────────┘ └──┘ └──┘ └──┘ └─────────────────┘ ``` Use `GradientStatCard` for Hero Metrics (single big number), and inline `_statChip` / `_amountChip` widgets for pairs/rows. --- ## 5. Shimmer Loading & Skeleton ### Simple Shimmer Block ```dart class AppShimmer extends StatelessWidget { const AppShimmer({ super.key, this.width = double.infinity, this.height = 16, this.borderRadius = 8, }); final double width, height, borderRadius; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Container( width: width, height: height, decoration: BoxDecoration( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(borderRadius), ), ); } } ``` ### Loading State with Animation ```dart class AppLoading extends StatefulWidget { const AppLoading({super.key, this.width, this.height, this.margin}); final double? width, height; final EdgeInsetsGeometry? margin; @override State<AppLoading> createState() => _AppLoadingState(); } class _AppLoadingState extends State<AppLoading> with SingleTickerProviderStateMixin { late final AnimationController _ctrl; late final Animation<double> _anim; @override void initState() { super.initState(); _ctrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), )..repeat(); _anim = Tween(begin: 0.0, end: 1.0).animate(_ctrl); } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return AnimatedBuilder( animation: _anim, builder: (context, _) => Container( width: widget.width ?? double.infinity, height: widget.height ?? 120, margin: widget.margin ?? const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), child: Center( child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator( strokeWidth: 2.5, color: cs.primary.withValues(alpha: 0.6), ), ), ), ), ); } } ``` --- ## 6. Error State with Shake Animation ```dart class AppErrorWidget extends StatefulWidget { const AppErrorWidget({required this.message, super.key, this.onRetry}); final String message; final VoidCallback? onRetry; @override State<AppErrorWidget> createState() => _AppErrorWidgetState(); } class _AppErrorWidgetState extends State<AppErrorWidget> with SingleTickerProviderStateMixin { late final AnimationController _ctrl; late final Animation<double> _shakeAnim; @override void initState() { super.initState(); _ctrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 600), ); _shakeAnim = Tween<double>(begin: 0, end: 4).animate( CurvedAnimation(parent: _ctrl, curve: Curves.elasticIn), ); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _ctrl.forward(); }); } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ AnimatedBuilder( animation: _shakeAnim, builder: (context, _) => Transform.translate( offset: Offset( _shakeAnim.value * (DateTime.now().millisecondsSinceEpoch.isEven ? 1 : -1), 0, ), child: Container( width: 72, height: 72, decoration: BoxDecoration( color: cs.errorContainer.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(24), ), child: Icon( Icons.error_outline_rounded, size: 32, color: cs.error, ), ), ), ), const SizedBox(height: 20), Text( widget.message, textAlign: TextAlign.center, style: TextStyle( fontSize: 15, color: cs.onSurfaceVariant, height: 1.4, ), ), if (widget.onRetry != null) ...[ const SizedBox(height: 20), FilledButton.tonalIcon( onPressed: widget.onRetry, icon: const Icon(Icons.refresh_rounded, size: 18), label: const Text('Try Again'), style: FilledButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), ), ), ], ], ), ), ); } } ``` --- ## 7. Empty State with Staggered Entrance ```dart class EmptyStateWidget extends StatefulWidget { const EmptyStateWidget({ required this.message, super.key, this.icon = Icons.inbox_outlined, this.actionLabel, this.onAction, }); final IconData icon; final String message; final String? actionLabel; final VoidCallback? onAction; @override State<EmptyStateWidget> createState() => _EmptyStateWidgetState(); } class _EmptyStateWidgetState extends State<EmptyStateWidget> with SingleTickerProviderStateMixin { late final AnimationController _ctrl; late final Animation<double> _fadeAnim, _iconAnim; late final Animation<Offset> _slideAnim; @override void initState() { super.initState(); _ctrl = AnimationController(vsync: this, duration: AnimConstants.kLong); _iconAnim = CurvedAnimation( parent: _ctrl, curve: const Interval(0, 0.5, curve: Curves.easeOutBack), ); _fadeAnim = CurvedAnimation( parent: _ctrl, curve: const Interval(0.3, 0.8, curve: Curves.easeOut), ); _slideAnim = Tween<Offset>( begin: const Offset(0, 0.05), end: Offset.zero, ).animate(_fadeAnim); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _ctrl.forward(); }); } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ ScaleTransition( scale: _iconAnim, child: Container( width: 80, height: 80, decoration: BoxDecoration( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(24), ), child: Icon(widget.icon, size: 36, color: cs.outlineVariant), ), ), const SizedBox(height: 20), FadeTransition( opacity: _fadeAnim, child: SlideTransition( position: _slideAnim, child: Text( widget.message, textAlign: TextAlign.center, style: TextStyle( fontSize: 15, color: cs.onSurfaceVariant, height: 1.4, ), ), ), ), if (widget.actionLabel != null && widget.onAction != null) ...[ const SizedBox(height: 20), FadeTransition( opacity: _fadeAnim, child: FilledButton.tonalIcon( onPressed: widget.onAction, icon: const Icon(Icons.add_rounded, size: 18), label: Text(widget.actionLabel!), style: FilledButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), ), ), ), ], ], ), ), ); } } ``` --- ## 8. Supporting UI Components ### SectionHeader ```dart class SectionHeader extends StatelessWidget { const SectionHeader({ required this.title, super.key, this.subtitle, this.trailing, }); final String title; final String? subtitle; final Widget? trailing; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, color: cs.onSurface, ), ), if (subtitle != null) ...[ const SizedBox(height: 2), Text(subtitle!, style: Theme.of(context).textTheme.bodySmall ?.copyWith(color: cs.onSurfaceVariant), ), ], ], ), ), if (trailing != null) trailing!, ], ), ); } } ``` ### StatusBadge ```dart class StatusBadge extends StatelessWidget { const StatusBadge({ required this.label, required this.color, super.key, this.icon, }); final String label; final Color color; final IconData? icon; @override Widget build(BuildContext context) => Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (icon != null) ...[ Icon(icon, size: 14, color: color), const SizedBox(width: 4), ], Text( label.toUpperCase(), style: TextStyle( fontSize: 10, fontWeight: FontWeight.w700, color: color, letterSpacing: 0.5, ), ), ], ), ); } ``` --- ## 9. Multi-Tenant Store Selection When a dashboard serves multiple stores/locations, the user needs to select which store's data to view. This selection must persist across app restarts. ### Store Selection Notifier (Riverpod) ```dart const _storeIdKey = 'selected_store_id'; /// Persisted store ID loaded on demand by ownedStoresProvider. final persistedStoreIdProvider = FutureProvider<String?>((ref) async { try { return await const FlutterSecureStorage().read(key: _storeIdKey); } catch (_) { return null; } }); /// Currently selected store ID persisted across sessions. class StoreSelection extends Notifier<String?> { @override String? build() => null; void selectStore(String storeId) { state = storeId; const FlutterSecureStorage().write(key: _storeIdKey, value: storeId); } void clear() { state = null; const FlutterSecureStorage().delete(key: _storeIdKey); } } final storeSelectionProvider = NotifierProvider<StoreSelection, String?>( StoreSelection.new, ); /// Fetches owned stores restores persisted selection or auto-selects first. final ownedStoresProvider = FutureProvider.autoDispose<List<OwnedStore>>(( ref, ) async { final authState = ref.watch(authProvider); if (authState is! AuthAuthenticated) return []; final persisted = await ref.watch(persistedStoreIdProvider.future); final dataSource = OwnedStoreDataSource(); final json = await dataSource.getOwnedStores(userId); var list = /* parse JSON List<OwnedStore> */ []; final notifier = ref.read(storeSelectionProvider.notifier); final current = ref.read(storeSelectionProvider); if (persisted != null && list.any((s) => s.storeId == persisted)) { if (current != persisted) notifier.selectStore(persisted); } else if (current == null && list.isNotEmpty) { notifier.selectStore(list.first.storeId); } return list; }); ``` ### Store Selector Bottom Sheet ```dart void _showStoreSelector(BuildContext context, WidgetRef ref) { showOlposBottomSheet( context: context, builder: (_) => StoreSelectorSheet(stores: stores), ); } class StoreSelectorSheet extends ConsumerWidget { const StoreSelectorSheet({required this.stores, super.key}); final List<OwnedStore> stores; @override Widget build(BuildContext context, WidgetRef ref) { final selectedId = ref.watch(storeSelectionProvider); final cs = Theme.of(context).colorScheme; return Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.all(16), child: Text('Pilih Toko', style: Theme.of(context).textTheme.titleMedium, ), ), ...stores.map((store) => ListTile( leading: Icon( store.storeId == selectedId ? Icons.radio_button_checked : Icons.radio_button_unchecked, color: store.storeId == selectedId ? cs.primary : cs.onSurfaceVariant, ), title: Text(store.store.name), subtitle: store.store.phone != null ? Text(store.store.phone!) : null, onTap: () { ref.read(storeSelectionProvider.notifier) .selectStore(store.storeId); context.pop(); }, )), ], ); } } ``` --- ## 10. Schema-Based Feature Visibility When a single codebase serves multiple business types (retail, laundry, restaurant, workshop), use a `Provider` to compute which UI sections are visible based on the store's schema. ```dart /// Exposes the schema of the currently selected store. final storeSchemaProvider = Provider<String?>((ref) { final stores = ref.watch(ownedStoresProvider).valueOrNull ?? []; final selectedId = ref.watch(storeSelectionProvider); // ... find store by selectedId, return store.schema }); /// Dashboard feature visibility based on store schema. class SalesDashboardVisibility { final bool showConsignment; final bool showTechnician; final bool showServiceIncome; const SalesDashboardVisibility({ required this.showConsignment, required this.showTechnician, required this.showServiceIncome, }); static const none = SalesDashboardVisibility( showConsignment: false, showTechnician: false, showServiceIncome: false, ); } final salesDashboardVisibilityProvider = Provider<SalesDashboardVisibility>((ref) { final schema = ref.watch(storeSchemaProvider); switch (schema) { case 'SIMASKO': return const SalesDashboardVisibility( showConsignment: true, showTechnician: true, showServiceIncome: true, ); case 'LAUNDRY': case 'BARBERSHOP': return const SalesDashboardVisibility( showConsignment: false, showTechnician: false, showServiceIncome: true, ); case 'RETAIL': case 'MINIMARKET': case 'FASHION': case 'APOTEK': return const SalesDashboardVisibility( showConsignment: true, showTechnician: false, showServiceIncome: false, ); default: return SalesDashboardVisibility.none; } }); ``` ### Usage in the Dashboard Screen ```dart final visibility = ref.watch(salesDashboardVisibilityProvider); if (visibility.showTechnician) { TechnicianSection(); } if (visibility.showConsignment) { ConsignmentSection(); } ``` ### Derived Schema Providers ```dart final isSchemaLaundryProvider = Provider<bool>((ref) { final schema = ref.watch(storeSchemaProvider); return schema == 'LAUNDRY'; }); final isVariantProvider = Provider<bool>((ref) { final stores = ref.watch(ownedStoresProvider).valueOrNull ?? []; final selectedId = ref.watch(storeSelectionProvider); // ... find store and return store.store.isVariant }); ``` --- ## 11. Dashboard Data Flow with Riverpod ### Complete Dashboard Screen Pattern ```dart class SalesDashboardScreen extends ConsumerWidget { const SalesDashboardScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final visibility = ref.watch(salesDashboardVisibilityProvider); final summaryAsync = ref.watch(salesSummaryProvider); return Scaffold( body: RefreshIndicator( onRefresh: () => ref.refresh(salesSummaryProvider.future), child: ListView( padding: const EdgeInsets.all(16), children: [ // Hero metric cards (always visible) SectionHeader(title: l10n.overview), Row( children: [ Expanded( child: GradientStatCard( gradient: OlposGradients.brand, icon: Icons.trending_up_rounded, label: l10n.totalRevenue, value: summaryAsync.valueOrNull?.totalRevenue ?? 0, prefix: 'Rp ', ), ), const SizedBox(width: 12), Expanded( child: GradientStatCard( gradient: OlposGradients.success, icon: Icons.account_balance_wallet_rounded, label: l10n.totalProfit, value: summaryAsync.valueOrNull?.totalProfit ?? 0, prefix: 'Rp ', ), ), ], ), const SizedBox(height: 16), // Conditional sections if (visibility.showTechnician) TechnicianSection(), if (visibility.showConsignment) ConsignmentSection(), if (visibility.showServiceIncome) ServiceIncomeSection(), // Error/loading/empty handled via AsyncValue.when when needed ], ), ), ); } } ``` ### Cross-Provider Invalidation on Store Change ```dart // When the user switches stores, invalidate all data providers: ref.listen(storeSelectionProvider, (prev, next) { if (prev != next) { ref.invalidate(salesSummaryProvider); ref.invalidate(financeHistoryProvider); ref.invalidate(hrDataProvider); } }); ``` ### Cross-App Auth Change: Clear Store + Invalidate All ```dart void logout() { ref.read(storeSelectionProvider.notifier).clear(); clearBusinessAnalysisCache(); state = const AuthInitial(); } ``` --- ## Complete pubspec Dependencies ```yaml dependencies: flutter_riverpod: ^2.6.1 go_router: ^14.8.0 flutter_secure_storage: ^9.2.4 intl: ^0.20.2 fl_chart: ^0.70.2 # for revenue charts shimmer: ^3.0.0 # enhanced loading skeletons dev_dependencies: flutter_test: sdk: flutter mocktail: ^1.0.4 ``` --- ## Design Decisions Reference | Component | Choice | Alternative Considered | Why | | ------------------- | ------------------------------------------ | ---------------------- | ------------------------------------------------------------------------------- | | Card style | Glassmorphism | Solid cards | Brand-consistent, modern feel. Glass is intentional (not default). | | Card entrance | Slide + fade staggered | Instant render | Each card entering tells the user data is fresh/live. | | Number animation | `AnimatedBuilder` with lerp | `AnimatedSwitcher` | Smooth continuous animation vs. abrupt tween. | | Loading placeholder | Pulse opacity | Shimmer package | Lighter dependency, simpler code. Use `shimmer` for richer effects when needed. | | Error shake | `elasticIn` curve | Static error icon | Draws attention without being annoying. | | Store selection | Riverpod Notifier + `FlutterSecureStorage` | SharedPreferences | Secure token storage, no plaintext IDs. | | Feature visibility | Riverpod `Provider` | If/else in widget | Testable, reactive, debuggable in isolation. |