State Management
State Management with Riverpod
Section titled “State Management with Riverpod”Lumo uses Riverpod for state management, providing reactive, testable, and maintainable state handling throughout the application.
Riverpod Architecture
Section titled “Riverpod Architecture”Provider Types Used
Section titled “Provider Types Used”// State providers for simple statefinal counterProvider = StateProvider<int>((ref) => 0);
// StateNotifier providers for complex statefinal otpListProvider = StateNotifierProvider<OtpListNotifier, AsyncValue<List<OtpEntry>>>( (ref) => OtpListNotifier(ref.read(otpRepositoryProvider)),);
// Future providers for async operationsfinal backupStatusProvider = FutureProvider<BackupStatus>((ref) async { final service = ref.read(backupServiceProvider); return await service.getBackupStatus();});
// Stream providers for real-time datafinal otpTimerProvider = StreamProvider<int>((ref) { return Stream.periodic(const Duration(seconds: 1), (count) => count);});Core State Providers
Section titled “Core State Providers”OTP List Management
Section titled “OTP List Management”@riverpodclass OtpList extends _$OtpList { @override Future<List<OtpEntry>> build() async { // Load initial data final result = await ref.read(otpRepositoryProvider).getAllOtpEntries(); return result.fold( (failure) => throw Exception(failure.message), (entries) => entries, ); }
// Add new OTP entry Future<void> addEntry(String otpUri) async { state = const AsyncLoading();
try { final result = await ref.read(addOtpEntryProvider).call( AddOtpEntryParams(otpUri: otpUri), );
result.fold( (failure) => state = AsyncError(failure, StackTrace.current), (_) => ref.invalidateSelf(), // Refresh the list ); } catch (e, stack) { state = AsyncError(e, stack); } }
// Update existing entry Future<void> updateEntry(OtpEntry entry) async { final currentState = state.valueOrNull ?? [];
// Optimistic update state = AsyncData( currentState.map((e) => e.id == entry.id ? entry : e).toList(), );
try { final result = await ref.read(updateOtpEntryProvider).call( UpdateOtpEntryParams(entry: entry), );
result.fold( (failure) { // Revert on failure state = AsyncData(currentState); throw Exception(failure.message); }, (_) {}, // Success - optimistic update was correct ); } catch (e) { // Revert to previous state state = AsyncData(currentState); rethrow; } }
// Delete entry Future<void> deleteEntry(String id) async { final currentState = state.valueOrNull ?? [];
// Optimistic delete state = AsyncData( currentState.where((e) => e.id != id).toList(), );
try { final result = await ref.read(deleteOtpEntryProvider).call( DeleteOtpEntryParams(id: id), );
result.fold( (failure) { // Revert on failure state = AsyncData(currentState); throw Exception(failure.message); }, (_) {}, // Success ); } catch (e) { // Revert to previous state state = AsyncData(currentState); rethrow; } }
// Search entries List<OtpEntry> searchEntries(String query) { final entries = state.valueOrNull ?? []; if (query.isEmpty) return entries;
return entries.where((entry) { return entry.issuer.toLowerCase().contains(query.toLowerCase()) || entry.account.toLowerCase().contains(query.toLowerCase()); }).toList(); }}
// Use case providers@riverpodAddOtpEntry addOtpEntry(AddOtpEntryRef ref) { return AddOtpEntry(ref.read(otpRepositoryProvider));}
@riverpodUpdateOtpEntry updateOtpEntry(UpdateOtpEntryRef ref) { return UpdateOtpEntry(ref.read(otpRepositoryProvider));}
@riverpodDeleteOtpEntry deleteOtpEntry(DeleteOtpEntryRef ref) { return DeleteOtpEntry(ref.read(otpRepositoryProvider));}Authentication State
Section titled “Authentication State”@riverpodclass AuthState extends _$AuthState { @override Future<AuthStatus> build() async { // Check initial authentication state final isAuthenticated = await ref.read(authServiceProvider).isAuthenticated(); final biometricEnabled = await ref.read(settingsServiceProvider).isBiometricEnabled();
return AuthStatus( isAuthenticated: isAuthenticated, biometricEnabled: biometricEnabled, requiresAuthentication: !isAuthenticated, ); }
Future<void> authenticate() async { state = const AsyncLoading();
try { final authService = ref.read(authServiceProvider); final settingsService = ref.read(settingsServiceProvider);
// Try biometric first if enabled if (await settingsService.isBiometricEnabled()) { final biometricResult = await authService.authenticateWithBiometric(); if (biometricResult) { state = AsyncData(AuthStatus( isAuthenticated: true, biometricEnabled: true, requiresAuthentication: false, )); return; } }
// Fall back to PIN/password // This would typically open a PIN entry dialog // For now, we'll assume authentication succeeded state = AsyncData(AuthStatus( isAuthenticated: true, biometricEnabled: await settingsService.isBiometricEnabled(), requiresAuthentication: false, )); } catch (e, stack) { state = AsyncError(e, stack); } }
Future<void> enableBiometric() async { try { final authService = ref.read(authServiceProvider); final settingsService = ref.read(settingsServiceProvider);
final success = await authService.enableBiometric(); if (success) { await settingsService.setBiometricEnabled(true);
final currentStatus = state.valueOrNull; if (currentStatus != null) { state = AsyncData(currentStatus.copyWith(biometricEnabled: true)); } } } catch (e) { // Handle error rethrow; } }
void logout() { state = AsyncData(AuthStatus( isAuthenticated: false, biometricEnabled: false, requiresAuthentication: true, )); }}
class AuthStatus { final bool isAuthenticated; final bool biometricEnabled; final bool requiresAuthentication;
const AuthStatus({ required this.isAuthenticated, required this.biometricEnabled, required this.requiresAuthentication, });
AuthStatus copyWith({ bool? isAuthenticated, bool? biometricEnabled, bool? requiresAuthentication, }) { return AuthStatus( isAuthenticated: isAuthenticated ?? this.isAuthenticated, biometricEnabled: biometricEnabled ?? this.biometricEnabled, requiresAuthentication: requiresAuthentication ?? this.requiresAuthentication, ); }}Settings Management
Section titled “Settings Management”@riverpodclass AppSettings extends _$AppSettings { @override Future<SettingsState> build() async { final service = ref.read(settingsServiceProvider);
return SettingsState( themeMode: await service.getThemeMode(), biometricEnabled: await service.isBiometricEnabled(), autoLockEnabled: await service.isAutoLockEnabled(), autoLockTimeout: await service.getAutoLockTimeout(), backupEnabled: await service.isBackupEnabled(), analyticsEnabled: await service.isAnalyticsEnabled(), ); }
Future<void> updateThemeMode(ThemeMode themeMode) async { final service = ref.read(settingsServiceProvider); await service.setThemeMode(themeMode);
final currentState = state.valueOrNull; if (currentState != null) { state = AsyncData(currentState.copyWith(themeMode: themeMode)); } }
Future<void> toggleBiometric(bool enabled) async { final service = ref.read(settingsServiceProvider); await service.setBiometricEnabled(enabled);
final currentState = state.valueOrNull; if (currentState != null) { state = AsyncData(currentState.copyWith(biometricEnabled: enabled)); } }
Future<void> updateAutoLockTimeout(Duration timeout) async { final service = ref.read(settingsServiceProvider); await service.setAutoLockTimeout(timeout);
final currentState = state.valueOrNull; if (currentState != null) { state = AsyncData(currentState.copyWith(autoLockTimeout: timeout)); } }}
class SettingsState { final ThemeMode themeMode; final bool biometricEnabled; final bool autoLockEnabled; final Duration autoLockTimeout; final bool backupEnabled; final bool analyticsEnabled;
const SettingsState({ required this.themeMode, required this.biometricEnabled, required this.autoLockEnabled, required this.autoLockTimeout, required this.backupEnabled, required this.analyticsEnabled, });
SettingsState copyWith({ ThemeMode? themeMode, bool? biometricEnabled, bool? autoLockEnabled, Duration? autoLockTimeout, bool? backupEnabled, bool? analyticsEnabled, }) { return SettingsState( themeMode: themeMode ?? this.themeMode, biometricEnabled: biometricEnabled ?? this.biometricEnabled, autoLockEnabled: autoLockEnabled ?? this.autoLockEnabled, autoLockTimeout: autoLockTimeout ?? this.autoLockTimeout, backupEnabled: backupEnabled ?? this.backupEnabled, analyticsEnabled: analyticsEnabled ?? this.analyticsEnabled, ); }}UI State Patterns
Section titled “UI State Patterns”Consuming Providers in Widgets
Section titled “Consuming Providers in Widgets”class OtpListPage extends ConsumerWidget { const OtpListPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) { // Watch provider for reactive updates final otpListAsync = ref.watch(otpListProvider); final searchQuery = ref.watch(searchQueryProvider);
return Scaffold( appBar: AppBar( title: const Text('Your Accounts'), actions: [ IconButton( onPressed: () => _navigateToScanner(context, ref), icon: const Icon(Icons.qr_code_scanner), ), ], ), body: Column( children: [ // Search bar SearchBar( onChanged: (query) { ref.read(searchQueryProvider.notifier).state = query; }, ),
// OTP list Expanded( child: otpListAsync.when( data: (entries) { final filteredEntries = searchQuery.isEmpty ? entries : ref.read(otpListProvider.notifier).searchEntries(searchQuery);
return _buildOtpList(filteredEntries, ref); }, loading: () => const Center( child: CircularProgressIndicator(), ), error: (error, stack) => _buildErrorState(error, ref), ), ), ], ), ); }
Widget _buildOtpList(List<OtpEntry> entries, WidgetRef ref) { if (entries.isEmpty) { return const EmptyOtpListWidget(); }
return ListView.builder( itemCount: entries.length, itemBuilder: (context, index) { final entry = entries[index]; return OtpTile( key: ValueKey(entry.id), entry: entry, onDelete: () => _deleteEntry(entry.id, ref), onEdit: () => _editEntry(entry, ref), ); }, ); }
Widget _buildErrorState(Object error, WidgetRef ref) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.error_outline, size: 64, color: Colors.red), const SizedBox(height: 16), Text('Error: $error'), const SizedBox(height: 16), ElevatedButton( onPressed: () => ref.invalidate(otpListProvider), child: const Text('Retry'), ), ], ), ); }
void _deleteEntry(String id, WidgetRef ref) { ref.read(otpListProvider.notifier).deleteEntry(id); }
void _editEntry(OtpEntry entry, WidgetRef ref) { // Navigate to edit page or show edit dialog }
void _navigateToScanner(BuildContext context, WidgetRef ref) { Navigator.of(context).push( MaterialPageRoute( builder: (context) => const QrScannerPage(), ), ); }}
// Search query providerfinal searchQueryProvider = StateProvider<String>((ref) => '');Stateful Widget with Riverpod
Section titled “Stateful Widget with Riverpod”class OtpTile extends ConsumerStatefulWidget { final OtpEntry entry; final VoidCallback onDelete; final VoidCallback onEdit;
const OtpTile({ super.key, required this.entry, required this.onDelete, required this.onEdit, });
@override ConsumerState<OtpTile> createState() => _OtpTileState();}
class _OtpTileState extends ConsumerState<OtpTile> { Timer? _timer; late String _currentOtp; late int _remainingTime;
@override void initState() { super.initState(); _updateOtp(); _startTimer(); }
@override void dispose() { _timer?.cancel(); super.dispose(); }
void _startTimer() { _timer = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) { _updateOtp(); } }); }
void _updateOtp() { setState(() { _currentOtp = widget.entry.generateOtp(); _remainingTime = widget.entry.getRemainingTime(); }); }
@override Widget build(BuildContext context) { // Access theme settings through provider final settings = ref.watch(appSettingsProvider);
return Card( child: ListTile( leading: CircleAvatar( backgroundImage: AssetImage( IssuerIcons.getIcon(widget.entry.issuer), ), ), title: Text(widget.entry.issuer), subtitle: Text(widget.entry.account), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ // OTP code Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( _currentOtp, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, fontFamily: 'monospace', ), ), if (widget.entry.type == OtpType.totp) Text( '${_remainingTime}s', style: TextStyle( fontSize: 12, color: _remainingTime <= 10 ? Colors.red : Colors.grey, ), ), ], ),
// More options PopupMenuButton( itemBuilder: (context) => [ PopupMenuItem( onTap: () => _copyToClipboard(_currentOtp), child: const Text('Copy'), ), PopupMenuItem( onTap: widget.onEdit, child: const Text('Edit'), ), PopupMenuItem( onTap: widget.onDelete, child: const Text('Delete'), ), ], ), ], ), onTap: () => _copyToClipboard(_currentOtp), ), ); }
void _copyToClipboard(String otp) { Clipboard.setData(ClipboardData(text: otp)); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('OTP copied to clipboard')), );
// Update last used timestamp final updatedEntry = widget.entry.copyWith(lastUsed: DateTime.now()); ref.read(otpListProvider.notifier).updateEntry(updatedEntry); }}Advanced Patterns
Section titled “Advanced Patterns”Family Providers
Section titled “Family Providers”// Provider that takes parameters@riverpodFuture<OtpEntry?> otpEntry(OtpEntryRef ref, String id) async { final result = await ref.read(otpRepositoryProvider).getOtpEntryById(id); return result.fold( (failure) => null, (entry) => entry, );}
// Usage in widgetclass OtpDetailPage extends ConsumerWidget { final String entryId;
const OtpDetailPage({super.key, required this.entryId});
@override Widget build(BuildContext context, WidgetRef ref) { final entryAsync = ref.watch(otpEntryProvider(entryId));
return entryAsync.when( data: (entry) => entry != null ? _buildEntryDetail(entry) : const Text('Entry not found'), loading: () => const CircularProgressIndicator(), error: (error, stack) => Text('Error: $error'), ); }
Widget _buildEntryDetail(OtpEntry entry) { // Build detail view return Container(); }}Provider Dependencies
Section titled “Provider Dependencies”// Provider that depends on other providers@riverpodFuture<BackupInfo> backupInfo(BackupInfoRef ref) async { // Depend on settings and auth state final settings = await ref.watch(appSettingsProvider.future); final auth = await ref.watch(authStateProvider.future);
if (!settings.backupEnabled || !auth.isAuthenticated) { return BackupInfo.disabled(); }
final service = ref.read(backupServiceProvider); return await service.getBackupInfo();}
// Auto-dispose providers that clean up when not used@riverpodStream<int> otpTimer(OtpTimerRef ref) { // This stream will be cancelled when no longer watched return Stream.periodic( const Duration(seconds: 1), (count) => DateTime.now().second, );}Error Handling Patterns
Section titled “Error Handling Patterns”// Global error handling@riverpodclass ErrorHandler extends _$ErrorHandler { @override String? build() => null;
void handleError(Object error, StackTrace stack) { if (error is NetworkException) { state = 'Network connection failed. Please check your internet connection.'; } else if (error is AuthenticationException) { state = 'Authentication failed. Please try again.'; // Redirect to login ref.read(authStateProvider.notifier).logout(); } else { state = 'An unexpected error occurred. Please try again.'; }
// Clear error after 5 seconds Timer(const Duration(seconds: 5), () { state = null; });
// Log error for debugging if (kDebugMode) { debugPrint('Error: $error\nStack: $stack'); } }}
// Usage in widgetclass ErrorDisplay extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final error = ref.watch(errorHandlerProvider);
if (error == null) return const SizedBox.shrink();
return Material( child: Container( padding: const EdgeInsets.all(16), color: Colors.red, child: Row( children: [ const Icon(Icons.error, color: Colors.white), const SizedBox(width: 8), Expanded( child: Text( error, style: const TextStyle(color: Colors.white), ), ), IconButton( onPressed: () => ref.read(errorHandlerProvider.notifier).state = null, icon: const Icon(Icons.close, color: Colors.white), ), ], ), ), ); }}Testing State Management
Section titled “Testing State Management”Provider Testing
Section titled “Provider Testing”void main() { late ProviderContainer container; late MockOtpRepository mockRepository;
setUp(() { mockRepository = MockOtpRepository(); container = ProviderContainer( overrides: [ otpRepositoryProvider.overrideWithValue(mockRepository), ], ); });
tearDown(() { container.dispose(); });
group('OtpListProvider', () { test('should load OTP entries on initialization', () async { // Arrange const entries = [ OtpEntry( id: '1', issuer: 'Google', account: 'test@gmail.com', secret: 'SECRET', algorithm: OtpAlgorithm.sha1, digits: 6, interval: 30, type: OtpType.totp, created: DateTime.now(), ), ];
when(mockRepository.getAllOtpEntries()) .thenAnswer((_) async => const Right(entries));
// Act final provider = container.read(otpListProvider.future); final result = await provider;
// Assert expect(result, entries); verify(mockRepository.getAllOtpEntries()).called(1); });
test('should add new OTP entry', () async { // Arrange const otpUri = 'otpauth://totp/Test:user@test.com?secret=SECRET&issuer=Test';
when(mockRepository.getAllOtpEntries()) .thenAnswer((_) async => const Right([])); when(mockRepository.addOtpEntry(any)) .thenAnswer((_) async => const Right(null));
// Act await container.read(otpListProvider.notifier).addEntry(otpUri);
// Assert verify(mockRepository.addOtpEntry(any)).called(1); }); });}Widget Testing with Providers
Section titled “Widget Testing with Providers”void main() { testWidgets('OtpListPage displays loading indicator', (tester) async { await tester.pumpWidget( ProviderScope( overrides: [ otpListProvider.overrideWith( () => throw const AsyncLoading<List<OtpEntry>>(), ), ], child: const MaterialApp( home: OtpListPage(), ), ), );
expect(find.byType(CircularProgressIndicator), findsOneWidget); });
testWidgets('OtpListPage displays OTP entries', (tester) async { const entries = [ OtpEntry( id: '1', issuer: 'Google', account: 'test@gmail.com', secret: 'SECRET', algorithm: OtpAlgorithm.sha1, digits: 6, interval: 30, type: OtpType.totp, created: DateTime.now(), ), ];
await tester.pumpWidget( ProviderScope( overrides: [ otpListProvider.overrideWith( () => AsyncData(entries), ), ], child: const MaterialApp( home: OtpListPage(), ), ), );
expect(find.text('Google'), findsOneWidget); expect(find.text('test@gmail.com'), findsOneWidget); });}Performance Optimization
Section titled “Performance Optimization”Provider Optimization
Section titled “Provider Optimization”// Use keepAlive for providers that should persist@Riverpod(keepAlive: true)Future<AppSettings> appSettings(AppSettingsRef ref) async { // This provider will not be disposed when not watched return await loadSettings();}
// Use autoDispose for temporary data@riverpodFuture<List<SearchResult>> searchResults( SearchResultsRef ref, String query,) async { // This provider will be disposed when not watched if (query.isEmpty) return []; return await performSearch(query);}Selective Rebuilds
Section titled “Selective Rebuilds”// Use select to watch only specific parts of stateclass SomeWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // Only rebuild when theme mode changes, not other settings final themeMode = ref.watch( appSettingsProvider.select((settings) => settings.value?.themeMode), );
return Container( color: themeMode == ThemeMode.dark ? Colors.black : Colors.white, child: const Text('Theme-dependent widget'), ); }}This comprehensive state management setup ensures Lumo’s UI stays in sync with the data layer while maintaining good performance and testability. 🔄