When a Button Should Do More Than Close

posted 6 min read

Some days in Flutter are not about huge refactors or dramatic crashes. Some days are about a button that says one thing and does another.

Today’s task looked harmless: clean up a few dialog buttons in an event-planning mobile app. The dialogs were already showing at the right moments. The UI looked fine enough. The copy was clear.

But then I tapped the buttons mentally and noticed the problem: most of them just closed the dialog.

That is not always wrong. Sometimes a dialog is only a nudge. Sometimes “Okay” really does mean “go away, I understand.” But if a button says “View Pending Tasks” and then only pops the modal, that is a tiny betrayal. Not catastrophic, not release-blocking, just one of those product papercuts that makes an app feel less intentional.

So the day became less about button styling and more about answering a deceptively annoying question:

The dialog was innocent

The dialog components themselves were simple Flutter widgets. A few icons, some copy, and an ElevatedButton at the bottom.

The original pattern looked roughly like this:

class DayDialog extends StatelessWidget {
  const DayDialog({super.key});

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {
        Navigator.pop(context);
      },
      child: const Text("Let's Go"),
    );
  }
}

There is nothing technically broken about that. It compiles. It works. The dialog closes.

But the app had dialogs with stronger intent:

  • “It’s D-Day” should take the user to the event happening today.
  • “You’ve got tasks waiting” should take the user somewhere useful for those tasks.
  • “Missed something?” should open notifications.
  • “One step closer” might honestly just dismiss, because it is more encouragement than navigation.

That last distinction ended up mattering. Not every button needs to become a route change just because we are in a “make buttons useful” mood.

The first useful clue: don’t make the dialog smart

The tempting mistake would have been to import screens into every dialog and let the dialog figure out where to navigate.

That would work for about five minutes.

Then the dialog layer would start knowing about event models, notification screens, task picking rules, dashboard cache, route transitions, and probably someone’s lunch order if I looked away long enough.

The cleaner move was to keep the dialogs dumb and let the screen that shows them inject behavior.

So the dialog becomes callback-driven:

class DayDialog extends StatelessWidget {
  final VoidCallback? onLetsGo;

  const DayDialog({
    super.key,
    this.onLetsGo,
  });

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {
        if (onLetsGo != null) {
          onLetsGo!();
          return;
        }

        Navigator.pop(context);
      },
      child: const Text("Let's Go"),
    );
  }
}

This is a small change, but it keeps the boundary clean:

  • The dialog owns presentation.
  • The home screen owns app state.
  • Navigation is decided where the relevant state already exists.

Flutter makes it very easy to blur those lines because BuildContext is everywhere. That convenience is lovely until every widget becomes slightly aware of the entire app.

D-Day was the easy one

The D-Day dialog had a nice property: the event it referred to was already known.

The home screen was computing the earliest upcoming event and storing it as a mapped event model. The D-Day eligibility check was based on that same event date. In plain terms, if the app says “your event starts now,” then the event to open should be the earliest event that made that condition true.

That gave us a clean action:

DayDialog(
  onLetsGo: () {
    final event = earliestEvent;
    Navigator.of(dialogContext).pop();

    if (event == null || !mounted) return;

    Navigator.of(context).push(
      MaterialPageRoute(
        builder: (_) => EventDetailsScreen(event: event),
      ),
    );
  },
);

There are two contexts here, and that is not accidental.

The dialog builder gives a dialogContext, which is the safest context for popping the dialog. The screen’s own context is then used for pushing the next route.

Could one context have worked? Probably. But “probably” is not my favorite navigation strategy, especially around dialogs. Flutter navigation bugs tend to wait until a specific route stack shape appears, then quietly ruin your afternoon.

The task dialog was less polite

The pending task dialog sounded simple:

Cool. Open pending tasks.

Except there was no single “pending tasks screen.” Also, tasks belonged to different events.

A dashboard task had enough information to know that it was incomplete and which event it belonged to, but the event details screen needed a fuller event model. So the app had two related but different pieces of state:

  • dashboard task summaries
  • full mapped events from the events response

That meant the button needed a selection rule.

We considered a few choices:

  • open the calendar, which is safe but broad
  • build a grouped pending tasks view, which is better UX but more work
  • open the event containing the next task needing attention

The last option felt like the best daily tradeoff. It makes the button do something concrete without inventing a new screen.

The rule became:

  1. Find incomplete tasks.
  2. Prefer the nearest upcoming incomplete task.
  3. If all incomplete tasks are overdue, pick the most recently overdue one.
  4. Find the event that owns that task.
  5. Open event details.
  6. If the event cannot be found, fall back to the calendar.

That sounds like a lot for one button, but it is really just product intent expressed as code.

TaskSummary? findTaskNeedingAttention(List<TaskSummary> tasks) {
  final pending = tasks.where((task) => !task.isCompleted).toList();
  if (pending.isEmpty) return null;

  final now = DateTime.now();

  final upcoming = pending
      .where((task) => task.date != null && !task.date!.isBefore(now))
      .toList()
    ..sort((a, b) => a.date!.compareTo(b.date!));

  if (upcoming.isNotEmpty) return upcoming.first;

  final overdue = pending.where((task) => task.date != null).toList()
    ..sort((a, b) => b.date!.compareTo(a.date!));

  if (overdue.isNotEmpty) return overdue.first;

  return pending.first;
}

The fallback mattered to me. Real mobile state is messy. Maybe the dashboard response has a task whose event is missing from the events response. Maybe caching gives you a slightly stale combination. Maybe the backend shape changes in some tiny way on a tired Tuesday.

In those cases, the app should still take the user somewhere useful instead of turning the button into a dead end.

The callback pattern held up

Once the dialog had a callback, the home screen could own the messy part:

PendingTasksDialog(
  onViewPendingTasks: () {
    final event = findPendingTaskEvent();
    Navigator.of(dialogContext).pop();

    if (!mounted) return;

    if (event == null) {
      openCalendarScreen();
      return;
    }

    Navigator.of(context).push(
      MaterialPageRoute(
        builder: (_) => EventDetailsScreen(event: event),
      ),
    );
  },
);

That made the dialog more reusable and the navigation more honest.

I also had to make sure the mapped event list was cached alongside the rest of the home state. Otherwise the behavior would work after a fresh fetch but fail or degrade after cached dashboard state restored. This is exactly the kind of small state detail that does not announce itself loudly. It just becomes a weird “works sometimes” bug later.

I do not enjoy future-me debugging “works sometimes.” Future-me is busy enough.

The button we left alone

The most interesting decision might have been the one where we did nothing.

There was a “One step closer” dialog shown after the user completed more tasks. The button said “Keep Planning.” At first, it looked like another candidate for navigation.

But after thinking through it, dismissing the dialog actually made sense.

That dialog is not saying “go inspect this exact object.” It is more like a little celebration. The user has made progress; the app is acknowledging it; closing the dialog returns them to whatever planning context they were already in.

Changing that button to navigate somewhere might have made the app feel jumpy. Useful is good. Over-eager is not.

This was a nice reminder that consistency does not mean every button does the same category of thing. It means every button does what its label and context imply.

Small Flutter details that mattered

A few practical details stood out:

  • Navigator.pop should close the dialog before pushing the next screen.
  • Use the dialog’s context to pop the dialog route.
  • Use the parent screen’s context to push the app route.
  • Check mounted before navigating after state-dependent callbacks.
  • Keep UI widgets dumb when the state lives somewhere else.
  • Cache all derived state needed by a callback, not only what the UI visibly renders.

None of those are groundbreaking. They are just the little edges that separate “it works in my test tap” from “this behaves reliably in the app.”

The quiet lesson

Today’s work started with dialog buttons and ended up being about intent.

A button label is a contract. If it says “View Notifications,” it should not merely close. If it says “View Pending Tasks,” it should make a reasonable decision about which task needs attention. If it says “Keep Planning,” sometimes the best behavior is simply to get out of the way.

The technical implementation was not huge: a couple of callbacks, a mapped event list, a task selection helper, and a fallback route. But the thinking behind it was the useful part.

Flutter gives us the tools to wire screens together very quickly. The harder part is deciding where the knowledge should live, what the user expects, and how much logic a tiny button deserves.

Today, one tiny button deserved more than Navigator.pop(context).

Another one did not.

That balance was the actual feature.

More Posts

Flutter’s Execution Model — Explained Like a Human

Lordhacker756verified - Apr 14

Why Email-Only Contact Forms Are Failing in 2026 (And What Developers Should Do Instead)

JayCode - Mar 2

Building a Zomato-Like Multi-City Food Delivery App with Flutter & Firebase

MorningStar47 - Apr 28

How I Built a Complete On-Demand Home Services App (ServeNow) with Flutter & Firebase

MorningStar47 - Apr 27

Building a Resilient Real-Time WebSocket Stream in Flutter (RxDart + Clean Architecture + BLoC)

Lordhacker756verified - Apr 27
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

3 comments
2 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!