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
Versionobjects 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
versionpackage
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:
Option 1: Register callbacks then init (Recommended)
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
- Execute Once: Each action (identified by its
id) runs at most once per app lifetime - Persistent Tracking: Executed action IDs are stored in shared preferences
- Auto-Cleanup: Actions removed from
runOnce()list have their IDs automatically cleaned from storage - Shared Tracking: Both
runOnce()andrunOnceNow()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
OneTimeActionis an interface - implement it or useSimpleOneTimeAction- Use custom implementations for better organization and dependency injection
- See
ONE_TIME_ACTIONS.mdfor 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
-
First Install: When the app is installed for the first time,
firstInstallCallbackis executed and a flag is stored in shared preferences. -
Version Upgrade: On subsequent launches, the package compares the stored version with the current app version. If they differ,
migrationCallbackis executed with both versions asVersionobjects for type-safe comparison, followed by any registered one-time actions. -
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). -
Normal Launch: If the app version hasn't changed and first install has already been executed, no callbacks are triggered.
-
State Persistence: All state is stored in shared preferences:
app_lifecycle_first_install_executed: Boolean flag for first installapp_lifecycle_last_version: Last known app version stringapp_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_preferencesfor persistent storagepackage_info_plusfor retrieving app version informationversionfor semantic versioning and type-safe version comparisons
The package is platform-agnostic and works on iOS, Android, Web, Windows, macOS, and Linux.