Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(mobile): split store into repo and service #16199

Merged
merged 4 commits into from
Feb 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions mobile/analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ custom_lint:
# required / wanted
- lib/entities/*.entity.dart
- lib/repositories/{album,asset,backup,database,etag,exif_info,user}.repository.dart
- lib/infrastructure/entities/*.entity.dart
- lib/infrastructure/repositories/{store,db}.repository.dart
- lib/providers/infrastructure/db.provider.dart
# acceptable exceptions for the time being (until Isar is fully replaced)
- integration_test/test_utils/general_helper.dart
- lib/main.dart
Expand Down
8 changes: 6 additions & 2 deletions mobile/integration_test/test_utils/general_helper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/main.dart' as app;
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:integration_test/integration_test.dart';
import 'package:isar/isar.dart';
// ignore: depend_on_referenced_packages
import 'package:meta/meta.dart';
import 'package:immich_mobile/main.dart' as app;

import 'login_helper.dart';

Expand Down Expand Up @@ -44,7 +45,10 @@ class ImmichTestHelper {
// Load main Widget
await tester.pumpWidget(
ProviderScope(
overrides: [dbProvider.overrideWithValue(db)],
overrides: [
dbProvider.overrideWithValue(db),
isarProvider.overrideWithValue(db),
],
child: const app.MainWidget(),
),
);
Expand Down
34 changes: 34 additions & 0 deletions mobile/lib/domain/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Domain Layer

This directory contains the domain layer of Immich. The domain layer is responsible for the business logic of the app. It includes interfaces for repositories, models, services and utilities. This layer should never depend on anything from the presentation layer or from the infrastructure layer.

## Structure

- **[Interfaces](./interfaces/)**: These are the interfaces that define the contract for data operations.
- **[Models](./models/)**: These are the core data classes that represent the business models.
- **[Services](./services/)**: These are the classes that contain the business logic and interact with the repositories.
- **[Utils](./utils/)**: These are utility classes and functions that provide common functionalities used across the domain layer.

```
domain/
├── interfaces/
│ └── user.interface.dart
├── models/
│ └── user.model.dart
├── services/
│ └── user.service.dart
└── utils/
└── date_utils.dart
```

## Usage

The domain layer provides services that implement the business logic by consuming repositories through dependency injection. Services are exposed through Riverpod providers in the root `providers` directory.

```dart
// In presentation layer
final userService = ref.watch(userServiceProvider);
final user = await userService.getUser(userId);
```

The presentation layer should never directly use repositories, but instead interact with the domain layer through services.
3 changes: 3 additions & 0 deletions mobile/lib/domain/interfaces/db.interface.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
abstract interface class IDatabaseRepository {
Future<T> transaction<T>(Future<T> Function() callback);
}
17 changes: 17 additions & 0 deletions mobile/lib/domain/interfaces/store.interface.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import 'package:immich_mobile/entities/store.entity.dart';

abstract interface class IStoreRepository {
Future<bool> insert<T>(StoreKey<T> key, T value);

Future<T?> tryGet<T>(StoreKey<T> key);

Stream<T?> watch<T>(StoreKey<T> key);

Stream<StoreUpdateEvent> watchAll();

Future<bool> update<T>(StoreKey<T> key, T value);

Future<void> delete<T>(StoreKey<T> key);

Future<void> deleteAll();
}
106 changes: 106 additions & 0 deletions mobile/lib/domain/services/store.service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import 'dart:async';

import 'package:immich_mobile/domain/interfaces/store.interface.dart';
import 'package:immich_mobile/entities/store.entity.dart';

class StoreService {
final IStoreRepository _storeRepository;

final Map<int, dynamic> _cache = {};
late final StreamSubscription<StoreUpdateEvent> _storeUpdateSubscription;

StoreService._({
required IStoreRepository storeRepository,
}) : _storeRepository = storeRepository;

// TODO: Temporary typedef to make minimal changes. Remove this and make the presentation layer access store through a provider
static StoreService? _instance;
static StoreService get I {
if (_instance == null) {
throw UnsupportedError("StoreService not initialized. Call init() first");
}
return _instance!;
}

// TODO: Replace the implementation with the one from create after removing the typedef
/// Initializes the store with the given [storeRepository]
static Future<StoreService> init({
required IStoreRepository storeRepository,
}) async {
_instance ??= await create(storeRepository: storeRepository);
return _instance!;
}

/// Initializes the store with the given [storeRepository]
static Future<StoreService> create({
required IStoreRepository storeRepository,
}) async {
final instance = StoreService._(storeRepository: storeRepository);
await instance._populateCache();
instance._storeUpdateSubscription = instance._listenForChange();
return instance;
}

/// Fills the cache with the values from the DB
Future<void> _populateCache() async {
for (StoreKey key in StoreKey.values) {
final storeValue = await _storeRepository.tryGet(key);
_cache[key.id] = storeValue;
}
}

/// Listens for changes in the DB and updates the cache
StreamSubscription<StoreUpdateEvent> _listenForChange() =>
_storeRepository.watchAll().listen((event) {
_cache[event.key.id] = event.value;
});

/// Disposes the store and cancels the subscription. To reuse the store call init() again
void dispose() async {
await _storeUpdateSubscription.cancel();
_cache.clear();
}

/// Returns the stored value for the given key (possibly null)
T? tryGet<T>(StoreKey<T> key) => _cache[key.id];

/// Returns the stored value for the given key or if null the [defaultValue]
/// Throws a [StoreKeyNotFoundException] if both are null
T get<T>(StoreKey<T> key, [T? defaultValue]) {
final value = tryGet(key) ?? defaultValue;
if (value == null) {
throw StoreKeyNotFoundException(key);
}
return value;
}

/// Asynchronously stores the value in the DB and synchronously in the cache
Future<void> put<T>(StoreKey<T> key, T value) async {
if (_cache[key.id] == value) return;
await _storeRepository.insert(key, value);
_cache[key.id] = value;
}

/// Watches a specific key for changes
Stream<T?> watch<T>(StoreKey<T> key) => _storeRepository.watch(key);

/// Removes the value asynchronously from the DB and synchronously from the cache
Future<void> delete<T>(StoreKey<T> key) async {
await _storeRepository.delete(key);
_cache.remove(key.id);
}

/// Clears all values from this store (cache and DB)
Future<void> clear() async {
await _storeRepository.deleteAll();
_cache.clear();
}
}

class StoreKeyNotFoundException implements Exception {
final StoreKey key;
const StoreKeyNotFoundException(this.key);

@override
String toString() => "Key - <${key.name}> not available in Store";
}
Loading
Loading