Navigation, Architecture & Release in Flutter

By the end of this lesson you will be able to move between screens with Flutter's Navigator and say exactly what each call does to the stack of screens, hand values back from a screen, control the back button with PopScope, find the right navigator when there are several, and open the right screens from a link (a deep link) with the Router. Then you will structure an app in layers that can be tested, give it different settings per environment, and ship it: release builds, obfuscation, symbol files and app size. Every printed line on this page comes from a real Flutter test; the few build-tool facts that cannot run inside a test are marked as coming from Flutter's documentation.

How this lesson proves things. Every Dart code box lives in a real test file, verify_flutter/test/f06_test.dart, run with flutter test on Flutter 3.47 (stable). The // => lines under each box are what the test really printed. A widget test (Step 12.5) gives us a real Navigator and Router on a pretend screen, and tester.binding.handlePopRoute() is exactly the message Android sends when the user presses the back button. The two players where you type your own input run JavaScript copies of Dart functions; the test file runs those JavaScript copies with Node and checks them against the Dart functions and against a real Navigator or Router on 80 or more random inputs. Section 6's build commands cannot run inside a test: they are quoted from Flutter's documentation and from the help text of the flutter tool installed here, and two of their effects are shown with the plain Dart compiler, which the test does run. This lesson builds on Step 12.1 (the element tree and BuildContext), Step 12.2 (ChangeNotifier, ListenableBuilder), Step 12.4 (Future, await, platform messages) and Step 12.5 (build modes, widget tests).

1. The Navigator: a stack of screens

Think of a pile of paper cards on a desk. Each card is one screen. You can only see the card on top. Opening a new screen is putting a new card on top (push); going back is taking the top card away (pop), and the card underneath is exactly as you left it. The pile is a stack: last in, first out.

In Flutter, one full screen (or a dialog, or a bottom sheet) is a route — an object that knows how to build its page and how to animate in and out. A Navigator is a widget that keeps a stack of routes and shows the top one. MaterialApp creates one for you. From any widget below it you reach it with Navigator.of(context): Flutter walks up the element tree from your context (Step 12.1) until it finds a NavigatorState. Navigator.push(context, route) is short for Navigator.of(context).push(route).

The test below starts an app whose first route is called Start, then calls each kind of Navigator method and prints the stack, bottom first. MaterialPageRoute is the usual route: it slides (Android) or fades (iOS) the page in. RouteSettings(name: ...) gives a route a name, which is how popUntil finds it:

Try it: what does each call do to the stack?

Type Navigator calls separated by commas. The player runs a JavaScript copy of the Dart function navigatorCall from interview question q14 and shows the stack and what a NavigatorObserver (an object you give the Navigator so it tells you about every change) hears. The test file runs the same calls on a real Navigator and checks that the stack and the observer's events agree on every suggestion and on 80 random sequences.

Returning a result from a screen

push returns a Future (Step 12.4) that completes when that route is popped, with whatever value was passed to pop. So "open a picker and wait for the choice" is one await. The value is nullable: if the user leaves with the back button, nobody passes a value, and you get null.

Named routes, onGenerateRoute and arguments

You can also push by name: Navigator.pushNamed(context, '/product', arguments: 42). The Navigator calls your onGenerateRoute with a RouteSettings holding the name and the arguments, and you build the route. If you return null, it calls onUnknownRoute. The arguments arrive typed as Object? — anything — so check their type with a pattern instead of casting blindly:

Flutter's navigation overview says named routes are no longer recommended for most apps: when a deep link arrives, a named-routes app always pushes a new route on top wherever the user is, the behaviour cannot be customised, and the browser's forward button is not supported. Section 3's Router is the answer to those limits.

Stack mistakes interviewers love. (1) popUntil with a name no route has (a typo, or a route pushed without settings) pops every route — the test above shows an empty stack and a black screen, with no exception. (2) Pushing Home after sign-in instead of replacing Login: back goes to Login again. (3) Using settings.arguments as int: one wrong caller crashes the screen. (4) Forgetting that await push can give null.
Under the hood. The Navigator keeps a list of route entries and an Overlay (a stack of layers drawn on top of each other); every route adds its own overlay entries. A route below an opaque route is kept in the tree but marked offstage, so it does not paint or take taps, yet its State objects survive. Dialogs and bottom sheets are routes too (DialogRoute, ModalBottomSheetRoute), which is why showDialog returns a Future and why Navigator.pop(context, value) closes them. The observer events in the player come from NavigatorObserver: didPush, didPop, didReplace and didRemove. Removing a route (by pushAndRemoveUntil) completes the Future of whoever pushed it with null, and pushReplacement(route, result: x) completes the replaced route's Future with x — both checked in the test file and used by question q31.
The Navigator is a stack of routes; only the top one is visible. push/pop for normal moves, pushReplacement to forget the current screen, popUntil to go back several screens, pushAndRemoveUntil to start fresh. await push gives the popped value, or null.

2. The back button: PopScope and nested navigators

A form with unsaved changes is like a shop assistant who sees you walking out with an unpaid basket: "Leave the basket, or come back and pay?" The door does not lock — the assistant just gets a chance to ask first. PopScope is that assistant standing at the screen's exit.

When the user presses the system back button (or makes the back gesture), Flutter calls maybePop on the app's navigator. The top route then decides: pop, refuse, or "I am the first route, let the system handle it" (on Android that means the app closes). PopScope is a widget you put in a page to take part in that decision:

WillPopScope is deprecated. In this Flutter version its deprecation message reads: "Use PopScope instead. The Android predictive back feature will not work with WillPopScope. This feature was deprecated after v3.12.0-1.0.pre." The reason: Android's predictive back shows a preview of the screen underneath while the user is still swiping, so the system must know before the gesture ends whether back is allowed. WillPopScope's onWillPop answered later, with a Future; PopScope declares it up front with canPop. Its first callback, onPopInvoked, is also deprecated (after v3.22) in favour of onPopInvokedWithResult.

Several navigators: which one does Navigator.of find?

Apps with bottom tabs often give each tab its own Navigator, so each tab keeps its own history (open a post in Feed, switch to Search and back, and the post is still open). Now there are two navigators, one inside the other. Navigator.of(context) finds the nearest one above context; Navigator.of(context, rootNavigator: true) finds the top-most one (the app's). showDialog uses the root navigator unless you pass useRootNavigator: false (question q10). And the system back button only talks to the root navigator — so a nested navigator needs a bridge:

Back-button traps. (1) canPop: false does not stop your own Navigator.pop — the test shows the route popping anyway, with didPop: true. It only stops back gestures and maybePop. (2) A nested tab navigator without a NavigatorPopHandler (or similar forwarding): the user presses back on a post inside a tab and the whole app closes. (3) Calling Navigator.of(context) with a context that is above the navigator you mean (for example the context of the widget that creates MaterialApp): there is no navigator above it, and you get an error.
How the decision is made. maybePop asks the top route for its popDisposition: pop (go ahead), doNotPop (a PopScope with canPop: false is registered on this route — then onPopInvokedWithResult(false, result) runs and maybePop returns true, meaning "handled"), or bubble (this is the first route — maybePop returns false and the system decides; Android closes the app). PopScope finds its route with ModalRoute.of(context) and registers itself there. NavigatorPopHandler is itself built from a PopScope: it listens to a NavigationNotification that the inner navigator sends up whenever its stack changes, sets canPop: false on the outer route while the inner one can pop, and calls your onPopWithResult when back is pressed.
PopScope(canPop:, onPopInvokedWithResult:) replaces the deprecated WillPopScope. Navigator.of(context) = nearest navigator; rootNavigator: true = the app's. The back button talks to the root navigator; forward it to a tab's navigator with NavigatorPopHandler.

3. Declarative routing and deep links (Router)

There are two ways to tell a taxi driver where to go. Turn by turn: "left, left, straight, right" — that is push and pop. Or you give an address: "42 Market Street" — and the driver works out the route. A deep link (a link such as https://shop.example.com/products/42 that opens a specific screen inside the app) is an address. The Router is the driver who turns an address into a stack of screens.

The calls in section 1 are imperative: you give commands one by one. Flutter also has a declarative style, often called Navigator 2.0: you hand the Navigator a list of pages (Navigator(pages: [...])), and Flutter makes the stack match the list, animating whatever changed. The list is ordinary state: change it and rebuild, and the screens follow (question q17 shows this with a ValueNotifier). The Router connects that list to URLs. It has four parts:

Our app's "data" is just the list of page names, bottom first. pagesFor (question q21) turns a Uri into that list and locationFor (q22) turns a list back into a URL. The test opens the app with a link, sends two more links while it runs, and presses back:

Read the output from the top. The app was started by /products/42 and built three pages, not one: Home and Products are put underneath, so back has somewhere sensible to go even though the user never visited them. A link that arrives later replaces the whole stack. On back, onDidRemovePage drops the page from the delegate's list too (otherwise the next rebuild would bring it back), and the Router reports the new URL, /products. On the web, those reported URLs are what the browser's address bar shows.

Try it: from a link to the screens

Type a link. The player runs JavaScript copies of pagesFor (shown in the code panel) and locationFor. The test file checks them against the Dart functions on every suggestion and 120 random links, and against a real Router (stack on screen and the URL it reports, before and after back) on every suggestion and 60 random links.

Getting links to the app is set up per platform and is described in Flutter's deep-linking documentation (not run here): on Android, App Links (an intent filter in the app's manifest plus a verification file on your website); on iOS, Universal Links (an associated-domains entitlement plus a file on your website). Once set up, the link reaches the app as a RouteInformation — the same message the test sends with handlePushRoute. On the web, Flutter by default puts the route after a # in the address (/#/products/42); usePathUrlStrategy() from the SDK's flutter_web_plugins library switches to clean paths.

Why packages such as go_router exist. Writing a parser, a delegate, page keys and onDidRemovePage by hand for every app is a lot of code. Routing packages (go_router is a widely used one) wrap the Router API: you declare a table of path patterns, each building a screen; they handle parameters in paths, redirects (for example "not signed in → login", question q28) and a navigator per tab. In an interview, explain the ideas — URL → state → pages, and back → state → URL — rather than one package's API.

Router mistakes. (1) Forgetting onDidRemovePage (or updating the list in it): back pops the page on screen, but your list still has it, and the next rebuild pushes it back. (2) A deep link that builds only the target page: back then closes the app instead of going to the list. (3) Parsing with int.parse without checking: /products/abc throws during parsing; return a "not found" state instead. (4) Treating a link as trusted: a link can come from anyone, so check sign-in and permissions before showing the page (q28).
Parsing in the same frame. parseRouteInformation returns a Future because some apps must load something first. Returning a SynchronousFuture (a Future that is already complete and runs then callbacks immediately) lets the Router build the right pages in the very first frame, without a blank frame first. Uri.pathSegments splits the path at / and decodes each piece; queryParameters decodes the query, and when a key appears twice the last value wins (the player's /search?q=a&q=b suggestion shows it). The Router calls restoreRouteInformation(currentConfiguration) after every change to report the URL, so the two functions must be exact inverses — the test checks pagesFor(locationFor(p)) == p on 200 random links.
Declarative routing: the page list is state; Navigator(pages:) follows it. Router = provider (URLs in) → parser (URL ↔ your data) → delegate (data → pages, back → data) → URL out. A deep link should build the whole sensible stack, not only the target.

4. Architecture: layers you can test

In a restaurant, the dining room shows the food, the waiter takes orders and brings plates, the kitchen keeps the stock and cooks, and suppliers deliver from outside. The waiter never drives to the market, and the supplier never walks into the dining room. Each person talks only to the next one — so you can change the supplier without retraining the waiters.

Architecture is how you split an app into parts and decide which part may talk to which. A common, testable split for Flutter has four kinds of class:

Flutter's own app architecture guide (on docs.flutter.dev) describes the same idea: a UI layer made of views and view models, a data layer made of repositories and services, and an optional domain layer of "use cases" for logic that would otherwise be repeated across view models. It recommends separating concerns this way, data that flows in one direction (state down to the views, events up from them), and repositories as the source of truth for application data. The code below follows that shape; the names are ours.

Dependency direction: each class knows only the one directly below it (view → view model → repository → service), never the one above. Dependency injection by constructor: a class receives what it needs as a constructor argument (OrdersRepository(this._service)) instead of creating it, so the code that wires the app — or a test — chooses the real thing or a fake. That is why the test above never touches the network: FakeOrdersService implements the same interface, answers after a pretend 300 ms, and counts calls. Widget tests with fakes are covered in Step 12.5.

Folders. Two common layouts: by layer (lib/ui/, lib/data/) or by feature (lib/features/orders/ holding that feature's view, view model and repository). Feature folders keep everything for one feature together, which helps larger teams work without stepping on each other; shared services (HTTP client, storage) then live in a common folder. Either works if the dependency direction is respected — question q27 writes a checker that a CI job could run.

Architecture smells. (1) A widget that calls http.get in build or initState: impossible to test without a network, and it runs again on rebuilds. (2) A repository that imports a widget or BuildContext: the arrow points up. (3) Creating services inside classes (final api = HttpOrdersService(); as a field): nothing can swap it for a fake. (4) Twenty layers for a three-screen app: layers are a tool; start simple and split when a class does two jobs.
Where the wiring happens. Something has to create the real objects once: often main(), or a widget near the top that passes them down. Packages for dependency injection or service location (provider, Riverpod, get_it and others) automate that passing; the idea underneath is the same constructor injection shown here. Two repositories can share one service; two view models can share one repository — then both screens see the same cached data (question q18 counts one network call for two screens).
View → view model → repository → service; arrows point down only. Pass dependencies in through constructors. The payoff: every layer can be tested with a fake of the layer below.

5. Environments, flavors and secrets

A bakery bakes the same cake for its test kitchen and for its shops; only the label and the delivery address differ. You do not want two recipes — you want one recipe and a label printed at packing time. Build-time settings are that label.

Apps usually talk to different servers while developing, testing and in production. Flutter's build tool lets you pass settings when you build:

Your code reads them with String.fromEnvironment, bool.fromEnvironment and int.fromEnvironment, written with const. They are compile-time constants: the compiler replaces them with the value while building, so changing a value means building again. Dart's documentation says these constructors are only guaranteed to work when called as const. The test runs with no defines, so every default shows:

Flavors go one step further: flutter run --flavor staging picks a matching native build variant — on Android a product flavor in the app's Gradle file, on iOS an Xcode scheme and build configuration — so each flavor can have its own app id (and so be installed side by side), name and icon. That native set-up is described in Flutter's flavors documentation and is not run here. Dart code can read the chosen name from the constant appFlavor (from package:flutter/services.dart); its source in this SDK reads the build tool's FLUTTER_APP_FLAVOR define, and it is null without --flavor, as printed above. Another common pattern is one entry file per environment: flutter run -t lib/main_staging.dart.

Secrets: anything you ship can be read

A --dart-define value is baked into the app. So is every string, asset and config file. Anyone can download your app and read its bytes. The test compiles two tiny programs ahead of time (machine code, the way release builds are compiled) with dart compile exe -DAPI_KEY=... and searches the finished file:

Program one only uses the key's length, so the compiler folded it into a number and the key text is not in the file — that is tree shaking and constant folding at work. Program two sends the key, as a real app would, and the full text Bearer sk_live_DEMO_123 sits in the binary for anyone to find. Obfuscation (section 6) renames code; it does not hide strings.

Rules for secrets. (1) Never ship a secret that grants power on its own: payment provider secret keys, cloud admin keys, database passwords. Keep them on your server; the app calls your server with the signed-in user's own short-lived token, and the server calls the provider (question q29). (2) Keys that are meant to be public (a maps key restricted to your app id, a public analytics id) may ship, restricted on the provider's side. (3) Do not commit config/prod.json with real secrets to git; CI injects them. (4) A .env file bundled as an asset is just as readable as a define.
Why const matters. Because apiBase is a constant, if (verboseLogs) { ... } with a false constant is removed from a release build entirely, and the map settings above is one canonical object (identical prints true). The test file also uses apiBase as a pattern inside a switch, which only compiles for constants. The tool itself reserves a few defines for its own use (it rejects some of its internal names), and passes the flavor through FLUTTER_APP_FLAVOR, as the SDK's appFlavor source shows.
--dart-define / --dart-define-from-file → const String.fromEnvironment(...), fixed at build time, default when missing. --flavor = native build variants + appFlavor. Everything in the binary is readable: keep real secrets on a server.

6. Release builds: modes, obfuscation, symbols, app size

Rehearsal versus opening night. In rehearsal (debug) the actors stop, repeat lines and talk to the director; it is slow but easy to fix things. On opening night (release) the show runs straight through, fast, with nothing extra on stage — and if something goes wrong, you need the stage manager's notes (symbol files) to work out what happened.

The three build modes from Step 12.5: debug compiles your Dart code just in time (JIT — while it runs, so hot reload works) with asserts switched on; profile and release compile ahead of time (AOT — to machine code before shipping) with asserts switched off. A release build is what users install. The commands, as documented by Flutter (not run here):

# Android: one APK containing machine code for every CPU type (ABI)
flutter build apk
# Android: one smaller APK per ABI (arm64-v8a, armeabi-v7a, x86_64)
flutter build apk --split-per-abi
# Android: an app bundle; Google Play builds a download for each device from it
flutter build appbundle
# iOS: an archive for the App Store (needs a Mac with Xcode)
flutter build ipa
# any of the above, with renamed identifiers and the debug symbols kept OUT of the app
flutter build appbundle --obfuscate --split-debug-info=build/symbols
# turn an obfuscated crash stack back into names, using the matching symbols file
flutter symbolize -i crash.txt -d build/symbols/app.android-arm64.symbols
# a size breakdown (Android: one target platform at a time)
flutter build apk --analyze-size --target-platform android-arm64

Obfuscation and symbol files

The flutter tool installed here describes the two flags like this (quoted from its help text): --obfuscate "removes identifiers and replaces them with randomized values for the purposes of source code obfuscation. This flag must always be combined with --split-debug-info"; and "Because all identifiers are renamed, methods like Object.runtimeType, Type.toString, Enum.toString, Stacktrace.toString, Symbol.toString (for constant symbols or those generated by runtime system) will return obfuscated results." --split-debug-info "reduces application size by storing Dart program symbols in a separate file on the host rather than in the application", and then "the flutter symbolize command with the right program symbol file is required to obtain a human readable stack trace". The help text also says --split-debug-info cannot be combined with --analyze-size.

So obfuscation hides names (class, method and field names in the compiled Dart code) and makes reverse engineering slower. It does not encrypt strings, assets or network traffic, does not hide your logic from a determined reader, and does not protect secrets. Its price: crash stacks become unreadable until you symbolize them, and any code that relies on type or enum names as text breaks (question q32). Keep every build's symbol files — a crash from version 2.3.1 needs 2.3.1's files.

The plain Dart compiler has the same "move debug info out" switch, -S, so the test can show its effect for real:

Without the debug info, the crash stack has no function names at all — only the build_id of the binary and raw offsets (virt addresses) into the machine code. A crash reporting service, or flutter symbolize, matches those offsets against the symbols file with the same build id to get checkout back. (The flutter symbolize step itself is not run here: it reads the symbol files produced by flutter build, which needs a phone toolchain.)

App size

Tree shaking: an AOT build only keeps code that can be reached from main; unused classes and functions are dropped (the secrets test above shows a whole string disappearing). Icon tree shaking: in release builds the tool cuts icon fonts down to the glyphs your code uses, and prints a message of the form "Font asset ... was tree-shaken, reducing it from ... to ... bytes ... Tree-shaking can be disabled by providing the --no-tree-shake-icons flag" (the message text is in the installed tool's source). It can only see icons written as constants: IconData's constructor marks codePoint as @mustBeConst, so the analyzer warns on IconData(someVariable) (question q13), and the tool refuses to tree-shake an app with "non-constant instances of IconData".

The levers, roughly in order of effect: ship an app bundle (or --split-per-abi APKs) so each phone downloads machine code for one CPU only; compress and resize images, and drop unused assets and fonts; remove packages you do not need (each brings code and sometimes native libraries); --split-debug-info; and measure with --analyze-size, whose output DevTools' App Size tool can open to show what each package costs (question q26 walks such a tree). Measure the size of a release build: debug builds contain the JIT and debugging support and are much bigger.

Release-day mistakes. (1) Losing the symbol files: obfuscated crash reports become unreadable forever. Archive them per version, or upload them to your crash reporting service. (2) Thinking obfuscation protects an API key — it does not touch strings. (3) Logic inside assert(...): asserts do not run in release, so the code inside them disappears. (4) Saving myEnum.toString() or runtimeType.toString() to a database or analytics, then turning on --obfuscate. (5) Judging app size from a debug APK.
What one release build does, in order: the Dart front end compiles the whole program into a kernel file; tree shaking drops unreachable code; the AOT compiler (gen_snapshot) turns what is left into machine code for each target ABI; with --obfuscate it renames identifiers and with --split-debug-info writes the debug information into separate files on your machine; the platform build (Gradle, Xcode) adds the engine, assets and native code and signs the package. The player above walks through those steps; its facts come from Flutter's documentation and tool help, not from running a phone build here.
Release = AOT, asserts off. appbundle / --split-per-abi for size per device. --obfuscate needs --split-debug-info; it renames identifiers, not strings; keep the symbol files and use flutter symbolize. Measure size on release builds with --analyze-size; keep icons constant.

7. How to answer the classic interview questions

A strong answer sounds like an engineer describing a house they built: the plan (structure), why each room is where it is (trade-offs), and one problem they hit and fixed (a real story) — not a list of package names.
QuestionA strong answer, in order
"How do you structure a large Flutter app?"(1) Layers: views → view models → repositories → services, dependencies pointing down only, injected through constructors. (2) Feature folders for screens, shared services in a common place. (3) State management chosen per need (Step 12.2), not one tool for everything. (4) Testing follows the layers: unit tests for repositories and view models with fakes, widget tests for screens, a few integration tests (Step 12.5). (5) A CI check for layer rules (q27). Mention Flutter's architecture guide: UI layer, data layer, optional domain layer.
"How do you handle deep links?"(1) Platform set-up: Android App Links, iOS Universal Links, web path URLs. (2) The link arrives as a RouteInformation; a parser turns the URI into navigation state; the delegate builds the full stack (Home underneath) — or a routing package does this from a route table. (3) Unknown or malformed links get a "not found" page, never a crash. (4) Guards: redirect to login when needed and come back afterwards (q28). (5) Back and the web address bar stay in sync through restoreRouteInformation. (6) Test it: send a link in a widget test with handlePushRoute and check the stack.
"How do you keep API keys safe?"(1) Anything in the app can be extracted — defines, strings, assets; obfuscation does not hide strings. (2) Real secrets stay on a server; the app sends the user's short-lived token; the server calls the third party. (3) Public keys are restricted by app id or domain on the provider's side. (4) Per-environment config via --dart-define-from-file, with files kept out of git and injected by CI. (5) Optionally, device attestation services (such as Google's Play Integrity or Apple's App Attest) so the server can tell a real app from a script.
"What does --obfuscate do?"It renames identifiers in the AOT-compiled Dart code; it must be used with --split-debug-info, which moves debug symbols out of the app into files you keep. It does not encrypt strings or assets. Crash stacks need flutter symbolize with that build's symbols. Code that prints type or enum names gets renamed results. It works on release (and profile) AOT builds; on the web, release builds are minified by the JavaScript compiler instead.
Weak answers: "I use go_router for everything" (without saying what it does), "Navigator 2.0 replaced Navigator 1.0" (both exist; push/pop still work inside a Router app), "obfuscation secures the app", "I put keys in a .env file", "Clean Architecture with 7 layers for every app".
Follow-ups to expect: "What happens to a screen's state when another screen is pushed on top?" (it stays built, offstage), "How do you return data from a dialog?" (await showDialog), "Why is WillPopScope deprecated?" (predictive back), "Why does the back button close my app from inside a tab?" (nested navigator not forwarded), "How do you test navigation?" (a widget test with a NavigatorObserver, or check the stack as this page's tests do).
Structure → layers + injection + tests. Deep links → URI → state → full stack, guards, round trip. Keys → never in the app. Obfuscation → renames names, keep the symbols.

Quiz

Interview questions

Cheat sheet

ConceptFact
NavigatorA stack of routes; the top one is visible, the ones below stay built offstage. Navigator.of(context) = nearest; rootNavigator: true = the app's.
Stack callspush, pop([result]), pushReplacement (didReplace), popUntil(ModalRoute.withName(n)) (a missing name pops everything), pushAndRemoveUntil(route, (r) => false) (push first, then remove), maybePop (refuses on the first route), canPop.
Resultsawait Navigator.push<T>(...) → value from pop(context, value); null on back; removed routes complete with null; pushReplacement(result:) completes the old route. Check context.mounted after await.
Named routespushNamed(name, arguments:) → onGenerateRoute(settings) → onUnknownRoute. Match argument types; no blind casts. Not recommended for most apps (docs).
PopScopecanPop: false blocks back gestures and maybePop (which then returns true) but not Navigator.pop. onPopInvokedWithResult(didPop, result). WillPopScope: deprecated after v3.12 (predictive back).
Nested navigatorsBack goes to the root navigator; forward it with NavigatorPopHandler. showDialog uses the root navigator by default.
RouterRouteInformationProvider → RouteInformationParser (parseRouteInformation / restoreRouteInformation) → RouterDelegate (build, setNewRoutePath, currentConfiguration, onDidRemovePage) + BackButtonDispatcher.
Deep linksBuild the full stack (Home underneath); not-found page for bad links; guards; App Links / Universal Links set up per platform (docs); web: usePathUrlStrategy().
ArchitectureView → view model → repository → service. Constructor injection. Fakes in tests. UI layer, data layer, optional domain layer (Flutter's architecture guide).
Build settings--dart-define, --dart-define-from-file (.json or .env) → const String/bool/int.fromEnvironment, bool.hasEnvironment. --flavor + native variants → appFlavor.
SecretsEverything shipped is readable (strings, defines, assets). Secrets stay on your server.
ReleaseAOT, asserts off. build apk / --split-per-abi / appbundle / ipa.
Obfuscation--obfuscate --split-debug-info=dir: renames identifiers (not strings); keep symbols; flutter symbolize -i trace -d symbols; type and enum names change.
SizeTree shaking, icon tree shaking (const IconData only), app bundles, assets, packages, --analyze-size + DevTools App Size. Measure release builds.