Files
Thiru-tenext 8427824951 A launch that opens in brand, and a sheet that stops resizing
── The splash, in three beats ──

Crimson edge to edge with the truck running across it in white; the invert;
then the mark.

The red starts before Flutter does. Four surfaces painted white before any
Dart runs — launch_background at both API levels, windowSplashScreenBackground
on Android 12+ in light and dark, and the iOS launch storyboard — and leaving
any one of them white makes the launch a white flash followed by a red one.
That flash is the only part of a launch a customer consciously notices.

The invert is one gesture rather than a fade. A white truck on a background
turning white is an invisible truck, so the ground lightens as the truck
darkens, off one controller, and the screen turns itself inside out with the
truck still on it. Fading it would have left the screen empty for the moment
before the mark.

The truck's colour is no longer its own: it is painted through srcIn, so the
file's palette is discarded and only alpha survives. A replacement Lottie now
needs no preparation, and tool/lottie_brand.py is off this path.

`splash.json` is a seamless 3.9s loop — frame 0 is frame 60, the truck never
arrives or departs — so there is no completion to hand over on. _truckBeat is
a decision about how long a launch may hold somebody, not a property of the
file. The comment claiming six seconds was wrong on both counts.

── Three things the splash was getting wrong quietly ──

It showed the wrong logo: doormile-icon.png, the previous mark, to a customer
who had just tapped the new one. tool/icons.py now cuts doormile-mark.png from
the same master alpha as the launcher icon, so they cannot drift again.

The fallback loader was invisible on red. _RoadLoader painted in DmColors.brand
on what used to be a white screen; on crimson that is crimson on crimson, and
it drew nothing at all on exactly the devices that had fallen back to it.

The mark appeared and left in the same frame — _minimum was the sum of the
beats exactly, so the clock ran out as the entrance finished. Hence _markHold.

── The truck was not in the middle ──

Not a layout bug: both beats sit in a Center and always did. The artwork is
drawn low and to the right inside its own 500x500 composition, so a centred
widget rendered an off-centre picture — 30pt right, 36pt down.

splash_centring_test.dart renders a frame at phone size and density, finds the
ink and fails if either beat drifts. It is the only form of test that could
have caught this, and the one that will catch it again when the Lottie is
replaced, which is when the correction goes stale. Two things it taught:
one enormous pump does not let the splash's async start-up chain advance, and
capturing at pixelRatio 1 rasterizes the speed lines too faintly to detect,
which truncates the bounding box and moves the measured centre by 12pt.

── The destination sheet stops resizing ──

Tapping ONE TOUCH opened a tall sheet that snapped shorter a few frames later.
DmAsyncList renders four skeleton rows while it loads — 302pt — and the states
that replace them are nearer 200; the sheet was Flexible, so it was as tall as
whichever state its content happened to be in, and the modal is still
animating up while that swap happens.

The list now lives in a box of one height. That also removes a second resize:
the sheet grew from 48% of the screen to 74% when a state was picked. Both
steps now measure 64% and it never changes size again.

And DmAsyncList takes initialItems, fed by AppState.cachedCities: FutureBuilder
reports `waiting` on its first build even for an already-complete future, so a
warm cache still flashed a skeleton over data it already had.

Three skeleton rows here rather than four — sheet_stability_test caught that
302pt overflows the smallest box the clamp can produce.
2026-09-28 12:54:03 +05:30

247 lines
7.4 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,
this.initialItems,
});
/// 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;
/// 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<T>? 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<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,
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.';
}
}