Files
doormile_customer_app/lib/ui/widgets/option_tile.dart
Thiru-tenext 0d66627c3c 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>
2026-09-15 16:07:33 +05:30

286 lines
8.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;
final Color background = selected
? DmColors.brand
: (enabled ? DmColors.surface : DmColors.surfaceAlt);
final Color edge = selected
? DmColors.brand
: (enabled ? DmColors.border : DmColors.border);
final titleColor = selected
? Colors.white
: (enabled ? DmColors.ink : DmColors.ink3);
final subColor = selected
? DmColors.onBrand2
: (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: Border.all(color: edge),
boxShadow: selected ? DmShadow.lift : null,
),
child: Row(
children: [
if (widget.leading != null) ...[
Icon(
widget.leading,
size: 20,
color: selected ? Colors.white : 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,
border: Border.all(
color: selected
? Colors.white
: (enabled ? DmColors.ink4 : DmColors.border),
width: selected ? 2 : 1.5,
),
),
child: selected
? Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
color: Colors.white,
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,
),
],
),
),
),
),
);
}
}