Why I Stopped Building Apps and Started Building an Engine in Flutter

Published: (March 12, 2026 at 08:46 AM EDT)
4 min read
Source: Dev.to

Source: Dev.to

Introduction

As an indie developer, the biggest bottleneck isn’t skill—it’s time. Maintaining multiple apps can quickly destroy your productivity.

The Problem

A few months ago I started building PyMaster, a gamified app to help people learn Python on their phones. After shipping the first version, I immediately wanted to add SQL, JavaScript, maybe even Rust. The obvious move was to duplicate the codebase, swap logos, change content, and publish a new app.

I tried it: cloned the repo, renamed a few things, and within ten minutes I already hated what I was looking at.

  • Three separate codebases
  • Three separate sets of bugs
  • Three separate times I’d have to push a fix for the streak logic

As a solo founder, that’s not a roadmap—it’s a slow death.

The Solution: A White‑Label Engine

Instead of building another app, I built an engine—one codebase that can compile into an infinite number of apps. A white‑label educational platform powered by Flutter + Riverpod.

Decoupling UI from Content

In a normal app the UI knows too much:

Text("Welcome to Python")

It also contains tightly coupled parsers that only understand a specific language. To white‑label this, the UI must become completely dumb—it should only render what it’s given.

Core Contract: CourseBlueprint

// The master contract every app flavor must fulfill
abstract class CourseBlueprint {
  String get appTitle;
  String get virtualCurrencyName;

  // Theme Engine
  Color get brandPrimaryColor;
  Color get brandSecondaryColor;

  // Logic Injection
  CodeParserStrategy get codeParser;

  // Per‑app 3rd Party Config
  String get analyticsId;
  String get aiSystemPrompt;
}

Every flavor (Python, SQL, JavaScript, …) implements this interface. The UI never imports a concrete parser; it simply asks for a parser supplied by the configuration.

Example: Python Flavor

// Python‑specific implementation
class PythonCourseConfig implements CourseBlueprint {
  @override
  String get appTitle => "PyMaster";

  @override
  String get virtualCurrencyName => "Tokens";

  @override
  Color get brandPrimaryColor => const Color(0xFFFFD43B); // Python Yellow

  @override
  Color get brandSecondaryColor => const Color(0xFF306998); // Python Blue

  @override
  CodeParserStrategy get codeParser => PythonCodeParser(); // Handles indentation

  @override
  String get analyticsId => "UA-XXXXX-PYTHON";

  @override
  String get aiSystemPrompt =>
      "You are an expert Python tutor. Explain the error in simple terms.";
}

Launching a new flavor (e.g., SQL) is as simple as creating SqlCourseConfig, changing the theme colors, injecting a SqlKeywordParser, and updating the AI prompt.

Selecting the Right Configuration

During compilation I use Flutter’s --dart-define flag. A small factory reads the flavor and returns the appropriate config:

class BlueprintFactory {
  static CourseBlueprint getForFlavor(String flavor) {
    switch (flavor.toLowerCase()) {
      case 'python':
        return PythonCourseConfig();
      case 'sql':
        return SqlCourseConfig();
      default:
        return PythonCourseConfig(); // Safe fallback
    }
  }
}

A common pitfall: forgetting to pass the build flag (--dart-define=APP_FLAVOR=sql) caused the app to fall back to the default Python theme.

Global Access with Riverpod

Riverpod eliminates the need for prop‑drilling:

// Global provider — overridden at startup
final blueprintProvider = Provider((ref) {
  throw UnimplementedError("Must be overridden in main.dart");
});

void main() {
  const flavor = String.fromEnvironment('APP_FLAVOR', defaultValue: 'python');
  final selectedConfig = BlueprintFactory.getForFlavor(flavor);

  runApp(
    ProviderScope(
      overrides: [
        blueprintProvider.overrideWithValue(selectedConfig),
      ],
      child: const MyEduApp(),
    ),
  );
}

Any widget can now read the configuration:

Widget build(BuildContext context, WidgetRef ref) {
  final config = ref.watch(blueprintProvider);

  return Text(
    "Welcome to ${config.appTitle}!",
    style: TextStyle(color: config.brandPrimaryColor),
  );
}

The UI is completely blind to the domain; it simply renders what it receives.

Benefits

  • Single source of truth: Fix a bug in the core gamification engine (streaks, XP, offline progression) once, and the fix propagates to every flavor.
  • Rapid iteration: Spinning up a new app takes hours instead of weeks.
  • Focused effort: Most of my time is now spent on content rather than infrastructure.

Outcome

I shipped the first flavor, PyMaster, which is now live on the Google Play Store. It features:

  • Gamified offline progression
  • A dynamic theme engine
  • A built‑in AI tutor

Discussion

Have you architected something similar? Did you choose:

  • App flavors
  • Monorepos
  • Plugin architectures

I’m curious about the trade‑offs you encountered. Drop your thoughts in the comments below.

0 views
Back to Blog

Related posts

Read more »