Today started with one of those tasks that looks almost too small to deserve a branch: prepare the Flutter app for dark mode by making sure colors are centralized.
Simple enough, right?
Find the hardcoded colors. Move them into AppColors class. Replace the literals. Run analyzer. Go eat something.
Flutter, naturally, had a few notes.
The first scan told the real story. There were direct Colors.white calls, direct Colors.black, Colors.grey[600], Colors.transparent, and a handful of Color(0x...) values hiding in screens, cards, bottom sheets, painters, buttons, notification badges, and little UI edges that probably nobody thinks about until dark mode makes them painfully visible.
And that is the thing about dark mode: it punishes casual color decisions.
A hardcoded Colors.white is harmless in a light theme. It feels obvious. Of course the card background is white. Of course the modal is white. Of course the icon on the primary button is white.
Until the app theme changes and that same “obvious” value becomes the one thing refusing to adapt.
The suspicious innocence of Colors.white
The cleanup made me notice how often Flutter code uses color constants as if they are design decisions instead of implementation details.
This kind of code is easy to write:
Container(
color: Colors.white,
child: Text(
title,
style: const TextStyle(color: Colors.black),
),
)
There is nothing technically wrong with it. The widget renders. The code is readable. Everyone understands what it does.
But architecturally, it is making a decision in the wrong place.
The widget is deciding that the surface must be white and the text must be black. That may be true today, but it is not the widget's job to know forever. Once dark mode enters the conversation, that decision belongs somewhere higher up: a theme, token system, semantic color layer, or at the very least a central color class.
So the goal was not to make the code prettier. It was to reduce the number of tiny places that would later need to “remember” how dark mode works.
Centralization is not the same as theming, but it is a start
The app already had an AppColors class, which was good. It meant the project had a place where visual constants could live.
The messy part was that some colors were already centralized while others were still written directly inside widgets. A few values already existed in AppColors, just under literal-style names. Some did not exist yet. Some were Flutter palette values like Colors.grey[400], which are convenient in the moment and annoying later.
The rule for the cleanup became very plain:
If the value already existed in AppColors, use it.
If it did not exist, add a static constant.
Do not rename the entire design system today.
Do not pretend this is a full dark mode implementation.
Just make sure the color literals stop leaking through the UI.
That last point mattered. It is tempting during a cleanup like this to turn it into a full semantic-token refactor:
AppColors.surfacePrimary
AppColors.surfaceElevated
AppColors.textPrimary
AppColors.textMuted
AppColors.borderDefault
Honestly, that is probably where the app should end up.
But today was not the day to redesign the whole color vocabulary. The immediate goal was containment. Before a color can become theme-aware, it needs to stop being scattered across a hundred widgets.
So even constants like this were useful:
class AppColors {
static const Color white = Color(0xFFFFFFFF);
static const Color black = Color(0xFF000000);
static const Color transparent = Color(0x00000000);
static const Color grey200 = Color(0xFFEEEEEE);
static const Color grey400 = Color(0xFFBDBDBD);
static const Color grey600 = Color(0xFF757575);
static const Color background = Color(0xFFFEFEFE);
static const Color border = Color(0xFFE8E8E8);
}
Not perfect. But centralized. And centralized is the first real step toward controllable.
The part that quietly mattered
One interesting detail was deciding what counted as a color problem.
A direct Color(0xFFAF6D57) in a widget is obviously a problem. It is a visual value hardcoded into UI code.
A dynamic parser like this is different:
Color(int.parse(cleanedHexValue, radix: 16));
That is not really a hardcoded design color. It is converting a value that came from elsewhere, probably an API or configuration payload. Centralizing that would be a different conversation: validation, fallback behavior, allowed palettes, maybe server-driven UI constraints.
So I left dynamic parsing alone and focused on literals: Colors.*, Color(0x...), and raw hex strings that represented fixed UI choices.
This is the kind of small boundary decision that does not look exciting in a diff, but it keeps a refactor from becoming a swamp.
Search and replace, with consequences
Most of the work was mechanical, but mechanical does not mean brainless.
For example, replacing Colors.grey with a color constant is not the same as replacing Colors.grey[400]. Flutter's material color swatches have shades, while a plain Color does not.
This was one of those tiny compiler reminders that the original code was relying on more than just a color value:
// Before
Paint()..color = Colors.grey.shade400;
// After
Paint()..color = AppColors.grey400;
That one line explains a lot. The old code depended on a MaterialColor API. The centralized version needed a concrete shade constant.
Another small gotcha: when doing broad replacements, the color class itself has to be protected. If a replacement pass rewrites the constants inside AppColors, you can accidentally create recursive constants like:
static const Color white = AppColors.white;
Which is both funny and very much not useful.
The analyzer caught that immediately. A mildly embarrassing moment, but also a good reminder: refactoring with tools is still refactoring. The compiler is part of the conversation.
Why this matters before dark mode
The easiest dark mode bugs are the ones you can see instantly: black text on a dark background, white cards floating awkwardly on a dark scaffold, disabled buttons with contrast that disappears.
The harder bugs are the ones caused by ownership confusion.
If every widget owns its own color decisions, then dark mode becomes a scavenger hunt. You change the theme, run the app, notice one broken screen, patch it, then find another. A bottom sheet here. A map overlay there. A custom painter somewhere else. Eventually you are debugging vibes.
Centralizing colors changes the shape of the work. It does not magically create dark mode, but it makes the next step sane. Instead of asking, “Where did this white come from?” you can ask, “Which token should this surface use?”
That is a much better question.
It also creates room for a future migration from raw constants to semantic ones. Today, AppColors.white may still be literal white. Later, a component might use AppThemeColors.surface(context) or a theme extension. That migration is much easier when the codebase is not full of anonymous hex values.
The small win
By the end of the cleanup, the app had no remaining direct Colors.* usage outside the central theme file, no direct Color(0x...) UI literals outside AppColors, and no raw hex color strings floating around in feature code.
The analyzer still had unrelated warnings, because real apps always have a little background noise. But the color cleanup itself was clean.
That felt like progress.
Not the shiny kind where a new feature appears on screen. More like the quieter kind where future work becomes less annoying because today's code stopped hiding decisions in random corners.
And honestly, that is a big part of mobile development. Some days you build screens. Some days you chase state bugs. Some days you move every Colors.white into one place because future-you deserves fewer headaches.