The Tiny Red Dot That Wasn't Tiny
Today’s task looked harmless from the outside: show unread notifications, let users mark them as seen, and put a red dot on the bell icon when there is something new.
Classic mobile app stuff. Nothing dramatic. Just a bell, a list, a few cards, and one dot.
Naturally, it was not that simple.
The funny thing about notification work is that it lives in the uncomfortable space between backend truth and frontend immediacy. The backend knows what is actually unread. The UI knows what the user just tapped. Push notifications know something happened. Sockets know something happened too, sometimes faster. And snackbars are off on the side saying, “Hey, should I also be involved?”
By midday, the red dot was no longer a red dot. It was a state synchronization problem wearing a very small costume.
The backend changed its mind, which was actually helpful
The first shape of the notification payload was fairly raw. Every notification had a type, a timestamp, a read flag, and optional metadata. Some metadata had a bookingId, messages had sender/message IDs, and a few account-related notifications had no metadata at all.
Then the backend evolved in a useful direction: every notification metadata payload started carrying a summary field.
That changed the UI strategy quite a bit.
Instead of the app inventing every notification message from scratch, the backend could provide the user-facing summary while Flutter handled presentation: title, tone, icon/avatar, time label, action hints, and where the tap should eventually go.
That felt like the right division of responsibility.
The server knows the event context. The app knows how to make it feel native.
So the mapper became less of a sentence factory and more of a translation layer:
class NotificationUiItem {
final String id;
final String type;
final String title;
final String summary;
final DateTime createdAt;
final bool read;
final String? bookingId;
final String? serviceId;
final String? senderId;
final String? messageId;
final String? photo;
final NotificationUiTone tone;
const NotificationUiItem({
required this.id,
required this.type,
required this.title,
required this.summary,
required this.createdAt,
required this.read,
required this.tone,
this.bookingId,
this.serviceId,
this.senderId,
this.messageId,
this.photo,
});
}
Nothing wild there, but it gave the UI a stable language. Client notifications and vendor notifications could both consume the same backend response without pretending they were separate worlds.
That was one of the nicer parts of the day: deleting mental duplication before it had time to harden.
The trap: “just fetch when something happens”
At some point I had to decide how the red dot should react to realtime events.
The obvious approach is tempting:
Socket event comes in, call
GET https://api.example.com/notifications, update the unread state.
It sounds clean because the backend remains the single source of truth. But it also means every realtime event can become a network call. Booking accepted? Fetch. Client paid? Fetch. Vendor account approved? Fetch. Push received while the app is open? Fetch again.
That starts to feel a little too eager.
The red dot does not need the full notification list. It only needs to know whether there is probably something unread.
So I split the behavior into two layers:
Realtime events are allowed to set
hasUnreadtotrueimmediately.The actual notifications endpoint is fetched when the user opens or refreshes the notifications screen.
Tapping or clearing notifications updates the badge optimistically.
A successful refresh becomes the source of truth again.
The controller ended up intentionally small:
class UnreadNotificationsController {
final ValueNotifier<bool> hasUnread = ValueNotifier<bool>(false);
Future<void> refreshFromBackend() async {
final result = await getUnreadNotifications();
result.fold(
(_) {
// Keep the current local state if refresh fails.
},
(response) {
hasUnread.value = response.notifications.isNotEmpty;
},
);
}
void notifyNewUnread() {
hasUnread.value = true;
}
void markAllSeenLocally() {
hasUnread.value = false;
}
}
The small detail I care about there: failed refresh does not force the dot off.
That is the kind of bug that feels tiny until a user sees it. Imagine a socket event turns the dot on, then a shaky network request fails, and suddenly the app says there are no notifications. That is not truth. That is the UI being overconfident.
So failure preserves the last known state.
Optimistic UI, but not reckless UI
The notification cards needed two actions: tap one notification as seen, and clear all notifications.
Both should feel immediate. Nobody wants to tap a notification and wait for a spinner before the card visually calms down.
So the screen fades a card instantly when tapped, calls the mark-as-seen endpoint in the background, and restores state only if the request fails.
Same with clear-all: empty the list immediately, turn off the badge, then restore if the backend refuses.
That pattern is simple on paper, but it gets tricky around shared state. The screen has local state like:
final Set<String> seenNotificationIds = {};
The badge has global-ish app state:
final ValueNotifier<bool> hasUnread;
Those two need to agree without becoming the same thing.
Local card fading is about presentation. The badge is about app-level unread status. If one notification is tapped but others remain unread, the card should fade but the bell should stay red. If the last visible unread notification is tapped, the bell can turn off optimistically.
That distinction saved the implementation from becoming mushy.
Messages were the one awkward guest
Messages complicated the decision a bit.
The app already has message socket events and snackbars. A new chat message can appear while the user is in another tab, and the app may show a snackbar preview. The backend may also create a notification for that message. But once the user reads the chat, the backend can mark that message notification as read on the next fetch.
So should message events turn on the notification bell dot?
I chose not to, at least for now.
Not because messages are unimportant. Actually, the opposite: messages often deserve their own unread behavior. If the chat tab already has message-specific state, using the general notification bell for every message can make the app feel noisy.
So the controller ignores message-style push events and lets the backend refresh settle it later:
void handlePushPayload(PushPayload payload) {
final eventType = payload.eventType.trim();
if (payload.isMessageEvent || eventType == 'NEW_MESSAGE') return;
if (eventType.isEmpty) return;
notifyNewUnread();
}
This means booking, payment, account, document, and status notifications can light the bell immediately. Messages can still appear in the notifications list if the backend returns them, but they do not drive the red dot in realtime.
That might change later. And if it does, the change is thankfully tiny: remove that guard and subscribe to message socket events too.
I like when a decision is reversible without surgery.
The part that felt architectural
The more interesting thought came after the unread state was working: what should happen when a user taps a notification card?
Push notifications already navigate somewhere. Usually not super deep, but enough to open the right section of the app. For example, a booking event might open the bookings tab. A message might open the messages tab. A payout-related notification might open the payout screen.
The in-app notification cards should not invent a second routing brain.
The cleaner direction is to create a shared notification navigation resolver. Push payloads and backend notification cards can both be converted into the same internal input:
class NotificationRouteInput {
final String type;
final String? bookingId;
final String? serviceId;
final String? senderId;
final String? messageId;
final String? vendorId;
const NotificationRouteInput({
required this.type,
this.bookingId,
this.serviceId,
this.senderId,
this.messageId,
this.vendorId,
});
}
Then the resolver decides:
message notification: messages tab, or exact chat if enough data exists
booking notification: bookings tab, ideally with the exact booking opened
document notification: payout or document area
account rejected/suspended: support or account status screen
vendor application: admin approval flow, if the app has one
The important thing is consistency. If a push notification and an in-app notification represent the same event, they should not send the user to different places because two separate pieces of code drifted apart.
That is the kind of duplication that does not hurt today, then quietly taxes every future feature.
What I’d keep from today
The biggest lesson was not about ValueNotifier, sockets, or endpoints. It was about being careful with what kind of truth each part of the app owns.
The backend owns durable unread state.
Sockets and push own immediacy.
Notification screens own presentation and local optimistic feedback.
The badge owns one tiny app-level question: “Should I show a dot?”
Once those boundaries were clear, the implementation stopped feeling like a pile of callbacks and started feeling like a small system.
Still a slightly annoying system. Notifications always are. They are where product expectations, backend timing, OS behavior, and user attention all meet in a narrow hallway.
But today’s version is better than the naive one I almost built.
No overfetching on every event. No forcing the dot off on refresh failure. No duplicated client/vendor notification models. No pretending message notifications are the same as booking/payment/account events when the app already treats chat specially.
All of that for a red dot.
A very small red dot.