Files
doormile_customer_app/lib/ui/widgets/states.dart
Thiru-tenext 0d66627c3c Replace the customer app with Doormile CX
Book a pickup, track it to delivery — the rebuilt customer app.

- Design language from doormile-screens.html: brand #8F0F06, Manrope +
  Geist Mono (variable fonts), bordered cards instead of shadows, crimson
  brand headers, sliding tab indicator, mono for anything read digit by digit.
- lib/data (one live API implementation, plus a debug-only offline fake),
  lib/state, lib/ui (tokens, widgets, screens).
- 84 tests, plus a design snapshot harness that renders every screen with the
  real fonts: flutter test test/design_snapshot_test.dart --run-skipped
  --update-goldens

This replaces the previous app (pubspec 'doormile', app id
com.doormile.customer). That tree remains in history at 6c7d656; note its
android/app/google-services.json is not carried over, and the application id
here is in.doormile.customer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 16:07:33 +05:30

231 lines
6.6 KiB
Dart

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<DmSkeleton> createState() => _DmSkeletonState();
}
class _DmSkeletonState extends State<DmSkeleton>
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<T> 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,
});
/// Called on first build and on every retry.
final Future<List<T>> Function({bool refresh}) load;
final Widget Function(BuildContext context, List<T> 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;
/// 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<DmAsyncList<T>> createState() => DmAsyncListState<T>();
}
class DmAsyncListState<T> extends State<DmAsyncList<T>> {
late Future<List<T>> _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<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.reloadToken != oldWidget.reloadToken) reload();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<T>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return DmSkeleton(rows: widget.skeletonRows);
}
if (snap.hasError) {
return DmEmptyState(
icon: LucideIcons.wifiOff,
title: "Couldn't load this",
message: _messageFor(snap.error),
actionLabel: 'Retry',
onAction: reload,
);
}
final items = snap.data ?? const [];
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.';
}
}