Four passes, and the shape they landed on. The type face is Plus Jakarta Sans (variable, wght 200-800), which brings a fix with it: it carries the rupee glyph and Switzer does not, so prices stop being set in Geist Mono to work around a missing character. Mono stays where it is earned - references and phone numbers, read digit by digit. Surfaces lift rather than outline. Cards carry two very soft shadow layers instead of a hairline, because eight outlined boxes down a screen read as a wireframe. The tab bar floats as a pill again for the same reason it was right to: it is now the same kind of object as everything above it. Screens: * Home is the greeting, the address, the sphere and one card. The card lost its progress bar - a filling line says "wait", and a parcel two days into a journey is not something anyone is waiting through - and gained the size that buys. * Orders cards are four bands: identity, destination, route, and whatever is happening right now. Plus a search field, because the list is the archive. * Tracking leads with the state at display size, then TRIP MILESTONES with a step counter, then the courier. * Review is a route thread over two particular cards. * Account opens on the person: avatar, name, and two counted figures. Three real bugs the redesign surfaced: * Quick dispatch handed `loadCities()` straight to a FutureBuilder, so the catalogue was refetched on every rebuild and Home never settled. * Order cards showed the whole visit's weight on one destination's row - somebody else's parcel. Per group now, and only once actually weighed. * The pickup window was printed beside "In transit", where it reads as a delivery time nobody promised. Nothing invented. The reference shows EXPRESS PRIORITY, CARBON OFFSET, CONCIERGE ELITE and hub-to-hub routing; this backend sends none of them, so they are absent rather than mocked up. flutter analyze: clean. flutter test: 88 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
300 lines
9.8 KiB
Dart
300 lines
9.8 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
|
|
import '../tokens.dart';
|
|
|
|
/// The selectable row used for serviceable states, districts and pickup slots.
|
|
///
|
|
/// Selection **fills**: the chosen slot becomes a crimson card with a glow
|
|
/// under it, and every other option stays a white card with a hairline. The
|
|
/// earlier design tinted the selected tile instead, which on a list of five
|
|
/// slots left the customer hunting for which one they had picked. A filled row
|
|
/// is unmistakable from across the room, and the radio on the right keeps the
|
|
/// control legible for anyone who does not read the colour.
|
|
///
|
|
/// Unavailable options stay visible but recede, with the backend's reason
|
|
/// attached — coverage should be legible, not hidden.
|
|
class DmOptionTile extends StatefulWidget {
|
|
const DmOptionTile({
|
|
super.key,
|
|
required this.title,
|
|
this.subtitle,
|
|
this.selected = false,
|
|
this.enabled = true,
|
|
this.onTap,
|
|
this.leading,
|
|
this.tags,
|
|
this.trailing,
|
|
this.titleStyle,
|
|
});
|
|
|
|
final String title;
|
|
final String? subtitle;
|
|
final bool selected;
|
|
final bool enabled;
|
|
final VoidCallback? onTap;
|
|
|
|
/// Optional leading glyph.
|
|
final IconData? leading;
|
|
|
|
/// Chips above the title — "Fastest", "4 Milers nearby".
|
|
final List<Widget>? tags;
|
|
|
|
/// Replaces the radio — "Notify me" on a full slot.
|
|
final Widget? trailing;
|
|
|
|
/// Slots set their time in the display face; states and districts do not.
|
|
final TextStyle? titleStyle;
|
|
|
|
@override
|
|
State<DmOptionTile> createState() => _DmOptionTileState();
|
|
}
|
|
|
|
class _DmOptionTileState extends State<DmOptionTile> {
|
|
bool _down = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final selected = widget.selected;
|
|
final enabled = widget.enabled;
|
|
|
|
// ── Selection is a tint and an edge, not a crimson slab ──
|
|
//
|
|
// A chosen slot used to fill solid brand and carry [DmShadow.lift] — the
|
|
// 80%-crimson glow written for the one committing button on a screen. In
|
|
// a list of six windows that made the choice the loudest object in the
|
|
// sheet, louder than the button that confirms it, and the white text
|
|
// inside it broke the type colour every other row was set in.
|
|
//
|
|
// A wash with a crimson edge says *chosen* just as unambiguously, keeps
|
|
// the row's type where it was, and leaves the crimson fill to the footer
|
|
// button — which is the thing the customer presses next.
|
|
final Color background = selected
|
|
? DmColors.brandSoft
|
|
: (enabled ? DmColors.surface : DmColors.surfaceAlt);
|
|
|
|
final titleColor = selected
|
|
? DmColors.brandPress
|
|
: (enabled ? DmColors.ink : DmColors.ink3);
|
|
|
|
final subColor = selected
|
|
? DmColors.brandPress
|
|
: (enabled ? DmColors.ink2 : DmColors.ink3);
|
|
|
|
return Semantics(
|
|
button: true,
|
|
selected: selected,
|
|
enabled: enabled,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTapDown: enabled ? (_) => setState(() => _down = true) : null,
|
|
onTapUp: enabled ? (_) => setState(() => _down = false) : null,
|
|
onTapCancel: enabled ? () => setState(() => _down = false) : null,
|
|
onTap: enabled
|
|
? () {
|
|
HapticFeedback.selectionClick();
|
|
widget.onTap?.call();
|
|
}
|
|
: null,
|
|
child: AnimatedSlide(
|
|
offset: _down ? const Offset(0, 0.012) : Offset.zero,
|
|
duration: DmMotion.fast,
|
|
curve: DmMotion.ease,
|
|
child: AnimatedContainer(
|
|
duration: DmMotion.base,
|
|
curve: DmMotion.ease,
|
|
constraints: const BoxConstraints(minHeight: 62),
|
|
padding: EdgeInsets.fromLTRB(
|
|
widget.leading == null ? 16 : 14,
|
|
14,
|
|
16,
|
|
14,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: background,
|
|
borderRadius: DmRadius.all(DmRadius.md),
|
|
border: selected
|
|
? Border.all(color: DmColors.brand, width: 1.5)
|
|
: null,
|
|
// An unchosen, available option is a card like any other: lifted
|
|
// off the sheet rather than outlined on it. A disabled one is
|
|
// flat, because it is not an object you can pick up.
|
|
boxShadow: enabled && !selected ? DmShadow.card : null,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
if (widget.leading != null) ...[
|
|
Icon(
|
|
widget.leading,
|
|
size: 20,
|
|
color: selected ? DmColors.brand : DmColors.ink3,
|
|
),
|
|
const SizedBox(width: 13),
|
|
],
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (widget.tags != null && widget.tags!.isNotEmpty) ...[
|
|
Wrap(
|
|
spacing: 7,
|
|
runSpacing: 6,
|
|
children: widget.tags!,
|
|
),
|
|
const SizedBox(height: 9),
|
|
],
|
|
Text(
|
|
widget.title,
|
|
style: (widget.titleStyle ?? DmText.cardTitle)
|
|
.copyWith(color: titleColor),
|
|
),
|
|
if (widget.subtitle != null) ...[
|
|
const SizedBox(height: 3),
|
|
Text(
|
|
widget.subtitle!,
|
|
style: DmText.small.copyWith(color: subColor),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
widget.trailing ??
|
|
_Radio(selected: selected, enabled: enabled),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A radio, not a check: these lists are single-choice, and a tick reads as
|
|
/// "done" where a radio reads as "this one, of these".
|
|
class _Radio extends StatelessWidget {
|
|
const _Radio({required this.selected, required this.enabled});
|
|
|
|
final bool selected;
|
|
final bool enabled;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedContainer(
|
|
duration: DmMotion.base,
|
|
curve: DmMotion.ease,
|
|
width: 22,
|
|
height: 22,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
// The mark takes the brand now that the tile behind it is a wash
|
|
// rather than a crimson field — white on `brandSoft` is invisible.
|
|
border: Border.all(
|
|
color: selected
|
|
? DmColors.brand
|
|
: (enabled ? DmColors.ink4 : DmColors.border),
|
|
width: selected ? 2 : 1.5,
|
|
),
|
|
),
|
|
child: selected
|
|
? Container(
|
|
width: 10,
|
|
height: 10,
|
|
decoration: const BoxDecoration(
|
|
color: DmColors.brand,
|
|
shape: BoxShape.circle,
|
|
),
|
|
)
|
|
: null,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A tappable row with a leading tile — "Drop a map pin", "Saved addresses".
|
|
class DmActionRow extends StatelessWidget {
|
|
const DmActionRow({
|
|
super.key,
|
|
required this.icon,
|
|
required this.title,
|
|
required this.subtitle,
|
|
this.onTap,
|
|
this.subtitleColor,
|
|
this.active = false,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String title;
|
|
final String subtitle;
|
|
final VoidCallback? onTap;
|
|
final Color? subtitleColor;
|
|
|
|
/// Tints the tile once the action has been completed.
|
|
final bool active;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: DmColors.surface,
|
|
borderRadius: DmRadius.all(DmRadius.md),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: DmRadius.all(DmRadius.md),
|
|
child: Ink(
|
|
decoration: BoxDecoration(
|
|
color: DmColors.surface,
|
|
border: Border.all(color: DmColors.border),
|
|
borderRadius: DmRadius.all(DmRadius.md),
|
|
),
|
|
child: Container(
|
|
constraints: const BoxConstraints(minHeight: 64),
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 38,
|
|
height: 38,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: active ? DmColors.okSoft : DmColors.brandSoft,
|
|
borderRadius: DmRadius.all(DmRadius.xs),
|
|
),
|
|
child: Icon(
|
|
icon,
|
|
size: 18,
|
|
color: active ? DmColors.ok : DmColors.brand,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(title, style: DmText.cardTitle),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
subtitle,
|
|
style: DmText.small.copyWith(
|
|
color: subtitleColor ?? DmColors.ink2,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Icon(
|
|
LucideIcons.chevronRight,
|
|
size: 20,
|
|
color: DmColors.ink4,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|