Chris Tam · Last updated 21 October 2026
Flutter builds mobile screens from widgets, so navigation events need the names of Flutter routes. I use product analytics to connect those screens with the actions people take inside the app, including opening a report.
In this tutorial, I initialize one analytics client, attach the screen observer, record a report preview, and connect the app's existing authenticated identity without replacing its login system.
An iOS or Android app with Home and Report screens. Named navigation produces [Amplitude] Screen Viewed, and opening the report records Report Previewed with report_type, days, and framework. Signed-in events use the same user ID as the backend.
Prerequisites
- A Flutter mobile project on Flutter 3.29 or later and Dart 3.7 or later, with an iOS simulator/device or Android emulator/device configured. This page targets
amplitude_flutter4.7.3. The three Dart files below are complete; merge shared initialization and route changes into an existing app. - A working native toolchain. iOS needs Xcode and CocoaPods; Android needs the Android SDK and the compatible Gradle/Kotlin versions in the SDK compatibility reference. Set the iOS deployment target to at least 13.0.
- An Amplitude project API key: Settings > Projects > your project > General, then Manage next to API Key. Use the key for the region where the project lives.
No Amplitude account yet? A free account is enough for this tutorial; nothing here needs a paid plan. Sign up for free to get your project API key.
1. Install the Flutter SDK package
Use the current Flutter SDK 4 package from the installation reference. These examples pin the package version so the source and app agree:
Terminal, existing project root
flutter pub add amplitude_flutter:4.7.3For iOS, keep the generated Podfile and set its existing platform declaration to:
ios/Podfile, platform declaration
platform :ios, '13.0'Then update the native dependencies:
Terminal, existing project root
cd ios
pod install
cd ..Keep the rest of the generated platform configuration. This guide uses the native iOS/Android SDK path; Flutter Web has a separate Browser SDK setup.
2. Create the analytics and identity modules
Keep the application user model separate from analytics. The default session adapter returns null, so the sample runs anonymously until connected to existing verified authentication.
lib/auth.dart
class AppUser {
const AppUser({required this.id, required this.plan});
final String id;
final String plan;
}
Future<AppUser?> restoreSignedInUser() async {
// Connect the existing verified session here. No session means anonymous.
return null;
}Create one analytics service. It waits for SDK initialization before exposing the client, uses the region passed from the app, and enables screen capture explicitly.
lib/analytics.dart
// Keep additions from later Flutter flags, replay, and guides pages.
import 'package:amplitude_flutter/amplitude.dart';
import 'package:amplitude_flutter/autocapture/autocapture.dart';
import 'package:amplitude_flutter/configuration.dart';
import 'package:amplitude_flutter/constants.dart';
import 'package:amplitude_flutter/events/base_event.dart';
import 'package:amplitude_flutter/events/identify.dart';
import 'package:flutter/foundation.dart';
import 'auth.dart';
class AppAnalytics {
AppAnalytics._(this.client);
final Amplitude client;
static Future<AppAnalytics> create({
required String apiKey,
ServerZone serverZone = ServerZone.us,
}) async {
if (apiKey.trim().isEmpty) {
throw ArgumentError('Set AMPLITUDE_API_KEY with --dart-define.');
}
final client = Amplitude(
Configuration(
apiKey: apiKey,
serverZone: serverZone,
autocapture: const AutocaptureOptions(
sessions: true,
appLifecycles: true,
screenViews: true,
),
),
);
if (!await client.isBuilt) {
throw StateError('Amplitude initialization did not complete.');
}
return AppAnalytics._(client);
}
Future<void> signedIn(AppUser user) async {
if (user.id.length < 5) {
throw ArgumentError('Use the same stable ID of at least 5 characters.');
}
await client.setUserId(user.id);
await client.identify(Identify()..set('plan', user.plan));
}
Future<void> signedOut() async {
// Later hub pages extend this with their cleanup before reset/refetch.
await client.reset();
}
Future<void> reportPreviewed() async {
try {
await client.track(
BaseEvent(
'Report Previewed',
eventProperties: {
'report_type': 'usage',
'days': 7,
'framework': 'flutter',
},
),
);
} catch (_) {
// An analytics failure should not block the report UI.
debugPrint('The report analytics event could not be queued.');
}
}
Future<void> flush() => client.flush();
}Call signedIn after the application confirms a login, with its canonical user ID. Keep that ID identical to backend instrumentation; prefix short internal IDs consistently on both sides. Identify sets the plan before later events so it can be used in charts.
After the application's real sign-out completes, await signedOut. The SDK clears the user ID and generates a fresh device ID. Do not reset on every anonymous startup, because the existing device identity connects anonymous visits.
Why. The service is passed to screens instead of created during widget builds. Rebuilding a widget should not initialize a second analytics client or change identity. Authentication remains the application's job; these methods only synchronize analytics.
3. Connect named routes to the same client
Initialize Flutter bindings first, create analytics once, restore any verified signed-in account, and then run the app. Keep the root app, routes, observers, and authentication wiring from other pages when merging this code.
lib/main.dart
// Keep initialization, routes, and observers added by later Flutter pages.
import 'package:amplitude_flutter/constants.dart';
import 'package:amplitude_flutter/observers/amplitude_navigator_observer.dart';
import 'package:flutter/material.dart';
import 'analytics.dart';
import 'auth.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
const apiKey = String.fromEnvironment('AMPLITUDE_API_KEY');
const zone = String.fromEnvironment(
'AMPLITUDE_SERVER_ZONE',
defaultValue: 'US',
);
final analytics = await AppAnalytics.create(
apiKey: apiKey,
serverZone: zone == 'EU' ? ServerZone.eu : ServerZone.us,
);
final user = await restoreSignedInUser();
if (user != null) await analytics.signedIn(user);
runApp(ReportsApp(analytics: analytics));
}
class ReportsApp extends StatelessWidget {
const ReportsApp({super.key, required this.analytics});
final AppAnalytics analytics;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Usage reports',
navigatorObservers: [AmplitudeNavigatorObserver(analytics.client)],
routes: {
'/':
(context) => Scaffold(
appBar: AppBar(title: const Text('Usage reports')),
body: Center(
child: FilledButton(
onPressed: () => Navigator.pushNamed(context, '/report'),
child: const Text('Preview report'),
),
),
),
'/report': (context) => ReportScreen(analytics: analytics),
},
);
}
}
class ReportScreen extends StatefulWidget {
const ReportScreen({super.key, required this.analytics});
final AppAnalytics analytics;
@override
State<ReportScreen> createState() => _ReportScreenState();
}
class _ReportScreenState extends State<ReportScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.analytics.reportPreviewed();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Seven-day usage')),
body: const Center(child: Text('Your usage report is ready.')),
);
}
}Both screenViews: true and AmplitudeNavigatorObserver are needed. The observer reads RouteSettings.name; these named routes provide / and /report. It records route navigation, including Back, without an extra manual screen event.
The report event runs once after that Report screen's first frame. Rebuilding the same widget does not record another preview; leaving and reopening the route is a new preview. In an existing app, place the event after the real report is ready rather than tracking it from every build() call.
MCP. With existing project access, use the Amplitude MCP server to inspect the event taxonomy before naming report events. Reuse Report Previewed if it is already the app's event rather than adding a near-duplicate name.
4. Run the app with the project key
List the available mobile targets, then start the app with a build-time configuration value. Select the configured iOS or Android target if Flutter prompts.
Terminal, project root
flutter devices
flutter run --dart-define=AMPLITUDE_API_KEY=your-project-api-keyFor an EU project, pass both the EU project key and the region:
Terminal, EU project
flutter run --dart-define=AMPLITUDE_API_KEY=your-eu-project-api-key --dart-define=AMPLITUDE_SERVER_ZONE=EURestart the app after changing build-time defines. The Dart code deliberately rejects an empty key rather than initializing an unusable client.
The SDK batches events. For a deliberate development upload, await the service's flush() from a debugger or development action after opening the report. A completed platform method is not an ingestion receipt, and force-closing an app can interrupt pending work.
Verify it worked
- Open Home, tap Preview report, then go Back. The app should show the report and return to Home without another client initialization. Repeat the navigation to create a second report preview.
- On the device, check the SDK's delivery/network diagnostics. Allow the normal batching interval, or request a development flush. Separate a queued Dart call from an actual native upload; a UI response alone does not prove delivery.
- In Amplitude's Ingestion Debugger, check successful requests, events, Identify counts, and errors. Use Users > User Profiles to find the same user or device and inspect the event stream.
- Confirm
[Amplitude] Screen Viewedhas screen names/,/report, and/in sequence. ConfirmReport Previewedcontainsreport_type: usage,days: 7, andframework: flutter. One opening produces one custom report event. - Connect the session adapter to the app's verified auth, sign in, and open another report. Confirm the canonical user ID and plan on subsequent events. Complete the app's sign-out, call
signedOut, and confirm later activity is anonymous on a new device identity.
Troubleshooting
The app reports a missing project key
Pass AMPLITUDE_API_KEY with --dart-define and restart. A shell variable alone does not become a String.fromEnvironment value unless it is passed into the Flutter build.
Initialization returns false
Wait for isBuilt and inspect the native plugin/toolchain error. Stop and relaunch after adding a plugin; hot reload does not install its native components. A simulator/device build is needed to validate native integration.
Navigation produces no screen events
Enable screenViews, attach the observer to the active Navigator, and give routes names. Anonymous routes are skipped. If your application uses a different router, connect its observer integration and preserve its route names rather than replacing it with this sample's routes.
Report previews are counted more than once
Keep tracking out of build(). This sample records after the route's first frame. Do not add another identical event in the navigation button and the destination screen.
Signed-out events still use the previous account
Call reset() through signedOut() after the application's sign-out and before allowing another person's activity. Setting a shared user ID like anonymous does not anonymize users.
Identify shows on a profile but not an earlier event
Identify affects later events. Set the plan before tracking the next report preview, then inspect that new event rather than expecting prior events to change.
FAQ
Does this start session recording?
This page initializes the analytics SDK. Session replay uses the separate Flutter replay package and setup; adding this analytics service does not start recording.
Does the default auth adapter sign anyone in?
It returns no user until connected to the application's verified session. The sample still records anonymous product events. Do not use an email typed into a form as proof of authentication.
Do I need to track screen views manually too?
Use the observer for the named routes shown here. Adding a second manual event for the same navigation duplicates screen measurement. A custom event such as Report Previewed measures a different action.
Full code reference
These are the same three files used above. Keep the existing generated platform configuration, app-specific authentication, and route additions from other Flutter pages.
// -- lib/auth.dart --
class AppUser {
const AppUser({required this.id, required this.plan});
final String id;
final String plan;
}
Future<AppUser?> restoreSignedInUser() async {
// Connect the existing verified session here. No session means anonymous.
return null;
}
// -- lib/analytics.dart --
// Keep additions from later Flutter flags, replay, and guides pages.
import 'package:amplitude_flutter/amplitude.dart';
import 'package:amplitude_flutter/autocapture/autocapture.dart';
import 'package:amplitude_flutter/configuration.dart';
import 'package:amplitude_flutter/constants.dart';
import 'package:amplitude_flutter/events/base_event.dart';
import 'package:amplitude_flutter/events/identify.dart';
import 'package:flutter/foundation.dart';
import 'auth.dart';
class AppAnalytics {
AppAnalytics._(this.client);
final Amplitude client;
static Future<AppAnalytics> create({
required String apiKey,
ServerZone serverZone = ServerZone.us,
}) async {
if (apiKey.trim().isEmpty) {
throw ArgumentError('Set AMPLITUDE_API_KEY with --dart-define.');
}
final client = Amplitude(
Configuration(
apiKey: apiKey,
serverZone: serverZone,
autocapture: const AutocaptureOptions(
sessions: true,
appLifecycles: true,
screenViews: true,
),
),
);
if (!await client.isBuilt) {
throw StateError('Amplitude initialization did not complete.');
}
return AppAnalytics._(client);
}
Future<void> signedIn(AppUser user) async {
if (user.id.length < 5) {
throw ArgumentError('Use the same stable ID of at least 5 characters.');
}
await client.setUserId(user.id);
await client.identify(Identify()..set('plan', user.plan));
}
Future<void> signedOut() async {
// Later hub pages extend this with their cleanup before reset/refetch.
await client.reset();
}
Future<void> reportPreviewed() async {
try {
await client.track(
BaseEvent(
'Report Previewed',
eventProperties: {
'report_type': 'usage',
'days': 7,
'framework': 'flutter',
},
),
);
} catch (_) {
// An analytics failure should not block the report UI.
debugPrint('The report analytics event could not be queued.');
}
}
Future<void> flush() => client.flush();
}
// -- lib/main.dart --
// Keep initialization, routes, and observers added by later Flutter pages.
import 'package:amplitude_flutter/constants.dart';
import 'package:amplitude_flutter/observers/amplitude_navigator_observer.dart';
import 'package:flutter/material.dart';
import 'analytics.dart';
import 'auth.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
const apiKey = String.fromEnvironment('AMPLITUDE_API_KEY');
const zone = String.fromEnvironment(
'AMPLITUDE_SERVER_ZONE',
defaultValue: 'US',
);
final analytics = await AppAnalytics.create(
apiKey: apiKey,
serverZone: zone == 'EU' ? ServerZone.eu : ServerZone.us,
);
final user = await restoreSignedInUser();
if (user != null) await analytics.signedIn(user);
runApp(ReportsApp(analytics: analytics));
}
class ReportsApp extends StatelessWidget {
const ReportsApp({super.key, required this.analytics});
final AppAnalytics analytics;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Usage reports',
navigatorObservers: [AmplitudeNavigatorObserver(analytics.client)],
routes: {
'/':
(context) => Scaffold(
appBar: AppBar(title: const Text('Usage reports')),
body: Center(
child: FilledButton(
onPressed: () => Navigator.pushNamed(context, '/report'),
child: const Text('Preview report'),
),
),
),
'/report': (context) => ReportScreen(analytics: analytics),
},
);
}
}
class ReportScreen extends StatefulWidget {
const ReportScreen({super.key, required this.analytics});
final AppAnalytics analytics;
@override
State<ReportScreen> createState() => _ReportScreenState();
}
class _ReportScreenState extends State<ReportScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.analytics.reportPreviewed();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Seven-day usage')),
body: const Center(child: Text('Your usage report is ready.')),
);
}
}Hand it to your agent
Coding-agent prompt
Add Amplitude product analytics to this Flutter iOS/Android app using https://amplitude.com/build/flutter-product-analytics.
Target amplitude_flutter 4.7.3. Merge initialization/routes with the existing app instead of replacing app-specific code. Keep future flags, replay, and guides additions in shared files.
Call WidgetsFlutterBinding.ensureInitialized, create one Amplitude client from Configuration using AMPLITUDE_API_KEY passed via --dart-define, and await isBuilt. Check false initialization explicitly. Honor the project region through ServerZone.
Enable sessions, appLifecycles and screenViews through AutocaptureOptions. Attach AmplitudeNavigatorObserver to the active Navigator and preserve route names. Do not manually duplicate its screen events.
Keep the existing verified auth layer. Set the same canonical user ID as the backend after sign-in, reject IDs shorter than five characters, and Identify plan before later events. On anonymous startup keep the existing device ID. After actual sign-out call reset before another user's activity; merge later hub cleanup in the documented order.
Track Report Previewed only when the report is ready, once per opening, with report_type=usage, days=7 and framework=flutter. Do not track from every widget build.
Preserve native platform settings, iOS 13+ and compatible Android tools. Verify native plugin integration and remote ingestion on a device separately from widget/method-channel tests. Report every file changed.What this unlocks
Product analytics: compare report previews by plan and follow the named screen journey.
Feature flags: add a later report variant while reusing the same user identity and preview event.
Session replay: connect recordings in a later setup using shared analytics device and session IDs.