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>
This commit is contained in:
230
lib/ui/widgets/states.dart
Normal file
230
lib/ui/widgets/states.dart
Normal file
@@ -0,0 +1,230 @@
|
||||
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.';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user