feat(mobile): Archive feature on mobile (#2258)

* update asset to include isArchive property

* Not display archived assets on timeline

* replace share button to archive button

* Added archive page

* Add bottom nav bar

* clean up homepage

* remove deadcode

* improve on sync is archive

* show archive asset correctly

* better merge condition

* Added back renderList to re-rendering don't jump around

* Better way to handle showing archive assets

* complete ArchiveSelectionNotifier

* toggle archive

* remove deadcode

* fix unit tests

* update assets in DB when changing assets

* update asset state to reflect archived status

* allow to archive assets via multi-select from timeline

* fixed logic

* Add options to bulk unarchive

* regenerate api

* Change position of toast message

---------

Co-authored-by: Fynn Petersen-Frey <zoodyy@users.noreply.github.com>
This commit is contained in:
Alex
2023-04-17 00:02:07 -05:00
committed by GitHub
parent 635eee9e5e
commit 2e5cd986dd
27 changed files with 523 additions and 114 deletions

View File

@@ -51,14 +51,14 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.tr(
namedArgs: { "album": album.name },
namedArgs: {"album": album.name},
),
);
} else {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_added'.tr(
namedArgs: { "album": album.name },
namedArgs: {"album": album.name},
),
);
}
@@ -71,6 +71,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
}
return Card(
elevation: 0,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(15),
@@ -99,8 +100,15 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
style: Theme.of(context).textTheme.displayMedium,
),
TextButton.icon(
icon: const Icon(Icons.add),
label: Text('common_create_new_album'.tr()),
icon: Icon(
Icons.add,
color: Theme.of(context).primaryColor,
),
label: Text(
'common_create_new_album'.tr(),
style:
TextStyle(color: Theme.of(context).primaryColor),
),
onPressed: () {
ref
.watch(assetSelectionProvider.notifier)

View File

@@ -43,7 +43,8 @@ class LibraryPage extends HookConsumerWidget {
);
}
final selectedAlbumSortOrder = useState(settings.getSetting(AppSettingsEnum.selectedAlbumSortOrder));
final selectedAlbumSortOrder =
useState(settings.getSetting(AppSettingsEnum.selectedAlbumSortOrder));
List<Album> sortedAlbums() {
if (selectedAlbumSortOrder.value == 0) {
@@ -179,13 +180,13 @@ class LibraryPage extends HookConsumerWidget {
label,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12.0,
color: isDarkMode ? Colors.white : Colors.black,
fontSize: 13.0,
color: isDarkMode ? Colors.white : Colors.grey[800],
),
),
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
backgroundColor: isDarkMode ? Colors.grey[900] : Colors.grey[50],
side: BorderSide(
color: isDarkMode ? Colors.grey[800]! : Colors.grey[300]!,
@@ -225,8 +226,8 @@ class LibraryPage extends HookConsumerWidget {
}),
const SizedBox(width: 12.0),
buildLibraryNavButton(
"library_page_sharing".tr(), Icons.group_outlined, () {
AutoRouter.of(context).navigate(const SharingRoute());
"library_page_archive".tr(), Icons.archive_outlined, () {
AutoRouter.of(context).navigate(const ArchiveRoute());
}),
],
),

View File

@@ -0,0 +1,55 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
class ArchiveSelectionNotifier extends StateNotifier<Set<int>> {
ArchiveSelectionNotifier(this.db, this.assetNotifier) : super({}) {
state = db.assets
.filter()
.isArchivedEqualTo(true)
.findAllSync()
.map((e) => e.id)
.toSet();
}
final Isar db;
final AssetNotifier assetNotifier;
void _setArchiveForAssetId(int id, bool archive) {
if (!archive) {
state = state.difference({id});
} else {
state = state.union({id});
}
}
bool _isArchive(int id) {
return state.contains(id);
}
Future<void> toggleArchive(Asset asset) async {
if (!asset.isRemote) return;
_setArchiveForAssetId(asset.id, !_isArchive(asset.id));
await assetNotifier.toggleArchive(
[asset],
state.contains(asset.id),
);
}
Future<void> addToArchives(Iterable<Asset> assets) {
state = state.union(assets.map((a) => a.id).toSet());
return assetNotifier.toggleArchive(assets, true);
}
}
final archiveProvider =
StateNotifierProvider<ArchiveSelectionNotifier, Set<int>>((ref) {
return ArchiveSelectionNotifier(
ref.watch(dbProvider),
ref.watch(assetProvider.notifier),
);
});

View File

@@ -0,0 +1,124 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:isar/isar.dart';
class ArchivePage extends HookConsumerWidget {
const ArchivePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final User me = Store.get(StoreKey.currentUser);
final query = ref
.watch(dbProvider)
.assets
.filter()
.ownerIdEqualTo(me.isarId)
.isArchivedEqualTo(true);
final stream = query.watch();
final archivedAssets = useState<List<Asset>>([]);
final selectionEnabledHook = useState(false);
final selection = useState(<Asset>{});
useEffect(
() {
query.findAll().then((value) => archivedAssets.value = value);
final subscription = stream.listen((e) {
archivedAssets.value = e;
});
// Cancel the subscription when the widget is disposed
return subscription.cancel;
},
[],
);
void selectionListener(
bool multiselect,
Set<Asset> selectedAssets,
) {
selectionEnabledHook.value = multiselect;
selection.value = selectedAssets;
}
AppBar buildAppBar() {
return AppBar(
leading: IconButton(
onPressed: () => AutoRouter.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
centerTitle: true,
automaticallyImplyLeading: false,
title: const Text(
'archive_page_title',
).tr(args: [archivedAssets.value.length.toString()]),
);
}
Widget buildBottomBar() {
return Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 64,
child: Card(
child: Column(
children: [
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
leading: const Icon(
Icons.unarchive_rounded,
),
title:
const Text("Unarchive", style: TextStyle(fontSize: 14)),
onTap: () {
if (selection.value.isNotEmpty) {
ref
.watch(assetProvider.notifier)
.toggleArchive(selection.value, false);
final assetOrAssets =
selection.value.length > 1 ? 'assets' : 'asset';
ImmichToast.show(
context: context,
msg:
'Moved ${selection.value.length} $assetOrAssets to library',
gravity: ToastGravity.CENTER,
);
}
selectionEnabledHook.value = false;
},
)
],
),
),
),
);
}
return Scaffold(
appBar: buildAppBar(),
body: Stack(
children: [
ImmichAssetGrid(
assets: archivedAssets.value,
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
),
if (selectionEnabledHook.value) buildBottomBar()
],
),
);
}
}

View File

@@ -9,8 +9,6 @@ class TopControlAppBar extends HookConsumerWidget {
required this.asset,
required this.onMoreInfoPressed,
required this.onDownloadPressed,
required this.onSharePressed,
required this.onDeletePressed,
required this.onAddToAlbumPressed,
required this.onToggleMotionVideo,
required this.isPlayingMotionVideo,
@@ -22,10 +20,8 @@ class TopControlAppBar extends HookConsumerWidget {
final Function onMoreInfoPressed;
final VoidCallback? onDownloadPressed;
final VoidCallback onToggleMotionVideo;
final VoidCallback onDeletePressed;
final VoidCallback onAddToAlbumPressed;
final VoidCallback onFavorite;
final Function onSharePressed;
final bool isPlayingMotionVideo;
final bool isFavorite;
@@ -34,15 +30,15 @@ class TopControlAppBar extends HookConsumerWidget {
const double iconSize = 18.0;
Widget buildFavoriteButton() {
return IconButton(
onPressed: () {
onFavorite();
},
icon: Icon(
isFavorite ? Icons.star : Icons.star_border,
color: Colors.grey[200],
),
);
return IconButton(
onPressed: () {
onFavorite();
},
icon: Icon(
isFavorite ? Icons.star : Icons.star_border,
color: Colors.grey[200],
),
);
}
return AppBar(
@@ -86,15 +82,6 @@ class TopControlAppBar extends HookConsumerWidget {
color: Colors.grey[200],
),
),
IconButton(
onPressed: () {
onSharePressed();
},
icon: Icon(
Icons.ios_share_rounded,
color: Colors.grey[200],
),
),
if (asset.isRemote)
IconButton(
onPressed: () {
@@ -105,15 +92,6 @@ class TopControlAppBar extends HookConsumerWidget {
color: Colors.grey[200],
),
),
IconButton(
onPressed: () {
onDeletePressed();
},
icon: Icon(
Icons.delete_outline_rounded,
color: Colors.grey[200],
),
),
IconButton(
onPressed: () {
onMoreInfoPressed();

View File

@@ -231,11 +231,10 @@ class GalleryViewerPage extends HookConsumerWidget {
void addToAlbum(Asset addToAlbumAsset) {
showModalBottomSheet(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15.0),
),
barrierColor: Colors.transparent,
backgroundColor: Colors.transparent,
context: context,
builder: (BuildContext _) {
return AddToAlbumBottomSheet(
@@ -267,6 +266,19 @@ class GalleryViewerPage extends HookConsumerWidget {
}
}
shareAsset() {
ref
.watch(imageViewerStateProvider.notifier)
.shareAsset(assetList[indexOfAsset.value], context);
}
handleArchive(Asset asset) {
ref
.watch(assetProvider.notifier)
.toggleArchive([asset], !asset.isArchived);
AutoRouter.of(context).pop();
}
buildAppBar() {
final show = (showAppBar.value || // onTap has the final say
(showAppBar.value && !isZoomed.value)) &&
@@ -297,16 +309,9 @@ class GalleryViewerPage extends HookConsumerWidget {
context,
);
},
onSharePressed: () {
ref
.watch(imageViewerStateProvider.notifier)
.shareAsset(assetList[indexOfAsset.value], context);
},
onToggleMotionVideo: (() {
isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
}),
onDeletePressed: () =>
handleDelete((assetList[indexOfAsset.value])),
onAddToAlbumPressed: () =>
addToAlbum(assetList[indexOfAsset.value]),
),
@@ -314,6 +319,59 @@ class GalleryViewerPage extends HookConsumerWidget {
);
}
buildBottomBar() {
final show = (showAppBar.value || // onTap has the final say
(showAppBar.value && !isZoomed.value)) &&
!isPlayingVideo.value;
final currentAsset = assetList[indexOfAsset.value];
return AnimatedOpacity(
duration: const Duration(milliseconds: 100),
opacity: show ? 1.0 : 0.0,
child: BottomNavigationBar(
backgroundColor: Colors.black.withOpacity(0.4),
unselectedIconTheme: const IconThemeData(color: Colors.white),
selectedIconTheme: const IconThemeData(color: Colors.white),
unselectedLabelStyle: const TextStyle(color: Colors.black),
selectedLabelStyle: const TextStyle(color: Colors.black),
showSelectedLabels: false,
showUnselectedLabels: false,
items: [
const BottomNavigationBarItem(
icon: Icon(Icons.ios_share_rounded),
label: 'Share',
tooltip: 'Share',
),
BottomNavigationBarItem(
icon: currentAsset.isArchived
? const Icon(Icons.unarchive_rounded)
: const Icon(Icons.archive_outlined),
label: 'Archive',
tooltip: 'Archive',
),
const BottomNavigationBarItem(
icon: Icon(Icons.delete_outline),
label: 'Delete',
tooltip: 'Delete',
),
],
onTap: (index) {
switch (index) {
case 0:
shareAsset();
break;
case 1:
handleArchive(assetList[indexOfAsset.value]);
break;
case 2:
handleDelete(assetList[indexOfAsset.value]);
break;
}
},
),
);
}
return Scaffold(
backgroundColor: Colors.black,
body: WillPopScope(
@@ -481,6 +539,12 @@ class GalleryViewerPage extends HookConsumerWidget {
right: 0,
child: buildAppBar(),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: buildBottomBar(),
),
],
),
),

View File

@@ -109,7 +109,7 @@ class RenderList {
final groups = _groupAssets(allAssets, groupBy);
groups.entries.sortedBy((e) =>e.key).reversed.forEach((entry) {
groups.entries.sortedBy((e) => e.key).reversed.forEach((entry) {
final date = entry.key;
final assets = entry.value;

View File

@@ -50,10 +50,9 @@ class ImmichAssetGrid extends HookConsumerWidget {
// Unfortunately, using the transition animation itself didn't
// seem to work reliably. So instead, wait until the duration of the
// animation has elapsed to re-enable the hero animations
Future.delayed(transitionDuration)
.then((_) {
enableHeroAnimations.value = true;
});
Future.delayed(transitionDuration).then((_) {
enableHeroAnimations.value = true;
});
}
return null;
},

View File

@@ -9,6 +9,7 @@ import 'package:immich_mobile/shared/models/album.dart';
class ControlBottomAppBar extends ConsumerWidget {
final Function onShare;
final Function onFavorite;
final Function onArchive;
final Function onDelete;
final Function(Album album) onAddToAlbum;
final void Function() onCreateNewAlbum;
@@ -20,6 +21,7 @@ class ControlBottomAppBar extends ConsumerWidget {
Key? key,
required this.onShare,
required this.onFavorite,
required this.onArchive,
required this.onDelete,
required this.sharedAlbums,
required this.albums,
@@ -62,6 +64,11 @@ class ControlBottomAppBar extends ConsumerWidget {
);
},
),
ControlBoxButton(
iconData: Icons.archive,
label: "control_bottom_app_bar_archive".tr(),
onPressed: () => onArchive(),
),
],
);
}

View File

@@ -94,7 +94,6 @@ class HomePage extends HookConsumerWidget {
barrierDismissible: false,
);
// ref.watch(shareServiceProvider).shareAssets(selection.value.toList());
selectionEnabledHook.value = false;
}
@@ -132,6 +131,24 @@ class HomePage extends HookConsumerWidget {
selectionEnabledHook.value = false;
}
void onArchiveAsset() {
final remoteAssets = remoteOnlySelection(
localErrorMessage: 'home_page_archive_err_local'.tr(),
);
if (remoteAssets.isNotEmpty) {
ref.watch(assetProvider.notifier).toggleArchive(remoteAssets, true);
final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset';
ImmichToast.show(
context: context,
msg: 'Moved ${remoteAssets.length} $assetOrAssets to archive',
gravity: ToastGravity.CENTER,
);
}
selectionEnabledHook.value = false;
}
void onDelete() {
ref.watch(assetProvider.notifier).deleteAssets(selection.value);
selectionEnabledHook.value = false;
@@ -265,7 +282,7 @@ class HomePage extends HookConsumerWidget {
? buildLoadingIndicator()
: ImmichAssetGrid(
renderList: ref.watch(assetProvider).renderList!,
assets: ref.watch(assetProvider).allAssets,
assets: ref.read(assetProvider).allAssets,
assetsPerRow: appSettingService
.getSetting(AppSettingsEnum.tilesPerRow),
showStorageIndicator: appSettingService
@@ -278,6 +295,7 @@ class HomePage extends HookConsumerWidget {
ControlBottomAppBar(
onShare: onShareAssets,
onFavorite: onFavoriteAssets,
onArchive: onArchiveAsset,
onDelete: onDelete,
onAddToAlbum: onAddToAlbum,
albums: albums,
@@ -291,9 +309,7 @@ class HomePage extends HookConsumerWidget {
return Scaffold(
appBar: !selectionEnabledHook.value
? HomePageAppBar(
onPopBack: reloadAllAsset,
)
? HomePageAppBar(onPopBack: reloadAllAsset)
: null,
drawer: const ProfileDrawer(),
body: buildBody(),