Architecture Overview
Architecture Overview
Section titled “Architecture Overview”Lumo follows Clean Architecture principles with a feature-driven structure, ensuring maintainable, testable, and scalable code.
Architectural Principles
Section titled “Architectural Principles”Clean Architecture
Section titled “Clean Architecture”- Separation of Concerns: Each layer has a single responsibility
- Dependency Inversion: Dependencies point inward to the domain layer
- Testability: Easy to unit test business logic in isolation
- Framework Independence: Core logic independent of Flutter specifics
Design Patterns
Section titled “Design Patterns”- Repository Pattern: Data access abstraction
- Provider Pattern: Dependency injection and state management
- Factory Pattern: Object creation abstraction
- Observer Pattern: Reactive state updates
Layer Structure
Section titled “Layer Structure”┌─────────────────────────────────────────────────────┐│ Presentation Layer ││ (UI, State Management) │├─────────────────────────────────────────────────────┤│ Domain Layer ││ (Business Logic, Entities) │├─────────────────────────────────────────────────────┤│ Data Layer ││ (Repositories, Data Sources) │└─────────────────────────────────────────────────────┘Data Layer
Section titled “Data Layer”Data Sources
Section titled “Data Sources”Local Data Sources
Section titled “Local Data Sources”abstract class OtpLocalDataSource { Future<List<OtpEntryModel>> getAllOtpEntries(); Future<void> insertOtpEntry(OtpEntryModel entry); Future<void> updateOtpEntry(OtpEntryModel entry); Future<void> deleteOtpEntry(String id); Future<void> clearAllOtpEntries();}
class OtpLocalDataSourceImpl implements OtpLocalDataSource { final Realm _realm;
const OtpLocalDataSourceImpl(this._realm);
@override Future<List<OtpEntryModel>> getAllOtpEntries() async { final results = _realm.all<OtpEntrySchema>(); return results.map((e) => OtpEntryModel.fromSchema(e)).toList(); }
// ... implementation details}Remote Data Sources
Section titled “Remote Data Sources”abstract class BackupRemoteDataSource { Future<void> uploadBackup(String userId, String backupData); Future<String> downloadBackup(String userId); Future<void> deleteBackup(String userId);}
class FirebaseBackupDataSource implements BackupRemoteDataSource { final FirebaseStorage _storage;
const FirebaseBackupDataSource(this._storage);
@override Future<void> uploadBackup(String userId, String backupData) async { final ref = _storage.ref().child('backups/$userId/otp_backup.json'); await ref.putString(backupData); }
// ... implementation details}Models
Section titled “Models”class OtpEntryModel extends OtpEntry { const OtpEntryModel({ required super.id, required super.issuer, required super.account, required super.secret, required super.algorithm, required super.digits, required super.interval, required super.type, super.counter, super.created, super.lastUsed, });
// Convert from domain entity factory OtpEntryModel.fromEntity(OtpEntry entity) { return OtpEntryModel( id: entity.id, issuer: entity.issuer, account: entity.account, secret: entity.secret, algorithm: entity.algorithm, digits: entity.digits, interval: entity.interval, type: entity.type, counter: entity.counter, created: entity.created, lastUsed: entity.lastUsed, ); }
// Convert from database schema factory OtpEntryModel.fromSchema(OtpEntrySchema schema) { return OtpEntryModel( id: schema.id, issuer: schema.issuer, account: schema.account, secret: schema.secret, algorithm: OtpAlgorithm.values.byName(schema.algorithm), digits: schema.digits, interval: schema.interval, type: OtpType.values.byName(schema.type), counter: schema.counter, created: schema.created, lastUsed: schema.lastUsed, ); }
// Convert to database schema OtpEntrySchema toSchema() { return OtpEntrySchema( id: id, issuer: issuer, account: account, secret: secret, algorithm: algorithm.name, digits: digits, interval: interval, type: type.name, counter: counter ?? 0, created: created, lastUsed: lastUsed, ); }}Repositories Implementation
Section titled “Repositories Implementation”class OtpRepositoryImpl implements OtpRepository { final OtpLocalDataSource _localDataSource; final NetworkInfo _networkInfo;
const OtpRepositoryImpl({ required OtpLocalDataSource localDataSource, required NetworkInfo networkInfo, }) : _localDataSource = localDataSource;
@override Future<Either<Failure, List<OtpEntry>>> getAllOtpEntries() async { try { final models = await _localDataSource.getAllOtpEntries(); final entities = models.map((m) => m as OtpEntry).toList(); return Right(entities); } catch (e) { return Left(DatabaseFailure(e.toString())); } }
@override Future<Either<Failure, void>> addOtpEntry(OtpEntry entry) async { try { final model = OtpEntryModel.fromEntity(entry); await _localDataSource.insertOtpEntry(model); return const Right(null); } catch (e) { return Left(DatabaseFailure(e.toString())); } }
// ... other implementations}Domain Layer
Section titled “Domain Layer”Entities
Section titled “Entities”class OtpEntry extends Equatable { final String id; final String issuer; final String account; final String secret; final OtpAlgorithm algorithm; final int digits; final int interval; final OtpType type; final int? counter; final DateTime created; final DateTime? lastUsed;
const OtpEntry({ required this.id, required this.issuer, required this.account, required this.secret, required this.algorithm, required this.digits, required this.interval, required this.type, this.counter, required this.created, this.lastUsed, });
@override List<Object?> get props => [ id, issuer, account, secret, algorithm, digits, interval, type, counter, created, lastUsed ];
// Business logic methods String generateOtp() { switch (type) { case OtpType.totp: return OtpUtils.generateTotp( secret: secret, algorithm: algorithm, digits: digits, interval: interval, ); case OtpType.hotp: return OtpUtils.generateHotp( secret: secret, counter: counter ?? 0, algorithm: algorithm, digits: digits, ); } }
int getRemainingTime() { if (type != OtpType.totp) return 0;
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final timeStep = now ~/ interval; final nextStep = (timeStep + 1) * interval; return nextStep - now; }
OtpEntry copyWith({ String? issuer, String? account, int? counter, DateTime? lastUsed, }) { return OtpEntry( id: id, issuer: issuer ?? this.issuer, account: account ?? this.account, secret: secret, algorithm: algorithm, digits: digits, interval: interval, type: type, counter: counter ?? this.counter, created: created, lastUsed: lastUsed ?? this.lastUsed, ); }}Use Cases
Section titled “Use Cases”class AddOtpEntry implements UseCase<void, AddOtpEntryParams> { final OtpRepository repository;
const AddOtpEntry(this.repository);
@override Future<Either<Failure, void>> call(AddOtpEntryParams params) async { // Validate OTP URI if (!OtpUriParser.isValidUri(params.otpUri)) { return const Left(ValidationFailure('Invalid OTP URI format')); }
try { // Parse URI to OTP entry final entry = OtpUriParser.parseUri(params.otpUri);
// Check for duplicates final existingEntries = await repository.getAllOtpEntries(); final duplicateExists = existingEntries.fold( false, (exists, entries) => exists || entries.any( (e) => e.issuer == entry.issuer && e.account == entry.account, ), );
if (duplicateExists) { return const Left(ValidationFailure('Account already exists')); }
// Add entry return await repository.addOtpEntry(entry); } catch (e) { return Left(ParsingFailure(e.toString())); } }}
class AddOtpEntryParams extends Equatable { final String otpUri;
const AddOtpEntryParams({required this.otpUri});
@override List<Object> get props => [otpUri];}Repository Interfaces
Section titled “Repository Interfaces”abstract class OtpRepository { Future<Either<Failure, List<OtpEntry>>> getAllOtpEntries(); Future<Either<Failure, void>> addOtpEntry(OtpEntry entry); Future<Either<Failure, void>> updateOtpEntry(OtpEntry entry); Future<Either<Failure, void>> deleteOtpEntry(String id); Future<Either<Failure, void>> clearAllOtpEntries(); Future<Either<Failure, OtpEntry?>> getOtpEntryById(String id);}Presentation Layer
Section titled “Presentation Layer”State Management (Riverpod)
Section titled “State Management (Riverpod)”@riverpodclass OtpList extends _$OtpList { @override Future<List<OtpEntry>> build() async { final result = await ref.read(otpRepositoryProvider).getAllOtpEntries(); return result.fold( (failure) => throw Exception(failure.message), (entries) => entries, ); }
Future<void> addOtpEntry(String otpUri) async { state = const AsyncLoading();
final result = await ref.read(addOtpEntryProvider).call( AddOtpEntryParams(otpUri: otpUri), );
result.fold( (failure) => state = AsyncError(failure, StackTrace.current), (_) => refresh(), ); }
Future<void> deleteOtpEntry(String id) async { final result = await ref.read(deleteOtpEntryProvider).call( DeleteOtpEntryParams(id: id), );
result.fold( (failure) => throw Exception(failure.message), (_) => refresh(), ); }
Future<void> updateOtpEntry(OtpEntry entry) async { final result = await ref.read(updateOtpEntryProvider).call( UpdateOtpEntryParams(entry: entry), );
result.fold( (failure) => throw Exception(failure.message), (_) => refresh(), ); }}
// Use case providers@riverpodAddOtpEntry addOtpEntry(AddOtpEntryRef ref) { return AddOtpEntry(ref.read(otpRepositoryProvider));}
@riverpodDeleteOtpEntry deleteOtpEntry(DeleteOtpEntryRef ref) { return DeleteOtpEntry(ref.read(otpRepositoryProvider));}
@riverpodUpdateOtpEntry updateOtpEntry(UpdateOtpEntryRef ref) { return UpdateOtpEntry(ref.read(otpRepositoryProvider));}UI Components
Section titled “UI Components”class OtpListPage extends ConsumerWidget { const OtpListPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) { final otpListAsync = ref.watch(otpListProvider);
return Scaffold( appBar: AppBar( title: const Text('Your Accounts'), actions: [ IconButton( onPressed: () => _navigateToScanner(context, ref), icon: const Icon(Icons.qr_code_scanner), ), ], ), body: otpListAsync.when( data: (entries) => _buildOtpList(entries, 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( entry: entry, onDelete: () => _deleteEntry(entry.id, ref), onEdit: () => _editEntry(entry, ref), ); }, ); }
void _deleteEntry(String id, WidgetRef ref) { ref.read(otpListProvider.notifier).deleteOtpEntry(id); }
// ... other methods}Dependency Injection
Section titled “Dependency Injection”Provider Setup
Section titled “Provider Setup”@riverpodRealm realm(RealmRef ref) { final config = Configuration.local([ OtpEntrySchema.schema, SettingsSchema.schema, ]); return Realm(config);}
@riverpodOtpLocalDataSource otpLocalDataSource(OtpLocalDataSourceRef ref) { return OtpLocalDataSourceImpl(ref.read(realmProvider));}
@riverpodOtpRepository otpRepository(OtpRepositoryRef ref) { return OtpRepositoryImpl( localDataSource: ref.read(otpLocalDataSourceProvider), networkInfo: ref.read(networkInfoProvider), );}
@riverpodFirebaseStorage firebaseStorage(FirebaseStorageRef ref) { return FirebaseStorage.instance;}
@riverpodBackupRemoteDataSource backupRemoteDataSource(BackupRemoteDataSourceRef ref) { return FirebaseBackupDataSource(ref.read(firebaseStorageProvider));}Error Handling
Section titled “Error Handling”Failure Classes
Section titled “Failure Classes”abstract class Failure extends Equatable { final String message;
const Failure(this.message);
@override List<Object> get props => [message];}
class DatabaseFailure extends Failure { const DatabaseFailure(super.message);}
class NetworkFailure extends Failure { const NetworkFailure(super.message);}
class ValidationFailure extends Failure { const ValidationFailure(super.message);}
class ParsingFailure extends Failure { const ParsingFailure(super.message);}
class AuthenticationFailure extends Failure { const AuthenticationFailure(super.message);}Exception Handling
Section titled “Exception Handling”class DatabaseException implements Exception { final String message; const DatabaseException(this.message);}
class NetworkException implements Exception { final String message; const NetworkException(this.message);}
class OtpUriException implements Exception { final String message; const OtpUriException(this.message);}Testing Strategy
Section titled “Testing Strategy”Unit Tests
Section titled “Unit Tests”void main() { late AddOtpEntry useCase; late MockOtpRepository mockRepository;
setUp(() { mockRepository = MockOtpRepository(); useCase = AddOtpEntry(mockRepository); });
group('AddOtpEntry', () { const tOtpUri = 'otpauth://totp/Example:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example'; const tOtpEntry = OtpEntry( id: '1', issuer: 'Example', account: 'user@example.com', secret: 'JBSWY3DPEHPK3PXP', algorithm: OtpAlgorithm.sha1, digits: 6, interval: 30, type: OtpType.totp, created: DateTime.now(), );
test('should add OTP entry when URI is valid', () async { // arrange when(mockRepository.getAllOtpEntries()) .thenAnswer((_) async => const Right([])); when(mockRepository.addOtpEntry(any)) .thenAnswer((_) async => const Right(null));
// act final result = await useCase(const AddOtpEntryParams(otpUri: tOtpUri));
// assert expect(result, const Right(null)); verify(mockRepository.addOtpEntry(any)); });
test('should return ValidationFailure when URI is invalid', () async { // arrange const invalidUri = 'invalid-uri';
// act final result = await useCase(const AddOtpEntryParams(otpUri: invalidUri));
// assert expect(result, const Left(ValidationFailure('Invalid OTP URI format'))); verifyNever(mockRepository.addOtpEntry(any)); }); });}Widget Tests
Section titled “Widget Tests”void main() { testWidgets('OtpTile displays OTP entry information', (tester) async { const entry = OtpEntry( id: '1', issuer: 'Google', account: 'user@gmail.com', secret: 'SECRET', algorithm: OtpAlgorithm.sha1, digits: 6, interval: 30, type: OtpType.totp, created: DateTime.now(), );
await tester.pumpWidget( MaterialApp( home: Scaffold( body: OtpTile( entry: entry, onDelete: () {}, onEdit: () {}, ), ), ), );
expect(find.text('Google'), findsOneWidget); expect(find.text('user@gmail.com'), findsOneWidget); expect(find.byType(CircularProgressIndicator), findsOneWidget); });}Performance Optimizations
Section titled “Performance Optimizations”State Management
Section titled “State Management”- Use
AsyncValuefor loading states - Implement proper state invalidation
- Cache frequently accessed data
- Use
familyproviders for parameterized state
UI Optimizations
Section titled “UI Optimizations”- Implement list virtualization for large datasets
- Use
constconstructors where possible - Minimize widget rebuilds with proper keys
- Implement proper disposal of resources
Database Optimizations
Section titled “Database Optimizations”- Use indexes for frequent queries
- Implement proper schema migration
- Batch operations when possible
- Use transactions for related operations
This architecture ensures Lumo is maintainable, testable, and scalable while following Flutter and Dart best practices. 🏗️