import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../tokens.dart'; import 'buttons.dart'; /// Shimmering placeholder rows shown while a remote list loads. class DmSkeleton extends StatefulWidget { const DmSkeleton({super.key, this.rows = 4, this.height = 68}); final int rows; final double height; @override State createState() => _DmSkeletonState(); } class _DmSkeletonState extends State with SingleTickerProviderStateMixin { late final AnimationController _c = AnimationController( vsync: this, duration: const Duration(milliseconds: 1300), )..repeat(); @override void dispose() { _c.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Semantics( label: 'Loading', child: AnimatedBuilder( animation: _c, builder: (context, _) { return Column( children: [ for (var i = 0; i < widget.rows; i++) Padding( padding: EdgeInsets.only( bottom: i == widget.rows - 1 ? 0 : 10, ), child: Container( height: widget.height, decoration: BoxDecoration( borderRadius: DmRadius.all(DmRadius.md), gradient: LinearGradient( begin: Alignment(-1 - 2 * (1 - _c.value), 0), end: Alignment(1 + 2 * (1 - _c.value), 0), colors: const [ DmColors.surfaceAlt, Color(0xFFFBFAF8), DmColors.surfaceAlt, ], stops: const [0.25, 0.4, 0.6], ), ), ), ), ], ); }, ), ); } } /// Empty / error state with an optional retry action. class DmEmptyState extends StatelessWidget { const DmEmptyState({ super.key, required this.title, required this.message, this.icon = LucideIcons.ban, this.actionLabel, this.onAction, }); final String title; final String message; final IconData icon; final String? actionLabel; final VoidCallback? onAction; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 16), child: Column( children: [ Container( width: 52, height: 52, alignment: Alignment.center, decoration: BoxDecoration( color: DmColors.surface, borderRadius: DmRadius.all(DmRadius.md), border: Border.all(color: DmColors.border), ), child: Icon(icon, size: 24, color: DmColors.ink3), ), const SizedBox(height: 16), Text( title, style: DmText.heading, textAlign: TextAlign.center, ), const SizedBox(height: 6), Text( message, style: DmText.small, textAlign: TextAlign.center, ), if (actionLabel != null) ...[ const SizedBox(height: 20), DmButton( label: actionLabel!, kind: DmButtonKind.outline, block: false, onPressed: onAction, ), ], ], ), ); } } /// Wraps every backend-driven list so loading, empty, unavailable and error + /// retry are handled identically wherever data comes from. /// /// Adding an endpoint needs no extra UI work: return a /// different [Future] and these states come along. class DmAsyncList extends StatefulWidget { const DmAsyncList({ super.key, required this.load, required this.builder, required this.emptyTitle, required this.emptyMessage, this.emptyIcon = LucideIcons.ban, this.emptyActionLabel = 'Retry', this.onEmptyAction, this.skeletonRows = 4, this.reloadToken, this.initialItems, }); /// Called on first build and on every retry. final Future> Function({bool refresh}) load; final Widget Function(BuildContext context, List items) builder; final String emptyTitle; final String emptyMessage; final IconData emptyIcon; final String emptyActionLabel; /// Defaults to reloading; override to send the customer somewhere else. final VoidCallback? onEmptyAction; final int skeletonRows; /// What to render on the very first frame, when the caller already has it. /// /// A `FutureBuilder` reports `waiting` on its first build even when the /// future it is given is already complete, so a list backed by a warm cache /// still flashed a skeleton for a frame before showing data it had all /// along. Passing the cached items here skips that: the list opens on the /// content and the future, when it lands, simply confirms it. /// /// Null means there is nothing cached, which is the honest first-run case /// and still gets the skeleton. final List? initialItems; /// Changes when the caller has invalidated what this list is showing. /// /// The future is held in state, so a screen the customer returns to would /// otherwise keep rendering the answer it got the first time — which is how /// a pickup slot from yesterday stays on screen after the server has /// rejected it. final Object? reloadToken; @override State> createState() => DmAsyncListState(); } class DmAsyncListState extends State> { late Future> _future = widget.load(); void reload() { // Note the block body: setState rejects a callback that returns a value, // and an expression-bodied closure would hand it the Future. setState(() { _future = widget.load(refresh: true); }); } @override void didUpdateWidget(covariant DmAsyncList oldWidget) { super.didUpdateWidget(oldWidget); if (widget.reloadToken != oldWidget.reloadToken) reload(); } @override Widget build(BuildContext context) { return FutureBuilder>( future: _future, initialData: widget.initialItems, builder: (context, snap) { if (snap.hasError) { return DmEmptyState( icon: LucideIcons.wifiOff, title: "Couldn't load this", message: _messageFor(snap.error), actionLabel: 'Retry', onAction: reload, ); } // Data before connection state, deliberately. `waiting` is true on the // first build even for an already-complete future, so checking it // first would throw away [initialItems] for the one frame it exists // to cover. final items = snap.data; if (items == null) return DmSkeleton(rows: widget.skeletonRows); if (items.isEmpty) { return DmEmptyState( icon: widget.emptyIcon, title: widget.emptyTitle, message: widget.emptyMessage, actionLabel: widget.emptyActionLabel, onAction: widget.onEmptyAction ?? reload, ); } return widget.builder(context, items); }, ); } String _messageFor(Object? error) { final text = error?.toString() ?? ''; final match = RegExp(r'\): (.+)$').firstMatch(text); return match?.group(1) ?? 'Check your connection and try again.'; } }