Tutorial 16 min read

Building a Mobile App with Flutter — From Idea to Play Store

Mahmoud Hamdy
March 12, 2026

Flutter has cemented itself as the top cross-platform mobile framework heading into 2026. With a single codebase you can target Android, iOS, web, and desktop — and the Dart language has matured into a genuinely productive tool. I have shipped three production apps with Flutter: Escore (a sports scoring app), Wasalni (a ride-hailing app for a regional client), and a Hadith reference app with full offline support. This guide distils everything I learned across those projects into one end-to-end walkthrough.

Why Flutter in 2026?

React Native is still a strong contender, but Flutter's main advantages are consistent rendering (it draws its own pixels via Skia/Impeller), near-native performance, and a mature ecosystem. The Impeller rendering engine, now the default on both iOS and Android, eliminated most of the jank issues that plagued early Flutter apps. Google's investment in the framework shows no signs of slowing: Flutter 3.x ships new features every quarter, and the community package ecosystem on pub.dev has over 40,000 packages.

For Arabic-speaking markets specifically, Flutter's built-in RTL support is far easier to configure than React Native's. You declare Directionality.rtl once, and the entire widget tree mirrors correctly — including navigation drawer positions, icon alignment, and text flow.

Environment Setup

Install Flutter via the official installer or via FVM (Flutter Version Manager) if you need to switch versions across projects. FVM is strongly recommended for teams:

# Install FVM
dart pub global activate fvm

# Install and use a specific Flutter version
fvm install 3.29.0
fvm use 3.29.0

# Verify
fvm flutter --version

For Android, you need Android Studio with the SDK Platform for API 34+. For iOS, Xcode 16+ on macOS is required. Run flutter doctor to check your setup — fix every red item before writing any code. The most common issues are missing Android licenses (flutter doctor --android-licenses) and Xcode Command Line Tools.

Clean Architecture in Flutter

Clean Architecture splits your app into three layers: Data, Domain, and Presentation. This might feel over-engineered for a small app, but it pays off immediately when you need to swap an API, add offline caching, or write unit tests without mocking the entire world.

lib/
├── core/
│   ├── error/           # Failures, exceptions
│   ├── network/         # Dio client, interceptors
│   └── utils/           # Constants, extensions
├── features/
│   └── auth/
│       ├── data/
│       │   ├── datasources/    # Remote & local data sources
│       │   ├── models/         # JSON models (extends entities)
│       │   └── repositories/   # Repository implementations
│       ├── domain/
│       │   ├── entities/       # Pure Dart classes
│       │   ├── repositories/   # Abstract interfaces
│       │   └── usecases/       # Single-responsibility use cases
│       └── presentation/
│           ├── bloc/           # Bloc/Cubit
│           ├── pages/          # Screens
│           └── widgets/        # Feature-specific widgets
└── injection_container.dart    # GetIt service locator

The Domain layer has zero Flutter dependencies — only pure Dart. This means your use cases and entities are fully testable without a device or emulator. The Data layer implements the domain repository interfaces and handles JSON parsing, Dio calls, and Hive/SQLite caching. The Presentation layer owns the UI and Bloc logic.

Widgets, Layouts, and Navigation

Flutter's widget system is compositional — every UI element, from padding to a full screen, is a widget. The most important layout widgets to master are Column, Row, Stack, Expanded, Flexible, and SliverList for long scrolling content. For responsive layouts, use LayoutBuilder and MediaQuery.

For navigation, GoRouter is the community standard in 2026. It supports deep linking, nested navigation, and redirect guards for auth flows:

final router = GoRouter(
  initialLocation: '/home',
  redirect: (context, state) {
    final isLoggedIn = context.read<AuthBloc>().state is AuthAuthenticated;
    final isOnAuth = state.matchedLocation.startsWith('/auth');
    if (!isLoggedIn && !isOnAuth) return '/auth/login';
    if (isLoggedIn && isOnAuth) return '/home';
    return null;
  },
  routes: [
    GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
    GoRoute(
      path: '/auth',
      routes: [
        GoRoute(path: 'login', builder: (_, __) => const LoginScreen()),
        GoRoute(path: 'register', builder: (_, __) => const RegisterScreen()),
      ],
    ),
  ],
);

Bloc and Cubit State Management

The Bloc library is the most popular state management solution for production Flutter apps. Cubit is a simplified version of Bloc — use Cubit when you have simple state transitions, Bloc when you need event-driven logic with history or complex transformations.

// Simple Cubit example
class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);
  void increment() => emit(state + 1);
  void decrement() => emit(state - 1);
}

// Full Bloc example for auth
sealed class AuthEvent {}
class AuthLoginRequested extends AuthEvent {
  final String email, password;
  AuthLoginRequested(this.email, this.password);
}

sealed class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState { final User user; AuthAuthenticated(this.user); }
class AuthError extends AuthState { final String message; AuthError(this.message); }

class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final LoginUseCase loginUseCase;
  AuthBloc(this.loginUseCase) : super(AuthInitial()) {
    on<AuthLoginRequested>(_onLogin);
  }

  Future<void> _onLogin(AuthLoginRequested event, Emitter<AuthState> emit) async {
    emit(AuthLoading());
    final result = await loginUseCase(LoginParams(event.email, event.password));
    result.fold(
      (failure) => emit(AuthError(failure.message)),
      (user) => emit(AuthAuthenticated(user)),
    );
  }
}

API Integration with Dio

Dio is the HTTP client of choice for Flutter. Set up interceptors for auth token injection, error handling, and logging in one place rather than spreading that logic across every repository:

class AppDio {
  static Dio getInstance() {
    final dio = Dio(BaseOptions(baseUrl: AppConstants.baseUrl));
    dio.interceptors.addAll([
      AuthInterceptor(),
      LogInterceptor(responseBody: true),
      RetryInterceptor(dio: dio, retries: 2),
    ]);
    return dio;
  }
}

class AuthInterceptor extends Interceptor {
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final token = SecureStorage.getToken();
    if (token != null) options.headers['Authorization'] = 'Bearer $token';
    handler.next(options);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode == 401) {
      final refreshed = await AuthService.refresh();
      if (refreshed) return handler.resolve(await _retry(err.requestOptions));
    }
    handler.next(err);
  }
}

Local Storage and Offline Support

For the Hadith app, offline-first was not optional — users in areas with poor connectivity needed full functionality. The strategy: cache API responses in Hive (a fast NoSQL box for Flutter) and expose a repository that tries the network first and falls back to cache.

Use flutter_secure_storage for tokens, shared_preferences for simple key-value settings, hive or isar for structured offline data, and sqflite if you need relational queries.

Push Notifications with FCM

Firebase Cloud Messaging (FCM) is the standard for Flutter push notifications. After adding the firebase_messaging package and initializing Firebase, handle three scenarios: foreground messages (show a local notification), background messages (handle in a top-level Dart function), and notification taps (navigate to the correct screen).

Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp();
  print('Background message: ${message.messageId}');
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
  runApp(const MyApp());
}

// In your notification setup
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
  // Show local notification via flutter_local_notifications
  localNotifications.show(
    message.hashCode,
    message.notification?.title,
    message.notification?.body,
    const NotificationDetails(/* ... */),
  );
});

Arabic RTL Support

Flutter handles RTL beautifully when you follow the right patterns. Wrap your MaterialApp with the supported locales and delegate, and avoid hardcoded EdgeInsets.only(left: ...)  — use EdgeInsetsDirectional instead:

MaterialApp(
  supportedLocales: const [Locale('en'), Locale('ar')],
  localizationsDelegates: const [
    AppLocalizations.delegate,
    GlobalMaterialLocalizations.delegate,
    GlobalWidgetsLocalizations.delegate,
    GlobalCupertinoLocalizations.delegate,
  ],
  // ...
)

// Use directional padding, not fixed left/right
Padding(
  padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
  child: Text(label),
)

In the Wasalni app, we supported both Arabic and English with easy_localization and stored the user's locale preference in SharedPreferences. The navigation drawer, back buttons, and icon alignment all flipped automatically.

Testing

Flutter supports three test types: unit tests (fast, no device needed), widget tests (render a widget in isolation), and integration tests (full app on a device/emulator). Aim for high coverage on use cases and repositories — those are the critical business logic layers.

// Unit test for a use case
void main() {
  late LoginUseCase useCase;
  late MockAuthRepository mockRepo;

  setUp(() {
    mockRepo = MockAuthRepository();
    useCase = LoginUseCase(mockRepo);
  });

  test('should return User when login succeeds', () async {
    when(mockRepo.login(any, any)).thenAnswer((_) async => Right(tUser));
    final result = await useCase(LoginParams('test@test.com', 'password'));
    expect(result, Right(tUser));
    verify(mockRepo.login('test@test.com', 'password'));
    verifyNoMoreInteractions(mockRepo);
  });
}

Building a Release

Before building, update your pubspec.yaml version (version: 1.0.0+1) and sign your Android app. Generate a keystore once and never commit it to git:

# Generate keystore
keytool -genkey -v -keystore ~/upload-keystore.jks \
  -keyalg RSA -keysize 2048 -validity 10000 \
  -alias upload

# Build Android App Bundle (preferred over APK for Play Store)
flutter build appbundle --release

# Build APK for direct distribution
flutter build apk --release --split-per-abi

# Build iOS (macOS only)
flutter build ipa --release

Google Play Publishing Step-by-Step

Publishing to Google Play involves several steps. First, create a Google Play Console account ($25 one-time fee). Then:

  1. Create a new app in the Console and fill in the store listing: title, short description (80 chars max), full description, screenshots (at least 2 per form factor), feature graphic (1024x500), and app icon (512x512 PNG).
  2. Set up content rating by completing the questionnaire — this is mandatory before any release.
  3. Configure pricing and distribution (countries, free/paid).
  4. Upload your AAB to the Internal Testing track first. Share with testers via email and verify the app installs and runs correctly.
  5. Promote to Closed Testing (beta) and gather feedback.
  6. Submit to Production review. Google's review typically takes 1–3 days for new apps, faster for updates.

The most common rejection reason is incomplete store listing content or policy violations (especially around permissions). Only request permissions you actually use, and add a privacy policy URL — it is required for any app that accesses personal data.

Apple App Store Overview

App Store submission requires an Apple Developer account ($99/year). The process is similar but stricter: use Xcode to archive the IPA, upload via Xcode Organizer or Transporter, then submit via App Store Connect. Apple's review is more thorough and typically takes 24–48 hours but can extend to a week for new apps. Common rejection reasons: crashes on review device, broken links in the app, incomplete metadata, and not following Human Interface Guidelines.

Common Mistakes to Avoid

After three shipped Flutter apps, these are the mistakes that cost me the most time. First, do not nest Column inside an unbounded height container — this causes "RenderFlex children have non-zero flex but incoming height constraints are unbounded" errors. Use Expanded or set explicit heights. Second, never call setState from within initState synchronously. Third, always dispose controllers (TextEditingController, AnimationController, ScrollController) in the dispose method to avoid memory leaks. Fourth, avoid rebuilding expensive widgets unnecessarily — use const constructors wherever possible. Fifth, test on a real low-end device before release. An app that runs smoothly on a flagship phone can be unusable on a mid-range device.