HydratedAppi
A powerful abstract state management class that extends Riverpod's StateNotifier with automatic state persistence using Flutter's secure storage. It provides seamless state hydration and dehydration, ensuring your app's state survives app restarts and device reboots.
Overviewโ
HydratedAppi
is an abstract class that combines the reactive state management capabilities of Riverpod's StateNotifier with automatic persistence using FlutterSecureStorage. It handles the complex logic of serializing, storing, and restoring state, allowing you to focus on your business logic while ensuring data persistence across app sessions.
Featuresโ
- ๐ Automatic Persistence - State changes are automatically saved to secure storage
- ๐ Secure Storage - Uses FlutterSecureStorage for encrypted data storage
- ๐ Riverpod Integration - Built on top of StateNotifier for reactive state management
- ๐ฆ JSON Serialization - Automatic JSON serialization and deserialization
- ๐ฏ Type Safety - Full type safety with generic state types
- ๐ง Customizable Keys - Custom storage keys for different state instances
- โก Performance Optimized - Efficient storage operations with minimal overhead
- ๐ก๏ธ Error Handling - Robust error handling for storage operations
- ๐ช Flexible Updates - Support for both persisted and non-persisted state updates
- ๐ Provider Integration - Seamless integration with Riverpod providers
- ๐ฑ Cross-Platform - Works on iOS, Android, and other Flutter platforms
- ๐งน Memory Efficient - Optimized memory usage for large state objects
Basic Usageโ
// Define your state class
class UserState {
final String name;
final int age;
final List<String> preferences;
UserState({
required this.name,
required this.age,
required this.preferences,
});
// JSON serialization methods
Map<String, dynamic> toJson() => {
'name': name,
'age': age,
'preferences': preferences,
};
factory UserState.fromJson(Map<String, dynamic> json) => UserState(
name: json['name'],
age: json['age'],
preferences: List<String>.from(json['preferences']),
);
}
// Create your HydratedAppi implementation
class UserNotifier extends HydratedAppi<UserState> {
UserNotifier() : super(UserState(name: '', age: 0, preferences: []));
String get key => 'user_state';
UserState fromJson(Map<String, dynamic> json) => UserState.fromJson(json);
Map<String, dynamic> toJson(UserState state) => state.toJson();
// Business logic methods
void updateName(String newName) {
store(state.copyWith(name: newName));
}
void updateAge(int newAge) {
store(state.copyWith(age: newAge));
}
void addPreference(String preference) {
final updatedPreferences = [...state.preferences, preference];
store(state.copyWith(preferences: updatedPreferences));
}
}
// Create provider
final userProvider = StateNotifierProvider<UserNotifier, UserState>((ref) {
return UserNotifier()..hyderate();
});
Properties and Methodsโ
Property/Method | Type | Description |
---|---|---|
key | String | Abstract - Unique storage key for this state instance |
fromJson | T fromJson(Map<String, dynamic>) | Abstract - Deserialize JSON to state object |
toJson | Map<String, dynamic> toJson(T) | Abstract - Serialize state object to JSON |
flutterSecureStorageProvider | Provider<FlutterSecureStorage> | Provider for secure storage instance |
provider | static Provider<T> | Static factory method for creating providers |
hyderate() | Future<void> | Load and restore state from storage |
store(T state) | Future<void> | Update state and persist to storage |
storeNoHydrate(T state) | void | Update state without persisting to storage |
Examplesโ
Simple Counter with Persistenceโ
class CounterState {
final int count;
final DateTime lastUpdated;
CounterState({
required this.count,
required this.lastUpdated,
});
CounterState copyWith({int? count, DateTime? lastUpdated}) {
return CounterState(
count: count ?? this.count,
lastUpdated: lastUpdated ?? this.lastUpdated,
);
}
Map<String, dynamic> toJson() => {
'count': count,
'lastUpdated': lastUpdated.toIso8601String(),
};
factory CounterState.fromJson(Map<String, dynamic> json) => CounterState(
count: json['count'],
lastUpdated: DateTime.parse(json['lastUpdated']),
);
}
class CounterNotifier extends HydratedAppi<CounterState> {
CounterNotifier() : super(CounterState(count: 0, lastUpdated: DateTime.now()));
String get key => 'counter_state';
CounterState fromJson(Map<String, dynamic> json) => CounterState.fromJson(json);
Map<String, dynamic> toJson(CounterState state) => state.toJson();
void increment() {
store(state.copyWith(
count: state.count + 1,
lastUpdated: DateTime.now(),
));
}
void decrement() {
store(state.copyWith(
count: state.count - 1,
lastUpdated: DateTime.now(),
));
}
void reset() {
store(CounterState(count: 0, lastUpdated: DateTime.now()));
}
void setCount(int newCount) {
store(state.copyWith(
count: newCount,
lastUpdated: DateTime.now(),
));
}
}
// Provider
final counterProvider = StateNotifierProvider<CounterNotifier, CounterState>((ref) {
return CounterNotifier()..hyderate();
});
// Usage in widget
class CounterWidget extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final counterState = ref.watch(counterProvider);
final counterNotifier = ref.read(counterProvider.notifier);
return Container(
padding: EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.blue[50]!, Colors.white],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.2),
spreadRadius: 2,
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.calculate, color: Colors.blue, size: 28),
SizedBox(width: 12),
Text(
'Persistent Counter',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.blue[800],
),
),
],
),
SizedBox(height: 20),
Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue[100],
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${counterState.count}',
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
color: Colors.blue[800],
),
),
),
SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(
onPressed: counterNotifier.decrement,
icon: Icon(Icons.remove),
label: Text('Decrease'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red[100],
foregroundColor: Colors.red[800],
),
),
ElevatedButton.icon(
onPressed: counterNotifier.reset,
icon: Icon(Icons.refresh),
label: Text('Reset'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[100],
foregroundColor: Colors.grey[800],
),
),
ElevatedButton.icon(
onPressed: counterNotifier.increment,
icon: Icon(Icons.add),
label: Text('Increase'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green[100],
foregroundColor: Colors.green[800],
),
),
],
),
SizedBox(height: 12),
Container(
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Last updated: ${DateFormat('MMM dd, yyyy HH:mm').format(counterState.lastUpdated)}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
),
],
),
);
}
}
User Preferences with Settingsโ
class UserPreferences {
final bool darkMode;
final String language;
final bool notifications;
final double fontSize;
final Map<String, dynamic> customSettings;
UserPreferences({
required this.darkMode,
required this.language,
required this.notifications,
required this.fontSize,
required this.customSettings,
});
UserPreferences copyWith({
bool? darkMode,
String? language,
bool? notifications,
double? fontSize,
Map<String, dynamic>? customSettings,
}) {
return UserPreferences(
darkMode: darkMode ?? this.darkMode,
language: language ?? this.language,
notifications: notifications ?? this.notifications,
fontSize: fontSize ?? this.fontSize,
customSettings: customSettings ?? this.customSettings,
);
}
Map<String, dynamic> toJson() => {
'darkMode': darkMode,
'language': language,
'notifications': notifications,
'fontSize': fontSize,
'customSettings': customSettings,
};
factory UserPreferences.fromJson(Map<String, dynamic> json) => UserPreferences(
darkMode: json['darkMode'],
language: json['language'],
notifications: json['notifications'],
fontSize: json['fontSize'].toDouble(),
customSettings: Map<String, dynamic>.from(json['customSettings']),
);
}
class PreferencesNotifier extends HydratedAppi<UserPreferences> {
PreferencesNotifier() : super(UserPreferences(
darkMode: false,
language: 'en',
notifications: true,
fontSize: 16.0,
customSettings: {},
));
String get key => 'user_preferences';
UserPreferences fromJson(Map<String, dynamic> json) => UserPreferences.fromJson(json);
Map<String, dynamic> toJson(UserPreferences state) => state.toJson();
void toggleDarkMode() {
store(state.copyWith(darkMode: !state.darkMode));
}
void setLanguage(String language) {
store(state.copyWith(language: language));
}
void toggleNotifications() {
store(state.copyWith(notifications: !state.notifications));
}
void setFontSize(double fontSize) {
store(state.copyWith(fontSize: fontSize));
}
void setCustomSetting(String key, dynamic value) {
final updatedSettings = {...state.customSettings, key: value};
store(state.copyWith(customSettings: updatedSettings));
}
void removeCustomSetting(String key) {
final updatedSettings = {...state.customSettings}..remove(key);
store(state.copyWith(customSettings: updatedSettings));
}
void resetToDefaults() {
store(UserPreferences(
darkMode: false,
language: 'en',
notifications: true,
fontSize: 16.0,
customSettings: {},
));
}
}
// Provider
final preferencesProvider = StateNotifierProvider<PreferencesNotifier, UserPreferences>((ref) {
return PreferencesNotifier()..hyderate();
});
Shopping Cart with Complex Stateโ
class CartItem {
final String id;
final String name;
final double price;
final int quantity;
CartItem({
required this.id,
required this.name,
required this.price,
required this.quantity,
});
CartItem copyWith({String? id, String? name, double? price, int? quantity}) {
return CartItem(
id: id ?? this.id,
name: name ?? this.name,
price: price ?? this.price,
quantity: quantity ?? this.quantity,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'price': price,
'quantity': quantity,
};
factory CartItem.fromJson(Map<String, dynamic> json) => CartItem(
id: json['id'],
name: json['name'],
price: json['price'].toDouble(),
quantity: json['quantity'],
);
}
class CartState {
final List<CartItem> items;
final double discount;
final DateTime lastModified;
CartState({
required this.items,
required this.discount,
required this.lastModified,
});
double get total => items.fold(0, (sum, item) => sum + (item.price * item.quantity)) - discount;
int get itemCount => items.fold(0, (sum, item) => sum + item.quantity);
CartState copyWith({List<CartItem>? items, double? discount, DateTime? lastModified}) {
return CartState(
items: items ?? this.items,
discount: discount ?? this.discount,
lastModified: lastModified ?? this.lastModified,
);
}
Map<String, dynamic> toJson() => {
'items': items.map((item) => item.toJson()).toList(),
'discount': discount,
'lastModified': lastModified.toIso8601String(),
};
factory CartState.fromJson(Map<String, dynamic> json) => CartState(
items: (json['items'] as List).map((item) => CartItem.fromJson(item)).toList(),
discount: json['discount'].toDouble(),
lastModified: DateTime.parse(json['lastModified']),
);
}
class CartNotifier extends HydratedAppi<CartState> {
CartNotifier() : super(CartState(items: [], discount: 0, lastModified: DateTime.now()));
String get key => 'shopping_cart';
CartState fromJson(Map<String, dynamic> json) => CartState.fromJson(json);
Map<String, dynamic> toJson(CartState state) => state.toJson();
void addItem(CartItem item) {
final existingIndex = state.items.indexWhere((i) => i.id == item.id);
List<CartItem> updatedItems;
if (existingIndex >= 0) {
updatedItems = [...state.items];
updatedItems[existingIndex] = updatedItems[existingIndex].copyWith(
quantity: updatedItems[existingIndex].quantity + item.quantity,
);
} else {
updatedItems = [...state.items, item];
}
store(state.copyWith(items: updatedItems, lastModified: DateTime.now()));
}
void removeItem(String itemId) {
final updatedItems = state.items.where((item) => item.id != itemId).toList();
store(state.copyWith(items: updatedItems, lastModified: DateTime.now()));
}
void updateQuantity(String itemId, int newQuantity) {
if (newQuantity <= 0) {
removeItem(itemId);
return;
}
final updatedItems = state.items.map((item) {
return item.id == itemId ? item.copyWith(quantity: newQuantity) : item;
}).toList();
store(state.copyWith(items: updatedItems, lastModified: DateTime.now()));
}
void applyDiscount(double discount) {
store(state.copyWith(discount: discount, lastModified: DateTime.now()));
}
void clearCart() {
store(CartState(items: [], discount: 0, lastModified: DateTime.now()));
}
void temporaryUpdate(CartState newState) {
// Update without persisting (useful for temporary calculations)
storeNoHydrate(newState);
}
}
// Provider
final cartProvider = StateNotifierProvider<CartNotifier, CartState>((ref) {
return CartNotifier()..hyderate();
});
Advanced Featuresโ
Custom Storage Providerโ
// Custom secure storage configuration
final customStorageProvider = Provider<FlutterSecureStorage>((ref) {
return FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
sharedPreferencesName: 'my_app_secure_prefs',
preferencesKeyPrefix: 'my_app_',
),
iOptions: IOSOptions(
groupId: 'group.com.myapp.shared',
accountName: 'MyAppAccount',
),
);
});
class CustomStorageNotifier extends HydratedAppi<MyState> {
CustomStorageNotifier() : super(MyState.initial());
Provider<FlutterSecureStorage> get flutterSecureStorageProvider => customStorageProvider;
// ... rest of implementation
}
Error Handling and Recoveryโ
class RobustNotifier extends HydratedAppi<MyState> {
RobustNotifier() : super(MyState.initial());
String get key => 'robust_state';
MyState fromJson(Map<String, dynamic> json) {
try {
return MyState.fromJson(json);
} catch (e) {
print('Error deserializing state: $e');
// Return default state on error
return MyState.initial();
}
}
Map<String, dynamic> toJson(MyState state) {
try {
return state.toJson();
} catch (e) {
print('Error serializing state: $e');
// Return minimal state on error
return {'error': 'serialization_failed'};
}
}
Future<void> hyderate() async {
try {
await super.hyderate();
} catch (e) {
print('Error hydrating state: $e');
// Continue with default state
}
}
Future<void> store(MyState state) async {
try {
await super.store(state);
} catch (e) {
print('Error storing state: $e');
// Optionally retry or handle gracefully
}
}
}
Migration Supportโ
class MigratableNotifier extends HydratedAppi<MyState> {
static const int currentVersion = 2;
MigratableNotifier() : super(MyState.initial());
String get key => 'migratable_state';
MyState fromJson(Map<String, dynamic> json) {
final version = json['version'] ?? 1;
switch (version) {
case 1:
return _migrateFromV1(json);
case 2:
return MyState.fromJson(json);
default:
return MyState.initial();
}
}
Map<String, dynamic> toJson(MyState state) {
final json = state.toJson();
json['version'] = currentVersion;
return json;
}
MyState _migrateFromV1(Map<String, dynamic> json) {
// Migrate old data structure to new format
return MyState(
newField: json['oldField'] ?? 'default',
// ... other migrations
);
}
}
Best Practicesโ
- State Design: Keep state classes immutable and provide copyWith methods
- JSON Serialization: Always handle serialization errors gracefully
- Key Naming: Use unique, descriptive keys for different state instances
- Error Handling: Implement robust error handling for storage operations
- Performance: Avoid storing large objects; consider pagination for lists
- Security: Never store sensitive data like passwords or tokens
- Migration: Plan for data structure changes with versioning
- Testing: Test serialization/deserialization thoroughly
Common Use Casesโ
- User Preferences: Theme, language, notification settings
- Shopping Cart: Persistent cart items across app sessions
- Game Progress: Levels, achievements, statistics
- Form Data: Draft content, temporary saves
- App State: Last viewed screens, user selections
- Cache Management: Offline data storage
- User Authentication: Session tokens (with proper security)
- Settings Configuration: App-specific configurations
- Analytics Data: User behavior tracking (anonymized)
- Bookmarks/Favorites: User-saved content
Related Widgetsโ
- StatefullWrapperAppi - For lifecycle management without persistence
- BoxAppi - For container widgets with interactions
- AnimatedBoxAppi - For animated containers
Migration Notesโ
When migrating from other state management solutions:
- Extend HydratedAppi instead of StateNotifier
- Implement the required abstract methods (key, fromJson, toJson)
- Call hyderate() in your provider initialization
- Use store() instead of state = newState for persistence
- Use storeNoHydrate() for temporary updates
Technical Notesโ
- Uses FlutterSecureStorage for encrypted data persistence
- Built on top of Riverpod's StateNotifier for reactive updates
- Automatic JSON serialization/deserialization
- Supports complex nested data structures
- Cross-platform compatibility (iOS, Android, Web, Desktop)
- Efficient storage operations with minimal performance impact
- Full null safety support
Ready to build apps with persistent state? HydratedAppi makes state management and persistence seamless!