Skip to content

Architecture Overview

Lumo follows Clean Architecture principles with a feature-driven structure, ensuring maintainable, testable, and scalable code.

  • 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
  • Repository Pattern: Data access abstraction
  • Provider Pattern: Dependency injection and state management
  • Factory Pattern: Object creation abstraction
  • Observer Pattern: Reactive state updates
┌─────────────────────────────────────────────────────┐
│ Presentation Layer │
│ (UI, State Management) │
├─────────────────────────────────────────────────────┤
│ Domain Layer │
│ (Business Logic, Entities) │
├─────────────────────────────────────────────────────┤
│ Data Layer │
│ (Repositories, Data Sources) │
└─────────────────────────────────────────────────────┘
lib/features/otp/data/datasources/otp_local_datasource.dart
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
}
lib/features/backup/data/datasources/backup_remote_datasource.dart
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
}
lib/features/otp/data/models/otp_entry_model.dart
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,
);
}
}
lib/features/otp/data/repositories/otp_repository_impl.dart
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
}
lib/features/otp/domain/entities/otp_entry.dart
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,
);
}
}
lib/features/otp/domain/usecases/add_otp_entry.dart
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];
}
lib/features/otp/domain/repositories/otp_repository.dart
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);
}
lib/features/otp/presentation/providers/otp_provider.dart
@riverpod
class 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
@riverpod
AddOtpEntry addOtpEntry(AddOtpEntryRef ref) {
return AddOtpEntry(ref.read(otpRepositoryProvider));
}
@riverpod
DeleteOtpEntry deleteOtpEntry(DeleteOtpEntryRef ref) {
return DeleteOtpEntry(ref.read(otpRepositoryProvider));
}
@riverpod
UpdateOtpEntry updateOtpEntry(UpdateOtpEntryRef ref) {
return UpdateOtpEntry(ref.read(otpRepositoryProvider));
}
lib/features/otp/presentation/pages/otp_list_page.dart
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
}
lib/core/di/injection_container.dart
@riverpod
Realm realm(RealmRef ref) {
final config = Configuration.local([
OtpEntrySchema.schema,
SettingsSchema.schema,
]);
return Realm(config);
}
@riverpod
OtpLocalDataSource otpLocalDataSource(OtpLocalDataSourceRef ref) {
return OtpLocalDataSourceImpl(ref.read(realmProvider));
}
@riverpod
OtpRepository otpRepository(OtpRepositoryRef ref) {
return OtpRepositoryImpl(
localDataSource: ref.read(otpLocalDataSourceProvider),
networkInfo: ref.read(networkInfoProvider),
);
}
@riverpod
FirebaseStorage firebaseStorage(FirebaseStorageRef ref) {
return FirebaseStorage.instance;
}
@riverpod
BackupRemoteDataSource backupRemoteDataSource(BackupRemoteDataSourceRef ref) {
return FirebaseBackupDataSource(ref.read(firebaseStorageProvider));
}
lib/core/errors/failures.dart
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);
}
lib/core/errors/exceptions.dart
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);
}
test/features/otp/domain/usecases/add_otp_entry_test.dart
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));
});
});
}
test/features/otp/presentation/widgets/otp_tile_test.dart
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);
});
}
  • Use AsyncValue for loading states
  • Implement proper state invalidation
  • Cache frequently accessed data
  • Use family providers for parameterized state
  • Implement list virtualization for large datasets
  • Use const constructors where possible
  • Minimize widget rebuilds with proper keys
  • Implement proper disposal of resources
  • 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. 🏗️