Files
doormile_customer_app/lib/ui/widgets/option_tile.dart
Thiru-tenext 86b6af48c2 Redesign on the Stitch reference, in Plus Jakarta Sans
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
2026-09-24 11:08:25 +05:30

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,
),
],
),
),
),
),
);
}
}