Skip to content

State Management

Lumo uses Riverpod for state management, providing reactive, testable, and maintainable state handling throughout the application.

// State providers for simple state
final counterProvider = StateProvider<int>((ref) => 0);
// StateNotifier providers for complex state
final otpListProvider = StateNotifierProvider<OtpListNotifier, AsyncValue<List<OtpEntry>>>(
(ref) => OtpListNotifier(ref.read(otpRepositoryProvider)),
);
// Future providers for async operations
final backupStatusProvider = FutureProvider<BackupStatus>((ref) async {
final service = ref.read(backupServiceProvider);
return await service.getBackupStatus();
});
// Stream providers for real-time data
final otpTimerProvider = StreamProvider<int>((ref) {
return Stream.periodic(const Duration(seconds: 1), (count) => count);
});
lib/features/otp/presentation/providers/otp_provider.dart
@riverpod
class 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
@riverpod
AddOtpEntry addOtpEntry(AddOtpEntryRef ref) {
return AddOtpEntry(ref.read(otpRepositoryProvider));
}
@riverpod
UpdateOtpEntry updateOtpEntry(UpdateOtpEntryRef ref) {
return UpdateOtpEntry(ref.read(otpRepositoryProvider));
}
@riverpod
DeleteOtpEntry deleteOtpEntry(DeleteOtpEntryRef ref) {
return DeleteOtpEntry(ref.read(otpRepositoryProvider));
}
lib/features/authentication/presentation/providers/auth_provider.dart
@riverpod
class 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,
);
}
}
lib/features/settings/presentation/providers/settings_provider.dart
@riverpod
class 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,
);
}
}
lib/features/otp/presentation/pages/otp_list_page.dart
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 provider
final searchQueryProvider = StateProvider<String>((ref) => '');
lib/features/otp/presentation/widgets/otp_tile.dart
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);
}
}
// Provider that takes parameters
@riverpod
Future<OtpEntry?> otpEntry(OtpEntryRef ref, String id) async {
final result = await ref.read(otpRepositoryProvider).getOtpEntryById(id);
return result.fold(
(failure) => null,
(entry) => entry,
);
}
// Usage in widget
class 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 that depends on other providers
@riverpod
Future<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
@riverpod
Stream<int> otpTimer(OtpTimerRef ref) {
// This stream will be cancelled when no longer watched
return Stream.periodic(
const Duration(seconds: 1),
(count) => DateTime.now().second,
);
}
// Global error handling
@riverpod
class 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 widget
class 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),
),
],
),
),
);
}
}
test/providers/otp_list_provider_test.dart
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);
});
});
}
test/widgets/otp_list_page_test.dart
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);
});
}
// 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
@riverpod
Future<List<SearchResult>> searchResults(
SearchResultsRef ref,
String query,
) async {
// This provider will be disposed when not watched
if (query.isEmpty) return [];
return await performSearch(query);
}
// Use select to watch only specific parts of state
class 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. 🔄