When starting a new Flutter project, it’s tempting to jump straight into building screens and features.
However, a proper initial setup can save you countless hours down the line — especially when your app starts to grow.
In this article, we’ll go through a step-by-step guide on how to set up a scalable and maintainable Flutter project, focusing on these key areas:
- Flavor setup (without external packages)
- Localization setup (using Flutter’s built-in tools)
- Asset generation (with flutter_gen)
- Theme setup (scalable, UI/UX-friendly theming)
- Network setup (using Dio)
- Navigation setup (wrapper-based, extendable for deep links)
- Logger setup (debug console and in-app request viewer)
1. Flavor Setup (No External Packages)
Flavors help you maintain different environments — like development, staging, and production— in a clean and maintainable way.
Instead of using a third-party package, you can use Flutter’s built-in flavor support through flutter run and Gradle/Xcode configurations.
Android side: build.gradle
flavorDimensions "default"
productFlavors {
dev {
applicationIdSuffix ".dev"
versionNameSuffix "-dev"
resValue "string", "app_name", "MyApp Dev"
}
prod {
resValue "string", "app_name", "MyApp"
}
}
If your Flutter project uses Gradle Kotlin DSL (build.gradle.kts), the flavor setup syntax changes slightly compared to Groovy.
android {
namespace = "com.example.myapp"
compileSdk = 34
defaultConfig {
applicationId = "com.example.myapp"
minSdk = 23
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
flavorDimensions += "default"
productFlavors {
create("dev") {
dimension = "default"
applicationIdSuffix = ".dev"
versionNameSuffix = "-dev"
resValue("string", "app_name", "MyApp Dev")
}
create("prod") {
dimension = "default"
resValue("string", "app_name", "MyApp")
}
}
buildTypes {
getByName("debug") {
isDebuggable = true
}
getByName("release") {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android.txt"),
"proguard-rules.pro"
)
}
}
}
If you use different Firebase configs or app icons per flavor, you can store them under:
android/app/src/dev/
android/app/src/prod/
and Gradle will automatically pick the correct resources for each flavor.
Unlike Android, iOS doesn’t have a direct “flavor” concept — it uses Schemes and Build Configurations.
We’ll mirror the same environments (dev, prod) that we used on Android.
iOS Side: XCode
Step 1:
- Go to: Runner → Project → Info tab
- Under “Configurations”, duplicate the existing ones
- Debug → rename copy to Debug-dev
- Release → rename copy to Release-prod
Step 2:
- In Xcode’s top bar, click Runner → Manage Schemes
- Duplicate the existing Runner scheme twice:
- Rename one to Runner-dev
- Rename the other to Runner-prod
- Edit each scheme:
- For Runner-dev, select the “Build Configuration” as Debug-dev.
- For Runner-prod, select the “Build Configuration” as Release-prod.
Step 3:
In ios/Runner/Info.plist, you’ll likely have something like:
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
Now, open your Build Settings → search for “Product Bundle Identifier”,
and define separate IDs per configuration, e.g.:
dev → com.example.myapp.dev
prod → com.example.myapp
To make Flutter recognize them, ensure you also defined your flavors in ios/Runner’s xcconfig files.
Example: ios/Flutter/Debug-dev.xcconfig
#include "Generated.xcconfig"
FLUTTER_TARGET=lib/main.dart
Example: ios/Flutter/Release-prod.xcconfig
#include "Generated.xcconfig"
FLUTTER_TARGET=lib/main.dart
In VS Code, open or create .vscode/launch.json file and add entries like this:
{
"version": "0.1.0",
"configurations": [
{
"name": "Run Dev Flavor",
"request": "launch",
"type": "dart",
"program": "lib/main.dart",
"args": [
"--flavor",
"dev",
"--dart-define",
"FLAVOR=dev"
]
},
{
"name": "Run Prod Flavor",
"request": "launch",
"type": "dart",
"program": "lib/main.dart",
"args": [
"--flavor",
"prod",
"--dart-define",
"FLAVOR=prod"
]
}
]
}
2. Localization Setup (Built-in Flutter Way)
Flutter supports localization out-of-the-box with the flutter_localizations package — no need for third-party solutions.
Step 1: Enable localization in pubspec.yaml
flutter:
generate: true
uses-material-design: true
generate-localized-resources: true
Step 2: Add supported locales
return MaterialApp(
supportedLocales: [
Locale('en'),
Locale('tr'),
],
localizationsDelegates: [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
);
Step 3: Add your translation files, create lib/l10n folder and add:
app_en.arb
app_tr.arb
Then run:
flutter gen-l10n
This automatically generates AppLocalizations— a strongly typed localization class.
3. Asset Generation (Using flutter_gen)
To avoid hardcoded asset paths and typo errors, you can use flutter_gen, which automatically generates Dart accessors for your assets.
Step 1: Add to your pubspec.yaml
dev_dependencies:
flutter_gen_runner:
flutter:
assets:
- assets/images/
- assets/icons/
Step 2: Generate
flutter pub run build_runner build
Then use:
Image.asset(Assets.images.logo.path);
4. Theme Setup (Custom Colors and Standardization)
Your theme is the foundation of UI consistency. Setting it up properly early on helps your design system scale smoothly.
Step 1: Define color schemes, create a theme folder with app_colors.dart:
class AppColors {
static const primary = Color(0xFF0057D9);
static const secondary = Color(0xFF00BFA5);
static const background = Color(0xFFF5F5F5);
}
Step 2: Define ThemeData
final lightTheme = ThemeData(
colorScheme: ColorScheme.light(
primary: AppColors.primary,
secondary: AppColors.secondary,
),
scaffoldBackgroundColor: AppColors.background,
appBarTheme: const AppBarTheme(elevation: 0),
);
Step 3: Use UI/UX guidelines
- Define spacing constants (AppSpacing.small, AppSpacing.medium, etc.)
- Use text themes with semantic naming (headline, body, caption).
- Don’t hardcode colors or font sizes in widgets.
5. Network Setup (Using Dio)
For a robust and modern network layer, Dio is still one of the best options in 2025.
Step 1: Create a network folder with api_client.dart
import 'package:dio/dio.dart';
class ApiClient {
final Dio _dio;
ApiClient({String? baseUrl})
: _dio = Dio(BaseOptions(baseUrl: baseUrl ?? "https://api.example.com"));
Future<Response<T>> get<T>(String path) async {
return _dio.get<T>(path);
}
Future<Response<T>> post<T>(String path, dynamic data) async {
return _dio.post<T>(path, data: data);
}
}
Step 2: Extend for error handling, interceptors, and token refresh later. Keep ApiClient stateless and inject dependencies via constructors — makes testing easier.
6. Navigation Setup (Universal Wrapper)
Whether you’re using go_router, auto_route, or built-in Navigator, a wrapper helps abstract navigation logic.
import 'package:flutter/material.dart';
class NavigationManager {
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
static Future<T?> push<T>(Route<T> route) =>
navigatorKey.currentState?.push(route);
static void pop<T>([T? result]) =>
navigatorKey.currentState?.pop(result);
static Future<T?> pushNamed<T>(String route, {Object? arguments}) =>
navigatorKey.currentState?.pushNamed(route, arguments: arguments);
}
Then, in your Material app:
navigatorKey: NavigationManager.navigatorKey,
This setup lets you:
- Navigate globally without BuildContext.
- Manage deep links and universal links from a single source later.
Create a DeepLinkHandler class that parses incoming URIs and maps them to routes using the same manager.
7. Logger Setup (Console + Network Tracking)
Logging is essential for debugging complex flows — especially network requests.
Step 1: Console logger
final log = Logger();
log.i("App started");
log.e("Error occurred", error, stackTrace);
Step 2: In-app network inspector
For monitoring requests:
Both integrate with Dio:
final alice = Alice();
final dio = Dio()..interceptors.add(alice.getDioInterceptor());
Use verbose logging only in dev flavor. Avoid exposing sensitive data in logs for production.
Thanks for reading. Happy weekends 🎈.
