Redesign: Poppins, a two-step destination, and a splash that says what the app does
The effort pass, end to end. Every screen was run through one test — if I remove this sentence, does the customer make a worse decision? — and the parts that failed it are gone. The flow Home ▸ BOOK ▸ Where is it going? ▸ When shall we collect? ▸ details ▸ booked BOOK opens a sheet, not a form. The destination is browsed state-then-district because a flat list of every serviceable district survives twelve and not sixty, and search cuts across states because somebody who knows they are sending to Chennai should not have to know which state it is in. Districts multi-select, but only where the server allows it: BookingLimits advertises maxDestinations: 1 until the Miler build keys on consignmentid, and a sheet that ignored that would sell a booking the network cannot complete. The pickup window is now a step the customer answers rather than a slot chosen for them. A pickup window is a promise about somebody's afternoon. What the screens stopped saying Home lost the orb caption for returning customers and a four-cell live card. Send lost the city strip, both address fields, the optional disclosure and three sentences about charging — the route, the packages and the button are what is left. Tracking lost a radar with a bike in it, a Milers-in-your-zone count, a "Step 2 of 7" and a sentence describing the screen you were looking at. The window sheet lost "Fastest pickup", "4 Milers nearby" and "Relaxed evening handover". Type Poppins, which has no variable release — four static cuts, and the sans styles set fontWeight alone because fontVariations on a static font is ignored in silence. Every weight dropped a step and the tracking went deeper: Poppins is built on near-circles and carries more ink than the humanist faces before it. Objects One lit sphere on Home, and the primary button now takes its gradient and rim because a committing action that is not lit like the hero reads as a different material. The tracking rail's connector is crimson as far as the parcel has come, so the line is the progress bar. Confirmation is a white tick on green: crimson is this app's action colour and that screen has nothing left to do. Bugs found on the way The OTP screen dropped digits. Four fields passing focus along lose a keystroke that arrives mid-transition, so "1234" became "124" and the screen answered "That code did not match" — blaming the customer for its own race. One field now, four boxes that only draw. Nothing ever asked for the customer's location: detectPickupLocation was the OTP screen's job, so a restored session or an auto-login never triggered the permission prompt and the pickup map had nothing to centre on. The launcher icon and both splash screens pointed at a house drawn as two vector paths — a placeholder that shipped. The splash clock started when the widget was built rather than when it was visible, so the truck got 0.45s of a 1.8s beat behind Android's own splash. It waits on waitUntilFirstFrameRasterized now, raced against a timeout so a binding that never reports one cannot strand the app. Also: design/screens/ holds all 19 screens under readable names, tool/ has the scripts that refresh them and rebrand the Lottie, and DESIGN.md is current. flutter analyze clean. 88 tests, 1 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
This commit is contained in:
446
lib/ui/screens/sheets/destination_sheet.dart
Normal file
446
lib/ui/screens/sheets/destination_sheet.dart
Normal file
@@ -0,0 +1,446 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../data/models.dart';
|
||||
import '../../../state/app_scope.dart';
|
||||
import '../../state_marks.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import '../../widgets/inputs.dart';
|
||||
import '../../widgets/states.dart';
|
||||
|
||||
/// "Where is it going?" — the first and only question after BOOK.
|
||||
///
|
||||
/// ── State, then district ──
|
||||
///
|
||||
/// It was one flat list of every serviceable district, on the argument that
|
||||
/// "nobody picks Tamil Nadu on the way to picking Chennai". True of twelve
|
||||
/// districts and false of sixty: the list this app will have in a year is a
|
||||
/// scroll, not a glance.
|
||||
///
|
||||
/// Browsing is two steps, and the **states carry the marks**. Districts do not:
|
||||
/// at state level a mark is a bounded editorial problem, and at district level
|
||||
/// it is an opinion about every district in India that nobody can keep honest.
|
||||
///
|
||||
/// **Search cuts straight through.** Typing at the first step searches
|
||||
/// districts across every state and returns them flat — somebody who knows
|
||||
/// they are sending to Chennai should not have to know which state it is in.
|
||||
///
|
||||
/// ── Several places in one pickup, but only when the server says so ──
|
||||
///
|
||||
/// One Miler visit can collect for more than one destination, and this sheet
|
||||
/// is where that is chosen: districts toggle, the selection survives moving
|
||||
/// between states, and the footer says how many are in.
|
||||
///
|
||||
/// It is single-select whenever [BookingLimits.allowsMultipleDestinations] is
|
||||
/// false, which is what the live backend advertises today. That is not a
|
||||
/// nicety. Multi-destination is undeliverable until the Miler build keys its
|
||||
/// work on `consignmentid`, so a sheet that let a customer pick three places
|
||||
/// against a server capped at one would sell them a booking the network cannot
|
||||
/// complete. The cap is read from the server on every launch and this sheet
|
||||
/// obeys it: one place means one tap and no confirm button, because a
|
||||
/// "Continue" under a single-select list asks the customer to say the same
|
||||
/// thing twice.
|
||||
Future<List<CityOption>?> showDestinationSheet(BuildContext context) {
|
||||
return showDmSheet<List<CityOption>>(
|
||||
context: context,
|
||||
builder: (context) => const _DestinationSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
class _DestinationSheet extends StatefulWidget {
|
||||
const _DestinationSheet();
|
||||
|
||||
@override
|
||||
State<_DestinationSheet> createState() => _DestinationSheetState();
|
||||
}
|
||||
|
||||
class _DestinationSheetState extends State<_DestinationSheet> {
|
||||
final _query = TextEditingController();
|
||||
String _q = '';
|
||||
|
||||
/// The state being browsed, or null while the states themselves are.
|
||||
ServiceArea? _openState;
|
||||
|
||||
/// Chosen districts, by district code, in the order they were picked.
|
||||
final _chosen = <String, CityOption>{};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_query.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _open(ServiceArea area) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() {
|
||||
_openState = area;
|
||||
_query.clear();
|
||||
_q = '';
|
||||
});
|
||||
}
|
||||
|
||||
void _back() {
|
||||
setState(() {
|
||||
_openState = null;
|
||||
_query.clear();
|
||||
_q = '';
|
||||
});
|
||||
}
|
||||
|
||||
void _tap(CityOption city, {required bool multi, required int cap}) {
|
||||
HapticFeedback.selectionClick();
|
||||
if (!multi) {
|
||||
Navigator.of(context).pop([city]);
|
||||
return;
|
||||
}
|
||||
final code = city.district.code;
|
||||
setState(() {
|
||||
if (_chosen.remove(code) != null) return;
|
||||
if (_chosen.length >= cap) {
|
||||
DmToast.show(
|
||||
context,
|
||||
'One pickup can serve up to $cap places',
|
||||
);
|
||||
return;
|
||||
}
|
||||
_chosen[code] = city;
|
||||
});
|
||||
}
|
||||
|
||||
/// The states, derived from the district list rather than fetched.
|
||||
///
|
||||
/// `loadCities` already returns every *serviceable* district with its state
|
||||
/// attached, so grouping it is one request instead of two and is the only
|
||||
/// version that cannot show a state with nothing open inside it.
|
||||
List<ServiceArea> _statesOf(List<CityOption> cities) {
|
||||
final seen = <String, ServiceArea>{};
|
||||
for (final city in cities) {
|
||||
seen.putIfAbsent(city.state.code, () => city.state);
|
||||
}
|
||||
return seen.values.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
final browsing = _openState;
|
||||
final cap = app.limits.maxDestinations;
|
||||
final multi = app.allowsMultipleDestinations;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Back inside the sheet, not out of it: the customer is one level
|
||||
// into a question they are still answering, and dismissing the
|
||||
// whole thing to change their mind about a state would throw away
|
||||
// both the step they got right and everything they have ticked.
|
||||
if (browsing != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: InkResponse(
|
||||
onTap: _back,
|
||||
radius: 22,
|
||||
highlightShape: BoxShape.circle,
|
||||
splashColor: DmColors.brandSoft,
|
||||
highlightColor: DmColors.brandSoft,
|
||||
child: const SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: Icon(
|
||||
LucideIcons.arrowLeft,
|
||||
size: 20,
|
||||
color: DmColors.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: DmSheetHeader(
|
||||
title: browsing?.name ?? 'Where is it going?',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
DmSearchField(
|
||||
controller: _query,
|
||||
hint: browsing == null
|
||||
? 'Search for a city'
|
||||
: 'Search in ${browsing.name}',
|
||||
onChanged: (v) => setState(() => _q = v.trim().toLowerCase()),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Flexible(
|
||||
child: DmAsyncList<CityOption>(
|
||||
key: const ValueKey('destinationList'),
|
||||
skeletonRows: 4,
|
||||
load: ({bool refresh = false}) => app.loadCities(refresh: refresh),
|
||||
emptyIcon: LucideIcons.mapPinOff,
|
||||
emptyTitle: 'No cities open yet',
|
||||
emptyMessage:
|
||||
"We're not accepting new pickups right now. Please check "
|
||||
'back shortly.',
|
||||
builder: (context, cities) => SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
..._body(cities, multi: multi, cap: cap),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (multi && _chosen.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
DmButton(
|
||||
label: _chosen.length == 1
|
||||
? 'Send to ${_chosen.values.first.district.name}'
|
||||
: 'Send to ${_chosen.length} places',
|
||||
icon: LucideIcons.arrowRight,
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(_chosen.values.toList()),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _body(
|
||||
List<CityOption> cities, {
|
||||
required bool multi,
|
||||
required int cap,
|
||||
}) {
|
||||
final browsing = _openState;
|
||||
|
||||
Widget district(CityOption city) => _DistrictRow(
|
||||
city: city,
|
||||
multi: multi,
|
||||
chosen: _chosen.containsKey(city.district.code),
|
||||
onTap: () => _tap(city, multi: multi, cap: cap),
|
||||
);
|
||||
|
||||
// Searching from the top level goes straight to districts, across every
|
||||
// state. Searching inside one stays inside it.
|
||||
if (_q.isNotEmpty) {
|
||||
final pool = browsing == null
|
||||
? cities
|
||||
: cities.where((c) => c.state.code == browsing.code);
|
||||
final matches = pool
|
||||
.where((c) => c.label.toLowerCase().contains(_q))
|
||||
.toList();
|
||||
if (matches.isEmpty) {
|
||||
return const [
|
||||
DmEmptyState(
|
||||
icon: LucideIcons.searchX,
|
||||
title: 'No match',
|
||||
message: 'Try another city.',
|
||||
),
|
||||
];
|
||||
}
|
||||
return [for (final city in matches) district(city)];
|
||||
}
|
||||
|
||||
if (browsing != null) {
|
||||
return [
|
||||
for (final city in cities.where((c) => c.state.code == browsing.code))
|
||||
district(city),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
for (final area in _statesOf(cities))
|
||||
_StateRow(
|
||||
area: area,
|
||||
// The count is the reason to open it, and it is free: the list it
|
||||
// came from is the list the next step shows.
|
||||
count: cities.where((c) => c.state.code == area.code).length,
|
||||
// How many of this state's districts are already in, so a customer
|
||||
// who has ticked two in Tamil Nadu can see that from the top.
|
||||
chosen: cities
|
||||
.where(
|
||||
(c) =>
|
||||
c.state.code == area.code &&
|
||||
_chosen.containsKey(c.district.code),
|
||||
)
|
||||
.length,
|
||||
onTap: () => _open(area),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// One state: its mark, its name, and how many cities are open inside it.
|
||||
class _StateRow extends StatelessWidget {
|
||||
const _StateRow({
|
||||
required this.area,
|
||||
required this.count,
|
||||
required this.chosen,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final ServiceArea area;
|
||||
final int count;
|
||||
final int chosen;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _Row(
|
||||
onTap: onTap,
|
||||
leading: DmTile(icon: stateMark(area.code), tone: DmTileTone.wash),
|
||||
title: area.name,
|
||||
subtitle:
|
||||
'$count ${count == 1 ? 'city' : 'cities'}'
|
||||
'${area.transitTag == null ? '' : ' · ${area.transitTag}'}',
|
||||
trailing: chosen == 0
|
||||
? null
|
||||
: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.brand,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
),
|
||||
child: Text(
|
||||
'$chosen',
|
||||
style: DmText.label.copyWith(color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One district. No mark — the name and the promise are the whole row, and a
|
||||
/// glyph beside every one of them would be decoration competing with the only
|
||||
/// two things on it that differ.
|
||||
class _DistrictRow extends StatelessWidget {
|
||||
const _DistrictRow({
|
||||
required this.city,
|
||||
required this.multi,
|
||||
required this.chosen,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final CityOption city;
|
||||
final bool multi;
|
||||
final bool chosen;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _Row(
|
||||
onTap: onTap,
|
||||
title: city.district.name,
|
||||
subtitle: city.note,
|
||||
selected: chosen,
|
||||
// A tick box only where ticking is possible. Where the server caps the
|
||||
// booking at one place the row is a choice, not a checklist, and a box
|
||||
// would promise a second pick the backend will refuse.
|
||||
trailing: multi ? _Check(on: chosen) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Check extends StatelessWidget {
|
||||
const _Check({required this.on});
|
||||
|
||||
final bool on;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: DmMotion.fast,
|
||||
curve: DmMotion.ease,
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: on ? DmColors.brand : Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: on ? DmColors.brand : DmColors.border,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: on
|
||||
? const Icon(LucideIcons.check, size: 14, color: Colors.white)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape both rows share: a target, a title, a quiet line, a trailing mark.
|
||||
class _Row extends StatelessWidget {
|
||||
const _Row({
|
||||
required this.onTap,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.leading,
|
||||
this.trailing,
|
||||
this.selected = false,
|
||||
});
|
||||
|
||||
final VoidCallback onTap;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
final bool selected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: DmSpace.tap),
|
||||
padding: EdgeInsets.symmetric(vertical: leading == null ? 14 : 10),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: DmColors.hairline)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (leading != null) ...[leading!, const SizedBox(width: 14)],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: DmText.cardTitle.copyWith(
|
||||
color: selected ? DmColors.brand : null,
|
||||
),
|
||||
),
|
||||
if (subtitle != null)
|
||||
Text(
|
||||
subtitle!,
|
||||
style: DmText.tiny,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
trailing ??
|
||||
const Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 16,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import '../../../state/app_state.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import '../../widgets/inputs.dart';
|
||||
import '../../widgets/states.dart';
|
||||
|
||||
/// Alias used from Review, where the sheet edits the pickup point in place.
|
||||
@@ -56,37 +57,11 @@ class _PlaceSearchBodyState extends State<_PlaceSearchBody> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const DmSheetHeader(
|
||||
title: 'Pickup location',
|
||||
subtitle: 'Search or pick a saved place.',
|
||||
),
|
||||
TextField(
|
||||
const DmSheetHeader(title: 'Collect from'),
|
||||
DmSearchField(
|
||||
controller: _query,
|
||||
hint: 'Search street, area or landmark',
|
||||
onChanged: _onChanged,
|
||||
autofocus: false,
|
||||
cursorColor: DmColors.brand,
|
||||
style: DmText.field,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search street, area or landmark',
|
||||
hintStyle: DmText.field.copyWith(
|
||||
color: DmColors.ink3,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
LucideIcons.search,
|
||||
size: 20,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: DmColors.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
border: _border(DmColors.lineStrong, 1),
|
||||
enabledBorder: _border(DmColors.lineStrong, 1),
|
||||
focusedBorder: _border(DmColors.brand, 2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Flexible(
|
||||
@@ -125,10 +100,6 @@ class _PlaceSearchBodyState extends State<_PlaceSearchBody> {
|
||||
);
|
||||
}
|
||||
|
||||
OutlineInputBorder _border(Color color, double width) => OutlineInputBorder(
|
||||
borderRadius: DmRadius.all(DmRadius.sm),
|
||||
borderSide: BorderSide(color: color, width: width),
|
||||
);
|
||||
}
|
||||
|
||||
class _PlaceRow extends StatelessWidget {
|
||||
|
||||
@@ -50,14 +50,7 @@ class _WindowSheetState extends State<_WindowSheet> {
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DmSheetHeader(
|
||||
title: 'Pickup window',
|
||||
subtitle: (app.draftPickup ?? app.pickup) == null
|
||||
? null
|
||||
: 'Collecting from ${(app.draftPickup ?? app.pickup)!.title}',
|
||||
),
|
||||
),
|
||||
const Expanded(child: DmSheetHeader(title: 'Pickup window')),
|
||||
DmIconButton(
|
||||
icon: LucideIcons.x,
|
||||
tooltip: 'Close',
|
||||
@@ -80,7 +73,13 @@ class _WindowSheetState extends State<_WindowSheet> {
|
||||
}
|
||||
final day = days.contains(_day) ? _day! : days.first;
|
||||
final shown = slots.where((s) => s.day == day).toList();
|
||||
final selected = _picked ?? _slotById(slots, app.draftSlotId);
|
||||
// Opens on something valid: the slot already chosen, or the
|
||||
// first one still available. A sheet whose only button is
|
||||
// disabled on arrival has made the customer work to reach the
|
||||
// state it could have started in.
|
||||
final selected = _picked ??
|
||||
_slotById(slots, app.draftSlotId) ??
|
||||
shown.where((s) => s.available).firstOrNull;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
@@ -158,27 +157,24 @@ class _SlotRow extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = slot.available;
|
||||
|
||||
// ── The time, and nothing else ──
|
||||
//
|
||||
// Each row carried up to four things: a "Fastest pickup" tag, a
|
||||
// "4 Milers nearby" tag, the window, and a caption like "Relaxed evening
|
||||
// handover". Three of those were the backend selling a slot to somebody
|
||||
// who had already decided to book. The only question this sheet asks is
|
||||
// *when*, and the answer is a time — so the time is the row.
|
||||
//
|
||||
// An unavailable slot keeps its note, because "Fully booked" is the reason
|
||||
// the row cannot be tapped and removing it would leave a dead row with no
|
||||
// explanation.
|
||||
return DmOptionTile(
|
||||
title: slot.window,
|
||||
titleStyle: DmText.display.copyWith(fontSize: 19, height: 1.2),
|
||||
subtitle: enabled ? slot.caption : (slot.note ?? 'Unavailable'),
|
||||
titleStyle: DmText.display.copyWith(fontSize: 21, height: 1.2),
|
||||
subtitle: enabled ? null : (slot.note ?? 'Unavailable'),
|
||||
selected: selected,
|
||||
enabled: enabled,
|
||||
onTap: onTap,
|
||||
tags: [
|
||||
if (slot.tag != null)
|
||||
DmTag(
|
||||
slot.tag!,
|
||||
tone: selected ? DmTagTone.onFill : DmTagTone.brand,
|
||||
icon: LucideIcons.zap,
|
||||
),
|
||||
if (slot.milersNearby > 0)
|
||||
DmTag(
|
||||
'${slot.milersNearby} Milers nearby',
|
||||
tone: selected ? DmTagTone.onFill : DmTagTone.neutral,
|
||||
icon: LucideIcons.userRound,
|
||||
),
|
||||
],
|
||||
trailing: enabled
|
||||
? null
|
||||
: DmTextAction(
|
||||
|
||||
Reference in New Issue
Block a user