Some tasks arrive wearing a tiny hat.
Today’s was: “inspect the vendor search screen.” Simple enough. Search screen, endpoint response, pagination, a couple of screenshots. Nothing too wild.
Then I looked closer and realized the UI was quietly making a promise the backend data did not support.
That is usually where mobile work gets interesting. Not broken in the loud, stack-trace way. Broken in the more slippery way, where the app technically works, but the user walks away with the wrong idea.
The suspicious “Price on request”
The search endpoint was returning vendors with two different service-ish fields:
At a glance, both sounded useful. One had pricing information. The other had names like photography, lighting, security, and catering. The UI had been built to show services first, and if that list was empty, fall back to vendorServices with the label “Price on request.”
That fallback looked harmless. It even made the screen feel more complete.
But it was wrong.
The deeper distinction was this:
services represented actual bookable services with prices and service IDs.
vendorServices represented capabilities or declared service categories.
That second list was not enough to create a booking. There was no real pricing model, no service ID, and no guarantee that the item was ready for a client to book. So showing “Price on request” created a nice-looking card while hiding the truth: this vendor had not added bookable services yet.
The old logic was basically:
List<ServiceDisplay> resolveServices(Vendor vendor) {
final services = vendor.services.map(mapPricedService).toList();
if (services.isNotEmpty) return services;
return vendor.vendorServices.map((service) {
return ServiceDisplay(
name: service.name,
priceText: 'Price on request',
);
}).toList();
}
The UI was trying to be helpful, but it was flattening two different concepts into one. That is one of those bugs that does not look like a bug until you ask, “Can the user act on this?”
In this case, the answer was no.
So the better version became much more boring, and much more honest:
List<ServiceDisplay> resolveServices(Vendor vendor) {
final services = vendor.services
.map(mapPricedService)
.whereType<ServiceDisplay>()
.take(2)
.toList();
if (services.isNotEmpty) return services;
return const [];
}
Then the card could render an empty state instead of pretending there was something to book.
Something like:
if (visibleServices.isEmpty) {
return Text('This vendor has no services yet.');
}
Not glamorous. Very necessary.
The profile screen had to tell the same truth
The search card was only half the story.
The vendor profile screen already had a guard in the booking flow. If a user tapped the book button and there were no services, the app showed an error message. Technically correct, but emotionally clumsy.
From a user’s point of view, the app was saying:
“Book this vendor.”
Then after the tap:
“Actually, no.”
That is not a great contract.
So the bottom CTA had to become state-aware. If the vendor had bookable services, keep the normal booking CTA and the small chat button. If the vendor had no services, remove booking entirely and make chat the primary action.
The important part was not just disabling a button. It was changing the user’s available path:
bool get hasBookableServices => vendor?.services.isNotEmpty == true;
bool get showChatAsPrimaryCta =>
!isLoading && vendor != null && !hasBookableServices;
That extra loading/vendor check matters. Without it, the screen could briefly think “no services” while the vendor was still loading and show the wrong CTA. Flutter will happily rebuild during every tiny state transition, so the UI has to be careful about what a state actually means.
There is a difference between:
- we have loaded the vendor and they have no services
- we have not loaded the vendor yet
- the request failed and vendor is null
Those states can all look like “empty” if you write the condition too casually.
That was the small detail that made me pause.
Search, suggestions, and the backend contract
The next piece was the search behavior itself.
There was a text field for vendor name search, and there were suggestion chips like “Caterer,” “Event Planner,” “Baker,” and “Rentals.” Originally, tapping a suggestion sent the chip text as the name query parameter.
That worked mechanically, but conceptually it was off.
Those suggestions were not vendor names. They were category or service filters.
The temptation in a mobile app is to “fix” this locally. Filter the current list in Dart, compare strings, hide cards, call it a day. But that would have been the wrong layer. The backend already owned filtering, pagination, and matching logic. The app’s responsibility was just to call the endpoint with the right parameters and render the result.
So suggestion taps became category calls:
static const suggestionCategoryValues = {
'Caterer': 'CATERER',
'Sound Engineer': 'SOUND_AND_PA_SYSTEM_PROVIDER',
'Event Planner': 'EVENT_PLANNER',
'Rentals': 'RENTALS',
'Food': 'FOOD_AND_BEVERAGE',
};
Future<void> onSuggestionTapped(String suggestion) async {
final category = suggestionCategoryValues[suggestion] ?? suggestion;
searchController.text = suggestion;
filterCategory = category;
await fetchVendors(
name: null,
category: category,
page: 1,
);
}
That little map is not the most exciting code in the world, but it protects the API contract. Display labels can be friendly. Query values can stay backend-shaped.
Mobile apps live in that translation layer more often than we admit.
The filter sheet caught up later
Once suggestions were using category filtering, another inconsistency appeared immediately: the bottom sheet had filters for location and price, but not category.
So category filtering existed only through suggestions. That meant users could discover it by accident, but not intentionally. Not great.
The filter sheet needed a category dropdown that did the same thing: send a category query to the backend.
The interesting decision here was what values to show and what values to send. I wanted the UI to say “Food and Beverage,” not FOOD_AND_BEVERAGE. But the API still needed the enum-style value.
So the dropdown carried backend values internally and used a display mapper for labels:
static const categoryOptions = [
'FOOD_AND_BEVERAGE',
'EVENT_PLANNER',
'PHOTOGRAPHER',
'RENTALS',
];
static const categoryLabels = {
'FOOD_AND_BEVERAGE': 'Food and Beverage',
'EVENT_PLANNER': 'Event Planner',
'PHOTOGRAPHER': 'Photographer',
'RENTALS': 'Rentals',
};
String formatCategory(String value) {
return categoryLabels[value] ?? value.replaceAll('_', ' ');
}
This is one of those places where clean architecture is not a diagram. It is a tiny choice about which string crosses a boundary.
The app can be warm and readable. The network request can be strict and boring. Everyone wins.
The sneaky problem with clearing results
After the category dropdown went in, another UX issue showed up.
Text search had a clear button inside the search field. If I typed a vendor name, I could tap the suffix X and reset the search.
But filters from the bottom sheet did not always put visible text in the search field. So after filtering by category, location, or price, the user could see “Result(s) Found” but had no obvious way to get back to the unfiltered list.
This is the kind of detail that is easy to miss when testing happy paths. The data was correct. The request was correct. Pagination still worked. But the screen had trapped the user in a filtered state unless they reopened the sheet and manually changed things.
So I added a small clear action beside the results count.
Not a giant reset panel. Not another modal. Just an X next to:
12 Result(s) Found
That X clears:
- name search
- category
- location
- price range
- suggestion state
Then it reloads page one from the backend with no filters.
The method is intentionally blunt:
Future<void> clearSearchAndFilters() async {
searchController.clear();
filterLocation = null;
filterMinPrice = null;
filterMaxPrice = null;
filterCategory = null;
categorySearchText = null;
await fetchVendors(
name: null,
category: null,
location: null,
minPrice: null,
maxPrice: null,
page: 1,
);
}
Sometimes the cleanest UX state reset is not clever. It is just complete.
The thing I kept coming back to
The technical work today was not hard because of algorithms. It was hard because the screen had several overlapping meanings:
- A search query can be a vendor name.
- A suggestion looks like text but behaves like a category filter.
- A vendor can have declared capabilities but no bookable services.
- A profile can be valid but not bookable.
- Empty data can mean “not loaded yet” or “loaded and truly empty.”
The main lesson was that UI should not invent certainty the data does not provide.
If there are no bookable services, say that.
If the backend owns filtering, call the backend.
If a filter can be applied, give the user a way to clear it.
Tiny decisions. Big difference.
By the end, the screen felt less magical and more honest. That is usually a good trade.