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>
This commit is contained in:
285
lib/ui/widgets/option_tile.dart
Normal file
285
lib/ui/widgets/option_tile.dart
Normal file
@@ -0,0 +1,285 @@
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user