Skip to content

Flutter Session & App Lifecycle Management

YuliyaArtsiomchykScope edited this page Jul 16, 2026 · 4 revisions

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.

Table of contents

1. The core principle

Three rules, unchanged from native — only where they run changes:

  1. Arm the SDK in the native ios/YOUR_FLUTTER_PROJECT/AppDelegate.swift, inside didFinishLaunchingWithOptions, guarded by isAuthorized(). 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.
  2. At launch, Dart asks native isAuthorized() and routes to Home or Login. It does not re-create or store the session.
  3. 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.

2. How the pieces fit together

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.

3. Lifecycle mapping, iOS to Flutter

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

4. Native side, the YOUR_FLUTTER_PROJECT AppDelegate

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.

5. Dart side

Dart never owns the session. It asks native, and it forwards lifecycle events.

5.1. The bridge

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')) ?? {};
}

5.2. Launch gate, login vs skip

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();
      },
    );
  }
}

5.3. App lifecycle observer

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;
}

5.4. Login and logout

// 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 Login

6. Permissions and background modes

Trip 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.

7. Common mistakes, Flutter edition

  • ❌ 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 native isAuthorized().
  • ❌ 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 FlutterViewController exists on a headless relaunch, so the channel isn't there — arming must be channel-independent.
  • ❌ Destructive work in didChangeAppLifecycleState (logout / SDK teardown on paused or detached).
  • ❌ 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, and logout() only on the Logout button.

8. How to verify

  1. Log in → confirm diagnostics() returns authorized: true and, once driving, recording: true.
  2. Screen off, then on → app resumes with no login screen; diagnostics() still authorized: true.
  3. Force-quit, reopen → login screen skipped.
  4. Drive with the app killed → a trip still records (native relaunch re-arms).
  5. 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.

Reference

Clone this wiki locally