Repository navigation
Flutter Session & App Lifecycle Management
Flutter analogue of iOS Session & App Lifecycle Management. For teams integrating the SmartDrive / SDApiManager / TripWrapper SDKs in a Flutter app. Last updated: 15 Jul, 2026.
The SmartDrive SDK is a native iOS framework. In a Flutter app it is not replaced by Dart — it runs natively, and Dart talks to it over a platform channel. Most session problems in Flutter come from trying to manage the session in Dart instead of letting the native SDK do it. This page maps every step of the iOS guide onto Flutter.
Important
The SDK persists the session natively. SDApiManager.isAuthorized() returns true on the next launch. Do not store the auth token in Dart (SharedPreferences, flutter_secure_storage, etc.) and do not treat a Dart-side flag as the source of truth. At launch, ask the native side isAuthorized() — nothing else.
- 1. The core principle
- 2. How the pieces fit together
- 3. Lifecycle mapping, iOS to Flutter
- 4. Native side, the YOUR_FLUTTER_PROJECT AppDelegate
- 5. Dart side
- 6. Permissions and background modes
- 7. Common mistakes, Flutter edition
- 8. How to verify
Three rules, unchanged from native — only where they run changes:
-
Arm the SDK in the native
ios/YOUR_FLUTTER_PROJECT/AppDelegate.swift, insidedidFinishLaunchingWithOptions, guarded byisAuthorized(). Not from Dart. This is the one method guaranteed to run on every launch, including when iOS relaunches a terminated app in the background with no UI. -
At launch, Dart asks native
isAuthorized()and routes to Home or Login. It does not re-create or store the session. -
logout()is called in exactly one place — the Dart Logout button, which forwards to native. Never on launch, resume, or background.
If your Flutter app forces a login after a screen-off, it is doing something explicit in Dart or in the YOUR_FLUTTER_PROJECT — iOS does not require it.
A Flutter iOS app still has a native ios/YOUR_FLUTTER_PROJECT/AppDelegate.swift (it subclasses FlutterAppDelegate). That is where the SDK lives and is armed. Dart only drives the UI and asks native for state.
Dart (UI) MethodChannel Native (ios/YOUR_FLUTTER_PROJECT)
───────── ───────────── ───────────────────
launch gate ───isAuthorized──────────────────────────────► SDApiManager.isAuthorized()
login screen ───login(user,pass)──────────────────────────► login → getAccount → arm SDK
resume ───refresh────────────────────────────────────► refreshFeatures()
background ───enterBackground────────────────────────────► onDidEnterBackground()
logout btn ───logout─────────────────────────────────────► SDApiManager.logout()
(independent of Dart)
iOS relaunch after kill ─────────────────────────────────► AppDelegate.didFinishLaunchingWithOptions
→ initializeFeatures() re-arms recording
Warning
The channel needs a FlutterViewController, which does not exist on a headless background relaunch. So arming must not depend on the channel — it must happen directly in native didFinishLaunchingWithOptions. The channel is only for foreground Dart↔native calls.
| Lifecycle moment | Native iOS (demo) | Flutter equivalent | What must happen |
|---|---|---|---|
| Cold launch / OS relaunch after kill | AppDelegate.didFinishLaunchingWithOptions |
Native YOUR_FLUTTER_PROJECT/AppDelegate.swift (still runs) |
Configure API, arm SDK guarded by isAuthorized()
|
| App entry point (UI) | — | Dart main() / runApp
|
UI only — do not arm the SDK here |
| Decide login vs skip | LoginViewController.checkExistingSession() |
Dart launch gate → channel isAuthorized()
|
Skip login if authorized |
| After successful login |
navigateToMainScreen() → initializeFeatures()
|
channel login() (native does login→account→arm) |
Login → fetch account → arm, in that order |
| Return to foreground | applicationDidBecomeActive |
didChangeAppLifecycleState(.resumed) → channel refresh
|
Refresh only, never re-login |
| Screen off / background | applicationDidEnterBackground |
didChangeAppLifecycleState(.paused) → channel enterBackground
|
Nothing destructive |
| Terminated | next didFinishLaunchingWithOptions
|
next native launch (same path) | Session reused automatically |
| Explicit logout | Logout button | Dart Logout button → channel logout
|
The only place logout() runs |
Arming and the session all live here. initializeFeatures, configureSmartDrive, refreshFeatures, and onDidEnterBackground are exactly the demo's implementations — copy them from the demo's AppDelegate.swift unchanged. The only additions for Flutter are subclassing FlutterAppDelegate and wiring the channel.
import UIKit
import Flutter
import SDApiManager
import SCPSmartDrive
import TripWrapper_iOS
@main
@objc class AppDelegate: FlutterAppDelegate {
private let channelName = "scope/smartdrive"
override func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
GeneratedPluginRegistrant.register(with: self)
// Runs on EVERY launch, including a background relaunch with no UI.
configureAPI()
// Arm using the PERSISTED session. Guards on isAuthorized(),
// so it is a safe no-op before first login. (Same method as the demo.)
initializeFeatures { error in
if let error = error { NSLog("initializeFeatures error: \(error)") }
}
// Foreground-only bridge for Dart <-> native.
if let controller = window?.rootViewController as? FlutterViewController {
let channel = FlutterMethodChannel(name: channelName,
binaryMessenger: controller.binaryMessenger)
channel.setMethodCallHandler { [weak self] call, result in
self?.handle(call, result: result)
}
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
// Always reply on the main thread.
let reply: (Any?) -> Void = { value in DispatchQueue.main.async { result(value) } }
switch call.method {
case "isAuthorized":
reply(SDApiManager.isAuthorized())
case "login":
guard let args = call.arguments as? [String: Any],
let username = args["username"] as? String,
let password = args["password"] as? String else {
reply(FlutterError(code: "bad_args", message: "username/password required", details: nil))
return
}
login(username: username, password: password, reply: reply)
case "refresh":
refreshFeatures(); reply(nil) // resume — refresh only
case "enterBackground":
onDidEnterBackground(); reply(nil) // background — nothing destructive
case "logout":
SDApiManager.logout(); reply(nil) // the ONLY place logout runs
case "diagnostics":
reply(diagnostics()) // for support: see §8
default:
reply(FlutterMethodNotImplemented)
}
}
// login -> fetch account -> arm, correct order, all native.
private func login(username: String, password: String, reply: @escaping (Any?) -> Void) {
SDApiManager.logout() // clears any stale session, only as part of explicit login
SDApiManager.login(withUsername: username, password: password) { [weak self] response in
guard response.success else {
reply(["success": false, "error": response.errorReason ?? "Login failed"]); return
}
SDApiManager.getAccountWithComplete { account in
guard account.success else {
reply(["success": false, "error": account.errorReason ?? "Account fetch failed"]); return
}
self?.initializeFeatures { error in
reply(["success": error == nil, "error": error ?? NSNull()])
}
}
}
}
// Decisive fields for debugging a stuck integration.
private func diagnostics() -> [String: Any] {
let sd = SCPSmartDriveManager.sharedInstance()
return [
"authorized": SDApiManager.isAuthorized(),
"userId": SDApiManager.userId(),
"recording": sd?.isRecordingTrip() ?? false,
"recorderState": Int(sd?.getRecorderState().rawValue ?? 0),
"autostart": sd?.isAutostartEnabled() ?? false,
"motion": sd?.isMotionActivityEnabled() ?? false
]
}
}Note
Keeping the whole login → getAccount → arm sequence on the native side (not split across Dart) guarantees the order is correct. Dart just calls login and navigates on success.
Dart never owns the session. It asks native, and it forwards lifecycle events.
import 'package:flutter/services.dart';
class SmartDrive {
static const _ch = MethodChannel('scope/smartdrive');
/// Reads the NATIVE persisted session. Do not cache this in Dart.
static Future<bool> isAuthorized() async =>
await _ch.invokeMethod<bool>('isAuthorized') ?? false;
static Future<({bool success, String? error})> login(String user, String pass) async {
final res = await _ch.invokeMapMethod<String, dynamic>(
'login', {'username': user, 'password': pass});
return (success: res?['success'] == true, error: res?['error'] as String?);
}
static Future<void> refresh() => _ch.invokeMethod('refresh');
static Future<void> enterBackground() => _ch.invokeMethod('enterBackground');
static Future<void> logout() => _ch.invokeMethod('logout');
static Future<Map<String, dynamic>> diagnostics() async =>
(await _ch.invokeMapMethod<String, dynamic>('diagnostics')) ?? {};
}Ask native every launch. Never read a Dart-stored token here.
class Root extends StatelessWidget {
const Root({super.key});
@override
Widget build(BuildContext context) {
return FutureBuilder<bool>(
future: SmartDrive.isAuthorized(),
builder: (context, snap) {
if (!snap.hasData) return const SplashScreen();
return snap.data! ? const HomeScreen() : const LoginScreen();
},
);
}
}Map Flutter lifecycle states to the native refresh/background calls. Do nothing destructive.
class LifecycleWatcher extends StatefulWidget {
final Widget child;
const LifecycleWatcher({super.key, required this.child});
@override
State<LifecycleWatcher> createState() => _LifecycleWatcherState();
}
class _LifecycleWatcherState extends State<LifecycleWatcher> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
SmartDrive.refresh(); // refresh only — no re-login
break;
case AppLifecycleState.paused:
SmartDrive.enterBackground(); // nothing session-related
break;
default:
break; // never logout on detached/inactive/hidden
}
}
@override
Widget build(BuildContext context) => widget.child;
}// Login button handler
final result = await SmartDrive.login(username, password);
if (result.success) {
// navigate to Home — the SDK is already armed natively
} else {
// show result.error
}
// Logout button handler — the ONLY place logout is called
await SmartDrive.logout();
// navigate to LoginTrip recording will not work — regardless of session — unless these are granted. This is a common reason a correctly-authenticated app still records nothing.
- Location: Always + precise. "When In Use" is not enough; background trips will not record. Reduced accuracy also blocks recording.
- Motion & Fitness. Required for trip detection.
- Notifications. For background trip start/end alerts.
In ios/YOUR_FLUTTER_PROJECT/Info.plist:
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key><string>...</string>
<key>NSLocationWhenInUseUsageDescription</key><string>...</string>
<key>NSMotionUsageDescription</key><string>...</string>
<key>UIBackgroundModes</key>
<array>
<string>location</string>
</array>Request Always location and motion at runtime (e.g. via permission_handler: Permission.locationAlways, Permission.sensors / Permission.activityRecognition). Requesting only locationWhenInUse is the usual mistake.
- ❌ Storing the auth token in Dart (
SharedPreferences/flutter_secure_storage) and treating it as the source of truth — then clearing it on launch. The SDK already persists the session; the Dart gate must call nativeisAuthorized(). - ❌ Arming the SDK from Dart (after the first frame or after the login screen). On a background relaunch the Flutter UI may never build, so recording never re-arms. Arm in native
didFinishLaunchingWithOptions. - ❌ Making the MethodChannel the only arming path. No
FlutterViewControllerexists on a headless relaunch, so the channel isn't there — arming must be channel-independent. - ❌ Destructive work in
didChangeAppLifecycleState(logout / SDK teardown onpausedordetached). - ❌ Requesting only "When In Use" location, or missing Motion — trips will not record even when logged in.
- ❌ Testing on the simulator. Automotive trip detection needs a real device and real motion.
- ✅ Correct: arm natively at launch guarded by
isAuthorized(), ask native for the session in the Dart gate, forward resume/background non-destructively, andlogout()only on the Logout button.
- Log in → confirm
diagnostics()returnsauthorized: trueand, once driving,recording: true. - Screen off, then on → app resumes with no login screen;
diagnostics()stillauthorized: true. - Force-quit, reopen → login screen skipped.
- Drive with the app killed → a trip still records (native relaunch re-arms).
- Tap Logout → next launch shows the login screen.
If step 2 or 3 shows a login screen, the Dart gate is reading a stored flag instead of native isAuthorized(), or something is calling logout outside the Logout button. If the user is authorized but nothing records, check §6 permissions and confirm you are on a real device.
Tip
Call diagnostics() and share the result when raising a support issue. authorized, recorderState, autostart, and motion tell us immediately whether the problem is the session, the arming, or permissions.
- iOS Session & App Lifecycle Management — the native version this mirrors.
-
SmartDrive demo app — copy
initializeFeatures,configureSmartDrive,refreshFeatures, andonDidEnterBackgroundfrom itsAppDelegate.swiftinto your YOUR_FLUTTER_PROJECT unchanged.
Consolidated SDK
SmartDrive
PortalApiBase
PortalApiClient
Wellness
SBeacon
- Overview
- Installation
- Initialization
- Beacon Scanning
- Beacon Pairing
- Trip Management
- Driving Behavior
- Calibration
- Event Handling
- Data Models
- API Reference
Consolidated SPM
SDApiManager
CCFrameworkOpen
SmartDrive
SBeacon