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>
231 lines
6.6 KiB
Dart
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.';
|
|
}
|
|
}
|