mirror of
https://github.com/immich-app/immich.git
synced 2026-07-16 05:34:30 +03:00
Compare commits
24 Commits
feat/workf
...
feat/uploa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9419c8456c | ||
|
|
23f26486a1 | ||
|
|
64607857f5 | ||
|
|
f3dd742a1f | ||
|
|
41e93a27fd | ||
|
|
15c716945a | ||
|
|
4b8f1f3194 | ||
|
|
ed0a6c54b9 | ||
|
|
0393523e61 | ||
|
|
be0e6d17fa | ||
|
|
5e48cfcaa7 | ||
|
|
1cf19f19df | ||
|
|
cc5ac5cca5 | ||
|
|
b033a18ce0 | ||
|
|
f67629ff07 | ||
|
|
7130553634 | ||
|
|
d26f6c0665 | ||
|
|
da89c75bdd | ||
|
|
2272583a7e | ||
|
|
73f8e90f0f | ||
|
|
2999b00e5d | ||
|
|
c668bd3342 | ||
|
|
c3a02b179a | ||
|
|
6b77c90e1c |
@@ -9,8 +9,6 @@ enum SortOrder {
|
||||
|
||||
enum TextSearchType { context, filename, description, ocr }
|
||||
|
||||
enum AssetVisibilityEnum { timeline, hidden, archive, locked }
|
||||
|
||||
enum ActionSource { timeline, viewer }
|
||||
|
||||
enum ShareAssetType { original, preview }
|
||||
|
||||
@@ -78,6 +78,7 @@ sealed class BaseAsset {
|
||||
bool get hasLocal => storage == AssetState.local || storage == AssetState.merged;
|
||||
bool get isLocalOnly => storage == AssetState.local;
|
||||
bool get isRemoteOnly => storage == AssetState.remote;
|
||||
bool get isMerged => storage == AssetState.merged;
|
||||
|
||||
bool get isEditable => false;
|
||||
|
||||
|
||||
@@ -52,6 +52,12 @@ class RemoteAsset extends BaseAsset {
|
||||
|
||||
bool get isTrashed => deletedAt != null;
|
||||
|
||||
bool get isStacked => stackId != null;
|
||||
|
||||
bool get isArchived => visibility == .archive;
|
||||
|
||||
bool get isLocked => visibility == .locked;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''Asset {
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class AssetService {
|
||||
final RemoteAssetRepository _remoteRepository;
|
||||
final RemoteExifRepository _exifRepository;
|
||||
final DriftLocalAssetRepository _localRepository;
|
||||
final AssetApiRepository _apiRepository;
|
||||
|
||||
const AssetService({required this._remoteRepository, required this._localRepository, required this._apiRepository});
|
||||
const AssetService({
|
||||
required this._remoteRepository,
|
||||
required this._exifRepository,
|
||||
required this._localRepository,
|
||||
required this._apiRepository,
|
||||
});
|
||||
|
||||
Future<BaseAsset?> getAsset(BaseAsset asset) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
||||
@@ -69,12 +79,91 @@ class AssetService {
|
||||
return _localRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection);
|
||||
}
|
||||
|
||||
Future<void> updateFavorite(List<String> remoteIds, bool isFavorite) async {
|
||||
Future<void> restoreTrash(List<String> remoteIds) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _apiRepository.updateFavorite(remoteIds, isFavorite);
|
||||
await _remoteRepository.updateFavorite(remoteIds, isFavorite);
|
||||
await _apiRepository.restoreTrash(remoteIds);
|
||||
await _remoteRepository.restoreTrash(remoteIds);
|
||||
}
|
||||
|
||||
Future<void> stack(String userId, List<String> remoteIds) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final stack = await _apiRepository.stack(remoteIds);
|
||||
await _remoteRepository.stack(userId, stack);
|
||||
}
|
||||
|
||||
Future<void> unstack(List<String> stackIds) async {
|
||||
if (stackIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _remoteRepository.unStack(stackIds);
|
||||
await _apiRepository.unStack(stackIds);
|
||||
}
|
||||
|
||||
Future<void> update(
|
||||
List<String> remoteIds, {
|
||||
Option<bool> isFavorite = const .none(),
|
||||
Option<AssetVisibility> visibility = const .none(),
|
||||
Option<LatLng> location = const .none(),
|
||||
Option<String> dateTime = const .none(),
|
||||
}) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final parsedDateTime = dateTime.map((dt) => DateTime.parse(dt));
|
||||
final offset = RegExp(r'[+-]\d{2}:\d{2}$').firstMatch(dateTime.unwrapOrNull ?? '')?.group(0);
|
||||
|
||||
await _apiRepository.update(
|
||||
remoteIds,
|
||||
isFavorite: isFavorite,
|
||||
visibility: visibility,
|
||||
location: location,
|
||||
dateTimeOriginal: dateTime,
|
||||
);
|
||||
await _remoteRepository.update(
|
||||
remoteIds,
|
||||
isFavorite: isFavorite,
|
||||
visibility: visibility,
|
||||
createdAt: parsedDateTime,
|
||||
);
|
||||
await _exifRepository.update(
|
||||
remoteIds,
|
||||
location: location,
|
||||
dateTimeOriginal: parsedDateTime,
|
||||
timeZone: .fromNullable(offset).map((o) => 'UTC$o'),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> trash(List<String> remoteIds) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _apiRepository.delete(remoteIds, false);
|
||||
await _remoteRepository.trash(remoteIds);
|
||||
}
|
||||
|
||||
Future<void> delete(List<String> remoteIds) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _apiRepository.delete(remoteIds, true);
|
||||
await _remoteRepository.delete(remoteIds);
|
||||
}
|
||||
|
||||
Future<void> applyEdits(String remoteId, List<AssetEdit> edits) async {
|
||||
if (edits.isEmpty) {
|
||||
await _apiRepository.removeEdits(remoteId);
|
||||
} else {
|
||||
await _apiRepository.editAsset(remoteId, edits);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +183,12 @@ class RemoteAlbumService {
|
||||
return album.added.length;
|
||||
}
|
||||
|
||||
Future<int> removeAssets({required String albumId, required List<String> assetIds}) async {
|
||||
final result = await _albumApiRepository.removeAssets(albumId, assetIds);
|
||||
await _repository.removeAssets(albumId, result.removed);
|
||||
return result.removed.length;
|
||||
}
|
||||
|
||||
/// !TODO The name here is not clear as we have addAssets method above,
|
||||
/// which is only add remote assets to album, for the next PR, we will allow
|
||||
/// adding local assets from album from the timeline as well with this flow.
|
||||
|
||||
@@ -10,7 +10,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
@@ -182,38 +182,6 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateLocation(List<String> ids, LatLng location) {
|
||||
return _db.batch((batch) async {
|
||||
for (final id in ids) {
|
||||
batch.update(
|
||||
_db.remoteExifEntity,
|
||||
RemoteExifEntityCompanion(latitude: Value(location.latitude), longitude: Value(location.longitude)),
|
||||
where: (e) => e.assetId.equals(id),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateDateTime(List<String> ids, DateTime dateTime, {String? timeZone}) {
|
||||
return _db.batch((batch) async {
|
||||
for (final id in ids) {
|
||||
batch.update(
|
||||
_db.remoteExifEntity,
|
||||
RemoteExifEntityCompanion(
|
||||
dateTimeOriginal: Value(dateTime),
|
||||
timeZone: timeZone == null ? const Value.absent() : Value(timeZone),
|
||||
),
|
||||
where: (e) => e.assetId.equals(id),
|
||||
);
|
||||
batch.update(
|
||||
_db.remoteAssetEntity,
|
||||
RemoteAssetEntityCompanion(createdAt: Value(dateTime)),
|
||||
where: (e) => e.id.equals(id),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> stack(String userId, StackResponse stack) {
|
||||
return _db.transaction(() async {
|
||||
final stackIds = await _db.managers.stackEntity
|
||||
@@ -286,4 +254,26 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
..orderBy([(row) => OrderingTerm.asc(row.sequence)]);
|
||||
return query.map((row) => row.toDto()!).get();
|
||||
}
|
||||
|
||||
Future<void> update(
|
||||
List<String> remoteIds, {
|
||||
Option<bool> isFavorite = const .none(),
|
||||
Option<AssetVisibility> visibility = const .none(),
|
||||
Option<DateTime> createdAt = const .none(),
|
||||
}) async {
|
||||
if ([isFavorite, visibility, createdAt].every((option) => option.isNone)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final companion = RemoteAssetEntityCompanion(
|
||||
visibility: visibility.toDriftValue(),
|
||||
isFavorite: isFavorite.toDriftValue(),
|
||||
createdAt: createdAt.toDriftValue(),
|
||||
);
|
||||
return _db.batch((batch) {
|
||||
for (final remoteId in remoteIds) {
|
||||
batch.update(_db.remoteAssetEntity, companion, where: (e) => e.id.equals(remoteId));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class RemoteExifRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
|
||||
const RemoteExifRepository(this._db) : super(_db);
|
||||
|
||||
Future<void> update(
|
||||
List<String> ids, {
|
||||
Option<DateTime> dateTimeOriginal = const .none(),
|
||||
Option<String> timeZone = const .none(),
|
||||
Option<LatLng> location = const .none(),
|
||||
}) async {
|
||||
if ([dateTimeOriginal, timeZone, location].every((option) => option.isNone)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final companion = RemoteExifEntityCompanion(
|
||||
dateTimeOriginal: dateTimeOriginal.toDriftValue(),
|
||||
timeZone: timeZone.toDriftValue(),
|
||||
latitude: location.map((loc) => loc.latitude).toDriftValue(),
|
||||
longitude: location.map((loc) => loc.longitude).toDriftValue(),
|
||||
);
|
||||
|
||||
return _db.batch((batch) {
|
||||
for (final id in ids) {
|
||||
batch.update(_db.remoteExifEntity, companion, where: (a) => a.assetId.equals(id));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/partner.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
|
||||
@@ -27,13 +28,14 @@ class PartnerPage extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sharedByAsync = ref.watch(partnersStateProvider);
|
||||
final scope = ActionScope.from(context, ref);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.t.partners),
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
actions: const [ActionIconButtonWidget(action: PartnerAddAction())],
|
||||
actions: [ActionIconButtonWidget(action: PartnerAddAction(scope: scope))],
|
||||
),
|
||||
body: sharedByAsync.when(
|
||||
data: (partners) => PartnerSharedByList(partners: partners.toList(growable: false)),
|
||||
@@ -44,11 +46,12 @@ class PartnerPage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyPartners extends StatelessWidget {
|
||||
class _EmptyPartners extends ConsumerWidget {
|
||||
const _EmptyPartners();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scope = ActionScope.from(context, ref);
|
||||
return Padding(
|
||||
padding: const .symmetric(horizontal: 16.0),
|
||||
child: Column(
|
||||
@@ -58,9 +61,9 @@ class _EmptyPartners extends StatelessWidget {
|
||||
padding: const .symmetric(vertical: 8),
|
||||
child: Text(context.t.partner_page_empty_message, style: const TextStyle(fontSize: 14)),
|
||||
),
|
||||
const Align(
|
||||
Align(
|
||||
alignment: .center,
|
||||
child: ActionButtonWidget(action: PartnerAddAction()),
|
||||
child: ActionButtonWidget(action: PartnerAddAction(scope: scope)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -69,16 +72,17 @@ class _EmptyPartners extends StatelessWidget {
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class PartnerSharedByList extends StatelessWidget {
|
||||
class PartnerSharedByList extends ConsumerWidget {
|
||||
const PartnerSharedByList({super.key, required this.partners});
|
||||
|
||||
final List<Partner> partners;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (partners.isEmpty) {
|
||||
return const _EmptyPartners();
|
||||
}
|
||||
final scope = ActionScope.from(context, ref);
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: partners.length,
|
||||
@@ -89,7 +93,7 @@ class PartnerSharedByList extends StatelessWidget {
|
||||
title: Text(partner.name),
|
||||
subtitle: Text(partner.email),
|
||||
trailing: ActionIconButtonWidget(
|
||||
action: PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
|
||||
action: PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name, scope: scope),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
class ActionScope {
|
||||
final BuildContext context;
|
||||
@@ -9,24 +11,26 @@ class ActionScope {
|
||||
final UserDto authUser;
|
||||
|
||||
const ActionScope({required this.context, required this.ref, required this.authUser});
|
||||
|
||||
factory ActionScope.from(BuildContext context, WidgetRef ref) {
|
||||
final authUser = ref.watch(currentUserProvider);
|
||||
if (authUser == null) {
|
||||
throw StateError('Auth user is not available in ActionScope');
|
||||
}
|
||||
|
||||
return ActionScope(context: context, ref: ref, authUser: authUser);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class BaseAction {
|
||||
const BaseAction();
|
||||
final ActionScope scope;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool isVisible;
|
||||
|
||||
IconData get icon;
|
||||
const BaseAction({required this.scope, required this.icon, required this.label, this.isVisible = true});
|
||||
|
||||
String label(ActionScope scope);
|
||||
Future<void> onAction();
|
||||
|
||||
bool isVisible(ActionScope scope) => true;
|
||||
|
||||
Future<void> onAction(ActionScope scope);
|
||||
}
|
||||
|
||||
abstract class AssetAction<T extends BaseAsset> extends BaseAction {
|
||||
final Iterable<BaseAsset> assets;
|
||||
|
||||
const AssetAction({required this.assets});
|
||||
|
||||
Iterable<T> filter(ActionScope scope) => assets.whereType<T>();
|
||||
Future<void> Function()? get onSecondaryAction => null;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/utils/error_handler.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class _ActionWidgetScope {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onAction;
|
||||
final FutureOr<void> Function() onAction;
|
||||
final FutureOr<void> Function()? onSecondaryAction;
|
||||
|
||||
const _ActionWidgetScope({required this.label, required this.onAction});
|
||||
const _ActionWidgetScope({required this.icon, required this.label, required this.onAction, this.onSecondaryAction});
|
||||
}
|
||||
|
||||
class _ActionWidget extends ConsumerWidget {
|
||||
@@ -19,27 +22,35 @@ class _ActionWidget extends ConsumerWidget {
|
||||
|
||||
const _ActionWidget({required this.action, required this.builder});
|
||||
|
||||
Future<void> _onAction(ActionScope scope) async {
|
||||
Future<void> _guard(Future<void> Function() handler) async {
|
||||
try {
|
||||
await action.onAction(scope);
|
||||
await handler();
|
||||
} catch (error, stackTrace) {
|
||||
handleError(scope.context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
|
||||
handleError(error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> Function() get _onAction =>
|
||||
() => _guard(action.onAction);
|
||||
|
||||
Future<void> Function()? get _onSecondaryAction {
|
||||
final onSecondaryAction = action.onSecondaryAction;
|
||||
if (onSecondaryAction == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return () => _guard(onSecondaryAction);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final authUser = ref.watch(currentUserProvider);
|
||||
if (authUser == null) {
|
||||
if (!action.isVisible) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final scope = ActionScope(context: context, ref: ref, authUser: authUser);
|
||||
if (!action.isVisible(scope)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return builder(.new(label: action.label(scope), onAction: () => _onAction(scope)));
|
||||
return builder(
|
||||
.new(icon: action.icon, label: action.label, onAction: _onAction, onSecondaryAction: _onSecondaryAction),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +63,8 @@ class ActionIconButtonWidget extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => _ActionWidget(
|
||||
action: action,
|
||||
builder: (ctx) => ImmichIconButton(icon: action.icon, onPressed: ctx.onAction, variant: variant),
|
||||
builder: (ctx) =>
|
||||
ImmichIconButton(icon: ctx.icon, onPressed: ctx.onAction, onLongPress: ctx.onSecondaryAction, variant: variant),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,8 +77,13 @@ class ActionButtonWidget extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => _ActionWidget(
|
||||
action: action,
|
||||
builder: (ctx) =>
|
||||
ImmichTextButton(labelText: ctx.label, icon: action.icon, onPressed: ctx.onAction, variant: variant),
|
||||
builder: (ctx) => ImmichTextButton(
|
||||
labelText: ctx.label,
|
||||
icon: ctx.icon,
|
||||
onPressed: ctx.onAction,
|
||||
onLongPress: ctx.onSecondaryAction,
|
||||
variant: variant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,7 +95,12 @@ class ActionColumnButtonWidget extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => _ActionWidget(
|
||||
action: action,
|
||||
builder: (ctx) => ImmichColumnButton(icon: action.icon, label: ctx.label, onPressed: ctx.onAction),
|
||||
builder: (ctx) => ImmichColumnButton(
|
||||
icon: ctx.icon,
|
||||
label: ctx.label,
|
||||
onPressed: ctx.onAction,
|
||||
onLongPress: ctx.onSecondaryAction,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,6 +112,6 @@ class ActionMenuItemWidget extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => _ActionWidget(
|
||||
action: action,
|
||||
builder: (ctx) => ImmichMenuItem(icon: action.icon, label: ctx.label, onPressed: ctx.onAction),
|
||||
builder: (ctx) => ImmichMenuItem(icon: ctx.icon, label: ctx.label, onPressed: ctx.onAction),
|
||||
);
|
||||
}
|
||||
|
||||
47
mobile/lib/presentation/actions/archive.action.dart
Normal file
47
mobile/lib/presentation/actions/archive.action.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
|
||||
class ArchiveAction extends BaseAction {
|
||||
final List<String> assetIds;
|
||||
final bool archive;
|
||||
|
||||
const ArchiveAction._({
|
||||
required this.assetIds,
|
||||
required this.archive,
|
||||
required super.scope,
|
||||
required super.icon,
|
||||
required super.label,
|
||||
super.isVisible,
|
||||
});
|
||||
|
||||
factory ArchiveAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final ownedAssets = AssetFilter(assets).owned(scope.authUser.id);
|
||||
final archive = ownedAssets.archived(isArchived: false).isNotEmpty;
|
||||
final assetIds = ownedAssets.archived(isArchived: !archive).map((asset) => asset.id).toList(growable: false);
|
||||
|
||||
return ArchiveAction._(
|
||||
assetIds: assetIds,
|
||||
archive: archive,
|
||||
scope: scope,
|
||||
icon: archive ? Icons.archive_outlined : Icons.unarchive_outlined,
|
||||
label: archive ? scope.context.t.archive : scope.context.t.unarchive,
|
||||
isVisible: assetIds.isNotEmpty,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:ref, :context) = scope;
|
||||
|
||||
await ref.read(assetServiceProvider).update(assetIds, visibility: .some(archive ? .archive : .timeline));
|
||||
final message = archive
|
||||
? context.t.archive_action_prompt(count: assetIds.length)
|
||||
: context.t.unarchive_action_prompt(count: assetIds.length);
|
||||
ref.read(toastRepositoryProvider).success(message);
|
||||
}
|
||||
}
|
||||
58
mobile/lib/presentation/actions/asset_actions.dart
Normal file
58
mobile/lib/presentation/actions/asset_actions.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/archive.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/delete.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/download.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/edit_location.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/lock.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/stack.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/tag.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/upload.action.dart';
|
||||
|
||||
class AssetActions {
|
||||
final AssetDebugAction debug;
|
||||
final FavoriteAction favorite;
|
||||
final ArchiveAction archive;
|
||||
final StackAction stack;
|
||||
final LockAction lock;
|
||||
final DeleteAction delete;
|
||||
final CleanupLocalAction cleanup;
|
||||
final EditDateTimeAction editDateTime;
|
||||
final EditLocationAction editLocation;
|
||||
final DownloadAction download;
|
||||
final TagAction tag;
|
||||
final UploadAction upload;
|
||||
|
||||
const AssetActions({
|
||||
required this.debug,
|
||||
required this.favorite,
|
||||
required this.archive,
|
||||
required this.stack,
|
||||
required this.lock,
|
||||
required this.delete,
|
||||
required this.cleanup,
|
||||
required this.editDateTime,
|
||||
required this.editLocation,
|
||||
required this.download,
|
||||
required this.tag,
|
||||
required this.upload,
|
||||
});
|
||||
|
||||
factory AssetActions.from(ActionScope scope, List<BaseAsset> assets) => .new(
|
||||
debug: AssetDebugAction(assets: assets, scope: scope),
|
||||
favorite: FavoriteAction(assets: assets, scope: scope),
|
||||
archive: ArchiveAction(assets: assets, scope: scope),
|
||||
stack: StackAction(assets: assets, scope: scope),
|
||||
lock: LockAction(assets: assets, scope: scope),
|
||||
delete: DeleteAction(assets: assets, scope: scope),
|
||||
cleanup: CleanupLocalAction(assets: assets, scope: scope),
|
||||
editDateTime: EditDateTimeAction(assets: assets, scope: scope),
|
||||
editLocation: EditLocationAction(assets: assets, scope: scope),
|
||||
download: DownloadAction(assets: assets, scope: scope),
|
||||
tag: TagAction(assets: assets, scope: scope),
|
||||
upload: UploadAction(assets: assets, scope: scope),
|
||||
);
|
||||
}
|
||||
@@ -8,20 +8,18 @@ import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class AssetDebugAction extends AssetAction<BaseAsset> {
|
||||
const AssetDebugAction({required super.assets});
|
||||
class AssetDebugAction extends BaseAction {
|
||||
final List<BaseAsset> asset;
|
||||
|
||||
AssetDebugAction._({this.asset = const [], required super.scope, super.isVisible})
|
||||
: super(icon: Icons.help_outline_rounded, label: scope.context.t.troubleshoot);
|
||||
|
||||
factory AssetDebugAction({required Iterable<BaseAsset> assets, required ActionScope scope}) => AssetDebugAction._(
|
||||
asset: assets.toList(growable: false),
|
||||
scope: scope,
|
||||
isVisible: scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting) && assets.length == 1,
|
||||
);
|
||||
|
||||
@override
|
||||
IconData get icon => Icons.help_outline_rounded;
|
||||
|
||||
@override
|
||||
String label(ActionScope scope) => scope.context.t.troubleshoot;
|
||||
|
||||
@override
|
||||
bool isVisible(ActionScope scope) =>
|
||||
assets.length == 1 && scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
|
||||
|
||||
@override
|
||||
Future<void> onAction(ActionScope scope) async =>
|
||||
unawaited(scope.context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
|
||||
Future<void> onAction() async => unawaited(scope.context.pushRoute(AssetTroubleshootRoute(asset: asset.first)));
|
||||
}
|
||||
|
||||
25
mobile/lib/presentation/actions/cast.action.dart
Normal file
25
mobile/lib/presentation/actions/cast.action.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
|
||||
|
||||
class CastAction extends BaseAction {
|
||||
const CastAction._({required super.scope, required super.icon, required super.label});
|
||||
|
||||
factory CastAction({required ActionScope scope}) {
|
||||
final casting = scope.ref.watch(castProvider.select((state) => state.isCasting));
|
||||
return CastAction._(
|
||||
scope: scope,
|
||||
icon: casting ? Icons.cast_connected_rounded : Icons.cast_rounded,
|
||||
label: scope.context.t.cast,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
unawaited(showDialog(context: scope.context, builder: (_) => const CastDialog()));
|
||||
}
|
||||
}
|
||||
177
mobile/lib/presentation/actions/delete.action.dart
Normal file
177
mobile/lib/presentation/actions/delete.action.dart
Normal file
@@ -0,0 +1,177 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/services/cleanup.service.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||
|
||||
class DeleteAction extends BaseAction {
|
||||
final List<String> localIds;
|
||||
final List<String> remoteIds;
|
||||
final bool trash;
|
||||
|
||||
DeleteAction._({
|
||||
required this.localIds,
|
||||
required this.remoteIds,
|
||||
required super.scope,
|
||||
required this.trash,
|
||||
super.isVisible,
|
||||
}) : super(icon: Icons.delete_outline, label: trash ? scope.context.t.trash : scope.context.t.delete);
|
||||
|
||||
factory DeleteAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final ActionScope(:ref) = scope;
|
||||
|
||||
final localIds = <String>[];
|
||||
final ownedRemote = <RemoteAsset>[];
|
||||
for (final asset in assets) {
|
||||
if (asset.localId case final id?) {
|
||||
localIds.add(id);
|
||||
}
|
||||
|
||||
if (asset case final RemoteAsset remote when remote.ownerId == scope.authUser.id) {
|
||||
ownedRemote.add(remote);
|
||||
}
|
||||
}
|
||||
final remoteIds = ownedRemote.map((asset) => asset.id).toList(growable: false);
|
||||
|
||||
final trashEnabled = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||
// Assets already in trash or in locked page should be permanently deleted irrespective of the trash feature being enabled
|
||||
final trash = trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked);
|
||||
|
||||
return ._(
|
||||
localIds: localIds,
|
||||
remoteIds: remoteIds,
|
||||
trash: trash,
|
||||
scope: scope,
|
||||
isVisible: remoteIds.isNotEmpty || localIds.isNotEmpty,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:ref, :context) = scope;
|
||||
final toast = ref.read(toastRepositoryProvider);
|
||||
|
||||
// Local-only
|
||||
// Single prompt on iOS & Android (without MANAGE_MEDIA)
|
||||
// No prompt on Android (with MANAGE_MEDIA)
|
||||
if (remoteIds.isEmpty) {
|
||||
if (localIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final count = await cleanupLocalAssets(assetIds: localIds, scope: scope);
|
||||
if (!context.mounted || count <= 0) {
|
||||
return;
|
||||
}
|
||||
toast.success(context.t.cleanup_deleted_assets(count: count));
|
||||
return;
|
||||
}
|
||||
|
||||
// Trash
|
||||
// No prompt on Android (with MANAGE_MEDIA)
|
||||
// Single prompt on iOS & Android (without MANAGE_MEDIA)
|
||||
// TODO(shenlong): Handle the native prompt response and skip deleting trash when user cancels the prompt
|
||||
if (trash) {
|
||||
if (localIds.isNotEmpty) {
|
||||
await cleanupLocalAssets(assetIds: localIds, scope: scope);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await ref.read(assetServiceProvider).trash(remoteIds);
|
||||
toast.success(context.t.trash_action_prompt(count: remoteIds.length));
|
||||
return;
|
||||
}
|
||||
|
||||
// Permanent delete
|
||||
// Single prompt on Android (with MANAGE_MEDIA)
|
||||
// Double prompts on iOS & Android (without MANAGE_MEDIA)
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) =>
|
||||
const ConfirmDialog(title: 'delete_dialog_title', content: 'delete_dialog_alert', ok: 'delete_permanently'),
|
||||
);
|
||||
if (confirmed != true || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform server deletion first so we don't remove the only local copy if the server delete fails
|
||||
await ref.read(assetServiceProvider).delete(remoteIds);
|
||||
if (localIds.isNotEmpty && context.mounted) {
|
||||
await cleanupLocalAssets(assetIds: localIds, scope: scope, requestPrompt: false);
|
||||
}
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(context.t.delete_permanently_action_prompt(count: remoteIds.length));
|
||||
}
|
||||
}
|
||||
|
||||
class CleanupLocalAction extends BaseAction {
|
||||
final List<String> assetIds;
|
||||
|
||||
CleanupLocalAction._({required this.assetIds, required super.scope})
|
||||
: super(
|
||||
icon: Icons.no_cell_outlined,
|
||||
label: scope.context.t.control_bottom_app_bar_delete_from_local,
|
||||
isVisible: assetIds.isNotEmpty,
|
||||
);
|
||||
|
||||
factory CleanupLocalAction({required Iterable<BaseAsset> assets, required ActionScope scope}) => ._(
|
||||
assetIds: AssetFilter(assets).backedUp().map((asset) => asset.localId).nonNulls.toList(growable: false),
|
||||
scope: scope,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:ref, :context) = scope;
|
||||
final count = await cleanupLocalAssets(assetIds: assetIds, scope: scope);
|
||||
if (!context.mounted || count <= 0) {
|
||||
return;
|
||||
}
|
||||
ref.read(toastRepositoryProvider).success(context.t.cleanup_deleted_assets(count: count));
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<int> cleanupLocalAssets({
|
||||
required List<String> assetIds,
|
||||
required ActionScope scope,
|
||||
bool requestPrompt = true,
|
||||
}) async {
|
||||
final ActionScope(:ref, :context) = scope;
|
||||
|
||||
/// OS prompts on iOS & Android (without MANAGE_MEDIA)
|
||||
/// Custom prompt on Android (with MANAGE_MEDIA)
|
||||
final requireUserPrompt =
|
||||
requestPrompt && CurrentPlatform.isAndroid && ref.read(storeServiceProvider).get(.manageLocalMediaAndroid, false);
|
||||
if (requireUserPrompt) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => ConfirmDialog(
|
||||
title: context.t.move_to_device_trash,
|
||||
content: context.t.free_up_space_description,
|
||||
ok: context.t.ok,
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!context.mounted) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await ref.read(cleanupServiceProvider).deleteLocalAssets(assetIds);
|
||||
}
|
||||
36
mobile/lib/presentation/actions/download.action.dart
Normal file
36
mobile/lib/presentation/actions/download.action.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/repositories/download.repository.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
|
||||
class DownloadAction extends BaseAction {
|
||||
final List<RemoteAsset> assets;
|
||||
|
||||
DownloadAction._({required this.assets, required super.scope, super.isVisible})
|
||||
: super(icon: Icons.download, label: scope.context.t.download);
|
||||
|
||||
factory DownloadAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final remote = AssetFilter(assets).remote().toList(growable: false);
|
||||
return ._(assets: remote, scope: scope, isVisible: remote.isNotEmpty);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:ref) = scope;
|
||||
final backgroundSync = ref.read(backgroundSyncProvider);
|
||||
|
||||
await ref.read(downloadRepositoryProvider).downloadAllAssets(assets);
|
||||
|
||||
unawaited(
|
||||
Future.delayed(const Duration(seconds: 1), () async {
|
||||
await backgroundSync.syncLocal();
|
||||
await backgroundSync.hashAssets();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
68
mobile/lib/presentation/actions/edit_asset.action.dart
Normal file
68
mobile/lib/presentation/actions/edit_asset.action.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
class EditAssetAction extends BaseAction {
|
||||
final Iterable<RemoteAsset> assets;
|
||||
|
||||
EditAssetAction._({required this.assets, required super.scope, super.isVisible})
|
||||
: super(icon: Icons.tune, label: scope.context.t.edit);
|
||||
|
||||
factory EditAssetAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final editable = AssetFilter(
|
||||
assets,
|
||||
).owned(scope.authUser.id).where((asset) => asset.isEditable).toList(growable: false);
|
||||
final isSupported = scope.ref.watch(serverInfoProvider).serverVersion >= const SemVer(major: 2, minor: 6, patch: 0);
|
||||
|
||||
return EditAssetAction._(assets: editable, scope: scope, isVisible: isSupported && editable.length == 1);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
|
||||
// TODO(shenlong): Move all EXIF and Apply Edits logic onto the Route
|
||||
final asset = assets.first;
|
||||
final repository = ref.read(remoteAssetRepositoryProvider);
|
||||
final (edits, exif) = await (repository.getAssetEdits(asset.id), repository.getExif(asset.id)).wait;
|
||||
if (exif == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(editorStateProvider.notifier).init(edits, exif);
|
||||
unawaited(
|
||||
context.pushRoute(
|
||||
DriftEditImageRoute(
|
||||
image: Image(image: getFullImageProvider(asset, edited: false)),
|
||||
applyEdits: (newEdits) => applyEdits(ref, asset.id, newEdits),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> applyEdits(WidgetRef ref, String remoteId, List<AssetEdit> edits) async {
|
||||
final websocket = ref.read(websocketProvider.notifier);
|
||||
|
||||
bool isCurrentId(dynamic data) => data is Map && (data['asset'] as Map?)?['id'] == remoteId;
|
||||
await ref.read(assetServiceProvider).applyEdits(remoteId, edits);
|
||||
await Future.any([
|
||||
websocket.waitForEvent('AssetEditReadyV1', isCurrentId, const Duration(seconds: 10)),
|
||||
websocket.waitForEvent('AssetEditReadyV2', isCurrentId, const Duration(seconds: 10)),
|
||||
]).catchError((_) {});
|
||||
}
|
||||
77
mobile/lib/presentation/actions/edit_datetime.action.dart
Normal file
77
mobile/lib/presentation/actions/edit_datetime.action.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
|
||||
|
||||
class EditDateTimeAction extends BaseAction {
|
||||
final List<String> assetIds;
|
||||
final RemoteAsset? origin;
|
||||
|
||||
EditDateTimeAction._({required this.assetIds, required this.origin, required super.scope, super.isVisible})
|
||||
: super(icon: Icons.edit_calendar_outlined, label: scope.context.t.control_bottom_app_bar_edit_time);
|
||||
|
||||
factory EditDateTimeAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final owned = AssetFilter(assets).owned(scope.authUser.id);
|
||||
|
||||
return EditDateTimeAction._(
|
||||
assetIds: owned.map((asset) => asset.id).toList(growable: false),
|
||||
origin: owned.firstOrNull,
|
||||
scope: scope,
|
||||
isVisible: owned.isNotEmpty,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
|
||||
DateTime? initialDate;
|
||||
String? timeZone;
|
||||
Duration? offset;
|
||||
|
||||
final seed = origin;
|
||||
if (seed != null) {
|
||||
final exif = await ref.read(remoteAssetRepositoryProvider).getExif(seed.id);
|
||||
|
||||
// Use EXIF timezone information if available (matching web app and display behavior)
|
||||
DateTime dt = seed.createdAt.toLocal();
|
||||
offset = dt.timeZoneOffset;
|
||||
if (exif?.dateTimeOriginal != null) {
|
||||
timeZone = exif!.timeZone;
|
||||
(dt, offset) = applyTimezoneOffset(dateTime: exif.dateTimeOriginal!, timeZone: exif.timeZone);
|
||||
}
|
||||
initialDate = dt;
|
||||
}
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final dateTime = await showDateTimePicker(
|
||||
context: context,
|
||||
initialDateTime: initialDate,
|
||||
initialTZ: timeZone,
|
||||
initialTZOffset: offset,
|
||||
);
|
||||
if (dateTime == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await save(dateTime);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> save(String dateTime) async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
|
||||
await ref.read(assetServiceProvider).update(assetIds, dateTime: .some(dateTime));
|
||||
ref.invalidate(assetExifProvider);
|
||||
ref.read(toastRepositoryProvider).success(context.t.edit_date_and_time_action_prompt(count: assetIds.length));
|
||||
}
|
||||
}
|
||||
73
mobile/lib/presentation/actions/edit_location.action.dart
Normal file
73
mobile/lib/presentation/actions/edit_location.action.dart
Normal file
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/location_picker.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class EditLocationAction extends BaseAction {
|
||||
final List<String> assetIds;
|
||||
final RemoteAsset? origin;
|
||||
|
||||
const EditLocationAction._({
|
||||
required this.assetIds,
|
||||
required this.origin,
|
||||
required super.scope,
|
||||
required super.icon,
|
||||
required super.label,
|
||||
super.isVisible,
|
||||
});
|
||||
|
||||
factory EditLocationAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final owned = assets
|
||||
.whereType<RemoteAsset>()
|
||||
.where((asset) => asset.ownerId == scope.authUser.id)
|
||||
.toList(growable: false);
|
||||
|
||||
return EditLocationAction._(
|
||||
assetIds: owned.map((asset) => asset.id).toList(growable: false),
|
||||
origin: owned.length == 1 ? owned.first : null,
|
||||
scope: scope,
|
||||
icon: Icons.edit_location_alt_outlined,
|
||||
label: scope.context.t.control_bottom_app_bar_edit_location,
|
||||
isVisible: owned.isNotEmpty,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
|
||||
LatLng? initialLatLng;
|
||||
final seed = origin;
|
||||
if (seed != null) {
|
||||
final exif = await ref.read(remoteAssetRepositoryProvider).getExif(seed.id);
|
||||
if (exif?.latitude != null && exif?.longitude != null) {
|
||||
initialLatLng = LatLng(exif!.latitude!, exif.longitude!);
|
||||
}
|
||||
}
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final location = await showLocationPicker(context: context, initialLatLng: initialLatLng);
|
||||
if (location == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await save(location);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> save(LatLng location) async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
|
||||
await ref.read(assetServiceProvider).update(assetIds, location: .some(location));
|
||||
ref.invalidate(assetExifProvider);
|
||||
ref.read(toastRepositoryProvider).success(context.t.edit_location_action_prompt(count: assetIds.length));
|
||||
}
|
||||
}
|
||||
@@ -3,38 +3,45 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
|
||||
class FavoriteAction extends AssetAction<RemoteAsset> {
|
||||
final bool shouldFavorite;
|
||||
class FavoriteAction extends BaseAction {
|
||||
final List<String> assetIds;
|
||||
final bool favorite;
|
||||
|
||||
FavoriteAction({required super.assets}) : shouldFavorite = assets.any((asset) => !asset.isFavorite);
|
||||
const FavoriteAction._({
|
||||
required this.assetIds,
|
||||
required this.favorite,
|
||||
required super.scope,
|
||||
required super.icon,
|
||||
required super.label,
|
||||
super.isVisible,
|
||||
});
|
||||
|
||||
factory FavoriteAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final ownedAssets = AssetFilter(assets).owned(scope.authUser.id);
|
||||
final favorite = ownedAssets.favorite(isFavorite: false).isNotEmpty;
|
||||
final assetIds = ownedAssets.favorite(isFavorite: !favorite).map((asset) => asset.id).toList(growable: false);
|
||||
|
||||
return FavoriteAction._(
|
||||
assetIds: assetIds,
|
||||
favorite: favorite,
|
||||
scope: scope,
|
||||
icon: favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded,
|
||||
label: favorite ? scope.context.t.favorite : scope.context.t.unfavorite,
|
||||
isVisible: assetIds.isNotEmpty,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
IconData get icon => shouldFavorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:ref, :context) = scope;
|
||||
|
||||
@override
|
||||
String label(ActionScope scope) => shouldFavorite ? scope.context.t.favorite : scope.context.t.unfavorite;
|
||||
|
||||
@override
|
||||
Iterable<RemoteAsset> filter(ActionScope scope) => assets
|
||||
.where(
|
||||
(asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite,
|
||||
)
|
||||
.cast<RemoteAsset>();
|
||||
|
||||
@override
|
||||
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> onAction(ActionScope scope) async {
|
||||
final ActionScope(:ref) = scope;
|
||||
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
|
||||
|
||||
await ref.read(assetServiceProvider).updateFavorite(assets, shouldFavorite);
|
||||
final message = shouldFavorite
|
||||
? StaticTranslations.instance.favorite_action_prompt(count: assets.length)
|
||||
: StaticTranslations.instance.unfavorite_action_prompt(count: assets.length);
|
||||
snackbar.success(message);
|
||||
await ref.read(assetServiceProvider).update(assetIds, isFavorite: .some(favorite));
|
||||
final message = favorite
|
||||
? context.t.favorite_action_prompt(count: assetIds.length)
|
||||
: context.t.unfavorite_action_prompt(count: assetIds.length);
|
||||
ref.read(toastRepositoryProvider).success(message);
|
||||
}
|
||||
}
|
||||
|
||||
47
mobile/lib/presentation/actions/lock.action.dart
Normal file
47
mobile/lib/presentation/actions/lock.action.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
|
||||
class LockAction extends BaseAction {
|
||||
final List<String> assetIds;
|
||||
final bool lock;
|
||||
|
||||
const LockAction._({
|
||||
required this.assetIds,
|
||||
required this.lock,
|
||||
required super.scope,
|
||||
required super.icon,
|
||||
required super.label,
|
||||
super.isVisible,
|
||||
});
|
||||
|
||||
factory LockAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final ownedAssets = AssetFilter(assets).owned(scope.authUser.id);
|
||||
final lock = ownedAssets.locked(isLocked: false).isNotEmpty;
|
||||
final assetIds = ownedAssets.locked(isLocked: !lock).map((asset) => asset.id).toList(growable: false);
|
||||
|
||||
return LockAction._(
|
||||
assetIds: assetIds,
|
||||
lock: lock,
|
||||
scope: scope,
|
||||
icon: lock ? Icons.lock_rounded : Icons.lock_open_rounded,
|
||||
label: lock ? scope.context.t.move_to_locked_folder : scope.context.t.remove_from_locked_folder,
|
||||
isVisible: assetIds.isNotEmpty,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:ref, :context) = scope;
|
||||
|
||||
await ref.read(assetServiceProvider).update(assetIds, visibility: .some(lock ? .locked : .timeline));
|
||||
final message = lock
|
||||
? scope.context.t.move_to_lock_folder_action_prompt(count: assetIds.length)
|
||||
: scope.context.t.remove_from_lock_folder_action_prompt(count: assetIds.length);
|
||||
ref.read(toastRepositoryProvider).success(message);
|
||||
}
|
||||
}
|
||||
31
mobile/lib/presentation/actions/open_in_browser.action.dart
Normal file
31
mobile/lib/presentation/actions/open_in_browser.action.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class OpenInBrowserAction extends BaseAction {
|
||||
final String remoteId;
|
||||
final TimelineOrigin origin;
|
||||
|
||||
OpenInBrowserAction({required this.remoteId, required this.origin, required super.scope})
|
||||
: super(icon: Icons.open_in_browser, label: scope.context.t.open_in_browser);
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final serverEndpoint = Store.get(.serverEndpoint).replaceFirst('/api', '');
|
||||
|
||||
final originPath = switch (origin) {
|
||||
.favorite => '/favorites',
|
||||
.trash => '/trash',
|
||||
.archive => '/archive',
|
||||
_ => '',
|
||||
};
|
||||
|
||||
final url = Uri.parse('$serverEndpoint$originPath/photos/$remoteId');
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: .externalApplication);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,16 +9,10 @@ import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||
|
||||
class PartnerAddAction extends BaseAction {
|
||||
const PartnerAddAction();
|
||||
PartnerAddAction({required super.scope}) : super(icon: Icons.person_add_rounded, label: scope.context.t.add_partner);
|
||||
|
||||
@override
|
||||
IconData get icon => Icons.person_add_rounded;
|
||||
|
||||
@override
|
||||
String label(ActionScope scope) => scope.context.t.add_partner;
|
||||
|
||||
@override
|
||||
Future<void> onAction(ActionScope scope) async {
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:context, :ref, :authUser) = scope;
|
||||
final selected = await showDialog<User>(context: context, builder: (_) => const PartnerSelectionDialog());
|
||||
if (selected == null) {
|
||||
@@ -30,19 +24,14 @@ class PartnerAddAction extends BaseAction {
|
||||
}
|
||||
|
||||
class PartnerRemoveAction extends BaseAction {
|
||||
const PartnerRemoveAction({required this.sharedWithId, required this.partnerName});
|
||||
PartnerRemoveAction({required super.scope, required this.sharedWithId, required this.partnerName})
|
||||
: super(icon: Icons.person_remove_rounded, label: scope.context.t.remove);
|
||||
|
||||
final String sharedWithId;
|
||||
final String partnerName;
|
||||
|
||||
@override
|
||||
IconData get icon => Icons.person_remove_rounded;
|
||||
|
||||
@override
|
||||
String label(ActionScope scope) => scope.context.t.remove;
|
||||
|
||||
@override
|
||||
Future<void> onAction(ActionScope scope) async {
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:context, :ref, :authUser) = scope;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
|
||||
class RemoveFromAlbumAction extends BaseAction {
|
||||
final String albumId;
|
||||
final List<String> assetIds;
|
||||
|
||||
RemoveFromAlbumAction._({required super.scope, required this.albumId, required this.assetIds, super.isVisible})
|
||||
: super(icon: Icons.remove_circle_outline, label: scope.context.t.remove_from_album);
|
||||
|
||||
factory RemoveFromAlbumAction({
|
||||
required Iterable<BaseAsset> assets,
|
||||
required String albumId,
|
||||
required ActionScope scope,
|
||||
}) {
|
||||
final assetIds = AssetFilter(assets).map((asset) => asset.remoteId).nonNulls.toList(growable: false);
|
||||
|
||||
return RemoveFromAlbumAction._(scope: scope, albumId: albumId, assetIds: assetIds, isVisible: assetIds.isNotEmpty);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:ref, :context) = scope;
|
||||
|
||||
final count = await ref.read(remoteAlbumServiceProvider).removeAssets(albumId: albumId, assetIds: assetIds);
|
||||
ref.read(toastRepositoryProvider).success(context.t.remove_from_album_action_prompt(count: count));
|
||||
}
|
||||
}
|
||||
29
mobile/lib/presentation/actions/restore.action.dart
Normal file
29
mobile/lib/presentation/actions/restore.action.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
|
||||
class RestoreAction extends BaseAction {
|
||||
final List<String> assetIds;
|
||||
|
||||
RestoreAction._({required super.scope, required this.assetIds, super.isVisible})
|
||||
: super(icon: Icons.history_rounded, label: scope.context.t.restore);
|
||||
|
||||
factory RestoreAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final assetIds = AssetFilter(
|
||||
assets,
|
||||
).owned(scope.authUser.id).trashed().map((asset) => asset.id).toList(growable: false);
|
||||
|
||||
return RestoreAction._(assetIds: assetIds, scope: scope, isVisible: assetIds.isNotEmpty);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:ref, :context) = scope;
|
||||
await ref.read(assetServiceProvider).restoreTrash(assetIds);
|
||||
ref.read(toastRepositoryProvider).success(context.t.assets_restored_count(count: assetIds.length));
|
||||
}
|
||||
}
|
||||
32
mobile/lib/presentation/actions/set_album_cover.action.dart
Normal file
32
mobile/lib/presentation/actions/set_album_cover.action.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
|
||||
class SetAlbumCoverAction extends BaseAction {
|
||||
final String albumId;
|
||||
final List<String> assetIds;
|
||||
|
||||
SetAlbumCoverAction._({required super.scope, required this.albumId, required this.assetIds, super.isVisible})
|
||||
: super(icon: Icons.image_outlined, label: scope.context.t.set_as_album_cover);
|
||||
|
||||
factory SetAlbumCoverAction({
|
||||
required Iterable<BaseAsset> assets,
|
||||
required String albumId,
|
||||
required ActionScope scope,
|
||||
}) {
|
||||
final assetIds = AssetFilter(assets).map((asset) => asset.remoteId).nonNulls.toList(growable: false);
|
||||
return SetAlbumCoverAction._(scope: scope, albumId: albumId, assetIds: assetIds, isVisible: assetIds.length == 1);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:ref, :context) = scope;
|
||||
|
||||
await ref.read(remoteAlbumServiceProvider).updateAlbum(albumId, thumbnailAssetId: assetIds.first);
|
||||
ref.read(toastRepositoryProvider).success(context.t.album_cover_updated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class SetProfilePictureAction extends BaseAction {
|
||||
final BaseAsset asset;
|
||||
|
||||
SetProfilePictureAction({required this.asset, required super.scope})
|
||||
: super(icon: Icons.account_circle_outlined, label: scope.context.t.set_as_profile_picture);
|
||||
|
||||
@override
|
||||
Future<void> onAction() async => unawaited(scope.context.pushRoute(ProfilePictureCropRoute(asset: asset)));
|
||||
}
|
||||
172
mobile/lib/presentation/actions/share.action.dart
Normal file
172
mobile/lib/presentation/actions/share.action.dart
Normal file
@@ -0,0 +1,172 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
|
||||
class ShareAction extends BaseAction {
|
||||
final List<BaseAsset> assets;
|
||||
|
||||
ShareAction._({required this.assets, required super.scope, super.isVisible})
|
||||
: super(
|
||||
icon: CurrentPlatform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
|
||||
label: scope.context.t.share,
|
||||
);
|
||||
|
||||
factory ShareAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final shareable = assets.toList(growable: false);
|
||||
return ._(assets: shareable, scope: scope, isVisible: shareable.isNotEmpty);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() => _share(scope.ref.read(appConfigProvider).share.fileType);
|
||||
|
||||
@override
|
||||
Future<void> Function()? get onSecondaryAction => _promptQualityAndShare;
|
||||
|
||||
Future<void> _promptQualityAndShare() async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
|
||||
// only show preview option when at least one of the assets is not a video
|
||||
// we cant share previews of videos
|
||||
final showPreview = assets.isEmpty || assets.any((asset) => !asset.isVideo);
|
||||
|
||||
final fileType = await showDialog<ShareAssetType>(
|
||||
context: context,
|
||||
builder: (_) => _ShareFileTypeDialog(showPreview: showPreview),
|
||||
useRootNavigator: false,
|
||||
);
|
||||
|
||||
if (fileType == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(settingsProvider).write(.shareFileType, fileType);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _share(fileType);
|
||||
}
|
||||
|
||||
Future<void> _share(ShareAssetType fileType) async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
final cancelCompleter = Completer<void>();
|
||||
final progress = ValueNotifier<double?>(null);
|
||||
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
useRootNavigator: false,
|
||||
builder: (dialogContext) {
|
||||
void finish({required bool failed}) {
|
||||
if (cancelCompleter.isCompleted || !dialogContext.mounted) {
|
||||
return;
|
||||
}
|
||||
if (failed) {
|
||||
ref.read(toastRepositoryProvider).error(context.t.scaffold_body_error_occurred);
|
||||
}
|
||||
dialogContext.pop();
|
||||
}
|
||||
|
||||
unawaited(
|
||||
ref
|
||||
.read(assetMediaRepositoryProvider)
|
||||
.shareAssets(
|
||||
assets,
|
||||
context,
|
||||
fileType: fileType,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: (value) => progress.value = value,
|
||||
)
|
||||
.then<void>(
|
||||
(count) => finish(failed: count == 0 && assets.isNotEmpty),
|
||||
onError: (_) => finish(failed: true),
|
||||
),
|
||||
);
|
||||
|
||||
// Show download progress with a "Preparing" message
|
||||
return _SharePreparingDialog(progress: progress);
|
||||
},
|
||||
).then((_) {
|
||||
if (!cancelCompleter.isCompleted) {
|
||||
cancelCompleter.complete();
|
||||
}
|
||||
progress.dispose();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _SharePreparingDialog extends StatelessWidget {
|
||||
final ValueNotifier<double?> progress;
|
||||
|
||||
const _SharePreparingDialog({required this.progress});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Container(margin: const .only(bottom: 12), child: Text(context.t.share_dialog_preparing)),
|
||||
SizedBox(
|
||||
width: 240,
|
||||
child: ValueListenableBuilder<double?>(
|
||||
valueListenable: progress,
|
||||
builder: (context, value, _) {
|
||||
final percent = value == null ? null : (value * 100).clamp(0, 100);
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
LinearProgressIndicator(value: value, minHeight: 8.0),
|
||||
if (percent != null)
|
||||
Container(margin: const .only(top: 8), child: Text('${percent.toStringAsFixed(0)}%')),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShareFileTypeDialog extends StatelessWidget {
|
||||
final bool showPreview;
|
||||
|
||||
const _ShareFileTypeDialog({this.showPreview = true});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(context.t.select_quality),
|
||||
contentPadding: const .symmetric(vertical: 8),
|
||||
content: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.high_quality_rounded),
|
||||
title: Text(context.t.share_original),
|
||||
onTap: () => context.pop(ShareAssetType.original),
|
||||
),
|
||||
if (showPreview)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_size_select_large_rounded),
|
||||
title: Text(context.t.share_preview),
|
||||
onTap: () => context.pop(ShareAssetType.preview),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [TextButton(onPressed: () => context.pop(), child: Text(context.t.cancel))],
|
||||
);
|
||||
}
|
||||
}
|
||||
24
mobile/lib/presentation/actions/share_link.action.dart
Normal file
24
mobile/lib/presentation/actions/share_link.action.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
|
||||
class ShareLinkAction extends BaseAction {
|
||||
final List<String> remoteIds;
|
||||
|
||||
ShareLinkAction._({required this.remoteIds, required super.scope, super.isVisible})
|
||||
: super(icon: Icons.link_rounded, label: scope.context.t.share_link);
|
||||
|
||||
factory ShareLinkAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final remoteIds = AssetFilter(assets).remote().map((asset) => asset.id).toList(growable: false);
|
||||
return ._(remoteIds: remoteIds, scope: scope, isVisible: remoteIds.isNotEmpty);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async => unawaited(scope.context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds)));
|
||||
}
|
||||
@@ -2,26 +2,23 @@ import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class SimilarPhotosActionButton extends ConsumerWidget {
|
||||
class SimilarPhotosAction extends BaseAction {
|
||||
final String assetId;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const SimilarPhotosActionButton({super.key, required this.assetId, this.iconOnly = false, this.menuItem = false});
|
||||
SimilarPhotosAction({required this.assetId, required super.scope})
|
||||
: super(icon: Icons.compare, label: scope.context.t.view_similar_photos);
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
|
||||
ref.invalidate(assetViewerProvider);
|
||||
ref.invalidate(paginatedSearchProvider);
|
||||
@@ -43,16 +40,4 @@ class SimilarPhotosActionButton extends ConsumerWidget {
|
||||
|
||||
unawaited(context.navigateTo(const DriftSearchRoute()));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.compare,
|
||||
label: "view_similar_photos".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
maxWidth: 100,
|
||||
);
|
||||
}
|
||||
}
|
||||
18
mobile/lib/presentation/actions/slideshow.action.dart
Normal file
18
mobile/lib/presentation/actions/slideshow.action.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class SlideshowAction extends BaseAction {
|
||||
SlideshowAction({required super.scope}) : super(icon: Icons.slideshow, label: scope.context.t.slideshow);
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
unawaited(context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider))));
|
||||
}
|
||||
}
|
||||
56
mobile/lib/presentation/actions/stack.action.dart
Normal file
56
mobile/lib/presentation/actions/stack.action.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
|
||||
class StackAction extends BaseAction {
|
||||
final List<String> assetIds;
|
||||
final List<String> stackIds;
|
||||
final bool stack;
|
||||
|
||||
const StackAction._({
|
||||
required this.assetIds,
|
||||
required this.stackIds,
|
||||
required this.stack,
|
||||
required super.scope,
|
||||
required super.icon,
|
||||
required super.label,
|
||||
super.isVisible,
|
||||
});
|
||||
|
||||
factory StackAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final ownedAssets = AssetFilter(assets).owned(scope.authUser.id);
|
||||
// Stack when any owned asset is not yet stacked; otherwise unstack them all.
|
||||
final stack = ownedAssets.stacked(isStacked: false).isNotEmpty;
|
||||
final assetIds = ownedAssets.map((asset) => asset.id).toList(growable: false);
|
||||
final stackIds = ownedAssets.map((asset) => asset.stackId).nonNulls.toList(growable: false);
|
||||
|
||||
return StackAction._(
|
||||
assetIds: assetIds,
|
||||
stackIds: stackIds,
|
||||
stack: stack,
|
||||
scope: scope,
|
||||
icon: stack ? Icons.filter_none_rounded : Icons.layers_clear_outlined,
|
||||
label: stack ? scope.context.t.stack : scope.context.t.unstack,
|
||||
isVisible: stack ? assetIds.length > 1 : stackIds.isNotEmpty,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:ref, :context) = scope;
|
||||
if (stack) {
|
||||
await ref.read(assetServiceProvider).stack(scope.authUser.id, assetIds);
|
||||
} else {
|
||||
await ref.read(assetServiceProvider).unstack(stackIds);
|
||||
}
|
||||
|
||||
final message = stack
|
||||
? context.t.stacked_assets_count(count: assetIds.length)
|
||||
: context.t.unstacked_assets_count(count: assetIds.length);
|
||||
ref.read(toastRepositoryProvider).success(message);
|
||||
}
|
||||
}
|
||||
50
mobile/lib/presentation/actions/tag.action.dart
Normal file
50
mobile/lib/presentation/actions/tag.action.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
import 'package:immich_mobile/widgets/common/tag_picker.dart';
|
||||
|
||||
class TagAction extends BaseAction {
|
||||
final List<String> assetIds;
|
||||
|
||||
TagAction._({required this.assetIds, required super.scope, super.isVisible})
|
||||
: super(icon: Icons.sell_outlined, label: scope.context.t.control_bottom_app_bar_add_tags);
|
||||
|
||||
factory TagAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final assetIds = AssetFilter(assets).owned(scope.authUser.id).map((asset) => asset.id).toList(growable: false);
|
||||
return ._(assetIds: assetIds, scope: scope, isVisible: assetIds.isNotEmpty);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final results = await showTagPickerModal(context: scope.context);
|
||||
if (results == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await tagAssets(selected: results.$1, created: results.$2);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> tagAssets({required Set<String> selected, required Set<String> created}) async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
final tagService = ref.read(tagServiceProvider);
|
||||
final tagIds = {...selected};
|
||||
|
||||
if (created.isNotEmpty) {
|
||||
final tags = await tagService.upsertTags(created.toList());
|
||||
tagIds.addAll(tags.map((tag) => tag.id));
|
||||
}
|
||||
if (tagIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final count = await tagService.bulkTagAssets(assetIds, tagIds.toList());
|
||||
ref.invalidate(tagProvider);
|
||||
ref.read(toastRepositoryProvider).success(context.t.tagged_assets(count: count));
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
class TimelineAction extends BaseAction {
|
||||
final BaseAction action;
|
||||
|
||||
const TimelineAction({required this.action});
|
||||
TimelineAction({required this.action})
|
||||
: super(scope: action.scope, icon: action.icon, label: action.label, isVisible: action.isVisible);
|
||||
|
||||
@override
|
||||
IconData get icon => action.icon;
|
||||
|
||||
@override
|
||||
String label(ActionScope scope) => action.label(scope);
|
||||
|
||||
@override
|
||||
bool isVisible(ActionScope scope) => action.isVisible(scope);
|
||||
|
||||
@override
|
||||
Future<void> onAction(ActionScope scope) async {
|
||||
await action.onAction(scope);
|
||||
Future<void> onAction() async {
|
||||
await action.onAction();
|
||||
scope.ref.read(multiSelectProvider.notifier).reset();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> Function()? get onSecondaryAction {
|
||||
final inner = action.onSecondaryAction;
|
||||
if (inner == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return () async {
|
||||
await inner();
|
||||
scope.ref.read(multiSelectProvider.notifier).reset();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
138
mobile/lib/presentation/actions/upload.action.dart
Normal file
138
mobile/lib/presentation/actions/upload.action.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class UploadAction extends BaseAction {
|
||||
final List<LocalAsset> assets;
|
||||
|
||||
final bool showProgress;
|
||||
|
||||
UploadAction._({required this.assets, required this.showProgress, required super.scope, super.isVisible})
|
||||
: super(icon: Icons.backup_outlined, label: scope.context.t.upload);
|
||||
|
||||
factory UploadAction({required Iterable<BaseAsset> assets, required ActionScope scope, bool showProgress = false}) {
|
||||
final local = AssetFilter(assets).backedUp(isBackedUp: false).local().toList(growable: false);
|
||||
return ._(assets: local, scope: scope, showProgress: showProgress, isVisible: local.isNotEmpty);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
if (assets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showProgress) {
|
||||
await upload();
|
||||
return;
|
||||
}
|
||||
|
||||
final context = scope.context;
|
||||
var isDialogOpen = true;
|
||||
unawaited(
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const _UploadProgressDialog(),
|
||||
).whenComplete(() => isDialogOpen = false),
|
||||
);
|
||||
|
||||
await upload();
|
||||
|
||||
if (isDialogOpen && context.mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> upload() async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
final progress = ref.read(assetUploadProgressProvider.notifier);
|
||||
final cancelToken = Completer<void>();
|
||||
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
|
||||
|
||||
final uploaded = <String>{};
|
||||
final failed = <String>{};
|
||||
for (final asset in assets) {
|
||||
progress.setProgress(asset.id, 0.0);
|
||||
}
|
||||
|
||||
try {
|
||||
await ref
|
||||
.read(foregroundUploadServiceProvider)
|
||||
.uploadManual(
|
||||
assets,
|
||||
cancelToken: cancelToken,
|
||||
callbacks: UploadCallbacks(
|
||||
onProgress: (id, _, bytes, total) => progress.setProgress(id, total > 0 ? bytes / total : 0.0),
|
||||
onSuccess: (id, _) {
|
||||
uploaded.add(id);
|
||||
progress.remove(id);
|
||||
},
|
||||
onError: (id, _) {
|
||||
failed.add(id);
|
||||
progress.setError(id);
|
||||
},
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
|
||||
}
|
||||
|
||||
final uploadedCount = uploaded.difference(failed).length;
|
||||
final wasCancelled = cancelToken.isCompleted;
|
||||
if (!wasCancelled && (uploadedCount != assets.length || failed.isNotEmpty)) {
|
||||
ref.read(toastRepositoryProvider).error(context.t.scaffold_body_error_occurred);
|
||||
}
|
||||
|
||||
unawaited(Future.delayed(const Duration(seconds: 2), progress.clear));
|
||||
}
|
||||
}
|
||||
|
||||
class _UploadProgressDialog extends ConsumerWidget {
|
||||
const _UploadProgressDialog();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final progressMap = ref.watch(assetUploadProgressProvider);
|
||||
|
||||
// Calculate overall progress from all assets
|
||||
final values = progressMap.values.where((v) => v >= 0).toList();
|
||||
final progress = values.isEmpty ? 0.0 : values.reduce((a, b) => a + b) / values.length;
|
||||
final hasError = progressMap.values.any((v) => v < 0);
|
||||
final percentage = (progress * 100).toInt();
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(context.t.uploading),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (hasError)
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 48)
|
||||
else
|
||||
CircularProgressIndicator(value: progress > 0 ? progress : null),
|
||||
const SizedBox(height: 16),
|
||||
Text(hasError ? 'Error' : '$percentage%'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
ImmichTextButton(
|
||||
onPressed: () {
|
||||
ref.read(manualUploadCancelTokenProvider)?.complete();
|
||||
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
},
|
||||
labelText: context.t.cancel,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,25 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
import 'package:immich_mobile/constants/enums.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/constants/enums.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/archive.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/lock.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
enum AddToMenuItem { album, archive, unarchive, lockedFolder }
|
||||
enum AddToMenuItem { album }
|
||||
|
||||
class AddActionButton extends ConsumerStatefulWidget {
|
||||
const AddActionButton({super.key, this.originalTheme});
|
||||
@@ -37,15 +36,6 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
case AddToMenuItem.album:
|
||||
_openAlbumSelector();
|
||||
break;
|
||||
case AddToMenuItem.archive:
|
||||
performArchiveAction(context, ref, source: ActionSource.viewer);
|
||||
break;
|
||||
case AddToMenuItem.unarchive:
|
||||
performUnArchiveAction(context, ref, source: ActionSource.viewer);
|
||||
break;
|
||||
case AddToMenuItem.lockedFolder:
|
||||
performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,11 +47,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
|
||||
final user = ref.read(currentUserProvider);
|
||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
|
||||
final hasRemote = asset is RemoteAsset;
|
||||
final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived;
|
||||
final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived;
|
||||
final scope = ActionScope.from(context, ref);
|
||||
|
||||
return [
|
||||
Padding(
|
||||
@@ -81,25 +67,11 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text("move_to".tr(), style: context.textTheme.labelMedium),
|
||||
),
|
||||
if (showArchive)
|
||||
BaseActionButton(
|
||||
iconData: Icons.archive_outlined,
|
||||
label: "archive".tr(),
|
||||
menuItem: true,
|
||||
onPressed: () => _handleMenuSelection(AddToMenuItem.archive),
|
||||
),
|
||||
if (showUnarchive)
|
||||
BaseActionButton(
|
||||
iconData: Icons.unarchive_outlined,
|
||||
label: "unarchive".tr(),
|
||||
menuItem: true,
|
||||
onPressed: () => _handleMenuSelection(AddToMenuItem.unarchive),
|
||||
),
|
||||
BaseActionButton(
|
||||
iconData: Icons.lock_outline,
|
||||
label: "locked_folder".tr(),
|
||||
menuItem: true,
|
||||
onPressed: () => _handleMenuSelection(AddToMenuItem.lockedFolder),
|
||||
ActionMenuItemWidget(
|
||||
action: ArchiveAction(assets: [asset], scope: scope),
|
||||
),
|
||||
ActionMenuItemWidget(
|
||||
action: LockAction(assets: [asset], scope: scope),
|
||||
),
|
||||
],
|
||||
];
|
||||
@@ -184,7 +156,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
|
||||
final themeData = widget.originalTheme ?? context.themeData;
|
||||
|
||||
return MenuAnchor(
|
||||
return ImmichMenu(
|
||||
consumeOutsideTap: true,
|
||||
style: MenuStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(themeData.scaffoldBackgroundColor),
|
||||
@@ -195,7 +167,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
),
|
||||
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
|
||||
),
|
||||
menuChildren: widget.originalTheme != null
|
||||
children: widget.originalTheme != null
|
||||
? [
|
||||
Theme(
|
||||
data: widget.originalTheme!,
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
// used to allow performing archive action from different sources (without duplicating code)
|
||||
Future<void> performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).archive(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ArchiveActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const ArchiveActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
|
||||
await performArchiveAction(context, ref, source: source);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.archive_outlined,
|
||||
label: "to_archive".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class BulkTagAssetsActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
|
||||
const BulkTagAssetsActionButton({super.key, required this.source});
|
||||
|
||||
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
|
||||
final result = await ref.read(actionProvider.notifier).tagAssets(source, context);
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success
|
||||
? 'tagged_assets'.t(context: context, args: {'count': result.count.toString()})
|
||||
: 'errors.failed_to_tag_assets'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.sell_outlined,
|
||||
label: "control_bottom_app_bar_add_tags".t(context: context),
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
|
||||
|
||||
class CastActionButton extends ConsumerWidget {
|
||||
const CastActionButton({super.key, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||
|
||||
return BaseActionButton(
|
||||
iconData: isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded,
|
||||
iconColor: isCasting ? context.primaryColor : null, // null = default color
|
||||
label: "cast".t(context: context),
|
||||
onPressed: () {
|
||||
showDialog(context: context, builder: (context) => const CastDialog());
|
||||
},
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
/// This delete action has the following behavior:
|
||||
/// - Set the deletedAt information, put the asset in the trash in the server
|
||||
/// which will be permanently deleted after the number of days configure by the admin
|
||||
/// - Prompt to delete the asset locally
|
||||
class DeleteActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool showConfirmation;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
const DeleteActionButton({
|
||||
super.key,
|
||||
required this.source,
|
||||
this.showConfirmation = false,
|
||||
this.iconOnly = false,
|
||||
this.menuItem = false,
|
||||
});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (showConfirmation) {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('delete'.t(context: context)),
|
||||
content: Text('delete_action_confirmation_message'.t(context: context)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text('cancel'.t(context: context)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(
|
||||
'confirm'.t(context: context),
|
||||
style: TextStyle(color: context.colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm != true) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'delete_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
maxWidth: 110.0,
|
||||
iconData: Icons.delete_sweep_outlined,
|
||||
label: "delete".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
|
||||
/// This delete action has the following behavior:
|
||||
/// - Prompt to delete the asset locally
|
||||
class DeleteLocalActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const DeleteLocalActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).deleteLocal(source, context);
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
if (result.count == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.invalidate(localAlbumProvider);
|
||||
|
||||
final successMessage = 'delete_local_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
maxWidth: 95.0,
|
||||
iconData: Icons.no_cell_outlined,
|
||||
label: "control_bottom_app_bar_delete_from_local".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/permanent_delete_dialog.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
/// This delete action has the following behavior:
|
||||
/// - Delete permanently on the server
|
||||
/// - Prompt to delete the asset locally
|
||||
class DeletePermanentActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
final bool useShortLabel;
|
||||
|
||||
const DeletePermanentActionButton({
|
||||
super.key,
|
||||
required this.source,
|
||||
this.iconOnly = false,
|
||||
this.menuItem = false,
|
||||
this.useShortLabel = false,
|
||||
});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final count = source == ActionSource.viewer ? 1 : ref.read(multiSelectProvider).selectedAssets.length;
|
||||
final confirm =
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => PermanentDeleteDialog(count: count),
|
||||
) ??
|
||||
false;
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'delete_permanently_action_prompt'.t(
|
||||
context: context,
|
||||
args: {'count': result.count.toString()},
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
maxWidth: 110.0,
|
||||
iconData: Icons.delete_forever,
|
||||
label: useShortLabel ? "delete".t(context: context) : "delete_permanently".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/permanent_delete_dialog.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
/// This delete action has the following behavior:
|
||||
/// - Delete permanently on the server
|
||||
/// - Prompt to delete the asset locally
|
||||
///
|
||||
/// This action is used when the asset is selected in multi-selection mode in the trash page
|
||||
class DeleteTrashActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
|
||||
const DeleteTrashActionButton({super.key, required this.source});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final selectCount = ref.watch(multiSelectProvider.select((s) => s.selectedAssets.length));
|
||||
|
||||
final confirmDelete =
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => PermanentDeleteDialog(count: selectCount),
|
||||
) ??
|
||||
false;
|
||||
if (!confirmDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'assets_permanently_deleted_count'.t(
|
||||
context: context,
|
||||
args: {'count': result.count.toString()},
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return TextButton.icon(
|
||||
icon: Icon(Icons.delete_forever, color: Colors.red[400]),
|
||||
label: Text(
|
||||
"delete".t(context: context),
|
||||
style: TextStyle(fontSize: 14, color: Colors.red[400], fontWeight: FontWeight.bold),
|
||||
),
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
class DownloadActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
const DownloadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ref.read(actionProvider.notifier).downloadAll(source);
|
||||
|
||||
Future.delayed(const Duration(seconds: 1), () async {
|
||||
await backgroundSyncManager.syncLocal();
|
||||
await backgroundSyncManager.hashAssets();
|
||||
});
|
||||
} finally {
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final backgroundManager = ref.watch(backgroundSyncProvider);
|
||||
|
||||
return BaseActionButton(
|
||||
iconData: Icons.download,
|
||||
maxWidth: 95,
|
||||
label: "download".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref, backgroundManager),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class EditDateTimeActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
|
||||
const EditDateTimeActionButton({super.key, required this.source});
|
||||
|
||||
_onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).editDateTime(source, context);
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'edit_date_and_time_action_prompt'.t(
|
||||
context: context,
|
||||
args: {'count': result.count.toString()},
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
maxWidth: 95.0,
|
||||
iconData: Icons.edit_calendar_outlined,
|
||||
label: "control_bottom_app_bar_edit_time".t(context: context),
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class EditImageActionButton extends ConsumerWidget {
|
||||
const EditImageActionButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
|
||||
Future<void> editImage(List<AssetEdit> edits) async {
|
||||
if (currentAsset == null || currentAsset.remoteId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(actionProvider.notifier).applyEdits(ActionSource.viewer, edits);
|
||||
}
|
||||
|
||||
Future<void> onPress() async {
|
||||
if (currentAsset == null || currentAsset.remoteId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final imageProvider = getFullImageProvider(currentAsset, edited: false);
|
||||
|
||||
final image = Image(image: imageProvider);
|
||||
final (edits, exifInfo) = await (
|
||||
ref.read(remoteAssetRepositoryProvider).getAssetEdits(currentAsset.remoteId!),
|
||||
ref.read(remoteAssetRepositoryProvider).getExif(currentAsset.remoteId!),
|
||||
).wait;
|
||||
|
||||
if (exifInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(editorStateProvider.notifier).init(edits, exifInfo);
|
||||
await context.pushRoute(DriftEditImageRoute(image: image, applyEdits: editImage));
|
||||
}
|
||||
|
||||
return BaseActionButton(
|
||||
iconData: Icons.tune,
|
||||
label: "edit".t(context: context),
|
||||
onPressed: onPress,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class EditLocationActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
|
||||
const EditLocationActionButton({super.key, required this.source});
|
||||
|
||||
_onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).editLocation(source, context);
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'edit_location_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.edit_location_alt_outlined,
|
||||
label: "control_bottom_app_bar_edit_location".t(context: context),
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class FavoriteActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const FavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).favorite(source);
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
if (result.success) {
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
if (currentAsset is RemoteAsset && !currentAsset.isFavorite) {
|
||||
ref.read(assetViewerProvider.notifier).setAsset(currentAsset.copyWith(isFavorite: true));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'favorite_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.favorite_border_rounded,
|
||||
label: "favorite".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
// Reusable helper: move to locked folder from any source (e.g called from menu)
|
||||
Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'move_to_lock_folder_action_prompt'.t(
|
||||
context: context,
|
||||
args: {'count': result.count.toString()},
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MoveToLockFolderActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const MoveToLockFolderActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
|
||||
await performMoveToLockFolderAction(context, ref, source: source);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
maxWidth: 115.0,
|
||||
iconData: Icons.lock_outline_rounded,
|
||||
label: "move_to_locked_folder".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class OpenInBrowserActionButton extends ConsumerWidget {
|
||||
final String remoteId;
|
||||
final TimelineOrigin origin;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const OpenInBrowserActionButton({
|
||||
super.key,
|
||||
required this.remoteId,
|
||||
required this.origin,
|
||||
this.iconOnly = false,
|
||||
this.menuItem = false,
|
||||
});
|
||||
|
||||
void _onTap() async {
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint).replaceFirst('/api', '');
|
||||
|
||||
String originPath = '';
|
||||
switch (origin) {
|
||||
case TimelineOrigin.favorite:
|
||||
originPath = '/favorites';
|
||||
break;
|
||||
case TimelineOrigin.trash:
|
||||
originPath = '/trash';
|
||||
break;
|
||||
case TimelineOrigin.archive:
|
||||
originPath = '/archive';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
final url = '$serverEndpoint$originPath/photos/$remoteId';
|
||||
if (await canLaunchUrl(Uri.parse(url))) {
|
||||
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
label: 'open_in_browser'.t(context: context),
|
||||
iconData: Icons.open_in_browser,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: _onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class RemoveFromAlbumActionButton extends ConsumerWidget {
|
||||
final String albumId;
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const RemoveFromAlbumActionButton({
|
||||
super.key,
|
||||
required this.albumId,
|
||||
required this.source,
|
||||
this.iconOnly = false,
|
||||
this.menuItem = false,
|
||||
});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'remove_from_album_action_prompt'.t(
|
||||
context: context,
|
||||
args: {'count': result.count.toString()},
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.remove_circle_outline,
|
||||
label: "remove_from_album".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
maxWidth: 100,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class RemoveFromLockFolderActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const RemoveFromLockFolderActionButton({
|
||||
super.key,
|
||||
required this.source,
|
||||
this.iconOnly = false,
|
||||
this.menuItem = false,
|
||||
});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).removeFromLockFolder(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'remove_from_lock_folder_action_prompt'.t(
|
||||
context: context,
|
||||
args: {'count': result.count.toString()},
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
maxWidth: 100.0,
|
||||
iconData: Icons.lock_open_rounded,
|
||||
label: "remove_from_locked_folder".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class RestoreActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const RestoreActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).restoreTrash(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.history_rounded,
|
||||
label: 'restore'.t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
maxWidth: 100.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class RestoreTrashActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
|
||||
const RestoreTrashActionButton({super.key, required this.source});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).restoreTrash(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return TextButton.icon(
|
||||
icon: const Icon(Icons.history_rounded),
|
||||
label: Text('restore'.t(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class SetAlbumCoverActionButton extends ConsumerWidget {
|
||||
final String albumId;
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const SetAlbumCoverActionButton({
|
||||
super.key,
|
||||
required this.albumId,
|
||||
required this.source,
|
||||
this.iconOnly = false,
|
||||
this.menuItem = false,
|
||||
});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).setAlbumCover(source, albumId);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'album_cover_updated'.t(context: context);
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.image_outlined,
|
||||
label: 'set_as_album_cover'.t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
maxWidth: 100,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class SetProfilePictureActionButton extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const SetProfilePictureActionButton({super.key, required this.asset, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context) {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.pushRoute(ProfilePictureCropRoute(asset: asset));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.account_circle_outlined,
|
||||
label: "set_as_profile_picture".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context),
|
||||
maxWidth: 100,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/settings_key.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class _SharePreparingDialog extends StatelessWidget {
|
||||
final ValueNotifier<double?> progress;
|
||||
|
||||
const _SharePreparingDialog({required this.progress});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(margin: const EdgeInsets.only(bottom: 12), child: const Text('share_dialog_preparing').tr()),
|
||||
SizedBox(
|
||||
width: 240,
|
||||
child: ValueListenableBuilder<double?>(
|
||||
valueListenable: progress,
|
||||
builder: (context, value, _) {
|
||||
final percent = value == null ? null : (value * 100).clamp(0, 100);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
LinearProgressIndicator(value: value, minHeight: 8.0),
|
||||
if (percent != null)
|
||||
Container(margin: const EdgeInsets.only(top: 8), child: Text('${percent.toStringAsFixed(0)}%')),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShareFileTypeDialog extends StatelessWidget {
|
||||
final bool showPreview;
|
||||
|
||||
const _ShareFileTypeDialog({this.showPreview = true});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(context.t.select_quality),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.high_quality_rounded),
|
||||
title: Text(context.t.share_original),
|
||||
onTap: () => context.pop(ShareAssetType.original),
|
||||
),
|
||||
if (showPreview)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_size_select_large_rounded),
|
||||
title: Text(context.t.share_preview),
|
||||
onTap: () => context.pop(ShareAssetType.preview),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [TextButton(onPressed: () => context.pop(), child: Text(context.t.cancel))],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShareActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const ShareActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
Set<BaseAsset> _getSelectedAssets(WidgetRef ref) {
|
||||
return switch (source) {
|
||||
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
|
||||
ActionSource.viewer => switch (ref.read(assetViewerProvider).currentAsset) {
|
||||
BaseAsset asset => {asset},
|
||||
null => const {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final fileType = ref.read(appConfigProvider).share.fileType;
|
||||
await _share(context, ref, fileType);
|
||||
}
|
||||
|
||||
void _onLongPress(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// only show preview option when at least one of the assets is not a video
|
||||
// we cant share previews of videos
|
||||
final assets = _getSelectedAssets(ref);
|
||||
final showPreview = assets.isEmpty || assets.any((asset) => !asset.isVideo);
|
||||
|
||||
final fileType = await showDialog<ShareAssetType>(
|
||||
context: context,
|
||||
builder: (_) => _ShareFileTypeDialog(showPreview: showPreview),
|
||||
useRootNavigator: false,
|
||||
);
|
||||
|
||||
if (fileType == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(settingsProvider).write(SettingsKey.shareFileType, fileType);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _share(context, ref, fileType);
|
||||
}
|
||||
|
||||
Future<void> _share(BuildContext context, WidgetRef ref, ShareAssetType fileType) async {
|
||||
final cancelCompleter = Completer<void>();
|
||||
final progress = ValueNotifier<double?>(null);
|
||||
final preparingDialog = _SharePreparingDialog(progress: progress);
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext buildContext) {
|
||||
ref
|
||||
.read(actionProvider.notifier)
|
||||
.shareAssets(
|
||||
source,
|
||||
context,
|
||||
fileType: fileType,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: (value) => progress.value = value,
|
||||
)
|
||||
.then((ActionResult result) {
|
||||
if (cancelCompleter.isCompleted || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (!result.success) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: context.t.scaffold_body_error_occurred,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
}
|
||||
|
||||
buildContext.pop();
|
||||
});
|
||||
|
||||
// Show download progress with a "Preparing" message
|
||||
return preparingDialog;
|
||||
},
|
||||
barrierDismissible: false,
|
||||
useRootNavigator: false,
|
||||
).then((_) {
|
||||
if (!cancelCompleter.isCompleted) {
|
||||
cancelCompleter.complete();
|
||||
}
|
||||
progress.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
|
||||
label: context.t.share,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
onLongPressed: () => _onLongPress(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
|
||||
class ShareLinkActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const ShareLinkActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
_onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(actionProvider.notifier).shareLink(source, context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.link_rounded,
|
||||
label: "share_link".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class SlideshowActionButton extends ConsumerWidget {
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const SlideshowActionButton({super.key, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.slideshow,
|
||||
label: "slideshow".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
maxWidth: 100,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class StackActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
|
||||
const StackActionButton({super.key, required this.source});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final user = ref.watch(currentUserProvider);
|
||||
if (user == null) {
|
||||
throw Exception('User must be logged in to access stack action');
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).stack(user.id, source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'stack_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.filter_none_rounded,
|
||||
label: "stack".t(context: context),
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
/// This delete action has the following behavior:
|
||||
/// - Set the deletedAt information, put the asset in the trash in the server
|
||||
/// which will be permanently deleted after the number of days configure by the admin
|
||||
class TrashActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const TrashActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).trash(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'trash_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
maxWidth: 85.0,
|
||||
iconData: Icons.delete_outline_rounded,
|
||||
label: "control_bottom_app_bar_trash_from_immich".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// dart
|
||||
// File: `lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart`
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
|
||||
// used to allow performing unarchive action from different sources (without duplicating code)
|
||||
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).unArchive(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UnArchiveActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const UnArchiveActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
|
||||
await performUnArchiveAction(context, ref, source: source);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.unarchive_outlined,
|
||||
label: "unarchive".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class UnFavoriteActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const UnFavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).unFavorite(source);
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
if (result.success) {
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
if (currentAsset is RemoteAsset && currentAsset.isFavorite) {
|
||||
ref.read(assetViewerProvider.notifier).setAsset(currentAsset.copyWith(isFavorite: false));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'unfavorite_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.favorite_rounded,
|
||||
label: "unfavorite".t(context: context),
|
||||
onPressed: () => _onTap(context, ref),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class UnStackActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const UnStackActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).unStack(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'unstack_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.layers_clear_outlined,
|
||||
label: "unstack".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/view_intent.service.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class UploadActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const UploadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final isTimeline = source == ActionSource.timeline;
|
||||
final viewerIntentFilePath = source == ActionSource.viewer ? ref.read(viewIntentFilePathProvider) : null;
|
||||
List<LocalAsset>? assets;
|
||||
var isUploadDialogOpen = false;
|
||||
var wasUploadCancelled = false;
|
||||
Future<void>? uploadDialogFuture;
|
||||
|
||||
if (source == ActionSource.timeline) {
|
||||
assets = ref.read(multiSelectProvider).selectedAssets.whereType<LocalAsset>().toList();
|
||||
if (assets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
} else {
|
||||
isUploadDialogOpen = true;
|
||||
uploadDialogFuture =
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => _UploadProgressDialog(
|
||||
onCancel: () {
|
||||
wasUploadCancelled = true;
|
||||
},
|
||||
),
|
||||
).whenComplete(() {
|
||||
isUploadDialogOpen = false;
|
||||
});
|
||||
unawaited(uploadDialogFuture);
|
||||
}
|
||||
|
||||
var success = false;
|
||||
if (!isTimeline && viewerIntentFilePath != null) {
|
||||
final viewIntentService = ref.read(viewIntentServiceProvider);
|
||||
viewIntentService.markUploadActive(viewerIntentFilePath);
|
||||
var hasError = false;
|
||||
try {
|
||||
await ref
|
||||
.read(foregroundUploadServiceProvider)
|
||||
.uploadShareIntent(
|
||||
[File(viewerIntentFilePath)],
|
||||
onError: (_, _) {
|
||||
hasError = true;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await viewIntentService.markUploadInactive(viewerIntentFilePath);
|
||||
}
|
||||
success = !hasError;
|
||||
} else {
|
||||
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
|
||||
success = result.success;
|
||||
}
|
||||
|
||||
if (!isTimeline && context.mounted && isUploadDialogOpen) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
|
||||
if (context.mounted && !success && !wasUploadCancelled) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.backup_outlined,
|
||||
label: "upload".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UploadProgressDialog extends ConsumerWidget {
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _UploadProgressDialog({required this.onCancel});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final progressMap = ref.watch(assetUploadProgressProvider);
|
||||
|
||||
// Calculate overall progress from all assets
|
||||
final values = progressMap.values.where((v) => v >= 0).toList();
|
||||
final progress = values.isEmpty ? 0.0 : values.reduce((a, b) => a + b) / values.length;
|
||||
final hasError = progressMap.values.any((v) => v < 0);
|
||||
final percentage = (progress * 100).toInt();
|
||||
|
||||
return AlertDialog(
|
||||
title: Text('uploading'.t(context: context)),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (hasError)
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 48)
|
||||
else
|
||||
CircularProgressIndicator(value: progress > 0 ? progress : null),
|
||||
const SizedBox(height: 16),
|
||||
Text(hasError ? 'Error' : '$percentage%'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
ImmichTextButton(
|
||||
onPressed: () {
|
||||
ref.read(manualUploadCancelTokenProvider)?.complete();
|
||||
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
|
||||
onCancel();
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
},
|
||||
labelText: 'cancel'.t(context: context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -8,6 +9,8 @@ import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
@@ -27,16 +30,15 @@ class DateTimeDetails extends ConsumerWidget {
|
||||
final asset = this.asset;
|
||||
final exifInfo = this.exifInfo;
|
||||
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
||||
final editDateTime = EditDateTimeAction(assets: [asset], scope: ActionScope.from(context, ref));
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SheetTile(
|
||||
title: _getDateTime(context, asset, exifInfo),
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
|
||||
onTap: asset.hasRemote && isOwner
|
||||
? () async => await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context)
|
||||
: null,
|
||||
trailing: const Icon(Icons.edit, size: 18),
|
||||
onTap: editDateTime.onAction,
|
||||
),
|
||||
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
|
||||
],
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/edit_location.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
@@ -53,10 +53,6 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
||||
}
|
||||
}
|
||||
|
||||
void editLocation() async {
|
||||
await ref.read(actionProvider.notifier).editLocation(ActionSource.viewer, context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asset = widget.asset;
|
||||
@@ -68,6 +64,7 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final editLocation = EditLocationAction(assets: [asset], scope: ActionScope.from(context, ref));
|
||||
final locationName = _getLocationName(exifInfo);
|
||||
final coordinates = "${exifInfo?.latitude?.toStringAsFixed(4)}, ${exifInfo?.longitude?.toStringAsFixed(4)}";
|
||||
|
||||
@@ -80,7 +77,7 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
||||
title: 'location'.t(context: context),
|
||||
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
trailing: hasCoordinates ? const Icon(Icons.edit_location_alt, size: 20) : null,
|
||||
onTap: editLocation,
|
||||
onTap: editLocation.onAction,
|
||||
),
|
||||
if (hasCoordinates)
|
||||
Padding(
|
||||
@@ -115,7 +112,7 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
||||
color: context.primaryColor,
|
||||
),
|
||||
leading: const Icon(Icons.location_off),
|
||||
onTap: editLocation,
|
||||
onTap: editLocation.onAction,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/timeline.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/delete.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/edit_asset.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/restore.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/upload.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_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/restore_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/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
|
||||
|
||||
class ViewerBottomBar extends ConsumerWidget {
|
||||
@@ -33,37 +28,32 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
}
|
||||
|
||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final serverInfo = ref.watch(serverInfoProvider);
|
||||
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
|
||||
final assets = [asset];
|
||||
final scope = ActionScope.from(context, ref);
|
||||
final restore = RestoreAction(assets: assets, scope: scope);
|
||||
final delete = DeleteAction(assets: assets, scope: scope);
|
||||
final editImage = EditAssetAction(assets: assets, scope: scope);
|
||||
final upload = UploadAction(assets: assets, scope: scope, showProgress: true);
|
||||
final actions = <Widget>[
|
||||
if (isInTrash && isOwner && asset.hasRemote)
|
||||
const RestoreActionButton(source: ActionSource.viewer)
|
||||
else
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
if (restore.isVisible) ActionColumnButtonWidget(action: restore),
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
if (editImage.isVisible) ActionColumnButtonWidget(action: editImage),
|
||||
|
||||
if (!isInLockedView) ...[
|
||||
if (!isInTrash) ...[
|
||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||
// edit sync was added in 2.6.0
|
||||
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
|
||||
const EditImageActionButton(),
|
||||
if (upload.isVisible) ActionColumnButtonWidget(action: upload),
|
||||
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
||||
],
|
||||
if (isOwner) ...[
|
||||
if (asset.isLocalOnly)
|
||||
const DeleteLocalActionButton(source: ActionSource.viewer)
|
||||
else if (asset.isTrashed)
|
||||
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
|
||||
else
|
||||
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
|
||||
],
|
||||
|
||||
if (delete.isVisible) ActionColumnButtonWidget(action: delete),
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ class ViewerKebabMenu extends ConsumerWidget {
|
||||
timelineOrigin: timelineOrigin,
|
||||
);
|
||||
|
||||
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context);
|
||||
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);
|
||||
|
||||
return ImmichMenu(
|
||||
consumeOutsideTap: true,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||
@@ -44,6 +45,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
final assetForAction = [asset];
|
||||
final scope = ActionScope.from(context, ref);
|
||||
|
||||
final actions = <Widget>[
|
||||
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
|
||||
@@ -61,7 +63,9 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
},
|
||||
),
|
||||
|
||||
ActionIconButtonWidget(action: FavoriteAction(assets: assetForAction)),
|
||||
ActionIconButtonWidget(
|
||||
action: FavoriteAction(assets: assetForAction, scope: scope),
|
||||
),
|
||||
|
||||
ViewerKebabMenu(originalTheme: originalTheme),
|
||||
];
|
||||
|
||||
@@ -3,25 +3,15 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_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_link_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
@@ -50,7 +40,6 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final multiselect = ref.watch(multiSelectProvider);
|
||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||
|
||||
Future<void> addToAlbum(RemoteAlbum album) async {
|
||||
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
|
||||
@@ -76,8 +65,9 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
||||
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
||||
}
|
||||
|
||||
final scope = ActionScope.from(context, ref);
|
||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||
final actions = [FavoriteAction(assets: assets)];
|
||||
final actions = AssetActions.from(scope, assets);
|
||||
|
||||
return BaseBottomSheet(
|
||||
controller: sheetController,
|
||||
@@ -85,22 +75,25 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
||||
maxChildSize: 0.85,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
const ShareActionButton(source: ActionSource.timeline),
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
if (multiselect.hasRemote) ...[
|
||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||
const UnArchiveActionButton(source: ActionSource.timeline),
|
||||
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
||||
isTrashEnable
|
||||
? const TrashActionButton(source: ActionSource.timeline)
|
||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||
const EditLocationActionButton(source: ActionSource.timeline),
|
||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareLinkAction(assets: assets, scope: scope),
|
||||
),
|
||||
...[
|
||||
actions.favorite,
|
||||
actions.archive,
|
||||
actions.delete,
|
||||
actions.cleanup,
|
||||
actions.stack,
|
||||
actions.lock,
|
||||
actions.editDateTime,
|
||||
actions.editLocation,
|
||||
actions.download,
|
||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
],
|
||||
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
slivers: [
|
||||
const AddToAlbumHeader(),
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.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/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_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_link_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
@@ -32,7 +21,6 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final multiselect = ref.watch(multiSelectProvider);
|
||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||
|
||||
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
|
||||
final selectedAssets = multiselect.selectedAssets;
|
||||
@@ -67,30 +55,34 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
}
|
||||
|
||||
final scope = ActionScope.from(context, ref);
|
||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||
final actions = [FavoriteAction(assets: assets)];
|
||||
final actions = AssetActions.from(scope, assets);
|
||||
|
||||
return BaseBottomSheet(
|
||||
initialChildSize: 0.4,
|
||||
maxChildSize: 0.7,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
const ShareActionButton(source: ActionSource.timeline),
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
if (multiselect.hasRemote) ...[
|
||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
const ArchiveActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
||||
isTrashEnable
|
||||
? const TrashActionButton(source: ActionSource.timeline)
|
||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||
const EditLocationActionButton(source: ActionSource.timeline),
|
||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareLinkAction(assets: assets, scope: scope),
|
||||
),
|
||||
...[
|
||||
actions.favorite,
|
||||
actions.archive,
|
||||
actions.delete,
|
||||
actions.cleanup,
|
||||
actions.stack,
|
||||
actions.lock,
|
||||
actions.editDateTime,
|
||||
actions.editLocation,
|
||||
actions.download,
|
||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
],
|
||||
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
slivers: multiselect.hasRemote
|
||||
? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addAssetsToAlbum)]
|
||||
|
||||
@@ -3,30 +3,15 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_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_link_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
@@ -55,10 +40,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final multiselect = ref.watch(multiSelectProvider);
|
||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||
final tagsEnabled = ref.watch(
|
||||
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
|
||||
);
|
||||
|
||||
Future<void> addToAlbum(RemoteAlbum album) async {
|
||||
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
|
||||
@@ -83,8 +64,9 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
||||
}
|
||||
|
||||
final scope = ActionScope.from(context, ref);
|
||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||
final actions = [AssetDebugAction(assets: assets)];
|
||||
final actions = AssetActions.from(scope, assets);
|
||||
|
||||
return BaseBottomSheet(
|
||||
controller: sheetController,
|
||||
@@ -93,27 +75,28 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
maxChildSize: 0.85,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
const ShareActionButton(source: ActionSource.timeline),
|
||||
...[
|
||||
actions.debug,
|
||||
actions.favorite,
|
||||
actions.archive,
|
||||
actions.delete,
|
||||
actions.cleanup,
|
||||
actions.stack,
|
||||
actions.lock,
|
||||
actions.editDateTime,
|
||||
actions.editLocation,
|
||||
actions.download,
|
||||
actions.tag,
|
||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
if (multiselect.hasRemote) ...[
|
||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
||||
isTrashEnable
|
||||
? const TrashActionButton(source: ActionSource.timeline)
|
||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||
const FavoriteActionButton(source: ActionSource.timeline),
|
||||
const ArchiveActionButton(source: ActionSource.timeline),
|
||||
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
|
||||
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||
const EditLocationActionButton(source: ActionSource.timeline),
|
||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.onlyLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareLinkAction(assets: assets, scope: scope),
|
||||
),
|
||||
],
|
||||
if (multiselect.onlyLocal || multiselect.hasMerged)
|
||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.onlyLocal) const UploadActionButton(source: ActionSource.timeline),
|
||||
ActionColumnButtonWidget(action: TimelineAction(action: actions.upload)),
|
||||
],
|
||||
slivers: [
|
||||
const AddToAlbumHeader(),
|
||||
|
||||
@@ -3,12 +3,15 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_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/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class LocalAlbumBottomSheet extends ConsumerStatefulWidget {
|
||||
@@ -59,15 +62,24 @@ class _LocalAlbumBottomSheetState extends ConsumerState<LocalAlbumBottomSheet> {
|
||||
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
||||
}
|
||||
|
||||
final scope = ActionScope.from(context, ref);
|
||||
final assets = ref.watch(multiSelectProvider).selectedAssets.toList(growable: false);
|
||||
final actions = AssetActions.from(scope, assets);
|
||||
|
||||
return BaseBottomSheet(
|
||||
controller: sheetController,
|
||||
initialChildSize: 0.25,
|
||||
maxChildSize: 0.85,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: const [
|
||||
ShareActionButton(source: ActionSource.timeline),
|
||||
DeleteLocalActionButton(source: ActionSource.timeline),
|
||||
UploadActionButton(source: ActionSource.timeline),
|
||||
actions: [
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
...[
|
||||
actions.delete,
|
||||
actions.cleanup,
|
||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
ActionColumnButtonWidget(action: TimelineAction(action: actions.upload)),
|
||||
],
|
||||
slivers: [
|
||||
const AddToAlbumHeader(),
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/lock.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
class LockedFolderBottomSheet extends ConsumerWidget {
|
||||
const LockedFolderBottomSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return const BaseBottomSheet(
|
||||
final scope = ActionScope.from(context, ref);
|
||||
final assets = ref.watch(multiSelectProvider).selectedAssets.toList(growable: false);
|
||||
final actions = AssetActions.from(scope, assets);
|
||||
|
||||
return BaseBottomSheet(
|
||||
initialChildSize: 0.25,
|
||||
maxChildSize: 0.4,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
ShareActionButton(source: ActionSource.timeline),
|
||||
DownloadActionButton(source: ActionSource.timeline),
|
||||
DeletePermanentActionButton(source: ActionSource.timeline),
|
||||
RemoveFromLockFolderActionButton(source: ActionSource.timeline),
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionColumnButtonWidget(action: TimelineAction(action: actions.download)),
|
||||
...[
|
||||
actions.delete,
|
||||
LockAction(assets: assets, scope: scope),
|
||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/download.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
class PartnerDetailBottomSheet extends ConsumerWidget {
|
||||
const PartnerDetailBottomSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return const BaseBottomSheet(
|
||||
final scope = ActionScope.from(context, ref);
|
||||
final assets = ref.watch(multiSelectProvider).selectedAssets.toList(growable: false);
|
||||
|
||||
return BaseBottomSheet(
|
||||
initialChildSize: 0.25,
|
||||
maxChildSize: 0.4,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
ShareActionButton(source: ActionSource.timeline),
|
||||
DownloadActionButton(source: ActionSource.timeline),
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionColumnButtonWidget(
|
||||
action: TimelineAction(
|
||||
action: DownloadAction(assets: assets, scope: scope),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,27 +3,17 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/remove_from_album.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/set_album_cover.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
@@ -54,7 +44,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final multiselect = ref.watch(multiSelectProvider);
|
||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||
final ownsAlbum = ref.watch(currentUserProvider)?.id == widget.album.ownerId;
|
||||
|
||||
Future<void> addToAlbum(RemoteAlbum album) async {
|
||||
@@ -85,8 +74,9 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
||||
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
||||
}
|
||||
|
||||
final scope = ActionScope.from(context, ref);
|
||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||
final actions = [FavoriteAction(assets: assets)];
|
||||
final actions = AssetActions.from(scope, assets);
|
||||
|
||||
return BaseBottomSheet(
|
||||
controller: sheetController,
|
||||
@@ -95,30 +85,40 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
||||
maxChildSize: 0.85,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
const ShareActionButton(source: ActionSource.timeline),
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
if (multiselect.hasRemote) ...[
|
||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareLinkAction(assets: assets, scope: scope),
|
||||
),
|
||||
|
||||
if (ownsAlbum) ...[
|
||||
const ArchiveActionButton(source: ActionSource.timeline),
|
||||
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
],
|
||||
const DownloadActionButton(source: ActionSource.timeline),
|
||||
if (ownsAlbum) ...[
|
||||
isTrashEnable
|
||||
? const TrashActionButton(source: ActionSource.timeline)
|
||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||
const EditLocationActionButton(source: ActionSource.timeline),
|
||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
||||
...[
|
||||
actions.favorite,
|
||||
actions.archive,
|
||||
actions.delete,
|
||||
actions.cleanup,
|
||||
actions.stack,
|
||||
actions.lock,
|
||||
actions.editDateTime,
|
||||
actions.editLocation,
|
||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
],
|
||||
ActionColumnButtonWidget(action: TimelineAction(action: actions.download)),
|
||||
],
|
||||
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||
if (ownsAlbum) RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
|
||||
if (ownsAlbum && multiselect.selectedAssets.length == 1)
|
||||
SetAlbumCoverActionButton(source: ActionSource.timeline, albumId: widget.album.id),
|
||||
if (ownsAlbum)
|
||||
ActionColumnButtonWidget(
|
||||
action: TimelineAction(
|
||||
action: RemoveFromAlbumAction(assets: assets, albumId: widget.album.id, scope: scope),
|
||||
),
|
||||
),
|
||||
if (ownsAlbum)
|
||||
ActionColumnButtonWidget(
|
||||
action: TimelineAction(
|
||||
action: SetAlbumCoverAction(assets: assets, albumId: widget.album.id, scope: scope),
|
||||
),
|
||||
),
|
||||
],
|
||||
slivers: ownsAlbum
|
||||
? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand)]
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/delete.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/restore.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
class TrashBottomBar extends ConsumerWidget {
|
||||
const TrashBottomBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final assets = ref.watch(multiSelectProvider.select((s) => s.selectedAssets)).toList(growable: false);
|
||||
final scope = ActionScope.from(context, ref);
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
color: context.themeData.canvasColor,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: const SafeArea(
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
DeleteTrashActionButton(source: ActionSource.timeline),
|
||||
RestoreTrashActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
DeleteAction(assets: assets, scope: scope),
|
||||
RestoreAction(assets: assets, scope: scope),
|
||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))).toList(growable: false),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,34 +1,20 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.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_edit.model.dart';
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
|
||||
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/action.service.dart';
|
||||
import 'package:immich_mobile/services/download.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final actionProvider = NotifierProvider<ActionNotifier, void>(ActionNotifier.new, dependencies: [multiSelectProvider]);
|
||||
|
||||
@@ -49,7 +35,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
late ActionService _service;
|
||||
late ForegroundUploadService _foregroundUploadService;
|
||||
late DownloadService _downloadService;
|
||||
late AssetService _assetService;
|
||||
|
||||
ActionNotifier() : super();
|
||||
|
||||
@@ -57,7 +42,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
void build() {
|
||||
_foregroundUploadService = ref.watch(foregroundUploadServiceProvider);
|
||||
_service = ref.watch(actionServiceProvider);
|
||||
_assetService = ref.watch(assetServiceProvider);
|
||||
_downloadService = ref.watch(downloadServiceProvider);
|
||||
_downloadService.onImageDownloadStatus = _downloadImageCallback;
|
||||
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
|
||||
@@ -87,44 +71,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
return _getAssets(source).whereType<RemoteAsset>().toIds().toList(growable: false);
|
||||
}
|
||||
|
||||
List<String> _getLocalIdsForSource(ActionSource source, {bool ignoreLocalOnly = false}) {
|
||||
final Set<BaseAsset> assets = _getAssets(source);
|
||||
final List<String> localIds = [];
|
||||
|
||||
for (final asset in assets) {
|
||||
if (ignoreLocalOnly && asset.storage != AssetState.merged) {
|
||||
continue;
|
||||
}
|
||||
if (asset is LocalAsset) {
|
||||
localIds.add(asset.id);
|
||||
} else if (asset is RemoteAsset && asset.localId != null) {
|
||||
localIds.add(asset.localId!);
|
||||
}
|
||||
}
|
||||
|
||||
return localIds;
|
||||
}
|
||||
|
||||
List<String> _getOwnedRemoteIdsForSource(ActionSource source) {
|
||||
final ownerId = ref.read(currentUserProvider)?.id;
|
||||
return _getAssets(source).whereType<RemoteAsset>().ownedAssets(ownerId).toIds().toList(growable: false);
|
||||
}
|
||||
|
||||
List<RemoteAsset> _getOwnedRemoteAssetsForSource(ActionSource source) {
|
||||
final ownerId = ref.read(currentUserProvider)?.id;
|
||||
return _getIdsForSource<RemoteAsset>(source).ownedAssets(ownerId).toList();
|
||||
}
|
||||
|
||||
Iterable<T> _getIdsForSource<T extends BaseAsset>(ActionSource source) {
|
||||
final Set<BaseAsset> assets = _getAssets(source);
|
||||
return switch (T) {
|
||||
const (RemoteAsset) => assets.whereType<RemoteAsset>(),
|
||||
const (LocalAsset) => assets.whereType<LocalAsset>(),
|
||||
_ => const [],
|
||||
}
|
||||
as Iterable<T>;
|
||||
}
|
||||
|
||||
Set<BaseAsset> _getAssets(ActionSource source) {
|
||||
return switch (source) {
|
||||
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
|
||||
@@ -135,117 +81,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
};
|
||||
}
|
||||
|
||||
Future<ActionResult> troubleshoot(ActionSource source, BuildContext context) async {
|
||||
final assets = _getAssets(source);
|
||||
if (assets.length > 1) {
|
||||
return ActionResult(count: assets.length, success: false, error: 'Cannot troubleshoot multiple assets');
|
||||
}
|
||||
unawaited(context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
|
||||
|
||||
return ActionResult(count: assets.length, success: true);
|
||||
}
|
||||
|
||||
Future<ActionResult> shareLink(ActionSource source, BuildContext context) async {
|
||||
final ids = _getRemoteIdsForSource(source);
|
||||
try {
|
||||
await _service.shareLink(ids, context);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to create shared link for assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> favorite(ActionSource source) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
try {
|
||||
await _service.favorite(ids);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to favorite assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> unFavorite(ActionSource source) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
try {
|
||||
await _service.unFavorite(ids);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to unfavorite assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> archive(ActionSource source) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
try {
|
||||
await _service.archive(ids);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to archive assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> unArchive(ActionSource source) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
try {
|
||||
await _service.unArchive(ids);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to unarchive assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> moveToLockFolder(ActionSource source) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
final localIds = _getLocalIdsForSource(source, ignoreLocalOnly: true);
|
||||
try {
|
||||
await _service.moveToLockFolder(ids, localIds);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to move assets to lock folder', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> removeFromLockFolder(ActionSource source) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
try {
|
||||
await _service.removeFromLockFolder(ids);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to remove assets from lock folder', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> trash(ActionSource source) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
|
||||
try {
|
||||
await _service.trash(ids);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to trash assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> restoreTrash(ActionSource source) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
try {
|
||||
await _service.restoreTrash(ids);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to restore trash assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> emptyTrash(String userId) async {
|
||||
try {
|
||||
final count = await _service.emptyTrash(userId);
|
||||
@@ -266,121 +101,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> trashRemoteAndDeleteLocal(ActionSource source) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
final localIds = _getLocalIdsForSource(source);
|
||||
try {
|
||||
await _service.trashRemoteAndDeleteLocal(ids, localIds);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to delete assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> deleteRemoteAndLocal(ActionSource source) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
final localIds = _getLocalIdsForSource(source);
|
||||
try {
|
||||
await _service.deleteRemoteAndLocal(ids, localIds);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to delete assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult?> deleteLocal(ActionSource source, BuildContext context) async {
|
||||
final assets = _getAssets(source);
|
||||
bool? backedUpOnly = assets.every((asset) => asset.storage == AssetState.merged)
|
||||
? true
|
||||
: await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => DeleteLocalOnlyDialog(onDeleteLocal: (_) {}),
|
||||
);
|
||||
|
||||
if (backedUpOnly == null) {
|
||||
// User cancelled the dialog
|
||||
return null;
|
||||
}
|
||||
|
||||
final List<String> ids;
|
||||
if (backedUpOnly) {
|
||||
ids = assets.where((asset) => asset.storage == AssetState.merged).map((asset) => asset.localId!).toList();
|
||||
} else {
|
||||
ids = _getLocalIdsForSource(source);
|
||||
}
|
||||
|
||||
try {
|
||||
final deletedCount = await _service.deleteLocal(ids);
|
||||
return ActionResult(count: deletedCount, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to delete assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult?> editLocation(ActionSource source, BuildContext context) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
try {
|
||||
final isEdited = await _service.editLocation(ids, context);
|
||||
if (!isEdited) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// This must be called since editing location
|
||||
// does not update the currentAsset which means
|
||||
// the exif provider will not be refreshed automatically
|
||||
if (source == ActionSource.viewer) {
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
if (currentAsset != null) {
|
||||
ref.invalidate(assetExifProvider(currentAsset));
|
||||
}
|
||||
}
|
||||
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to edit location for assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult?> editDateTime(ActionSource source, BuildContext context) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
try {
|
||||
final isEdited = await _service.editDateTime(ids, context);
|
||||
if (!isEdited) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
ref.invalidate(assetExifProvider);
|
||||
}
|
||||
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to edit date and time for assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult?> tagAssets(ActionSource source, BuildContext context) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
try {
|
||||
final count = await _service.tagAssets(ids, context);
|
||||
if (count == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ref.invalidate(tagProvider);
|
||||
return ActionResult(count: count, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to tag assets', error, stack);
|
||||
ref.invalidate(tagProvider);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> addToAlbum(ActionSource source, RemoteAlbum album) async {
|
||||
final selected = _getAssets(source).toList(growable: false);
|
||||
if (selected.isEmpty) {
|
||||
@@ -427,33 +147,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<ActionResult> removeFromAlbum(ActionSource source, String albumId) async {
|
||||
final ids = _getRemoteIdsForSource(source);
|
||||
try {
|
||||
final removedCount = await _service.removeFromAlbum(ids, albumId);
|
||||
return ActionResult(count: removedCount, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to remove assets from album', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> setAlbumCover(ActionSource source, String albumId) async {
|
||||
final assets = _getAssets(source);
|
||||
final asset = assets.first;
|
||||
if (asset is! RemoteAsset) {
|
||||
return const ActionResult(count: 1, success: false, error: 'Asset must be remote');
|
||||
}
|
||||
|
||||
try {
|
||||
await _service.setAlbumCover(albumId, asset.id);
|
||||
return const ActionResult(count: 1, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to set album cover', error, stack);
|
||||
return ActionResult(count: 1, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> updateDescription(ActionSource source, String description) async {
|
||||
final ids = _getRemoteIdsForSource(source);
|
||||
if (ids.length != 1) {
|
||||
@@ -486,71 +179,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> stack(String userId, ActionSource source) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
try {
|
||||
await _service.stack(userId, ids);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to stack assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> unStack(ActionSource source) async {
|
||||
final assets = _getOwnedRemoteAssetsForSource(source);
|
||||
try {
|
||||
await _service.unStack(assets.map((e) => e.stackId).nonNulls.toList());
|
||||
if (source == ActionSource.viewer) {
|
||||
final updatedParent = await _assetService.getRemoteAsset(assets.first.id);
|
||||
if (updatedParent != null) {
|
||||
ref.read(assetViewerProvider.notifier).setAsset(updatedParent);
|
||||
}
|
||||
}
|
||||
|
||||
return ActionResult(count: assets.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to unstack assets', error, stack);
|
||||
return ActionResult(count: assets.length, success: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> shareAssets(
|
||||
ActionSource source,
|
||||
BuildContext context, {
|
||||
ShareAssetType fileType = ShareAssetType.original,
|
||||
Completer<void>? cancelCompleter,
|
||||
void Function(double progress)? onAssetDownloadProgress,
|
||||
}) async {
|
||||
final ids = _getAssets(source).toList(growable: false);
|
||||
|
||||
try {
|
||||
final count = await _service.shareAssets(
|
||||
ids,
|
||||
context,
|
||||
fileType: fileType,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: onAssetDownloadProgress,
|
||||
);
|
||||
return ActionResult(count: count, success: count > 0 || ids.isEmpty);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to share assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> downloadAll(ActionSource source) async {
|
||||
final assets = _getAssets(source).whereType<RemoteAsset>().toList(growable: false);
|
||||
try {
|
||||
final didEnqueue = await _service.downloadAll(assets);
|
||||
final enqueueCount = didEnqueue.where((e) => e).length;
|
||||
return ActionResult(count: enqueueCount, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to download assets', error, stack);
|
||||
return ActionResult(count: assets.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> upload(
|
||||
ActionSource source, {
|
||||
List<LocalAsset>? assets,
|
||||
@@ -631,46 +259,8 @@ class ActionNotifier extends Notifier<void> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> applyEdits(ActionSource source, List<AssetEdit> edits) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
|
||||
if (ids.length != 1) {
|
||||
_logger.warning('applyEdits called with multiple assets, expected single asset');
|
||||
return ActionResult(count: ids.length, success: false, error: 'Expected single asset for applying edits');
|
||||
}
|
||||
|
||||
Future<void> editReady;
|
||||
if (ref.read(serverInfoProvider).serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) {
|
||||
editReady = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV2", (dynamic data) {
|
||||
final eventAsset = SyncAssetV2.fromJson(data["asset"]);
|
||||
return eventAsset?.id == ids.first;
|
||||
}, const Duration(seconds: 10));
|
||||
} else {
|
||||
editReady = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV1", (dynamic data) {
|
||||
final eventAsset = SyncAssetV1.fromJson(data["asset"]);
|
||||
return eventAsset?.id == ids.first;
|
||||
}, const Duration(seconds: 10));
|
||||
}
|
||||
|
||||
try {
|
||||
await _service.applyEdits(ids.first, edits);
|
||||
await editReady;
|
||||
return const ActionResult(count: 1, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to apply edits to assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension on Iterable<RemoteAsset> {
|
||||
Iterable<String> toIds() => map((e) => e.id);
|
||||
|
||||
Iterable<RemoteAsset> ownedAssets(String? ownerId) {
|
||||
if (ownerId == null) {
|
||||
return const [];
|
||||
}
|
||||
return whereType<RemoteAsset>().where((a) => a.ownerId == ownerId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
@@ -15,6 +16,10 @@ final remoteAssetRepositoryProvider = Provider<RemoteAssetRepository>(
|
||||
(ref) => RemoteAssetRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
|
||||
final remoteExifRepositoryProvider = Provider<RemoteExifRepository>(
|
||||
(ref) => RemoteExifRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
|
||||
final trashedLocalAssetRepository = Provider<DriftTrashedLocalAssetRepository>(
|
||||
(ref) => DriftTrashedLocalAssetRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
@@ -22,6 +27,7 @@ final trashedLocalAssetRepository = Provider<DriftTrashedLocalAssetRepository>(
|
||||
final assetServiceProvider = Provider(
|
||||
(ref) => AssetService(
|
||||
remoteRepository: ref.watch(remoteAssetRepositoryProvider),
|
||||
exifRepository: ref.watch(remoteExifRepositoryProvider),
|
||||
localRepository: ref.watch(localAssetRepository),
|
||||
apiRepository: ref.watch(assetApiRepositoryProvider),
|
||||
),
|
||||
|
||||
4
mobile/lib/providers/infrastructure/toast.provider.dart
Normal file
4
mobile/lib/providers/infrastructure/toast.provider.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/repositories/toast.repository.dart';
|
||||
|
||||
final toastRepositoryProvider = Provider<ToastRepository>((ref) => const .new());
|
||||
@@ -22,8 +22,6 @@ class MultiSelectState {
|
||||
bool get hasRemote =>
|
||||
selectedAssets.any((asset) => asset.storage == AssetState.remote || asset.storage == AssetState.merged);
|
||||
|
||||
bool get hasStacked => selectedAssets.any((asset) => asset is RemoteAsset && asset.stackId != null);
|
||||
|
||||
bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged);
|
||||
|
||||
bool get onlyLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart' hide AssetEditAction;
|
||||
import 'package:immich_mobile/domain/models/stack.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/repositories/api.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:openapi/api.dart' as api show AssetVisibility;
|
||||
import 'package:openapi/api.dart' hide AssetVisibility;
|
||||
|
||||
final assetApiRepositoryProvider = Provider(
|
||||
(ref) => AssetApiRepository(
|
||||
@@ -41,28 +43,10 @@ class AssetApiRepository extends ApiRepository {
|
||||
return response?.count ?? 0;
|
||||
}
|
||||
|
||||
Future<void> updateVisibility(List<String> ids, AssetVisibilityEnum visibility) async {
|
||||
Future<void> updateVisibility(List<String> ids, AssetVisibility visibility) async {
|
||||
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility))));
|
||||
}
|
||||
|
||||
Future<void> updateFavorite(List<String> ids, bool isFavorite) async {
|
||||
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, isFavorite: Optional.present(isFavorite)));
|
||||
}
|
||||
|
||||
Future<void> updateLocation(List<String> ids, LatLng location) async {
|
||||
return _api.updateAssets(
|
||||
AssetBulkUpdateDto(
|
||||
ids: ids,
|
||||
latitude: Optional.present(location.latitude),
|
||||
longitude: Optional.present(location.longitude),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateDateTime(List<String> ids, String dateTime) async {
|
||||
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, dateTimeOriginal: Optional.present(dateTime)));
|
||||
}
|
||||
|
||||
Future<StackResponse> stack(List<String> ids) async {
|
||||
final responseDto = await checkNull(_stacksApi.createStack(StackCreateDto(assetIds: ids)));
|
||||
|
||||
@@ -77,11 +61,11 @@ class AssetApiRepository extends ApiRepository {
|
||||
return _api.downloadAssetWithHttpInfo(id, edited: edited);
|
||||
}
|
||||
|
||||
_mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) {
|
||||
AssetVisibilityEnum.timeline => AssetVisibility.timeline,
|
||||
AssetVisibilityEnum.hidden => AssetVisibility.hidden,
|
||||
AssetVisibilityEnum.locked => AssetVisibility.locked,
|
||||
AssetVisibilityEnum.archive => AssetVisibility.archive,
|
||||
api.AssetVisibility _mapVisibility(AssetVisibility visibility) => switch (visibility) {
|
||||
AssetVisibility.timeline => api.AssetVisibility.timeline,
|
||||
AssetVisibility.hidden => api.AssetVisibility.hidden,
|
||||
AssetVisibility.locked => api.AssetVisibility.locked,
|
||||
AssetVisibility.archive => api.AssetVisibility.archive,
|
||||
};
|
||||
|
||||
Future<String?> getAssetMIMEType(String assetId) async {
|
||||
@@ -106,6 +90,25 @@ class AssetApiRepository extends ApiRepository {
|
||||
Future<void> removeEdits(String assetId) async {
|
||||
return _api.removeAssetEdits(assetId);
|
||||
}
|
||||
|
||||
Future<void> update(
|
||||
List<String> remoteIds, {
|
||||
Option<bool> isFavorite = const .none(),
|
||||
Option<AssetVisibility> visibility = const .none(),
|
||||
Option<String> dateTimeOriginal = const .none(),
|
||||
Option<LatLng> location = const .none(),
|
||||
}) {
|
||||
return _api.updateAssets(
|
||||
AssetBulkUpdateDto(
|
||||
ids: remoteIds,
|
||||
isFavorite: isFavorite.toOptional(),
|
||||
visibility: visibility.map(_mapVisibility).toOptional(),
|
||||
dateTimeOriginal: dateTimeOriginal.toOptional(),
|
||||
latitude: location.map((loc) => loc.latitude).toOptional(),
|
||||
longitude: location.map((loc) => loc.longitude).toOptional(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on StackResponseDto {
|
||||
|
||||
26
mobile/lib/repositories/toast.repository.dart
Normal file
26
mobile/lib/repositories/toast.repository.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class ToastOption {
|
||||
final Duration? timeout;
|
||||
final FutureOr<void> Function()? onUndo;
|
||||
|
||||
const ToastOption({this.timeout, this.onUndo});
|
||||
}
|
||||
|
||||
class ToastRepository {
|
||||
const ToastRepository();
|
||||
|
||||
FutureOr<void> success(String message, {ToastOption? toast}) {
|
||||
snackbar.success(message, duration: toast?.timeout);
|
||||
}
|
||||
|
||||
FutureOr<void> info(String message, {ToastOption? toast}) {
|
||||
snackbar.info(message, duration: toast?.timeout);
|
||||
}
|
||||
|
||||
FutureOr<void> error(String message, {ToastOption? toast}) {
|
||||
snackbar.error(message, duration: toast?.timeout);
|
||||
}
|
||||
}
|
||||
@@ -1,117 +1,19 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/download.repository.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
|
||||
import 'package:immich_mobile/widgets/common/location_picker.dart';
|
||||
import 'package:immich_mobile/widgets/common/tag_picker.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
|
||||
|
||||
final actionServiceProvider = Provider<ActionService>(
|
||||
(ref) => ActionService(
|
||||
ref.watch(assetApiRepositoryProvider),
|
||||
ref.watch(remoteAssetRepositoryProvider),
|
||||
ref.watch(localAssetRepository),
|
||||
ref.watch(driftAlbumApiRepositoryProvider),
|
||||
ref.watch(remoteAlbumRepository),
|
||||
ref.watch(trashedLocalAssetRepository),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
ref.watch(downloadRepositoryProvider),
|
||||
ref.watch(tagServiceProvider),
|
||||
),
|
||||
(ref) => ActionService(ref.watch(assetApiRepositoryProvider), ref.watch(remoteAssetRepositoryProvider)),
|
||||
);
|
||||
|
||||
class ActionService {
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
final RemoteAssetRepository _remoteAssetRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftAlbumApiRepository _albumApiRepository;
|
||||
final DriftRemoteAlbumRepository _remoteAlbumRepository;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final DownloadRepository _downloadRepository;
|
||||
final TagService _tagService;
|
||||
|
||||
const ActionService(
|
||||
this._assetApiRepository,
|
||||
this._remoteAssetRepository,
|
||||
this._localAssetRepository,
|
||||
this._albumApiRepository,
|
||||
this._remoteAlbumRepository,
|
||||
this._trashedLocalAssetRepository,
|
||||
this._assetMediaRepository,
|
||||
this._downloadRepository,
|
||||
this._tagService,
|
||||
);
|
||||
|
||||
Future<void> shareLink(List<String> remoteIds, BuildContext context) async {
|
||||
unawaited(context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds)));
|
||||
}
|
||||
|
||||
Future<void> favorite(List<String> remoteIds) async {
|
||||
await _assetApiRepository.updateFavorite(remoteIds, true);
|
||||
await _remoteAssetRepository.updateFavorite(remoteIds, true);
|
||||
}
|
||||
|
||||
Future<void> unFavorite(List<String> remoteIds) async {
|
||||
await _assetApiRepository.updateFavorite(remoteIds, false);
|
||||
await _remoteAssetRepository.updateFavorite(remoteIds, false);
|
||||
}
|
||||
|
||||
Future<void> archive(List<String> remoteIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.archive);
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.archive);
|
||||
}
|
||||
|
||||
Future<void> unArchive(List<String> remoteIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
|
||||
}
|
||||
|
||||
Future<void> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked);
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked);
|
||||
|
||||
// Ask user if they want to delete local copies
|
||||
if (localIds.isNotEmpty) {
|
||||
await _deleteLocalAssets(localIds);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeFromLockFolder(List<String> remoteIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
|
||||
}
|
||||
|
||||
Future<void> trash(List<String> remoteIds) async {
|
||||
await _assetApiRepository.delete(remoteIds, false);
|
||||
await _remoteAssetRepository.trash(remoteIds);
|
||||
}
|
||||
|
||||
Future<void> restoreTrash(List<String> ids) async {
|
||||
await _assetApiRepository.restoreTrash(ids);
|
||||
await _remoteAssetRepository.restoreTrash(ids);
|
||||
}
|
||||
const ActionService(this._assetApiRepository, this._remoteAssetRepository);
|
||||
|
||||
Future<int> emptyTrash(String userId) async {
|
||||
final count = await _assetApiRepository.emptyTrash();
|
||||
@@ -125,113 +27,6 @@ class ActionService {
|
||||
return count;
|
||||
}
|
||||
|
||||
Future<void> trashRemoteAndDeleteLocal(List<String> remoteIds, List<String> localIds) async {
|
||||
await _assetApiRepository.delete(remoteIds, false);
|
||||
await _remoteAssetRepository.trash(remoteIds);
|
||||
|
||||
if (localIds.isNotEmpty) {
|
||||
await _deleteLocalAssets(localIds);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteRemoteAndLocal(List<String> remoteIds, List<String> localIds) async {
|
||||
await _assetApiRepository.delete(remoteIds, true);
|
||||
await _remoteAssetRepository.delete(remoteIds);
|
||||
|
||||
if (localIds.isNotEmpty) {
|
||||
await _deleteLocalAssets(localIds);
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> deleteLocal(List<String> localIds) async {
|
||||
return await _deleteLocalAssets(localIds);
|
||||
}
|
||||
|
||||
Future<bool> editLocation(List<String> remoteIds, BuildContext context) async {
|
||||
maplibre.LatLng? initialLatLng;
|
||||
if (remoteIds.length == 1) {
|
||||
final exif = await _remoteAssetRepository.getExif(remoteIds[0]);
|
||||
|
||||
if (exif?.latitude != null && exif?.longitude != null) {
|
||||
initialLatLng = maplibre.LatLng(exif!.latitude!, exif.longitude!);
|
||||
}
|
||||
}
|
||||
|
||||
final location = await showLocationPicker(context: context, initialLatLng: initialLatLng);
|
||||
|
||||
if (location == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await _assetApiRepository.updateLocation(remoteIds, location);
|
||||
await _remoteAssetRepository.updateLocation(remoteIds, location);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> editDateTime(List<String> remoteIds, BuildContext context) async {
|
||||
DateTime? initialDate;
|
||||
String? timeZone;
|
||||
Duration? offset;
|
||||
|
||||
if (remoteIds.length == 1) {
|
||||
final assetId = remoteIds.first;
|
||||
final asset = await _remoteAssetRepository.get(assetId);
|
||||
if (asset == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final exifData = await _remoteAssetRepository.getExif(assetId);
|
||||
|
||||
// Use EXIF timezone information if available (matching web app and display behavior)
|
||||
DateTime dt = asset.createdAt.toLocal();
|
||||
offset = dt.timeZoneOffset;
|
||||
|
||||
if (exifData?.dateTimeOriginal != null) {
|
||||
timeZone = exifData!.timeZone;
|
||||
(dt, offset) = applyTimezoneOffset(dateTime: exifData.dateTimeOriginal!, timeZone: exifData.timeZone);
|
||||
}
|
||||
|
||||
initialDate = dt;
|
||||
}
|
||||
|
||||
final dateTime = await showDateTimePicker(
|
||||
context: context,
|
||||
initialDateTime: initialDate,
|
||||
initialTZ: timeZone,
|
||||
initialTZOffset: offset,
|
||||
);
|
||||
|
||||
if (dateTime == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await applyDateTime(remoteIds, dateTime);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> applyDateTime(List<String> remoteIds, String dateTime) async {
|
||||
final parsedDateTime = DateTime.parse(dateTime);
|
||||
final offset = RegExp(r'[+-]\d{2}:\d{2}$').firstMatch(dateTime)?.group(0);
|
||||
|
||||
await _assetApiRepository.updateDateTime(remoteIds, dateTime);
|
||||
await _remoteAssetRepository.updateDateTime(
|
||||
remoteIds,
|
||||
parsedDateTime,
|
||||
timeZone: offset == null ? null : 'UTC$offset',
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> removeFromAlbum(List<String> remoteIds, String albumId) async {
|
||||
final result = await _albumApiRepository.removeAssets(albumId, remoteIds);
|
||||
if (result.removed.isNotEmpty) {
|
||||
await _remoteAlbumRepository.removeAssets(albumId, result.removed);
|
||||
}
|
||||
return result.removed.length;
|
||||
}
|
||||
|
||||
Future<bool> updateDescription(String assetId, String description) async {
|
||||
// update remote first, then local to ensure consistency
|
||||
await _assetApiRepository.updateDescription(assetId, description);
|
||||
@@ -247,82 +42,4 @@ class ActionService {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<int?> tagAssets(List<String> remoteIds, BuildContext context) async {
|
||||
final tagResults = await showTagPickerModal(context: context);
|
||||
if (tagResults == null) {
|
||||
// user cancelled
|
||||
return null;
|
||||
}
|
||||
|
||||
final selectedTagIds = Set<String>.from(tagResults.$1);
|
||||
final selectedNewTagValues = tagResults.$2;
|
||||
|
||||
if (selectedNewTagValues.isNotEmpty) {
|
||||
final upsertedTags = await _tagService.upsertTags(selectedNewTagValues.toList());
|
||||
selectedTagIds.addAll(upsertedTags.map((t) => t.id));
|
||||
}
|
||||
if (selectedTagIds.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
return _tagService.bulkTagAssets(remoteIds, selectedTagIds.toList());
|
||||
}
|
||||
|
||||
Future<void> stack(String userId, List<String> remoteIds) async {
|
||||
final stack = await _assetApiRepository.stack(remoteIds);
|
||||
await _remoteAssetRepository.stack(userId, stack);
|
||||
}
|
||||
|
||||
Future<void> unStack(List<String> stackIds) async {
|
||||
await _remoteAssetRepository.unStack(stackIds);
|
||||
await _assetApiRepository.unStack(stackIds);
|
||||
}
|
||||
|
||||
Future<int> shareAssets(
|
||||
List<BaseAsset> assets,
|
||||
BuildContext context, {
|
||||
ShareAssetType fileType = ShareAssetType.original,
|
||||
Completer<void>? cancelCompleter,
|
||||
void Function(double progress)? onAssetDownloadProgress,
|
||||
}) {
|
||||
return _assetMediaRepository.shareAssets(
|
||||
assets,
|
||||
context,
|
||||
fileType: fileType,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: onAssetDownloadProgress,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<bool>> downloadAll(List<RemoteAsset> assets) {
|
||||
return _downloadRepository.downloadAllAssets(assets);
|
||||
}
|
||||
|
||||
Future<bool> setAlbumCover(String albumId, String assetId) async {
|
||||
final owner = await _remoteAlbumRepository.getOwner(albumId);
|
||||
final updatedAlbum = await _albumApiRepository.updateAlbum(albumId, owner, thumbnailAssetId: assetId);
|
||||
await _remoteAlbumRepository.update(updatedAlbum);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> applyEdits(String remoteId, List<AssetEdit> edits) async {
|
||||
if (edits.isEmpty) {
|
||||
await _assetApiRepository.removeEdits(remoteId);
|
||||
} else {
|
||||
await _assetApiRepository.editAsset(remoteId, edits);
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> _deleteLocalAssets(List<String> localIds) async {
|
||||
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
|
||||
if (deletedIds.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds);
|
||||
} else {
|
||||
await _localAssetRepository.delete(deletedIds);
|
||||
}
|
||||
return deletedIds.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,34 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.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/events.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/archive.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/cast.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/delete.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/download.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/lock.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/open_in_browser.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/remove_from_album.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/restore.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/set_album_cover.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/set_profile_picture.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/similar_photos.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/slideshow.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/stack.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/upload.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_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_link_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class ActionButtonContext {
|
||||
@@ -85,9 +82,7 @@ enum ActionButtonType {
|
||||
removeFromLockFolder,
|
||||
removeFromAlbum,
|
||||
restoreTrash,
|
||||
trash,
|
||||
deleteLocal,
|
||||
deletePermanent,
|
||||
delete,
|
||||
advancedInfo;
|
||||
|
||||
@@ -112,25 +107,12 @@ enum ActionButtonType {
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote && //
|
||||
!context.asset.hasLocal,
|
||||
ActionButtonType.trash =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote && //
|
||||
context.isTrashEnabled && //
|
||||
context.timelineOrigin != TimelineOrigin.trash,
|
||||
ActionButtonType.restoreTrash =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote && //
|
||||
context.timelineOrigin == TimelineOrigin.trash,
|
||||
ActionButtonType.deletePermanent =>
|
||||
context.isOwner && //
|
||||
context.asset.hasRemote && //
|
||||
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView),
|
||||
ActionButtonType.delete =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote,
|
||||
ActionButtonType.delete => true, //
|
||||
ActionButtonType.moveToLockFolder =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
@@ -141,7 +123,7 @@ enum ActionButtonType {
|
||||
context.asset.hasRemote,
|
||||
ActionButtonType.deleteLocal =>
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasLocal,
|
||||
context.asset.isMerged,
|
||||
ActionButtonType.upload =>
|
||||
!context.isInLockedView && //
|
||||
context.asset.storage == AssetState.local,
|
||||
@@ -186,84 +168,68 @@ enum ActionButtonType {
|
||||
}
|
||||
|
||||
Widget buildButton(
|
||||
ActionButtonContext context, [
|
||||
BuildContext? buildContext,
|
||||
ActionButtonContext context,
|
||||
BuildContext buildContext,
|
||||
|
||||
WidgetRef ref, [
|
||||
bool iconOnly = false,
|
||||
bool menuItem = false,
|
||||
]) {
|
||||
final assets = [context.asset];
|
||||
final scope = ActionScope.from(buildContext, ref);
|
||||
return switch (this) {
|
||||
ActionButtonType.advancedInfo => ActionMenuItemWidget(action: AssetDebugAction(assets: [context.asset])),
|
||||
ActionButtonType.share => ShareActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.shareLink => ShareLinkActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
ActionButtonType.advancedInfo => ActionMenuItemWidget(
|
||||
action: AssetDebugAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.slideshow => SlideshowActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.archive => ArchiveActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.unarchive => UnArchiveActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
ActionButtonType.share => ActionMenuItemWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.restoreTrash => RestoreActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
ActionButtonType.shareLink => ActionMenuItemWidget(
|
||||
action: ShareLinkAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.deletePermanent => DeletePermanentActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
ActionButtonType.slideshow => ActionMenuItemWidget(action: SlideshowAction(scope: scope)),
|
||||
ActionButtonType.archive || ActionButtonType.unarchive => ActionMenuItemWidget(
|
||||
action: ArchiveAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.delete => DeleteActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.moveToLockFolder => MoveToLockFolderActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
ActionButtonType.download => ActionMenuItemWidget(
|
||||
action: DownloadAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.removeFromLockFolder => RemoveFromLockFolderActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
ActionButtonType.restoreTrash => ActionMenuItemWidget(
|
||||
action: RestoreAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.deleteLocal => DeleteLocalActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
ActionButtonType.delete => ActionMenuItemWidget(
|
||||
action: DeleteAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.upload => UploadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.removeFromAlbum => RemoveFromAlbumActionButton(
|
||||
albumId: context.currentAlbum!.id,
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
ActionButtonType.moveToLockFolder => ActionMenuItemWidget(
|
||||
action: LockAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.setAlbumCover => SetAlbumCoverActionButton(
|
||||
albumId: context.currentAlbum!.id,
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
ActionButtonType.removeFromLockFolder => ActionMenuItemWidget(
|
||||
action: LockAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.deleteLocal => ActionMenuItemWidget(
|
||||
action: CleanupLocalAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.upload => ActionMenuItemWidget(
|
||||
action: UploadAction(assets: assets, scope: scope, showProgress: context.source == ActionSource.viewer),
|
||||
),
|
||||
ActionButtonType.removeFromAlbum => ActionMenuItemWidget(
|
||||
action: RemoveFromAlbumAction(assets: assets, albumId: context.currentAlbum!.id, scope: scope),
|
||||
),
|
||||
ActionButtonType.setAlbumCover => ActionMenuItemWidget(
|
||||
action: SetAlbumCoverAction(assets: assets, albumId: context.currentAlbum!.id, scope: scope),
|
||||
),
|
||||
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.openInBrowser => OpenInBrowserActionButton(
|
||||
remoteId: context.asset.remoteId!,
|
||||
origin: context.timelineOrigin,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
ActionButtonType.unstack => ActionMenuItemWidget(
|
||||
action: StackAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.similarPhotos => SimilarPhotosActionButton(
|
||||
assetId: (context.asset as RemoteAsset).id,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
ActionButtonType.openInBrowser => ActionMenuItemWidget(
|
||||
action: OpenInBrowserAction(remoteId: context.asset.remoteId!, origin: context.timelineOrigin, scope: scope),
|
||||
),
|
||||
ActionButtonType.setProfilePicture => SetProfilePictureActionButton(
|
||||
asset: context.asset,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
ActionButtonType.similarPhotos => ActionMenuItemWidget(
|
||||
action: SimilarPhotosAction(assetId: (context.asset as RemoteAsset).id, scope: scope),
|
||||
),
|
||||
ActionButtonType.setProfilePicture => ActionMenuItemWidget(
|
||||
action: SetProfilePictureAction(asset: context.asset, scope: scope),
|
||||
),
|
||||
ActionButtonType.openInfo => BaseActionButton(
|
||||
label: 'info'.tr(),
|
||||
@@ -276,14 +242,12 @@ enum ActionButtonType {
|
||||
iconData: Icons.image_search,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: buildContext == null
|
||||
? null
|
||||
: () async {
|
||||
await buildContext.router.navigate(const TabShellRoute(children: [MainTimelineRoute()]));
|
||||
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
|
||||
},
|
||||
onPressed: () async {
|
||||
await buildContext.router.navigate(const TabShellRoute(children: [MainTimelineRoute()]));
|
||||
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
|
||||
},
|
||||
),
|
||||
ActionButtonType.cast => CastActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.cast => ActionMenuItemWidget(action: CastAction(scope: scope)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -294,8 +258,6 @@ enum ActionButtonType {
|
||||
// 0: info
|
||||
ActionButtonType.openInfo => 0,
|
||||
// 10: move, remove, and delete
|
||||
ActionButtonType.trash => 10,
|
||||
ActionButtonType.deletePermanent => 10,
|
||||
ActionButtonType.removeFromLockFolder => 10,
|
||||
ActionButtonType.removeFromAlbum => 10,
|
||||
ActionButtonType.unstack => 10,
|
||||
@@ -323,14 +285,16 @@ class ActionButtonBuilder {
|
||||
ActionButtonType.archive,
|
||||
ActionButtonType.unarchive,
|
||||
ActionButtonType.restoreTrash,
|
||||
ActionButtonType.deletePermanent,
|
||||
};
|
||||
|
||||
static List<Widget> build(ActionButtonContext context) {
|
||||
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
|
||||
static List<Widget> build(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
|
||||
return _actionTypes
|
||||
.where((type) => type.shouldShow(context))
|
||||
.map((type) => type.buildButton(context, buildContext, ref))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) {
|
||||
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
|
||||
final visibleButtons = defaultViewerKebabMenuOrder
|
||||
.where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context))
|
||||
.toList();
|
||||
@@ -346,7 +310,7 @@ class ActionButtonBuilder {
|
||||
if (lastGroup != null && type.kebabMenuGroup != lastGroup) {
|
||||
result.add(const Divider(height: 1));
|
||||
}
|
||||
result.add(type.buildButton(context, buildContext, false, true));
|
||||
result.add(type.buildButton(context, buildContext, ref, false, true));
|
||||
lastGroup = type.kebabMenuGroup;
|
||||
}
|
||||
|
||||
|
||||
24
mobile/lib/utils/asset_filter.dart
Normal file
24
mobile/lib/utils/asset_filter.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
extension type const AssetFilter<T extends BaseAsset>(Iterable<T> assets) implements Iterable<T> {
|
||||
AssetFilter<T> where(bool Function(T asset) test) => AssetFilter(assets.where(test));
|
||||
AssetFilter<T> whereNot(bool Function(T asset) test) => AssetFilter(assets.where((asset) => !test(asset)));
|
||||
|
||||
AssetFilter<T> type(AssetType type) => where((asset) => asset.type == type);
|
||||
AssetFilter<T> favorite({bool isFavorite = true}) => where((asset) => asset.isFavorite == isFavorite);
|
||||
|
||||
AssetFilter<RemoteAsset> remote() => AssetFilter(assets.whereType<RemoteAsset>());
|
||||
AssetFilter<RemoteAsset> owned(String ownerId) => remote().where((asset) => asset.ownerId == ownerId);
|
||||
AssetFilter<RemoteAsset> visibility(AssetVisibility visibility) =>
|
||||
remote().where((asset) => asset.visibility == visibility);
|
||||
AssetFilter<RemoteAsset> notVisibility(AssetVisibility visibility) =>
|
||||
remote().where((asset) => asset.visibility != visibility);
|
||||
AssetFilter<RemoteAsset> archived({bool isArchived = true}) =>
|
||||
remote().where((asset) => asset.isArchived == isArchived);
|
||||
AssetFilter<RemoteAsset> locked({bool isLocked = true}) => remote().where((asset) => asset.isLocked == isLocked);
|
||||
AssetFilter<RemoteAsset> stacked({bool isStacked = true}) => remote().where((asset) => asset.isStacked == isStacked);
|
||||
AssetFilter<RemoteAsset> trashed({bool isTrashed = true}) => remote().where((asset) => asset.isTrashed == isTrashed);
|
||||
|
||||
AssetFilter<LocalAsset> local() => AssetFilter(assets.whereType<LocalAsset>());
|
||||
AssetFilter<BaseAsset> backedUp({bool isBackedUp = true}) => where((asset) => asset.isMerged == isBackedUp);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import 'package:openapi/api.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:stack_trace/stack_trace.dart';
|
||||
|
||||
void handleError(BuildContext context, Object error, {StackTrace? stack, String? description}) {
|
||||
void handleError(Object error, {StackTrace? stack, String? description}) {
|
||||
String? stackTrace;
|
||||
if (stack != null) {
|
||||
final trace = Trace.from(stack);
|
||||
@@ -23,17 +23,13 @@ void handleError(BuildContext context, Object error, {StackTrace? stack, String?
|
||||
() => 'Error${description != null ? ' ($description)' : ''}: $error${stackTrace != null ? '\n$stackTrace' : ''}',
|
||||
);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final String message;
|
||||
if (serverErrorMessage(error) case String serverMessage) {
|
||||
message = serverMessage;
|
||||
} else if (isConnectionError(error)) {
|
||||
message = context.t.login_form_server_error;
|
||||
message = StaticTranslations.instance.login_form_server_error;
|
||||
} else {
|
||||
message = context.t.scaffold_body_error_occurred;
|
||||
message = StaticTranslations.instance.scaffold_body_error_occurred;
|
||||
}
|
||||
|
||||
snackbar.error(message);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:openapi/api.dart' show Optional;
|
||||
|
||||
sealed class Option<T> {
|
||||
@@ -21,6 +22,11 @@ sealed class Option<T> {
|
||||
None() => null,
|
||||
};
|
||||
|
||||
Option<U> map<U>(U Function(T value) f) => switch (this) {
|
||||
Some(:final value) => Some(f(value)),
|
||||
None() => None<U>(),
|
||||
};
|
||||
|
||||
U fold<U>(U Function(T value) onSome, U Function() onNone) => switch (this) {
|
||||
Some(:final value) => onSome(value),
|
||||
None() => onNone(),
|
||||
@@ -65,3 +71,10 @@ extension OptionToOptional<T> on Option<T> {
|
||||
Some(:final value) => Optional.present(value),
|
||||
};
|
||||
}
|
||||
|
||||
extension OptionToDriftValue<T> on Option<T> {
|
||||
Value<T> toDriftValue() => switch (this) {
|
||||
Some(:final value) => Value(value),
|
||||
None() => const Value.absent(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ class ImmichColumnButton extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final FutureOr<void> Function() onPressed;
|
||||
final FutureOr<void> Function()? onLongPress;
|
||||
final bool disabled;
|
||||
final bool? loading;
|
||||
|
||||
@@ -16,6 +17,7 @@ class ImmichColumnButton extends StatefulWidget {
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.onLongPress,
|
||||
this.disabled = false,
|
||||
this.loading,
|
||||
});
|
||||
@@ -28,10 +30,10 @@ class _ImmichColumnButtonState extends State<ImmichColumnButton> {
|
||||
bool _loading = false;
|
||||
bool get _isLoading => widget.loading ?? _loading;
|
||||
|
||||
Future<void> _onPressed() async {
|
||||
Future<void> _run(FutureOr<void> Function() action) async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
await widget.onPressed();
|
||||
await action();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
@@ -42,9 +44,12 @@ class _ImmichColumnButtonState extends State<ImmichColumnButton> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final foreground = context.colorOverride ?? Theme.of(context).colorScheme.onSurface;
|
||||
final handlerDisabled = widget.disabled || _isLoading;
|
||||
final onLongPress = widget.onLongPress;
|
||||
|
||||
return TextButton(
|
||||
onPressed: widget.disabled || _isLoading ? null : _onPressed,
|
||||
onPressed: handlerDisabled ? null : () => _run(widget.onPressed),
|
||||
onLongPress: handlerDisabled || onLongPress == null ? null : () => _run(onLongPress),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: foreground,
|
||||
padding: const .symmetric(horizontal: ImmichSpacing.sm, vertical: ImmichSpacing.md),
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:immich_ui/src/internal.dart';
|
||||
class ImmichIconButton extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final FutureOr<void> Function() onPressed;
|
||||
final FutureOr<void> Function()? onLongPress;
|
||||
final ImmichVariant variant;
|
||||
final ImmichColor color;
|
||||
final bool disabled;
|
||||
@@ -16,6 +17,7 @@ class ImmichIconButton extends StatefulWidget {
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
this.onLongPress,
|
||||
this.color = .primary,
|
||||
this.variant = .filled,
|
||||
this.disabled = false,
|
||||
@@ -30,10 +32,10 @@ class _ImmichIconButtonState extends State<ImmichIconButton> {
|
||||
bool _loading = false;
|
||||
bool get _isLoading => widget.loading ?? _loading;
|
||||
|
||||
Future<void> _onPressed() async {
|
||||
Future<void> _run(FutureOr<void> Function() action) async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
await widget.onPressed();
|
||||
await action();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
@@ -66,6 +68,9 @@ class _ImmichIconButtonState extends State<ImmichIconButton> {
|
||||
},
|
||||
};
|
||||
|
||||
final handlerDisabled = widget.disabled || _isLoading;
|
||||
final onLongPress = widget.onLongPress;
|
||||
|
||||
return IconButton(
|
||||
icon: _isLoading
|
||||
? const SizedBox.square(
|
||||
@@ -73,7 +78,8 @@ class _ImmichIconButtonState extends State<ImmichIconButton> {
|
||||
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.md),
|
||||
)
|
||||
: Icon(widget.icon),
|
||||
onPressed: widget.disabled || _isLoading ? null : _onPressed,
|
||||
onPressed: handlerDisabled ? null : () => _run(widget.onPressed),
|
||||
onLongPress: handlerDisabled || onLongPress == null ? null : () => _run(onLongPress),
|
||||
style: IconButton.styleFrom(backgroundColor: background, foregroundColor: foreground),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ class ImmichTextButton extends StatefulWidget {
|
||||
final String labelText;
|
||||
final IconData? icon;
|
||||
final FutureOr<void> Function() onPressed;
|
||||
final FutureOr<void> Function()? onLongPress;
|
||||
final ImmichVariant variant;
|
||||
final bool expanded;
|
||||
final bool disabled;
|
||||
@@ -17,6 +18,7 @@ class ImmichTextButton extends StatefulWidget {
|
||||
required this.labelText,
|
||||
this.icon,
|
||||
required this.onPressed,
|
||||
this.onLongPress,
|
||||
this.variant = .filled,
|
||||
this.expanded = true,
|
||||
|
||||
@@ -32,10 +34,10 @@ class _ImmichTextButtonState extends State<ImmichTextButton> {
|
||||
bool _loading = false;
|
||||
bool get _isLoading => widget.loading ?? _loading;
|
||||
|
||||
Future<void> _onPressed() async {
|
||||
Future<void> _run(FutureOr<void> Function() action) async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
await widget.onPressed();
|
||||
await action();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
@@ -59,11 +61,26 @@ class _ImmichTextButtonState extends State<ImmichTextButton> {
|
||||
style: const .new(fontSize: ImmichTextSize.body, fontWeight: .bold),
|
||||
);
|
||||
final style = ElevatedButton.styleFrom(padding: const .symmetric(vertical: ImmichSpacing.md));
|
||||
final onPressed = widget.disabled || _isLoading ? null : _onPressed;
|
||||
final handlerDisabled = widget.disabled || _isLoading;
|
||||
final longPress = widget.onLongPress;
|
||||
final onPressed = handlerDisabled ? null : () => _run(widget.onPressed);
|
||||
final onLongPress = handlerDisabled || longPress == null ? null : () => _run(longPress);
|
||||
|
||||
final button = switch (widget.variant) {
|
||||
ImmichVariant.filled => ElevatedButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
|
||||
ImmichVariant.ghost => TextButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
|
||||
ImmichVariant.filled => ElevatedButton.icon(
|
||||
style: style,
|
||||
onPressed: onPressed,
|
||||
onLongPress: onLongPress,
|
||||
icon: icon,
|
||||
label: label,
|
||||
),
|
||||
ImmichVariant.ghost => TextButton.icon(
|
||||
style: style,
|
||||
onPressed: onPressed,
|
||||
onLongPress: onLongPress,
|
||||
icon: icon,
|
||||
label: label,
|
||||
),
|
||||
};
|
||||
|
||||
if (widget.expanded) {
|
||||
|
||||
@@ -6,18 +6,23 @@ final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
class SnackbarManager {
|
||||
const SnackbarManager();
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(String message, SnackbarType type) {
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(
|
||||
String message,
|
||||
SnackbarType type, {
|
||||
Duration? duration,
|
||||
}) {
|
||||
final messenger = scaffoldMessengerKey.currentState;
|
||||
final context = scaffoldMessengerKey.currentContext;
|
||||
if (messenger == null || context == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
duration ??= const .new(seconds: 4);
|
||||
messenger.hideCurrentSnackBar();
|
||||
return messenger.showSnackBar(_build(context, message, type));
|
||||
return messenger.showSnackBar(_build(context, message, type, duration));
|
||||
}
|
||||
|
||||
SnackBar _build(BuildContext context, String message, SnackbarType type) {
|
||||
SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.extension<ImmichColors>() ?? ImmichColors.harmonized(theme.colorScheme);
|
||||
final (IconData icon, Color background, Color foreground) = switch (type) {
|
||||
@@ -29,7 +34,7 @@ class SnackbarManager {
|
||||
return SnackBar(
|
||||
behavior: .floating,
|
||||
backgroundColor: background,
|
||||
duration: const .new(seconds: 4),
|
||||
duration: duration,
|
||||
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))),
|
||||
content: Row(
|
||||
children: [
|
||||
@@ -48,11 +53,14 @@ class SnackbarManager {
|
||||
);
|
||||
}
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message) => show(message, .info);
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message, {Duration? duration}) =>
|
||||
show(message, .info, duration: duration);
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message) => show(message, .success);
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message, {Duration? duration}) =>
|
||||
show(message, .success, duration: duration);
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message) => show(message, .error);
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message, {Duration? duration}) =>
|
||||
show(message, .error, duration: duration);
|
||||
}
|
||||
|
||||
const snackbar = SnackbarManager();
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/domain/services/partner.service.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/cleanup.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/gcast.service.dart';
|
||||
import 'package:immich_mobile/services/server_info.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockStoreService extends Mock implements StoreService {}
|
||||
@@ -20,3 +25,13 @@ class MockPartnerService extends Mock implements PartnerService {}
|
||||
class MockAssetService extends Mock implements AssetService {}
|
||||
|
||||
class MockUserService extends Mock implements UserService {}
|
||||
|
||||
class MockRemoteAlbumService extends Mock implements RemoteAlbumService {}
|
||||
|
||||
class MockGCastService extends Mock implements GCastService {}
|
||||
|
||||
class MockForegroundUploadService extends Mock implements ForegroundUploadService {}
|
||||
|
||||
class MockServerInfoService extends Mock implements ServerInfoService {}
|
||||
|
||||
class MockCleanupService extends Mock implements CleanupService {}
|
||||
|
||||
@@ -3,9 +3,10 @@ import 'package:immich_mobile/infrastructure/repositories/local_album.repository
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||
@@ -15,6 +16,7 @@ import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.re
|
||||
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/repositories/toast.repository.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
@@ -50,6 +52,10 @@ class MockUserRepository extends Mock implements UserRepository {}
|
||||
|
||||
class MockPartnerRepository extends Mock implements PartnerRepository {}
|
||||
|
||||
class MockToastRepository extends Mock implements ToastRepository {}
|
||||
|
||||
class MockRemoteExifRepository extends Mock implements RemoteExifRepository {}
|
||||
|
||||
// API Repos
|
||||
class MockUserApiRepository extends Mock implements UserApiRepository {}
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/action.service.dart';
|
||||
import 'package:immich_mobile/services/download.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockActionService extends Mock implements ActionService {}
|
||||
|
||||
class MockAssetService extends Mock implements AssetService {}
|
||||
|
||||
class MockDownloadService extends Mock implements DownloadService {}
|
||||
|
||||
class MockForegroundUploadService extends Mock implements ForegroundUploadService {}
|
||||
|
||||
class MockUserService extends Mock implements UserService {}
|
||||
|
||||
class FakeBuildContext extends Fake implements BuildContext {}
|
||||
|
||||
final _user = UserDto(id: 'user-1', email: 'user@test.dev', name: 'user', profileChangedAt: DateTime(2026));
|
||||
|
||||
final _asset = RemoteAsset(
|
||||
id: 'asset-1',
|
||||
name: 'photo.jpg',
|
||||
ownerId: 'user-1',
|
||||
checksum: 'checksum-1',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2026, 6, 10, 10, 27),
|
||||
updatedAt: DateTime(2026, 6, 10, 10, 27),
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
void main() {
|
||||
late ProviderContainer container;
|
||||
late MockActionService actionService;
|
||||
late MockAssetService assetService;
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(FakeBuildContext());
|
||||
registerFallbackValue(_asset);
|
||||
registerFallbackValue(<String>[]);
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
actionService = MockActionService();
|
||||
assetService = MockAssetService();
|
||||
final userService = MockUserService();
|
||||
|
||||
when(() => actionService.editDateTime(any(), any())).thenAnswer((_) async => true);
|
||||
when(() => assetService.watchAsset(any())).thenAnswer((_) => const Stream.empty());
|
||||
when(() => assetService.getExif(any())).thenAnswer((_) async => null);
|
||||
when(() => userService.tryGetMyUser()).thenReturn(_user);
|
||||
when(() => userService.watchMyUser()).thenAnswer((_) => const Stream.empty());
|
||||
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
actionServiceProvider.overrideWithValue(actionService),
|
||||
assetServiceProvider.overrideWithValue(assetService),
|
||||
downloadServiceProvider.overrideWithValue(MockDownloadService()),
|
||||
foregroundUploadServiceProvider.overrideWithValue(MockForegroundUploadService()),
|
||||
currentUserProvider.overrideWith((ref) => CurrentUserProvider(userService)),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
});
|
||||
|
||||
group('editDateTime', () {
|
||||
test('refreshes the exif provider when editing from the viewer', () async {
|
||||
container.read(assetViewerProvider.notifier).setAsset(_asset);
|
||||
container.listen(assetExifProvider(_asset), (_, __) {});
|
||||
await container.read(assetExifProvider(_asset).future);
|
||||
|
||||
final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.viewer, FakeBuildContext());
|
||||
|
||||
expect(result?.success, isTrue);
|
||||
await container.read(assetExifProvider(_asset).future);
|
||||
verify(() => assetService.getExif(_asset)).called(2);
|
||||
});
|
||||
|
||||
test('leaves the exif provider cached when editing from the timeline', () async {
|
||||
container.read(assetViewerProvider.notifier).setAsset(_asset);
|
||||
container.listen(assetExifProvider(_asset), (_, __) {});
|
||||
await container.read(assetExifProvider(_asset).future);
|
||||
|
||||
final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.timeline, FakeBuildContext());
|
||||
|
||||
expect(result?.success, isTrue);
|
||||
await container.read(assetExifProvider(_asset).future);
|
||||
verify(() => assetService.getExif(_asset)).called(1);
|
||||
});
|
||||
|
||||
test('does not refresh the exif provider when the edit is cancelled', () async {
|
||||
when(() => actionService.editDateTime(any(), any())).thenAnswer((_) async => false);
|
||||
container.read(assetViewerProvider.notifier).setAsset(_asset);
|
||||
container.listen(assetExifProvider(_asset), (_, __) {});
|
||||
await container.read(assetExifProvider(_asset).future);
|
||||
|
||||
final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.viewer, FakeBuildContext());
|
||||
|
||||
expect(result, isNull);
|
||||
await container.read(assetExifProvider(_asset).future);
|
||||
verify(() => assetService.getExif(_asset)).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/auth.repository.dart';
|
||||
import 'package:immich_mobile/repositories/auth_api.repository.dart';
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/repositories/download.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
@@ -10,6 +11,8 @@ class MockAssetApiRepository extends Mock implements AssetApiRepository {}
|
||||
|
||||
class MockAssetMediaRepository extends Mock implements AssetMediaRepository {}
|
||||
|
||||
class MockDownloadRepository extends Mock implements DownloadRepository {}
|
||||
|
||||
class MockPermissionRepository extends Mock implements IPermissionRepository {}
|
||||
|
||||
class MockAuthApiRepository extends Mock implements AuthApiRepository {}
|
||||
|
||||
@@ -2,32 +2,21 @@ import 'package:drift/drift.dart' as drift;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/repositories/download.repository.dart';
|
||||
import 'package:immich_mobile/services/action.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
|
||||
class MockDownloadRepository extends Mock implements DownloadRepository {}
|
||||
|
||||
void main() {
|
||||
late ActionService sut;
|
||||
|
||||
late MockAssetApiRepository assetApiRepository;
|
||||
late MockRemoteAssetRepository remoteAssetRepository;
|
||||
late MockDriftLocalAssetRepository localAssetRepository;
|
||||
late MockDriftAlbumApiRepository albumApiRepository;
|
||||
late MockRemoteAlbumRepository remoteAlbumRepository;
|
||||
late MockTrashedLocalAssetRepository trashedLocalAssetRepository;
|
||||
late MockAssetMediaRepository assetMediaRepository;
|
||||
late MockDownloadRepository downloadRepository;
|
||||
late MockTagService tagService;
|
||||
|
||||
late Drift db;
|
||||
|
||||
@@ -48,25 +37,8 @@ void main() {
|
||||
setUp(() {
|
||||
assetApiRepository = MockAssetApiRepository();
|
||||
remoteAssetRepository = MockRemoteAssetRepository();
|
||||
localAssetRepository = MockDriftLocalAssetRepository();
|
||||
albumApiRepository = MockDriftAlbumApiRepository();
|
||||
remoteAlbumRepository = MockRemoteAlbumRepository();
|
||||
trashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
||||
assetMediaRepository = MockAssetMediaRepository();
|
||||
downloadRepository = MockDownloadRepository();
|
||||
tagService = MockTagService();
|
||||
|
||||
sut = ActionService(
|
||||
assetApiRepository,
|
||||
remoteAssetRepository,
|
||||
localAssetRepository,
|
||||
albumApiRepository,
|
||||
remoteAlbumRepository,
|
||||
trashedLocalAssetRepository,
|
||||
assetMediaRepository,
|
||||
downloadRepository,
|
||||
tagService,
|
||||
);
|
||||
sut = ActionService(assetApiRepository, remoteAssetRepository);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
@@ -98,93 +70,4 @@ void main() {
|
||||
verify(() => remoteAssetRepository.updateRating(assetId, null)).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('ActionService.applyDateTime', () {
|
||||
const ids = ['asset_id_1'];
|
||||
|
||||
test('sends the picked value to the api with its offset intact', () async {
|
||||
const picked = '2026-06-10T19:15:00.000+06:00';
|
||||
when(() => assetApiRepository.updateDateTime(ids, picked)).thenAnswer((_) async {});
|
||||
when(
|
||||
() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC+06:00'),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
await sut.applyDateTime(ids, picked);
|
||||
|
||||
verify(() => assetApiRepository.updateDateTime(ids, picked)).called(1);
|
||||
verify(() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC+06:00')).called(1);
|
||||
});
|
||||
|
||||
test('handles negative offsets', () async {
|
||||
const picked = '2026-01-05T08:00:00.000-05:30';
|
||||
when(() => assetApiRepository.updateDateTime(ids, picked)).thenAnswer((_) async {});
|
||||
when(
|
||||
() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC-05:30'),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
await sut.applyDateTime(ids, picked);
|
||||
|
||||
verify(() => assetApiRepository.updateDateTime(ids, picked)).called(1);
|
||||
verify(() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC-05:30')).called(1);
|
||||
});
|
||||
|
||||
test('writes no timezone when the value has no offset', () async {
|
||||
const picked = '2026-06-10T13:15:00.000Z';
|
||||
when(() => assetApiRepository.updateDateTime(ids, picked)).thenAnswer((_) async {});
|
||||
when(
|
||||
() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: null),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
await sut.applyDateTime(ids, picked);
|
||||
|
||||
verify(() => assetApiRepository.updateDateTime(ids, picked)).called(1);
|
||||
verify(() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: null)).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('ActionService.deleteLocal', () {
|
||||
test('routes deleted ids to trashed repository when Android trash handling is enabled', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
const ids = ['a', 'b'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
|
||||
when(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).thenAnswer((_) async {});
|
||||
|
||||
final result = await sut.deleteLocal(ids);
|
||||
|
||||
expect(result, ids.length);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verify(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).called(1);
|
||||
verifyNever(() => localAssetRepository.delete(any()));
|
||||
});
|
||||
|
||||
test('deletes locally when Android trash handling is disabled', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
const ids = ['c'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
|
||||
when(() => localAssetRepository.delete(ids)).thenAnswer((_) async {});
|
||||
|
||||
final result = await sut.deleteLocal(ids);
|
||||
|
||||
expect(result, ids.length);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verify(() => localAssetRepository.delete(ids)).called(1);
|
||||
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
|
||||
});
|
||||
|
||||
test('short-circuits when nothing was deleted', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
const ids = ['x'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => <String>[]);
|
||||
|
||||
final result = await sut.deleteLocal(ids);
|
||||
|
||||
expect(result, 0);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
|
||||
verifyNever(() => localAssetRepository.delete(any()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import '../../utils.dart';
|
||||
class LocalAssetFactory {
|
||||
const LocalAssetFactory();
|
||||
|
||||
static LocalAsset create({String? id, String? name}) {
|
||||
static LocalAsset create({String? id, String? name, String? remoteId}) {
|
||||
id = TestUtils.uuid(id);
|
||||
|
||||
return LocalAsset(
|
||||
id: id,
|
||||
name: name ?? 'local_$id.jpg',
|
||||
remoteId: remoteId,
|
||||
type: AssetType.image,
|
||||
createdAt: TestUtils.yesterday(),
|
||||
updatedAt: TestUtils.now(),
|
||||
|
||||
38
mobile/test/unit/factories/remote_album_factory.dart
Normal file
38
mobile/test/unit/factories/remote_album_factory.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
|
||||
import '../../utils.dart';
|
||||
|
||||
class RemoteAlbumFactory {
|
||||
const RemoteAlbumFactory();
|
||||
|
||||
static RemoteAlbum create({
|
||||
String? id,
|
||||
String? name,
|
||||
String? ownerId,
|
||||
String? description,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
String? thumbnailAssetId,
|
||||
bool isActivityEnabled = false,
|
||||
AlbumAssetOrder order = AlbumAssetOrder.desc,
|
||||
int assetCount = 0,
|
||||
String? ownerName,
|
||||
bool isShared = false,
|
||||
}) {
|
||||
id = TestUtils.uuid(id);
|
||||
return RemoteAlbum(
|
||||
id: id,
|
||||
name: name ?? 'remote_album_$id',
|
||||
ownerId: TestUtils.uuid(ownerId),
|
||||
description: description ?? '',
|
||||
createdAt: TestUtils.date(createdAt),
|
||||
updatedAt: TestUtils.date(updatedAt),
|
||||
thumbnailAssetId: thumbnailAssetId,
|
||||
isActivityEnabled: isActivityEnabled,
|
||||
order: order,
|
||||
assetCount: assetCount,
|
||||
ownerName: ownerName ?? 'owner_$id',
|
||||
isShared: isShared,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user