mirror of
https://github.com/immich-app/immich.git
synced 2025-12-22 17:24:56 +03:00
refactor: proper layer archtecture
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
import 'package:immich_mobile/utils/action_button.utils.dart';
|
|
||||||
|
|
||||||
/// Key for each possible value in the `Store`.
|
/// Key for each possible value in the `Store`.
|
||||||
/// Defines the data type for each value
|
/// Defines the data type for each value
|
||||||
@@ -73,7 +72,7 @@ enum StoreKey<T> {
|
|||||||
|
|
||||||
autoPlayVideo<bool>._(139),
|
autoPlayVideo<bool>._(139),
|
||||||
albumGridView<bool>._(140),
|
albumGridView<bool>._(140),
|
||||||
viewerQuickActionOrder<List<ActionButtonType>>._(141),
|
viewerQuickActionOrder<String>._(141),
|
||||||
|
|
||||||
// Experimental stuff
|
// Experimental stuff
|
||||||
photoManagerCustomFilter<bool>._(1000),
|
photoManagerCustomFilter<bool>._(1000),
|
||||||
|
|||||||
109
mobile/lib/domain/services/quick_action.service.dart
Normal file
109
mobile/lib/domain/services/quick_action.service.dart
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import 'package:immich_mobile/infrastructure/repositories/action_button_order.repository.dart';
|
||||||
|
import 'package:immich_mobile/utils/action_button.utils.dart';
|
||||||
|
|
||||||
|
/// Service for managing quick action configurations.
|
||||||
|
/// Provides business logic for building quick action types based on context.
|
||||||
|
class QuickActionService {
|
||||||
|
final ActionButtonOrderRepository _repository;
|
||||||
|
|
||||||
|
const QuickActionService(this._repository);
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
static const int defaultQuickActionLimit = 4;
|
||||||
|
|
||||||
|
static const List<ActionButtonType> defaultQuickActionSeed = [
|
||||||
|
ActionButtonType.share,
|
||||||
|
ActionButtonType.upload,
|
||||||
|
ActionButtonType.edit,
|
||||||
|
ActionButtonType.add,
|
||||||
|
ActionButtonType.archive,
|
||||||
|
ActionButtonType.delete,
|
||||||
|
ActionButtonType.removeFromAlbum,
|
||||||
|
ActionButtonType.likeActivity,
|
||||||
|
];
|
||||||
|
|
||||||
|
static final Set<ActionButtonType> _quickActionSet = Set<ActionButtonType>.unmodifiable(defaultQuickActionSeed);
|
||||||
|
|
||||||
|
static final List<ActionButtonType> defaultQuickActionOrder = List<ActionButtonType>.unmodifiable(
|
||||||
|
defaultQuickActionSeed,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Get the list of available quick action options
|
||||||
|
// static List<ActionButtonType> get quickActionOptions => defaultQuickActionOrder;
|
||||||
|
|
||||||
|
/// Get the current quick action order
|
||||||
|
List<ActionButtonType> get() {
|
||||||
|
return _repository.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the quick action order
|
||||||
|
Future<void> set(List<ActionButtonType> order) async {
|
||||||
|
final normalized = _normalizeQuickActionOrder(order);
|
||||||
|
await _repository.set(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch for changes to quick action order
|
||||||
|
Stream<List<ActionButtonType>> watch() {
|
||||||
|
return _repository.watch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize quick action order by filtering valid types and ensuring all defaults are included
|
||||||
|
List<ActionButtonType> _normalizeQuickActionOrder(List<ActionButtonType> order) {
|
||||||
|
final ordered = <ActionButtonType>{};
|
||||||
|
|
||||||
|
for (final type in order) {
|
||||||
|
if (_quickActionSet.contains(type)) {
|
||||||
|
ordered.add(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered.addAll(defaultQuickActionSeed);
|
||||||
|
|
||||||
|
return ordered.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a list of quick action types based on context and custom order
|
||||||
|
List<ActionButtonType> buildQuickActionTypes(
|
||||||
|
ActionButtonContext context, {
|
||||||
|
List<ActionButtonType>? quickActionOrder,
|
||||||
|
int limit = defaultQuickActionLimit,
|
||||||
|
}) {
|
||||||
|
final normalized = _normalizeQuickActionOrder(
|
||||||
|
quickActionOrder == null || quickActionOrder.isEmpty ? defaultQuickActionOrder : quickActionOrder,
|
||||||
|
);
|
||||||
|
|
||||||
|
final seen = <ActionButtonType>{};
|
||||||
|
final result = <ActionButtonType>[];
|
||||||
|
|
||||||
|
for (final type in normalized) {
|
||||||
|
if (!_quickActionSet.contains(type)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final resolved = _resolveQuickActionType(type, context);
|
||||||
|
if (!seen.add(resolved) || !resolved.shouldShow(context)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.add(resolved);
|
||||||
|
if (result.length >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve quick action type based on context (e.g., archive -> unarchive)
|
||||||
|
ActionButtonType _resolveQuickActionType(ActionButtonType type, ActionButtonContext context) {
|
||||||
|
if (type == ActionButtonType.archive && context.isArchived) {
|
||||||
|
return ActionButtonType.unarchive;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == ActionButtonType.delete && context.asset.isLocalOnly) {
|
||||||
|
return ActionButtonType.deleteLocal;
|
||||||
|
}
|
||||||
|
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/utils/action_button.utils.dart';
|
||||||
|
|
||||||
|
/// Repository for managing quick action button order persistence.
|
||||||
|
/// Handles serialization, deserialization, and storage operations.
|
||||||
|
class ActionButtonOrderRepository {
|
||||||
|
const ActionButtonOrderRepository();
|
||||||
|
|
||||||
|
/// Default order for quick actions
|
||||||
|
static const List<ActionButtonType> defaultOrder = [
|
||||||
|
ActionButtonType.share,
|
||||||
|
ActionButtonType.upload,
|
||||||
|
ActionButtonType.edit,
|
||||||
|
ActionButtonType.add,
|
||||||
|
ActionButtonType.archive,
|
||||||
|
ActionButtonType.delete,
|
||||||
|
ActionButtonType.removeFromAlbum,
|
||||||
|
ActionButtonType.likeActivity,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Get the current quick action order from storage
|
||||||
|
List<ActionButtonType> get() {
|
||||||
|
final json = Store.tryGet(StoreKey.viewerQuickActionOrder);
|
||||||
|
if (json == null || json.isEmpty) {
|
||||||
|
return defaultOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
final deserialized = _deserialize(json);
|
||||||
|
return deserialized.isEmpty ? defaultOrder : deserialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save quick action order to storage
|
||||||
|
Future<void> set(List<ActionButtonType> order) async {
|
||||||
|
final json = _serialize(order);
|
||||||
|
await Store.put(StoreKey.viewerQuickActionOrder, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch for changes to quick action order
|
||||||
|
Stream<List<ActionButtonType>> watch() {
|
||||||
|
return Store.watch(StoreKey.viewerQuickActionOrder).map((json) {
|
||||||
|
if (json == null || json.isEmpty) {
|
||||||
|
return defaultOrder;
|
||||||
|
}
|
||||||
|
final deserialized = _deserialize(json);
|
||||||
|
return deserialized.isEmpty ? defaultOrder : deserialized;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize a list of ActionButtonType to JSON string
|
||||||
|
String _serialize(List<ActionButtonType> order) {
|
||||||
|
return jsonEncode(order.map((type) => type.name).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize a JSON string to a list of ActionButtonType
|
||||||
|
List<ActionButtonType> _deserialize(String json) {
|
||||||
|
try {
|
||||||
|
final list = jsonDecode(json) as List<dynamic>;
|
||||||
|
return list
|
||||||
|
.whereType<String>()
|
||||||
|
.map((name) {
|
||||||
|
try {
|
||||||
|
return ActionButtonType.values.byName(name);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.whereType<ActionButtonType>()
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
@@ -6,7 +5,6 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
||||||
import 'package:immich_mobile/utils/action_button.utils.dart';
|
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
// Temporary interface until Isar is removed to make the service work with both Isar and Sqlite
|
// Temporary interface until Isar is removed to make the service work with both Isar and Sqlite
|
||||||
@@ -86,11 +84,6 @@ class IsarStoreRepository extends IsarDatabaseRepository implements IStoreReposi
|
|||||||
const (DateTime) => entity.intValue == null ? null : DateTime.fromMillisecondsSinceEpoch(entity.intValue!),
|
const (DateTime) => entity.intValue == null ? null : DateTime.fromMillisecondsSinceEpoch(entity.intValue!),
|
||||||
const (UserDto) =>
|
const (UserDto) =>
|
||||||
entity.strValue == null ? null : await IsarUserRepository(_db).getByUserId(entity.strValue!),
|
entity.strValue == null ? null : await IsarUserRepository(_db).getByUserId(entity.strValue!),
|
||||||
const (List<ActionButtonType>) =>
|
|
||||||
(jsonDecode(entity.strValue ?? '[]') as List<dynamic>)
|
|
||||||
.map<ActionButtonType>((d) => ActionButtonType.values.byName(d))
|
|
||||||
.toList()
|
|
||||||
as T,
|
|
||||||
_ => null,
|
_ => null,
|
||||||
}
|
}
|
||||||
as T?;
|
as T?;
|
||||||
@@ -102,7 +95,6 @@ class IsarStoreRepository extends IsarDatabaseRepository implements IStoreReposi
|
|||||||
const (bool) => ((value as bool) ? 1 : 0, null),
|
const (bool) => ((value as bool) ? 1 : 0, null),
|
||||||
const (DateTime) => ((value as DateTime).millisecondsSinceEpoch, null),
|
const (DateTime) => ((value as DateTime).millisecondsSinceEpoch, null),
|
||||||
const (UserDto) => (null, (await IsarUserRepository(_db).update(value as UserDto)).id),
|
const (UserDto) => (null, (await IsarUserRepository(_db).update(value as UserDto)).id),
|
||||||
const (List<ActionButtonType>) => (null, jsonEncode(value)),
|
|
||||||
_ => throw UnsupportedError("Unsupported primitive type: ${key.type} for key: ${key.name}"),
|
_ => throw UnsupportedError("Unsupported primitive type: ${key.type} for key: ${key.name}"),
|
||||||
};
|
};
|
||||||
return StoreValue(key.id, intValue: intValue, strValue: strValue);
|
return StoreValue(key.id, intValue: intValue, strValue: strValue);
|
||||||
@@ -182,11 +174,6 @@ class DriftStoreRepository extends DriftDatabaseRepository implements IStoreRepo
|
|||||||
const (DateTime) => entity.intValue == null ? null : DateTime.fromMillisecondsSinceEpoch(entity.intValue!),
|
const (DateTime) => entity.intValue == null ? null : DateTime.fromMillisecondsSinceEpoch(entity.intValue!),
|
||||||
const (UserDto) =>
|
const (UserDto) =>
|
||||||
entity.stringValue == null ? null : await DriftAuthUserRepository(_db).get(entity.stringValue!),
|
entity.stringValue == null ? null : await DriftAuthUserRepository(_db).get(entity.stringValue!),
|
||||||
const (List<ActionButtonType>) =>
|
|
||||||
(jsonDecode(entity.stringValue ?? '[]') as List<dynamic>)
|
|
||||||
.map<ActionButtonType>((d) => ActionButtonType.values.byName(d))
|
|
||||||
.toList()
|
|
||||||
as T,
|
|
||||||
_ => null,
|
_ => null,
|
||||||
}
|
}
|
||||||
as T?;
|
as T?;
|
||||||
@@ -198,7 +185,6 @@ class DriftStoreRepository extends DriftDatabaseRepository implements IStoreRepo
|
|||||||
const (bool) => ((value as bool) ? 1 : 0, null),
|
const (bool) => ((value as bool) ? 1 : 0, null),
|
||||||
const (DateTime) => ((value as DateTime).millisecondsSinceEpoch, null),
|
const (DateTime) => ((value as DateTime).millisecondsSinceEpoch, null),
|
||||||
const (UserDto) => (null, (await DriftAuthUserRepository(_db).upsert(value as UserDto)).id),
|
const (UserDto) => (null, (await DriftAuthUserRepository(_db).upsert(value as UserDto)).id),
|
||||||
const (List<ActionButtonType>) => (null, jsonEncode(value)),
|
|
||||||
_ => throw UnsupportedError("Unsupported primitive type: ${key.type} for key: ${key.name}"),
|
_ => throw UnsupportedError("Unsupported primitive type: ${key.type} for key: ${key.name}"),
|
||||||
};
|
};
|
||||||
return StoreEntityCompanion(id: Value(key.id), intValue: Value(intValue), stringValue: Value(strValue));
|
return StoreEntityCompanion(id: Value(key.id), intValue: Value(intValue), stringValue: Value(strValue));
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
source: ActionSource.viewer,
|
source: ActionSource.viewer,
|
||||||
);
|
);
|
||||||
|
|
||||||
final quickActionTypes = ActionButtonBuilder.buildQuickActionTypes(
|
final quickActionService = ref.watch(quickActionServiceProvider);
|
||||||
|
final quickActionTypes = quickActionService.buildQuickActionTypes(
|
||||||
buttonContext,
|
buttonContext,
|
||||||
quickActionOrder: quickActionOrder,
|
quickActionOrder: quickActionOrder,
|
||||||
);
|
);
|
||||||
@@ -76,9 +77,10 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
final actions = quickActionTypes
|
final actions = ActionButtonBuilder.buildQuickActions(
|
||||||
.map((type) => GestureDetector(onLongPress: openConfigurator, child: type.buildButton(buttonContext)))
|
buttonContext,
|
||||||
.toList(growable: false);
|
quickActionTypes: quickActionTypes,
|
||||||
|
).map((widget) => GestureDetector(onLongPress: openConfigurator, child: widget)).toList(growable: false);
|
||||||
|
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
ignoring: opacity < 255,
|
ignoring: opacity < 255,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart';
|
import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/quick_action.service.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/viewer_quick_action_order.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/viewer_quick_action_order.provider.dart';
|
||||||
import 'package:immich_mobile/utils/action_button.utils.dart';
|
import 'package:immich_mobile/utils/action_button.utils.dart';
|
||||||
import 'package:immich_mobile/utils/action_button_visuals.dart';
|
import 'package:immich_mobile/utils/action_button_visuals.dart';
|
||||||
@@ -41,7 +42,7 @@ class _QuickActionConfiguratorState extends ConsumerState<QuickActionConfigurato
|
|||||||
|
|
||||||
void _resetToDefault() {
|
void _resetToDefault() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_order = List<ActionButtonType>.from(ActionButtonBuilder.defaultQuickActionOrder);
|
_order = List<ActionButtonType>.from(QuickActionService.defaultQuickActionOrder);
|
||||||
_hasLocalChanges = true;
|
_hasLocalChanges = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -49,9 +50,7 @@ class _QuickActionConfiguratorState extends ConsumerState<QuickActionConfigurato
|
|||||||
void _cancel() => Navigator.of(context).pop();
|
void _cancel() => Navigator.of(context).pop();
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
final normalized = ActionButtonBuilder.normalizeQuickActionOrder(_order);
|
await ref.read(viewerQuickActionOrderProvider.notifier).setOrder(_order);
|
||||||
|
|
||||||
await ref.read(viewerQuickActionOrderProvider.notifier).setOrder(normalized);
|
|
||||||
_hasLocalChanges = false;
|
_hasLocalChanges = false;
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
@@ -69,8 +68,8 @@ class _QuickActionConfiguratorState extends ConsumerState<QuickActionConfigurato
|
|||||||
if (!_hasLocalChanges && !listEquals(_order, currentOrder)) {
|
if (!_hasLocalChanges && !listEquals(_order, currentOrder)) {
|
||||||
_order = List<ActionButtonType>.from(currentOrder);
|
_order = List<ActionButtonType>.from(currentOrder);
|
||||||
}
|
}
|
||||||
final normalizedSelection = ActionButtonBuilder.normalizeQuickActionOrder(_order);
|
|
||||||
final hasChanges = !listEquals(currentOrder, normalizedSelection);
|
final hasChanges = !listEquals(currentOrder, _order);
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -91,7 +90,7 @@ class _QuickActionConfiguratorState extends ConsumerState<QuickActionConfigurato
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'quick_actions_settings_description'.tr(
|
'quick_actions_settings_description'.tr(
|
||||||
namedArgs: {'count': ActionButtonBuilder.defaultQuickActionLimit.toString()},
|
namedArgs: {'count': QuickActionService.defaultQuickActionLimit.toString()},
|
||||||
),
|
),
|
||||||
style: theme.textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
|
|||||||
@@ -1,53 +1,51 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/domain/services/quick_action.service.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/action_button_order.repository.dart';
|
||||||
import 'package:immich_mobile/utils/action_button.utils.dart';
|
import 'package:immich_mobile/utils/action_button.utils.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
||||||
|
|
||||||
part 'viewer_quick_action_order.provider.g.dart';
|
final actionButtonOrderRepositoryProvider = Provider<ActionButtonOrderRepository>(
|
||||||
|
(ref) => const ActionButtonOrderRepository(),
|
||||||
|
);
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
final quickActionServiceProvider = Provider<QuickActionService>(
|
||||||
class ViewerQuickActionOrder extends _$ViewerQuickActionOrder {
|
(ref) => QuickActionService(ref.watch(actionButtonOrderRepositoryProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final viewerQuickActionOrderProvider = StateNotifierProvider<ViewerQuickActionOrderNotifier, List<ActionButtonType>>(
|
||||||
|
(ref) => ViewerQuickActionOrderNotifier(ref.watch(quickActionServiceProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
class ViewerQuickActionOrderNotifier extends StateNotifier<List<ActionButtonType>> {
|
||||||
|
final QuickActionService _service;
|
||||||
StreamSubscription<List<ActionButtonType>>? _subscription;
|
StreamSubscription<List<ActionButtonType>>? _subscription;
|
||||||
|
|
||||||
|
ViewerQuickActionOrderNotifier(this._service) : super(_service.get()) {
|
||||||
|
_subscription = _service.watch().listen((order) {
|
||||||
|
state = order;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<ActionButtonType> build() {
|
void dispose() {
|
||||||
final service = ref.watch(appSettingsServiceProvider);
|
|
||||||
final initial = ActionButtonBuilder.normalizeQuickActionOrder(
|
|
||||||
service.getSetting(AppSettingsEnum.viewerQuickActionOrder),
|
|
||||||
);
|
|
||||||
|
|
||||||
_subscription ??= service.watchSetting(AppSettingsEnum.viewerQuickActionOrder).listen((order) {
|
|
||||||
state = ActionButtonBuilder.normalizeQuickActionOrder(order);
|
|
||||||
});
|
|
||||||
|
|
||||||
ref.onDispose(() {
|
|
||||||
_subscription?.cancel();
|
_subscription?.cancel();
|
||||||
_subscription = null;
|
super.dispose();
|
||||||
});
|
|
||||||
|
|
||||||
return initial;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setOrder(List<ActionButtonType> order) async {
|
Future<void> setOrder(List<ActionButtonType> order) async {
|
||||||
final normalized = ActionButtonBuilder.normalizeQuickActionOrder(order);
|
if (listEquals(state, order)) {
|
||||||
|
|
||||||
if (listEquals(state, normalized)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final previous = state;
|
final previous = state;
|
||||||
state = normalized;
|
state = order;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.viewerQuickActionOrder, normalized);
|
await _service.set(order);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
state = previous;
|
state = previous;
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mock class for testing
|
|
||||||
abstract class ViewerQuickActionOrderInternal extends _$ViewerQuickActionOrder {}
|
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'viewer_quick_action_order.provider.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// RiverpodGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
String _$viewerQuickActionOrderHash() =>
|
|
||||||
r'd539bc6ba5fae4fa07a7c30c42d9f6aee1488f97';
|
|
||||||
|
|
||||||
/// See also [ViewerQuickActionOrder].
|
|
||||||
@ProviderFor(ViewerQuickActionOrder)
|
|
||||||
final viewerQuickActionOrderProvider =
|
|
||||||
NotifierProvider<ViewerQuickActionOrder, List<ActionButtonType>>.internal(
|
|
||||||
ViewerQuickActionOrder.new,
|
|
||||||
name: r'viewerQuickActionOrderProvider',
|
|
||||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
|
||||||
? null
|
|
||||||
: _$viewerQuickActionOrderHash,
|
|
||||||
dependencies: null,
|
|
||||||
allTransitiveDependencies: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
typedef _$ViewerQuickActionOrder = Notifier<List<ActionButtonType>>;
|
|
||||||
// ignore_for_file: type=lint
|
|
||||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:immich_mobile/constants/colors.dart';
|
import 'package:immich_mobile/constants/colors.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/utils/action_button.utils.dart';
|
|
||||||
|
|
||||||
enum AppSettingsEnum<T> {
|
enum AppSettingsEnum<T> {
|
||||||
loadPreview<bool>(StoreKey.loadPreview, "loadPreview", true),
|
loadPreview<bool>(StoreKey.loadPreview, "loadPreview", true),
|
||||||
@@ -56,11 +55,7 @@ enum AppSettingsEnum<T> {
|
|||||||
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false),
|
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false),
|
||||||
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
|
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
|
||||||
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30),
|
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30),
|
||||||
viewerQuickActionOrder<List<ActionButtonType>>(
|
viewerQuickActionOrder<String>(StoreKey.viewerQuickActionOrder, null, '');
|
||||||
StoreKey.viewerQuickActionOrder,
|
|
||||||
null,
|
|
||||||
ActionButtonBuilder.defaultQuickActionSeed,
|
|
||||||
);
|
|
||||||
|
|
||||||
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
||||||
|
|
||||||
|
|||||||
@@ -169,99 +169,23 @@ enum ActionButtonType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builder class for creating action button widgets.
|
||||||
|
/// This class provides simple factory methods for building action button widgets
|
||||||
|
/// from ActionButtonContext. Business logic for quick actions is handled by QuickActionService.
|
||||||
class ActionButtonBuilder {
|
class ActionButtonBuilder {
|
||||||
static const List<ActionButtonType> _actionTypes = ActionButtonType.values;
|
static const List<ActionButtonType> _actionTypes = ActionButtonType.values;
|
||||||
|
|
||||||
static const int defaultQuickActionLimit = 4;
|
/// Build a list of quick action widgets based on context and custom order.
|
||||||
|
/// Uses QuickActionService for business logic.
|
||||||
static const List<ActionButtonType> defaultQuickActionSeed = [
|
|
||||||
ActionButtonType.share,
|
|
||||||
ActionButtonType.upload,
|
|
||||||
ActionButtonType.edit,
|
|
||||||
ActionButtonType.add,
|
|
||||||
ActionButtonType.archive,
|
|
||||||
ActionButtonType.delete,
|
|
||||||
ActionButtonType.removeFromAlbum,
|
|
||||||
ActionButtonType.likeActivity,
|
|
||||||
];
|
|
||||||
|
|
||||||
static final Set<ActionButtonType> _quickActionSet = Set<ActionButtonType>.unmodifiable(defaultQuickActionSeed);
|
|
||||||
|
|
||||||
static final List<ActionButtonType> defaultQuickActionOrder = List<ActionButtonType>.unmodifiable(
|
|
||||||
defaultQuickActionSeed,
|
|
||||||
);
|
|
||||||
|
|
||||||
static List<ActionButtonType> get quickActionOptions => defaultQuickActionOrder;
|
|
||||||
|
|
||||||
static List<ActionButtonType> buildQuickActionTypes(
|
|
||||||
ActionButtonContext context, {
|
|
||||||
List<ActionButtonType>? quickActionOrder,
|
|
||||||
int limit = defaultQuickActionLimit,
|
|
||||||
}) {
|
|
||||||
final normalized = normalizeQuickActionOrder(
|
|
||||||
quickActionOrder == null || quickActionOrder.isEmpty ? defaultQuickActionOrder : quickActionOrder,
|
|
||||||
);
|
|
||||||
|
|
||||||
final seen = <ActionButtonType>{};
|
|
||||||
final result = <ActionButtonType>[];
|
|
||||||
|
|
||||||
for (final type in normalized) {
|
|
||||||
if (!_quickActionSet.contains(type)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
final resolved = _resolveQuickActionType(type, context);
|
|
||||||
if (!seen.add(resolved) || !resolved.shouldShow(context)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.add(resolved);
|
|
||||||
if (result.length >= limit) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<Widget> buildQuickActions(
|
static List<Widget> buildQuickActions(
|
||||||
ActionButtonContext context, {
|
ActionButtonContext context, {
|
||||||
List<ActionButtonType>? quickActionOrder,
|
required List<ActionButtonType> quickActionTypes,
|
||||||
int limit = defaultQuickActionLimit,
|
|
||||||
}) {
|
}) {
|
||||||
final types = buildQuickActionTypes(context, quickActionOrder: quickActionOrder, limit: limit);
|
return quickActionTypes.map((type) => type.buildButton(context)).toList();
|
||||||
return types.map((type) => type.buildButton(context)).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build all available action button widgets for the given context.
|
||||||
static List<Widget> build(ActionButtonContext context) {
|
static List<Widget> build(ActionButtonContext context) {
|
||||||
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
|
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<ActionButtonType> normalizeQuickActionOrder(List<ActionButtonType> order) {
|
|
||||||
final ordered = <ActionButtonType>{};
|
|
||||||
|
|
||||||
for (final type in order) {
|
|
||||||
if (_quickActionSet.contains(type)) {
|
|
||||||
ordered.add(type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ordered.addAll(defaultQuickActionSeed);
|
|
||||||
|
|
||||||
return ordered.toList(growable: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
static ActionButtonType _resolveQuickActionType(ActionButtonType type, ActionButtonContext context) {
|
|
||||||
if (type == ActionButtonType.archive && context.isArchived) {
|
|
||||||
return ActionButtonType.unarchive;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type == ActionButtonType.delete && context.asset.isLocalOnly) {
|
|
||||||
return ActionButtonType.deleteLocal;
|
|
||||||
}
|
|
||||||
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool isSupportedQuickAction(ActionButtonType type) => _quickActionSet.contains(type);
|
|
||||||
}
|
}
|
||||||
|
|||||||
150
mobile/test/domain/services/quick_action_service_test.dart
Normal file
150
mobile/test/domain/services/quick_action_service_test.dart
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/quick_action.service.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/action_button_order.repository.dart';
|
||||||
|
import 'package:immich_mobile/utils/action_button.utils.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('QuickActionService', () {
|
||||||
|
late QuickActionService service;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
// Use repository with default behavior for testing
|
||||||
|
service = const QuickActionService(ActionButtonOrderRepository());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildQuickActionTypes should respect custom order', () {
|
||||||
|
final remoteAsset = RemoteAsset(
|
||||||
|
id: 'test-id',
|
||||||
|
name: 'test.jpg',
|
||||||
|
checksum: 'checksum',
|
||||||
|
type: AssetType.image,
|
||||||
|
ownerId: 'owner-id',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
|
source: ActionSource.viewer,
|
||||||
|
);
|
||||||
|
|
||||||
|
final customOrder = [
|
||||||
|
ActionButtonType.archive,
|
||||||
|
ActionButtonType.share,
|
||||||
|
ActionButtonType.edit,
|
||||||
|
ActionButtonType.delete,
|
||||||
|
];
|
||||||
|
|
||||||
|
final types = service.buildQuickActionTypes(context, quickActionOrder: customOrder);
|
||||||
|
|
||||||
|
expect(types.length, lessThanOrEqualTo(QuickActionService.defaultQuickActionLimit));
|
||||||
|
expect(types.first, ActionButtonType.archive);
|
||||||
|
expect(types[1], ActionButtonType.share);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildQuickActionTypes should resolve archive to unarchive when archived', () {
|
||||||
|
final remoteAsset = RemoteAsset(
|
||||||
|
id: 'test-id',
|
||||||
|
name: 'test.jpg',
|
||||||
|
checksum: 'checksum',
|
||||||
|
type: AssetType.image,
|
||||||
|
ownerId: 'owner-id',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: true, // archived
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
|
source: ActionSource.viewer,
|
||||||
|
);
|
||||||
|
|
||||||
|
final customOrder = [ActionButtonType.archive];
|
||||||
|
|
||||||
|
final types = service.buildQuickActionTypes(context, quickActionOrder: customOrder);
|
||||||
|
|
||||||
|
expect(types.contains(ActionButtonType.unarchive), isTrue);
|
||||||
|
expect(types.contains(ActionButtonType.archive), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildQuickActionTypes should filter types that shouldShow returns false', () {
|
||||||
|
final localAsset = LocalAsset(
|
||||||
|
id: 'local-id',
|
||||||
|
name: 'test.jpg',
|
||||||
|
checksum: 'checksum',
|
||||||
|
type: AssetType.image,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: localAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
|
source: ActionSource.viewer,
|
||||||
|
);
|
||||||
|
|
||||||
|
final customOrder = [
|
||||||
|
ActionButtonType.archive, // should not show for local-only asset
|
||||||
|
ActionButtonType.share,
|
||||||
|
];
|
||||||
|
|
||||||
|
final types = service.buildQuickActionTypes(context, quickActionOrder: customOrder);
|
||||||
|
|
||||||
|
expect(types.contains(ActionButtonType.archive), isFalse);
|
||||||
|
expect(types.contains(ActionButtonType.share), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildQuickActionTypes should respect limit', () {
|
||||||
|
final remoteAsset = RemoteAsset(
|
||||||
|
id: 'test-id',
|
||||||
|
name: 'test.jpg',
|
||||||
|
checksum: 'checksum',
|
||||||
|
type: AssetType.image,
|
||||||
|
ownerId: 'owner-id',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
|
source: ActionSource.viewer,
|
||||||
|
);
|
||||||
|
|
||||||
|
final types = service.buildQuickActionTypes(
|
||||||
|
context,
|
||||||
|
quickActionOrder: QuickActionService.defaultQuickActionOrder,
|
||||||
|
limit: 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(types.length, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/quick_action.service.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/action_button_order.repository.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
@@ -1029,7 +1031,8 @@ void main() {
|
|||||||
source: ActionSource.viewer,
|
source: ActionSource.viewer,
|
||||||
);
|
);
|
||||||
|
|
||||||
final quickActions = ActionButtonBuilder.buildQuickActions(
|
final quickActionService = const QuickActionService(ActionButtonOrderRepository());
|
||||||
|
final quickActionTypes = quickActionService.buildQuickActionTypes(
|
||||||
context,
|
context,
|
||||||
quickActionOrder: const [
|
quickActionOrder: const [
|
||||||
ActionButtonType.archive,
|
ActionButtonType.archive,
|
||||||
@@ -1039,7 +1042,9 @@ void main() {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(quickActions.length, ActionButtonBuilder.defaultQuickActionLimit);
|
final quickActions = ActionButtonBuilder.buildQuickActions(context, quickActionTypes: quickActionTypes);
|
||||||
|
|
||||||
|
expect(quickActions.length, QuickActionService.defaultQuickActionLimit);
|
||||||
expect(quickActions.first, isA<ArchiveActionButton>());
|
expect(quickActions.first, isA<ArchiveActionButton>());
|
||||||
expect(quickActions[1], isA<ShareActionButton>());
|
expect(quickActions[1], isA<ShareActionButton>());
|
||||||
expect(quickActions[2], isA<EditImageActionButton>());
|
expect(quickActions[2], isA<EditImageActionButton>());
|
||||||
|
|||||||
Reference in New Issue
Block a user