mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat: manual stack assets (#4198)
This commit is contained in:
parent
5ead4af2dc
commit
cf08ac7538
59 changed files with 2190 additions and 138 deletions
47
mobile/lib/modules/home/models/selection_state.dart
Normal file
47
mobile/lib/modules/home/models/selection_state.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
|
||||
class SelectionAssetState {
|
||||
final bool hasRemote;
|
||||
final bool hasLocal;
|
||||
final bool hasMerged;
|
||||
|
||||
const SelectionAssetState({
|
||||
this.hasRemote = false,
|
||||
this.hasLocal = false,
|
||||
this.hasMerged = false,
|
||||
});
|
||||
|
||||
SelectionAssetState copyWith({
|
||||
bool? hasRemote,
|
||||
bool? hasLocal,
|
||||
bool? hasMerged,
|
||||
}) {
|
||||
return SelectionAssetState(
|
||||
hasRemote: hasRemote ?? this.hasRemote,
|
||||
hasLocal: hasLocal ?? this.hasLocal,
|
||||
hasMerged: hasMerged ?? this.hasMerged,
|
||||
);
|
||||
}
|
||||
|
||||
SelectionAssetState.fromSelection(Set<Asset> selection)
|
||||
: hasLocal = selection.any((e) => e.storage == AssetState.local),
|
||||
hasMerged = selection.any((e) => e.storage == AssetState.merged),
|
||||
hasRemote = selection.any((e) => e.storage == AssetState.remote);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SelectionAssetState(hasRemote: $hasRemote, hasMerged: $hasMerged, hasMerged: $hasMerged)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant SelectionAssetState other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.hasRemote == hasRemote &&
|
||||
other.hasLocal == hasLocal &&
|
||||
other.hasMerged == hasMerged;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
hasRemote.hashCode ^ hasLocal.hashCode ^ hasMerged.hashCode;
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
|||
final Widget? topWidget;
|
||||
final bool shrinkWrap;
|
||||
final bool showDragScroll;
|
||||
final bool showStack;
|
||||
|
||||
const ImmichAssetGrid({
|
||||
super.key,
|
||||
|
|
@ -51,6 +52,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
|||
this.topWidget,
|
||||
this.shrinkWrap = false,
|
||||
this.showDragScroll = true,
|
||||
this.showStack = false,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -114,6 +116,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
|||
heroOffset: heroOffset(),
|
||||
shrinkWrap: shrinkWrap,
|
||||
showDragScroll: showDragScroll,
|
||||
showStack: showStack,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ class ImmichAssetGridView extends StatefulWidget {
|
|||
final int heroOffset;
|
||||
final bool shrinkWrap;
|
||||
final bool showDragScroll;
|
||||
final bool showStack;
|
||||
|
||||
const ImmichAssetGridView({
|
||||
super.key,
|
||||
|
|
@ -56,6 +57,7 @@ class ImmichAssetGridView extends StatefulWidget {
|
|||
this.heroOffset = 0,
|
||||
this.shrinkWrap = false,
|
||||
this.showDragScroll = true,
|
||||
this.showStack = false,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -71,7 +73,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||
|
||||
bool _scrolling = false;
|
||||
final Set<Asset> _selectedAssets =
|
||||
HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
|
||||
LinkedHashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
|
||||
|
||||
Set<Asset> _getSelectedAssets() {
|
||||
return Set.from(_selectedAssets);
|
||||
|
|
@ -90,7 +92,13 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||
|
||||
void _deselectAssets(List<Asset> assets) {
|
||||
setState(() {
|
||||
_selectedAssets.removeAll(assets);
|
||||
_selectedAssets.removeAll(
|
||||
assets.where(
|
||||
(a) =>
|
||||
widget.canDeselect ||
|
||||
!(widget.preselectedAssets?.contains(a) ?? false),
|
||||
),
|
||||
);
|
||||
_callSelectionListener(_selectedAssets.isNotEmpty);
|
||||
});
|
||||
}
|
||||
|
|
@ -129,6 +137,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||
useGrayBoxPlaceholder: true,
|
||||
showStorageIndicator: widget.showStorageIndicator,
|
||||
heroOffset: widget.heroOffset,
|
||||
showStack: widget.showStack,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -377,10 +386,6 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||
setState(() {
|
||||
_selectedAssets.clear();
|
||||
});
|
||||
} else if (widget.preselectedAssets != null) {
|
||||
setState(() {
|
||||
_selectedAssets.addAll(widget.preselectedAssets!);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ class ThumbnailImage extends StatelessWidget {
|
|||
final Asset Function(int index) loadAsset;
|
||||
final int totalAssets;
|
||||
final bool showStorageIndicator;
|
||||
final bool showStack;
|
||||
final bool useGrayBoxPlaceholder;
|
||||
final bool isSelected;
|
||||
final bool multiselectEnabled;
|
||||
|
|
@ -26,6 +27,7 @@ class ThumbnailImage extends StatelessWidget {
|
|||
required this.loadAsset,
|
||||
required this.totalAssets,
|
||||
this.showStorageIndicator = true,
|
||||
this.showStack = false,
|
||||
this.useGrayBoxPlaceholder = false,
|
||||
this.isSelected = false,
|
||||
this.multiselectEnabled = false,
|
||||
|
|
@ -93,6 +95,35 @@ class ThumbnailImage extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget buildStackIcon() {
|
||||
return Positioned(
|
||||
top: 5,
|
||||
right: 5,
|
||||
child: Row(
|
||||
children: [
|
||||
if (asset.stackCount > 1)
|
||||
Text(
|
||||
"${asset.stackCount}",
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (asset.stackCount > 1)
|
||||
const SizedBox(
|
||||
width: 3,
|
||||
),
|
||||
const Icon(
|
||||
Icons.burst_mode_rounded,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildImage() {
|
||||
final image = SizedBox(
|
||||
width: 300,
|
||||
|
|
@ -113,9 +144,9 @@ class ThumbnailImage extends StatelessWidget {
|
|||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
width: 0,
|
||||
color: assetContainerColor,
|
||||
color: onDeselect == null ? Colors.grey : assetContainerColor,
|
||||
),
|
||||
color: assetContainerColor,
|
||||
color: onDeselect == null ? Colors.grey : assetContainerColor,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
|
|
@ -144,6 +175,7 @@ class ThumbnailImage extends StatelessWidget {
|
|||
loadAsset: loadAsset,
|
||||
totalAssets: totalAssets,
|
||||
heroOffset: heroOffset,
|
||||
showStack: showStack,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -196,6 +228,7 @@ class ThumbnailImage extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
if (!asset.isImage) buildVideoIcon(),
|
||||
if (asset.isImage && asset.stackCount > 0) buildStackIcon(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import 'package:easy_localization/easy_localization.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
|
||||
import 'package:immich_mobile/modules/home/models/selection_state.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
|
|
@ -19,11 +19,12 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||
final Function(Album album) onAddToAlbum;
|
||||
final void Function() onCreateNewAlbum;
|
||||
final void Function() onUpload;
|
||||
final void Function() onStack;
|
||||
|
||||
final List<Album> albums;
|
||||
final List<Album> sharedAlbums;
|
||||
final bool enabled;
|
||||
final AssetState selectionAssetState;
|
||||
final SelectionAssetState selectionAssetState;
|
||||
|
||||
const ControlBottomAppBar({
|
||||
Key? key,
|
||||
|
|
@ -36,19 +37,24 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||
required this.onAddToAlbum,
|
||||
required this.onCreateNewAlbum,
|
||||
required this.onUpload,
|
||||
this.selectionAssetState = AssetState.remote,
|
||||
required this.onStack,
|
||||
this.selectionAssetState = const SelectionAssetState(),
|
||||
this.enabled = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
var hasRemote = selectionAssetState == AssetState.remote;
|
||||
var hasRemote =
|
||||
selectionAssetState.hasRemote || selectionAssetState.hasMerged;
|
||||
var hasLocal = selectionAssetState.hasLocal;
|
||||
final trashEnabled =
|
||||
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
|
||||
|
||||
Widget renderActionButtons() {
|
||||
return Row(
|
||||
return Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 15,
|
||||
children: [
|
||||
ControlBoxButton(
|
||||
iconData: Platform.isAndroid
|
||||
|
|
@ -92,7 +98,7 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||
if (!hasRemote)
|
||||
ControlBoxButton(
|
||||
iconData: Icons.backup_outlined,
|
||||
label: "Upload",
|
||||
label: "control_bottom_app_bar_upload".tr(),
|
||||
onPressed: enabled
|
||||
? () => showDialog(
|
||||
context: context,
|
||||
|
|
@ -104,6 +110,12 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||
)
|
||||
: null,
|
||||
),
|
||||
if (!hasLocal)
|
||||
ControlBoxButton(
|
||||
iconData: Icons.filter_none_rounded,
|
||||
label: "control_bottom_app_bar_stack".tr(),
|
||||
onPressed: enabled ? onStack : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -111,7 +123,7 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||
return DraggableScrollableSheet(
|
||||
initialChildSize: hasRemote ? 0.30 : 0.18,
|
||||
minChildSize: 0.18,
|
||||
maxChildSize: hasRemote ? 0.57 : 0.18,
|
||||
maxChildSize: hasRemote ? 0.60 : 0.18,
|
||||
snap: true,
|
||||
builder: (
|
||||
BuildContext context,
|
||||
|
|
|
|||
|
|
@ -7,11 +7,15 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/models/selection_state.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
|
||||
|
|
@ -36,7 +40,7 @@ class HomePage extends HookConsumerWidget {
|
|||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final multiselectEnabled = ref.watch(multiselectProvider.notifier);
|
||||
final selectionEnabledHook = useState(false);
|
||||
final selectionAssetState = useState(AssetState.remote);
|
||||
final selectionAssetState = useState(const SelectionAssetState());
|
||||
|
||||
final selection = useState(<Asset>{});
|
||||
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
|
||||
|
|
@ -83,9 +87,8 @@ class HomePage extends HookConsumerWidget {
|
|||
) {
|
||||
selectionEnabledHook.value = multiselect;
|
||||
selection.value = selectedAssets;
|
||||
selectionAssetState.value = selectedAssets.any((e) => e.isRemote)
|
||||
? AssetState.remote
|
||||
: AssetState.local;
|
||||
selectionAssetState.value =
|
||||
SelectionAssetState.fromSelection(selectedAssets);
|
||||
}
|
||||
|
||||
void onShareAssets() {
|
||||
|
|
@ -246,6 +249,55 @@ class HomePage extends HookConsumerWidget {
|
|||
}
|
||||
}
|
||||
|
||||
void onStack() async {
|
||||
try {
|
||||
processing.value = true;
|
||||
if (!selectionEnabledHook.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
final selectedAsset = selection.value.elementAt(0);
|
||||
|
||||
if (selection.value.length == 1) {
|
||||
final stackChildren =
|
||||
(await ref.read(assetStackProvider(selectedAsset).future))
|
||||
.toSet();
|
||||
AssetSelectionPageResult? returnPayload =
|
||||
await AutoRouter.of(context).push<AssetSelectionPageResult?>(
|
||||
AssetSelectionRoute(
|
||||
existingAssets: stackChildren,
|
||||
canDeselect: true,
|
||||
query: getAssetStackSelectionQuery(ref, selectedAsset),
|
||||
),
|
||||
);
|
||||
|
||||
if (returnPayload != null) {
|
||||
Set<Asset> selectedAssets = returnPayload.selectedAssets;
|
||||
// Do not add itself as its stack child
|
||||
selectedAssets.remove(selectedAsset);
|
||||
final removedChildren = stackChildren.difference(selectedAssets);
|
||||
final addedChildren = selectedAssets.difference(stackChildren);
|
||||
await ref.read(assetStackServiceProvider).updateStack(
|
||||
selectedAsset,
|
||||
childrenToAdd: addedChildren.toList(),
|
||||
childrenToRemove: removedChildren.toList(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Merge assets
|
||||
selection.value.remove(selectedAsset);
|
||||
final selectedAssets = selection.value;
|
||||
await ref.read(assetStackServiceProvider).updateStack(
|
||||
selectedAsset,
|
||||
childrenToAdd: selectedAssets.toList(),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
processing.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshAssets() async {
|
||||
final fullRefresh = refreshCount.value > 0;
|
||||
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
|
||||
|
|
@ -322,6 +374,7 @@ class HomePage extends HookConsumerWidget {
|
|||
currentUser.memoryEnabled!)
|
||||
? const MemoryLane()
|
||||
: const SizedBox(),
|
||||
showStack: true,
|
||||
),
|
||||
error: (error, _) => Center(child: Text(error.toString())),
|
||||
loading: buildLoadingIndicator,
|
||||
|
|
@ -339,6 +392,7 @@ class HomePage extends HookConsumerWidget {
|
|||
onUpload: onUpload,
|
||||
enabled: !processing.value,
|
||||
selectionAssetState: selectionAssetState.value,
|
||||
onStack: onStack,
|
||||
),
|
||||
if (processing.value) const Center(child: ImmichLoadingIndicator()),
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue