import 'package:flutter/material.dart'; import '../tokens.dart'; /// One accent, three intensities — the design's `.st`. enum DmPillTone { /// Something is happening right now and nobody has to do anything: searching /// for a Miler, out for delivery. Crimson field, white label, solid dot. live, /// Moving, but between beats — assigned, in transit. /// /// The crimson used to be held in an outline *and* in the label, so a list /// of six orders was six red words. It is held in the dot now: the colour /// still says "this one is running", and the word is read at ink weight /// like every other word on the card. neutral, /// Finished. Grey, with a filled dot: the journey happened. done, /// Never happened. Grey, hollow dot. off, } /// Status pill used on order rows and at the head of a tracking card. class DmPill extends StatelessWidget { const DmPill(this.label, {super.key, this.tone = DmPillTone.neutral}); final String label; final DmPillTone tone; @override Widget build(BuildContext context) { final (Color bg, Color fg, BoxBorder? edge) = switch (tone) { DmPillTone.live => (DmColors.brand, Colors.white, null), DmPillTone.neutral => ( DmColors.surface, DmColors.ink, Border.all(color: DmColors.border), ), DmPillTone.done => (DmColors.surfaceAlt, DmColors.ink3, null), DmPillTone.off => (DmColors.surfaceAlt, DmColors.ink3, null), }; // The dot is the tone's tell at a glance, before the word is read: filled // for anything that happened or is happening, hollow for what never did. final hollow = tone == DmPillTone.off; // On the neutral pill the dot is the only crimson left, so it does not // take its colour from the label the way the other tones do. final dot = tone == DmPillTone.neutral ? DmColors.brand : fg; return Container( padding: const EdgeInsets.fromLTRB(11, 5, 11, 5), decoration: BoxDecoration( color: bg, border: edge, borderRadius: DmRadius.all(5), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 6, height: 6, margin: const EdgeInsets.only(right: 7), decoration: BoxDecoration( color: hollow ? Colors.transparent : dot, shape: BoxShape.circle, border: hollow ? Border.all(color: dot, width: 1.5) : null, ), ), Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: DmText.label.copyWith(fontSize: 12.5, color: fg), ), ), ], ), ); } } /// The pulsing dot that marks a live booking. class DmLiveDot extends StatefulWidget { const DmLiveDot({super.key, this.size = 9, this.color = DmColors.brand}); final double size; final Color color; @override State createState() => _DmLiveDotState(); } class _DmLiveDotState extends State with SingleTickerProviderStateMixin { late final AnimationController _c = AnimationController( vsync: this, duration: const Duration(milliseconds: 1900), ); @override void initState() { super.initState(); // A ring that never stops is a battery cost and, for anyone who asked the // system not to animate, a distraction. Honour the setting: the dot still // reads as live because it is a filled brand dot on a quiet row. if (!WidgetsBinding.instance.platformDispatcher.accessibilityFeatures.disableAnimations) { _c.repeat(); } } @override void dispose() { _c.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final span = widget.size + 10; return SizedBox( width: span, height: span, child: AnimatedBuilder( animation: _c, builder: (context, child) { return Stack( alignment: Alignment.center, children: [ if (_c.isAnimating) Transform.scale( scale: 1 + _c.value * 1.6, child: Opacity( opacity: (1 - _c.value) * 0.5, child: Container( width: widget.size, height: widget.size, decoration: BoxDecoration( color: widget.color, shape: BoxShape.circle, ), ), ), ), child!, ], ); }, child: Container( width: widget.size, height: widget.size, decoration: BoxDecoration( color: widget.color, shape: BoxShape.circle, ), ), ), ); } } /// Initials avatar. Crimson-filled on a light surface, lightened white when it /// sits on the brand header. class DmAvatar extends StatelessWidget { const DmAvatar({ super.key, required this.initials, this.size = 42, this.quiet = false, this.badge = false, }); final String initials; final double size; /// A wash with the initials in crimson, instead of a crimson disc. /// /// The solid disc is right where the avatar stands in for a person the /// customer is looking at — the Miler on tracking. It is wrong where the /// avatar is chrome: on Home it put a second saturated circle on a screen /// whose whole design rests on there being exactly one, and the one that /// matters is the sphere you press. final bool quiet; /// A brand dot on the lower-right, cut out of the surface behind it. Home /// wears it when a booking is live, which is the one thing the avatar's /// destination — Account — would not tell you. final bool badge; @override Widget build(BuildContext context) { final disc = Container( width: size, height: size, alignment: Alignment.center, decoration: BoxDecoration( color: quiet ? DmColors.brandSoft : DmColors.brand, shape: BoxShape.circle, border: quiet ? Border.all(color: DmColors.brandLine) : null, ), child: Text( initials, style: DmText.label.copyWith( // Scales with the disc. It was capped at 13pt, which is right on a // 42pt avatar in a row and looks like a typo on the 86pt one at the // top of Account. fontSize: size < 40 ? 12 : (size <= 48 ? 13 : size * 0.30), fontWeight: FontWeight.w800, fontVariations: const [FontVariation('wght', 800)], letterSpacing: 0.13, color: quiet ? DmColors.brand : Colors.white, ), ), ); if (!badge) return disc; return Stack( clipBehavior: Clip.none, children: [ disc, Positioned( right: 0, bottom: 1, child: Container( width: size * 0.28, height: size * 0.28, decoration: BoxDecoration( color: DmColors.brand, shape: BoxShape.circle, border: Border.all(color: DmColors.canvas, width: 2), ), ), ), ], ); } } /// The Doormile logomark: a door inside a house silhouette. class DoormileMark extends StatelessWidget { const DoormileMark({super.key, this.size = 52, this.inverted = false}); final double size; /// White strokes on nothing, for use on the crimson field. final bool inverted; @override Widget build(BuildContext context) { return Container( width: size, height: size, decoration: BoxDecoration( color: inverted ? Colors.transparent : DmColors.brand, borderRadius: DmRadius.all(size * 0.24), ), child: CustomPaint( painter: const _MarkPainter(), size: Size.square(size), child: SizedBox.square(dimension: size), ), ); } } class _MarkPainter extends CustomPainter { const _MarkPainter(); @override void paint(Canvas canvas, Size size) { final s = size.width; final paint = Paint() ..color = Colors.white ..style = PaintingStyle.stroke ..strokeWidth = s * 0.072 ..strokeJoin = StrokeJoin.round ..strokeCap = StrokeCap.round; // Roof + walls, drawn on a 24x24 grid scaled to the mark. double u(double v) => v / 24 * s * 0.62 + s * 0.19; final house = Path() ..moveTo(u(3.6), u(9.2)) ..lineTo(u(12), u(4)) ..lineTo(u(20.4), u(9.2)) ..lineTo(u(20.4), u(20)) ..lineTo(u(3.6), u(20)) ..close(); final door = Path() ..moveTo(u(9.4), u(20)) ..lineTo(u(9.4), u(13)) ..lineTo(u(14.6), u(13)) ..lineTo(u(14.6), u(20)); canvas.drawPath(house, paint); canvas.drawPath(door, paint); } @override bool shouldRepaint(covariant _MarkPainter oldDelegate) => false; }