App Lifecycle Executor

A Flutter package that executes code on app installation and version upgrades, with persistent state tracking using shared preferences.

Features

  • ✅ Execute code on first app installation
  • ✅ Execute code on app version upgrades (with Version objects for comparison)
  • One-time actions - Run specific actions once per app lifetime (automatic or manual)
  • ✅ Automatic execution tracking using shared preferences
  • ✅ Ensures callbacks are only executed once per lifecycle event
  • ✅ Error handling support
  • ✅ Helper methods for testing and debugging
  • ✅ Type-safe version comparison using the version package

Getting started

Add the package to your pubspec.yaml:

dependencies:
  app_lifecycle_executor: ^0.0.1

Usage

There are two ways to use the package:

import 'package:flutter/material.dart';
import 'package:app_lifecycle_executor/app_lifecycle_executor.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  // Register callbacks
  AppLifecycleExecutor.onFirstInstall(() async {
    print('App installed for the first time!');
    // Initialize first-time user data
    // Show onboarding screens
    // Set default preferences
  });
  
  AppLifecycleExecutor.onVersionChanged((oldVersion, newVersion) async {
    print('App upgraded from $oldVersion to $newVersion');
    
    // Use Version objects for type-safe comparisons
    if (oldVersion < Version(2, 0, 0) && newVersion >= Version(2, 0, 0)) {
      // Migrate to version 2.0.0+
    }
    
    if (oldVersion.major != newVersion.major) {
      // Major version change
    }
    
    // Migrate data between versions
    // Show "What's New" screen
    // Update database schema
  });
  
  AppLifecycleExecutor.onError((error, stackTrace) {
    print('Lifecycle callback error: $error');
    // Handle errors gracefully
  });
  
  // Initialize
  await AppLifecycleExecutor.init();
  
  runApp(MyApp());
}

Option 2: Pass callbacks to init

import 'package:flutter/material.dart';
import 'package:app_lifecycle_executor/app_lifecycle_executor.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  await AppLifecycleExecutor.init(
    firstInstallCallback: () async {
      print('App installed for the first time!');
      // Initialize first-time user data
      // Show onboarding screens
      // Set default preferences
    },
    onVersionChanged: (oldVersion, newVersion) async {
      print('App upgraded from $oldVersion to $newVersion');
      
      // Use Version objects for type-safe comparisons
      if (oldVersion < Version(2, 0, 0) && newVersion >= Version(2, 0, 0)) {
        // Migrate to version 2.0.0+
      }
      
      if (oldVersion.major != newVersion.major) {
        // Major version change
      }
      
      // Migrate data between versions
      // Show "What's New" screen
      // Update database schema
    },
    onError: (error, stackTrace) {
      print('Lifecycle callback error: $error');
      // Handle errors gracefully
    },
  );
  
  runApp(MyApp());
}

Basic Examples

First Install Only

// Using registration
AppLifecycleExecutor.onFirstInstall(() async {
  // This runs only once, on first app installation
  await initializeDefaultSettings();
});
await AppLifecycleExecutor.init();

// Or pass directly to init
await AppLifecycleExecutor.init(
  firstInstallCallback: () async {
    // This runs only once, on first app installation
    await initializeDefaultSettings();
  },
);

Version Change Only

// Using registration
AppLifecycleExecutor.onVersionChanged((oldVersion, newVersion) async {
  // This runs when the app version changes
  // Use Version objects for semantic version comparisons
  
  if (oldVersion < Version(2, 0, 0) && newVersion >= Version(2, 0, 0)) {
    await migrateDataToV2();
  }
  
  // Check major version changes
  if (oldVersion.major != newVersion.major) {
    await performMajorVersionMigration();
  }
  
  // Check if it's a minor or patch update
  if (oldVersion.major == newVersion.major && 
      oldVersion.minor != newVersion.minor) {
    await performMinorVersionMigration();
  }
});
await AppLifecycleExecutor.init();

// Or pass directly to init
await AppLifecycleExecutor.init(
  onVersionChanged: (oldVersion, newVersion) async {
    if (oldVersion < Version(2, 0, 0) && newVersion >= Version(2, 0, 0)) {
      await migrateDataToV2();
    }
  },
);

Both Callbacks

// Using registration (recommended for clean code)
AppLifecycleExecutor.onFirstInstall(() async {
  print('Welcome to the app!');
  await setupInitialData();
});

AppLifecycleExecutor.onVersionChanged((oldVersion, newVersion) async {
  print('Updated from $oldVersion to $newVersion');
  
  // Semantic version comparisons
  if (newVersion > oldVersion) {
    await performMigration(oldVersion, newVersion);
  }
});

await AppLifecycleExecutor.init();

// Or pass directly to init
await AppLifecycleExecutor.init(
  firstInstallCallback: () async {
    print('Welcome to the app!');
    await setupInitialData();
  },
  onVersionChanged: (oldVersion, newVersion) async {
    print('Updated from $oldVersion to $newVersion');
    
    // Semantic version comparisons
    if (newVersion > oldVersion) {
      await performMigration(oldVersion, newVersion);
    }
  },
);

One-Time Actions

Execute specific actions only once per app lifetime. Perfect for showing release banners, initializing features, or running one-time migrations. Actions can run automatically on version upgrades or be triggered manually from anywhere in your app.

OneTimeAction is an interface that you can implement with your own classes. Use SimpleOneTimeAction for quick inline actions, or create custom implementations for better organization (e.g., with enum-based IDs).

Automatic Execution (On Version Upgrade)

Register actions before init() - they execute automatically during version upgrades:

// Option 1: Using SimpleOneTimeAction for inline actions
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  AppLifecycleExecutor.runOnce([
    SimpleOneTimeAction(
      id: 'show_v2_banner',
      callback: () async {
        print('🎉 Welcome to version 2.0!');
      },
    ),
    SimpleOneTimeAction(
      id: 'init_new_feature',
      callback: () async {
        await initializeNewFeature();
      },
    ),
  ]);
  
  await AppLifecycleExecutor.init();
  runApp(MyApp());
}

// Option 2: Custom implementations with enum-based IDs
enum ActionId {
  showBanner,
  initFeature,
  migrateData;
  
  String get value => name;
}

class ShowBannerAction implements OneTimeAction {
  @override
  String get id => ActionId.showBanner.value;
  
  @override
  Future<void> callback() async {
    await showNewFeatureBanner();
  }
}

class InitFeatureAction implements OneTimeAction {
  @override
  String get id => ActionId.initFeature.value;
  
  @override
  Future<void> callback() async {
    await initializeNewFeature();
  }
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  AppLifecycleExecutor.runOnce([
    ShowBannerAction(),
    InitFeatureAction(),
  ]);
  
  await AppLifecycleExecutor.init();
  runApp(MyApp());
}

Manual Execution (From Widgets)

Execute actions on-demand from anywhere in your app:

class FeatureButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () async {
        // Using SimpleOneTimeAction
        final wasExecuted = await AppLifecycleExecutor.runOnceNow(
          SimpleOneTimeAction(
            id: 'show_feature_dialog',
            callback: () async {
              await showDialog(
                context: context,
                builder: (_) => FeatureDialog(),
              );
            },
          ),
        );
        
        if (wasExecuted) {
          print('✅ Feature dialog shown');
        } else {
          print('ℹ️ User already saw this dialog');
        }
      },
      child: Text('Show Feature'),
    );
  }
}

// Or use a custom implementation
class ShowFeatureAction implements OneTimeAction {
  final BuildContext context;
  
  ShowFeatureAction(this.context);
  
  @override
  String get id => 'show_feature_dialog';
  
  @override
  Future<void> callback() async {
    await showDialog(
      context: context,
      builder: (_) => FeatureDialog(),
    );
  }
}

// Usage
await AppLifecycleExecutor.runOnceNow(ShowFeatureAction(context));

How One-Time Actions Work

  1. Execute Once: Each action (identified by its id) runs at most once per app lifetime
  2. Persistent Tracking: Executed action IDs are stored in shared preferences
  3. Auto-Cleanup: Actions removed from runOnce() list have their IDs automatically cleaned from storage
  4. Shared Tracking: Both runOnce() and runOnceNow() share the same tracking system

API Comparison

Method runOnce(List<OneTimeAction>) runOnceNow(OneTimeAction)
Where Before init() in main() Anywhere (widgets, handlers)
When Automatic on version upgrade Immediate when called
Use Case Version-based migrations User-triggered actions
Returns void Future<bool> (true if executed)
Cleanup Yes (auto-removes deleted IDs) No

More Examples

Show "What's New" Only Once:

SimpleOneTimeAction(
  id: 'whats_new_v2_5',
  callback: () async {
    await showWhatsNewDialog();
  },
)

Initialize Feature on First Encounter:

// In a widget
ElevatedButton(
  onPressed: () async {
    await AppLifecycleExecutor.runOnceNow(
      SimpleOneTimeAction(
        id: 'init_premium_features',
        callback: () async {
          await setupPremiumFeatures();
        },
      ),
    );
  },
  child: Text('Unlock Premium'),
)

Custom Implementation with Dependency Injection:

class MigrateDataAction implements OneTimeAction {
  final Database database;
  final Logger logger;
  
  MigrateDataAction(this.database, this.logger);
  
  @override
  String get id => 'migrate_data_v3';
  
  @override
  Future<void> callback() async {
    logger.info('Starting data migration');
    await database.migrate();
    logger.info('Migration completed');
  }
}

// Usage
AppLifecycleExecutor.runOnce([
  MigrateDataAction(database, logger),
]);

Important Notes:

  • Action IDs must be stable across app versions (don't change them)
  • Each action runs at most once per app lifetime
  • Both automatic and manual methods share the same ID tracking
  • OneTimeAction is an interface - implement it or use SimpleOneTimeAction
  • Use custom implementations for better organization and dependency injection
  • See ONE_TIME_ACTIONS.md for comprehensive documentation

Helper Methods

// Check if this is the first install (synchronous, cached value)
// Must call init() first, otherwise always returns false
if (AppLifecycleExecutor.isFirstInstall) {
  // Show welcome screen or onboarding
  print('This is a first-time user!');
}

// Check if init has been called
bool initialized = AppLifecycleExecutor.isInitialized;

// Check if first install has been executed (async)
bool executed = await AppLifecycleExecutor.hasFirstInstallExecuted();

// Get the last stored version
String? lastVersion = await AppLifecycleExecutor.getLastVersion();

// Get current app version
String currentVersion = await AppLifecycleExecutor.getCurrentVersion();

// Get list of executed one-time action IDs
List<String> executedActions = await AppLifecycleExecutor.getExecutedActionIds();
print('Executed actions: $executedActions');

// Check if a specific action has been executed
if (executedActions.contains('my_action_id')) {
  print('Action already executed');
}

// Reset lifecycle state (useful for testing)
// This also clears all executed action IDs
await AppLifecycleExecutor.reset();

Using isFirstInstall in Widgets

You can use the synchronous isFirstInstall getter directly in your widget build methods:

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Show different UI for first-time users
    if (AppLifecycleExecutor.isFirstInstall) {
      return OnboardingScreen();
    }
    
    return HomeScreen();
  }
}

// Or conditionally render widgets
Widget build(BuildContext context) {
  return Column(
    children: [
      Text('Welcome to the app!'),
      if (AppLifecycleExecutor.isFirstInstall)
        WelcomeBanner(),
      // ... rest of your UI
    ],
  );
}

Important: Make sure to call AppLifecycleExecutor.init() in your main() function before running the app, otherwise isFirstInstall will always return false.

How It Works

  1. First Install: When the app is installed for the first time, firstInstallCallback is executed and a flag is stored in shared preferences.

  2. Version Upgrade: On subsequent launches, the package compares the stored version with the current app version. If they differ, migrationCallback is executed with both versions as Version objects for type-safe comparison, followed by any registered one-time actions.

  3. One-Time Actions: Actions are tracked by their unique ID in shared preferences. Each action executes at most once, either automatically during version upgrades (runOnce) or manually when called (runOnceNow).

  4. Normal Launch: If the app version hasn't changed and first install has already been executed, no callbacks are triggered.

  5. State Persistence: All state is stored in shared preferences:

    • app_lifecycle_first_install_executed: Boolean flag for first install
    • app_lifecycle_last_version: Last known app version string
    • app_lifecycle_executed_actions: List of executed one-time action IDs

Version Comparison

The package uses the version package for semantic versioning support. This allows you to:

AppLifecycleExecutor.onVersionChanged((oldVersion, newVersion) async {
  // Compare versions
  if (oldVersion < newVersion) { /* ... */ }
  
  // Check major version
  if (oldVersion.major != newVersion.major) { /* ... */ }
  
  // Check specific version ranges
  if (oldVersion >= Version(1, 5, 0) && newVersion < Version(2, 0, 0)) { /* ... */ }
  
  // Access version parts
  print('Major: ${newVersion.major}');
  print('Minor: ${newVersion.minor}');
  print('Patch: ${newVersion.patch}');
});

Error Handling

Callbacks can throw errors, which can be handled with the onError callback:

// Using registration
AppLifecycleExecutor.onError((error, stackTrace) {
  // Log error to your error tracking service
  logError(error, stackTrace);
});

AppLifecycleExecutor.onFirstInstall(() async {
  throw Exception('Something went wrong');
});

await AppLifecycleExecutor.init();

// Or pass directly to init
await AppLifecycleExecutor.init(
  firstInstallCallback: () async {
    throw Exception('Something went wrong');
  },
  onError: (error, stackTrace) {
    // Log error to your error tracking service
    logError(error, stackTrace);
  },
);

If onError is not provided, errors will be rethrown.

Testing

For testing purposes, you can reset the lifecycle state:

// Reset all stored data (including executed action IDs)
await AppLifecycleExecutor.reset();

// Now the next init() call will treat it as a first install
// and all one-time actions will execute again
AppLifecycleExecutor.onFirstInstall(() async {
  // This will execute again
});

AppLifecycleExecutor.runOnce([
  SimpleOneTimeAction(
    id: 'test_action',
    callback: () async {
      // This will execute again after reset
    },
  ),
]);

await AppLifecycleExecutor.init();

Testing One-Time Actions:

// Check which actions have been executed
final executedIds = await AppLifecycleExecutor.getExecutedActionIds();
print('Executed: $executedIds');

// Test manual execution
final wasExecuted = await AppLifecycleExecutor.runOnceNow(
  SimpleOneTimeAction(id: 'test', callback: () async {}),
);
assert(wasExecuted == true); // First call executes

final wasExecutedAgain = await AppLifecycleExecutor.runOnceNow(
  SimpleOneTimeAction(id: 'test', callback: () async {}),
);
assert(wasExecutedAgain == false); // Second call skips

Additional Information

This package uses:

  • shared_preferences for persistent storage
  • package_info_plus for retrieving app version information
  • version for semantic versioning and type-safe version comparisons

The package is platform-agnostic and works on iOS, Android, Web, Windows, macOS, and Linux.