Flutter lets a team build much of an iOS and Android app from a single Dart project, with shared widgets for the interface and shared application logic. That reduces duplicated work, but it does not eliminate the need to choose between native and cross-platform approaches. Permissions, device behavior, signing, store metadata, and physical-device testing still require attention on both platforms.
Step 1: Define the App’s First Cross-Platform Release Before Creating the Project
Begin with the smallest version of the app that is genuinely useful on both iOS and Android. A focused first release keeps the Flutter project manageable and gives the team a chance to test the complete user journey before adding more features.
-
Write down the main user outcome. Describe what someone should be able to accomplish in the first release. For a delivery app, that could mean creating an account, choosing an address, placing an order, tracking it, and receiving status updates.
-
List the screens and actions needed to support that outcome. Keep the list practical. A delivery app might need:
- Welcome and sign-in screens
- Address search or selection
- Product or restaurant listings
- Cart and checkout
- Order status
- Account settings
Features such as loyalty points, referrals, complex preferences, and social sharing can usually wait. Add them later, once the main flow is working well.
-
Map the data and service requirements. Identify where the app gets its data and what it needs to store. This could include:
- A REST or GraphQL API
- User authentication
- Secure token storage
- Push notification service
- Payment provider
- Image upload service
- Local cache for offline access
This list highlights work that will affect both the Flutter code and platform configuration.
-
Identify device features early. Camera access, location, file selection, notifications, biometrics, and payments may require Flutter packages as well as Android and iOS setup. Each feature also needs a clear explanation when the app asks the user for permission.
-
Decide what should look the same and what may differ. The core flow should remain consistent across platforms. Someone should not have to relearn checkout after switching phones. Some platform-aware behavior is still appropriate:
- Respect iOS and Android keyboard behavior.
- Support each platform’s back-navigation expectations.
- Use native-style date, time, and permission interactions where helpful.
- Test system font scaling and gesture behavior on both platforms.
-
Set basic release requirements. Define the lowest operating-system versions you plan to support, the device sizes to test, expected startup speed, accessibility goals, and the data you collect. Store privacy declarations are easier to prepare when these details are settled before release week.
A short release brief is sufficient. It should cover the user flow, main screens, required services, device permissions, and the conditions that make the app ready to submit.
Real story
I once shipped a Flutter demo and felt wildly efficient right up until I tried to test it on my phone. The app launched, the UI looked perfect, and then I tapped a feature that needed permissions I had forgotten to add. My phone flashed a warning, I stared at it like it had betrayed me, and my laptop fan made the exact sound of a tiny laugh track. Two minutes later I was explaining to a room full of people that yes, the app was “cross-platform,” and no, my confidence was not.
Have a story of your own? Share it in the comments below.
Step 2: Install Flutter and Create a Project That Builds on iOS and Android
Flutter provides the SDK, command-line tools, framework libraries, and build process needed to design, build, and launch a mobile app. Before building features, confirm that the environment can compile and run a basic app on each platform you intend to support.
-
Install the Flutter SDK and choose an editor. Visual Studio Code and Android Studio both support Flutter and Dart through extensions. The choice of editor matters less than having code completion, debugging, device selection, and Flutter tooling working reliably.
-
Run Flutter’s environment check.
flutter doctorThe command reports missing tools and configuration problems. Treat its output as a checklist and resolve the relevant issues before continuing.
-
Set up Android development tools. Install the Android SDK through Android Studio, accept any required licenses, and create an Android emulator. You can also connect a physical Android device with developer options and USB debugging enabled.
-
Set up iOS development tools if you will ship for iOS. iOS builds require macOS and Xcode. Install Xcode, accept its license terms, install any required simulator runtimes, and confirm that Flutter detects the iOS toolchain.
iOS note: You can write Flutter code on other operating systems, but you need macOS with Xcode to build, sign, archive, and submit an iOS app.
-
Create and run a new project.
flutter create --org com.example delivery_app cd delivery_app flutter pub get flutter runWhen prompted, select an Android emulator, iOS simulator, or connected device. Run the starter app on both target platforms before adding features. A build problem is far easier to fix at this stage than after users and a release deadline are involved.
-
Understand the main project structure.
delivery_app/ ├── lib/ # Dart application code ├── test/ # Unit and widget tests ├── android/ # Android configuration and build files ├── ios/ # iOS configuration and Xcode project files └── pubspec.yaml # Packages, assets, fonts, version informationMost day-to-day work takes place in
lib/andtest/. Theandroid/andios/directories remain important when you add permissions, app identifiers, signing, deep links, notifications, or platform capabilities.Add an
integration_test/directory when you need integration tests, and add anassets/directory when the app includes bundled images, fonts, or other files. Declare bundled assets and fonts inpubspec.yaml.
Step 3: Shape the App Around Widgets, Screens, State, and Data Boundaries
Flutter interfaces are assembled from widgets. A small prototype can keep everything in a few files, but a production app becomes hard to change when the same widgets also handle API calls, authentication rules, storage, and navigation.
Give each part of the code a defined responsibility:
lib/
├── app/
│ ├── app.dart
│ └── router.dart
├── features/
│ ├── auth/
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ └── orders/
│ ├── data/
│ ├── domain/
│ └── presentation/
├── core/
│ ├── network/
│ ├── storage/
│ └── theme/
└── main.dart
This structure does not need to be rigid. The important distinction is that a sign-in form displays fields and validation messages, while an authentication service handles the request, token response, and stored session.
Build Screens from Small, Reusable Widgets
Divide each screen into focused widgets, such as a page shell, form fields, loading state, empty state, error message, and action button. These smaller pieces are easier to test and simpler to adjust when the design changes.
Use layout tools that respond to available space rather than assuming a single phone size. LayoutBuilder, MediaQuery, Expanded, Flexible, Wrap, and scrollable widgets help screens adapt to compact phones, larger devices, split-screen layouts, and different orientations.
The layout also needs to accommodate the keyboard. Test forms with the keyboard open, particularly on smaller devices, where a submit button can disappear at the worst possible moment.
Keep State and Business Rules Outside Display Widgets
Choose a state-management approach that suits the app and the team. Flutter’s built-in tools can be effective for local screen state, while larger apps often use a consistent pattern built around packages such as Provider, Riverpod, Bloc, or another established approach.
The package is less important than keeping the boundaries clear:
- Widgets render the current state and send user actions.
- A controller, notifier, or bloc coordinates state changes.
- Domain code contains business rules, such as whether an order can be canceled.
- Repositories and services communicate with APIs, local storage, or external SDKs.
For a sign-in flow, the sequence might look like this:
- The user enters an email address and password in a form widget.
- The form validates required fields and basic formatting.
- A sign-in controller sets a loading state and calls an
AuthService. - The service sends the request to the backend and receives a session result.
- The controller exposes success or failure to the screen.
- The app routes a signed-in user to the home screen or displays a useful error message.
A compact controller interface might look like this:
abstract class AuthService {
Future
The interface keeps the screen separate from the details of a particular API client. It also makes the authentication logic easier to test with a fake service.
Define Non-Happy Paths from the Start
Every important screen needs states beyond the ideal case. Plan for:
- Initial loading
- No data yet
- Slow network responses
- Server errors
- Invalid form input
- Expired sessions
- Permission denial
- Retry actions
Set up routes and navigation rules early as well. Decide which screens require authentication, what happens when a session expires, and whether a deep link should open a specific item or first send the user to sign in.
Step 4: Add Backend Services and Platform Features Without Hiding Their Constraints
Flutter packages provide access to many common services and device capabilities. A package can reduce duplicated platform work, but it does not remove the need to configure each platform correctly or understand what the user will encounter.
Connect Services Through Dedicated Layers
Keep API clients, database access, authentication, and storage behind services or repositories. This gives the rest of the app a stable interface and keeps network code out of individual widgets.
For example, an order repository might expose methods such as:
Future
Internally, the repository can use an HTTP client, map responses to Dart models, handle error codes, and cache suitable data. The screen only needs to know whether the request is loading, has returned results, or needs to display an error.
Plan for ordinary network problems:
- Show progress while a request is running.
- Give users a clear retry option after a temporary failure.
- Handle expired tokens without leaving people on a broken screen.
- Cache only data that is appropriate to retain locally.
- Decide what remains usable offline and what requires a connection.
- Avoid placing privileged API keys or server secrets in the app bundle.
Build-time values passed with --dart-define can be useful for non-secret configuration, such as an API base URL. They are not a secure place for credentials that grant privileged access.
Add Packages Carefully
Before making a package a core dependency, review its documentation, platform support, maintenance activity, permissions, and configuration instructions. Keep versions controlled in pubspec.yaml, and check periodically for outdated dependencies.
flutter pub outdated
flutter pub get
If a capability requires custom platform code because no suitable package exists, place that work behind a narrow Dart interface. The rest of the Flutter app should not need to know whether a device action comes from Android, iOS, or another implementation.
Example: A Photo-Upload Flow
A photo upload involves more than an “Upload” button. A reliable flow usually includes:
- Explain why the app needs a photo before requesting access.
- Request only the permission required for the chosen action.
- Let the user choose an image or take a new one.
- Check the file type and size.
- Compress or resize the image if appropriate.
- Show upload progress and allow a retry if the connection fails.
- Confirm success and update the relevant screen.
Android note: Android permissions are declared in the app manifest, and some permissions also require runtime requests. Storage and photo-access behavior can vary by Android version, so test the exact package behavior on supported devices.
iOS note: iOS requires plain-language usage descriptions in the app configuration for protected resources such as the camera or photo library. Some features also need capabilities or entitlements enabled in Xcode. The explanation should describe the actual benefit to the user rather than serve as a vague legal disclaimer.
The same pattern applies to location, notifications, payments, files, and deep links. Flutter shares much of the app code, but Android and iOS still enforce their own rules for permissions, capabilities, and user consent.
Step 5: Test Flutter Behavior Across Real Devices, Builds, and Failure States
A Flutter app can look correct in one simulator and still fail on a physical device, a smaller screen, an unreliable network, or a release build. Test the behavior users depend on, not just the path that was easiest to demonstrate.
Use Flutter’s Test Levels for Different Risks
- Unit tests cover business rules, model mapping, repositories, and validation.
- Widget tests check visual states and interactions, such as whether a form displays an error after invalid input.
- Integration tests exercise complete journeys across screens and services.
Run the standard checks regularly:
flutter analyze
flutter test
flutter test integration_test
Use mocks or fakes for services when testing isolated logic. For critical flows, run integration tests against a suitable test environment instead of relying solely on mocked responses.
Compare Flutter Build Modes
Flutter build modes behave differently, and some release problems appear only after debug conveniences are removed.
flutter run --debug
flutter run --profile
flutter run --release
On mobile, flutter run --profile and flutter run --release must be run on a connected physical Android or iOS device for testing; they do not run on mobile emulators or simulators.
- Debug mode is for active development and debugging.
- Profile mode helps inspect performance on a real device.
- Release mode is closest to what users receive and should be tested before submission.
Check startup time, scrolling, image loading, animation smoothness, and large-list behavior in profile or release mode. Confirm as well that assets, environment configuration, and error handling work outside debug mode.
Test a Realistic Device and Failure Matrix
Use both simulators or emulators and physical devices. Physical testing is especially important for cameras, push notifications, biometric prompts, keyboard behavior, payment flows, performance, and permission dialogs.
For a checkout flow, test product selection, address choice, payment success, payment failure, retry, and final confirmation on:
- A smaller Android phone
- A representative iPhone
- A device with a slower network
- A device with large system text enabled
- A device where permission has been denied
- A device with the app resumed after interruption
Pre-Release Testing Checklist
- Unit tests cover important business rules and data transformations.
- Widget tests cover forms, loading states, empty states, and error states.
- Integration tests cover sign-in, the primary app action, and sign-out.
- The app works on physical iOS and Android devices.
- Layouts work with small screens, larger screens, orientation changes, and large text settings.
- Keyboard, focus order, back navigation, and scroll behavior are checked.
- Screen-reader labels and controls are usable with VoiceOver and TalkBack.
- Permissions are tested when granted, denied, and denied permanently.
- Deep links and notification links open the intended route.
- Network loss, slow responses, interrupted uploads, and expired sessions have sensible recovery paths.
- Debug, profile, and release builds have all been checked.
- Crash reporting and production error monitoring are configured before public rollout.
Step 6: Build, Sign, Submit, and Release the Flutter App on Both Stores
A tested Flutter project still needs production configuration, signed build artifacts, store metadata, and review preparation. These tasks are part of the release process, not paperwork to leave until the app is considered finished.
-
Set production identifiers and version information. Confirm that the Android application ID and iOS bundle identifier are correct and stable. Set the release version and build number in pubspec.yaml.
version: 1.0.0+1
The human-readable version is what users see. The build number must increase for later submissions.
-
Separate production configuration from development configuration. Use different backend environments for development, testing, and production where possible. Make sure test endpoints, debug logging, and development credentials do not enter the public build.
Keep signing files, certificates, private keys, and service-account credentials out of version control. A .gitignore file is not a security strategy by itself, but it is a useful first line of defense.
-
Configure Android signing and build an app bundle. Android releases typically use an Android App Bundle (.aab) for Google Play distribution.
flutter build appbundle --release
Configure signing according to the Android and Google Play documentation, then upload the generated bundle through the appropriate Play Console release track.
Android note: Verify the package name, signing configuration, permission declarations, target SDK requirements, and any notification or deep-link settings before uploading. Store requirements can change, so check the current Google Play documentation before submission.
-
Configure iOS signing and build an archive. iOS distribution requires an Apple Developer account, a matching bundle identifier, signing certificates, provisioning configuration, and any needed entitlements.
flutter build ipa --release
You can also build the iOS app and archive it through Xcode when that fits the team’s signing workflow.
iOS note: Check the app’s signing settings, capabilities, privacy usage descriptions, icons, launch behavior, and bundle identifier in Xcode. Use Apple’s current App Store submission guidance because validation rules and privacy requirements can change.
-
Prepare store listing and privacy materials. Gather these before the build reaches testers:
- App name, short description, and full description
- Screenshots that match the current release
- App icon and required promotional assets
- Support contact and privacy policy URL
- Content rating or age-rating answers
- Data collection and privacy disclosures
- Account deletion details if the app supports user accounts
- Reviewer notes and test credentials when review access requires them
Privacy declarations must match what the app and its included SDKs actually collect or transmit. Review package behavior, analytics configuration, crash reporting, and authentication flows instead of guessing.
-
Release in stages before broad availability. Start with internal QA, then use Google Play testing tracks and TestFlight for a limited group of testers. Watch for crashes, blocked sign-in attempts, failed payments, notification issues, and confusion in the primary flow.
After store review, a controlled production rollout gives the team time to verify real-world behavior before widening availability. If a critical issue appears, pausing a limited release is much easier than explaining a broken checkout button to everyone at once.
Release Readiness Checklist
- Production API endpoints and feature configuration are correct.
- No privileged keys, development credentials, or debug settings are included in the app.
- Android signing is configured and the app bundle installs correctly through a test track.
- iOS signing, certificates, profiles, and entitlements are valid.
- The iOS archive has been tested through TestFlight.
- The Android release has been tested through an internal or closed testing track.
- Store screenshots and descriptions match the submitted build.
- Privacy disclosures match the app, backend services, and included packages.
- Support information, privacy policy, and required account-management details are available.
- The primary user journey works in a release build on physical iOS and Android devices.
- Crash reporting and error monitoring can receive production events.
- A rollback or pause plan exists if a severe issue is found after release.
Flutter provides a shared codebase, not a single set of platform obligations. Strong Flutter releases reuse Dart code and widgets where that makes sense, while treating iOS and Android configuration, device testing, and store submission as essential parts of the work.



