1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-28 22:51:59 +00:00

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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 434 additions and 80 deletions

View file

@ -111,6 +111,7 @@
"control_bottom_app_bar_create_new_album": "Create new album",
"control_bottom_app_bar_delete": "Delete",
"control_bottom_app_bar_favorite": "Favorite",
"control_bottom_app_bar_archive": "Archive",
"control_bottom_app_bar_share": "Share",
"create_album_page_untitled": "Untitled",
"create_shared_album_page_create": "Create",
@ -139,6 +140,7 @@
"home_page_add_to_album_success": "Added {added} assets to album {album}.",
"home_page_building_timeline": "Building the timeline",
"home_page_favorite_err_local": "Can not favorite local assets yet, skipping",
"home_page_archive_err_local": "Can not archive local assets yet, skipping",
"home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
"image_viewer_page_state_provider_download_error": "Download Error",
"image_viewer_page_state_provider_download_success": "Download Success",
@ -147,6 +149,7 @@
"library_page_favorites": "Favorites",
"library_page_new_album": "New album",
"library_page_sharing": "Sharing",
"library_page_archive": "Archive",
"library_page_sort_created": "Most recently created",
"library_page_sort_title": "Album title",
"login_form_api_exception": "API exception. Please check the server URL and try again.",
@ -268,5 +271,6 @@
"advanced_settings_troubleshooting_title": "Troubleshooting",
"advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting",
"description_input_submit_error": "Error updating description, check the log for more details",
"description_input_hint_text": "Add description..."
}
"description_input_hint_text": "Add description...",
"archive_page_title": "Archive ({})"
}

View file

@ -192,15 +192,18 @@ class ImmichAppState extends ConsumerState<ImmichApp>
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
// Sets the navigation bar color
SystemUiOverlayStyle overlayStyle = const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent);
SystemUiOverlayStyle overlayStyle = const SystemUiOverlayStyle(
systemNavigationBarColor: Colors.transparent,
);
if (Platform.isAndroid) {
// Android 8 does not support transparent app bars
final info = await DeviceInfoPlugin().androidInfo;
if (info.version.sdkInt <= 26) {
overlayStyle = MediaQuery.of(context).platformBrightness == Brightness.light
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark;
}
overlayStyle =
MediaQuery.of(context).platformBrightness == Brightness.light
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark;
}
}
SystemChrome.setSystemUIOverlayStyle(overlayStyle);
}
@ -213,9 +216,6 @@ class ImmichAppState extends ConsumerState<ImmichApp>
// needs to be delayed so that EasyLocalization is working
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
});
}
@override

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(),

View file

@ -9,6 +9,7 @@ import 'package:immich_mobile/modules/album/views/library_page.dart';
import 'package:immich_mobile/modules/album/views/select_additional_user_for_sharing_page.dart';
import 'package:immich_mobile/modules/album/views/select_user_for_sharing_page.dart';
import 'package:immich_mobile/modules/album/views/sharing_page.dart';
import 'package:immich_mobile/modules/archive/views/archive_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
@ -128,6 +129,13 @@ part 'router.gr.dart';
AutoRoute(
page: AppLogDetailPage,
),
AutoRoute(
page: ArchivePage,
guards: [
AuthGuard,
DuplicateGuard,
],
),
],
)
class AppRouter extends _$AppRouter {

View file

@ -240,6 +240,12 @@ class _$AppRouter extends RootStackRouter {
),
);
},
ArchiveRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const ArchivePage(),
);
},
HomeRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
@ -499,6 +505,14 @@ class _$AppRouter extends RootStackRouter {
AppLogDetailRoute.name,
path: '/app-log-detail-page',
),
RouteConfig(
ArchiveRoute.name,
path: '/archive-page',
guards: [
authGuard,
duplicateGuard,
],
),
];
}
@ -1022,6 +1036,18 @@ class AppLogDetailRouteArgs {
}
}
/// generated route for
/// [ArchivePage]
class ArchiveRoute extends PageRouteInfo<void> {
const ArchiveRoute()
: super(
ArchiveRoute.name,
path: '/archive-page',
);
static const String name = 'ArchiveRoute';
}
/// generated route for
/// [HomePage]
class HomeRoute extends PageRouteInfo<void> {

View file

@ -29,7 +29,8 @@ class Asset {
ownerId = fastHash(remote.ownerId),
exifInfo =
remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
isFavorite = remote.isFavorite;
isFavorite = remote.isFavorite,
isArchived = remote.isArchived;
Asset.local(AssetEntity local)
: localId = local.id,
@ -44,6 +45,7 @@ class Asset {
fileModifiedAt = local.modifiedDateTime,
updatedAt = local.modifiedDateTime,
isFavorite = local.isFavorite,
isArchived = false,
fileCreatedAt = local.createDateTime {
if (fileCreatedAt.year == 1970) {
fileCreatedAt = fileModifiedAt;
@ -70,6 +72,7 @@ class Asset {
this.exifInfo,
required this.isFavorite,
required this.isLocal,
required this.isArchived,
});
@ignore
@ -132,6 +135,8 @@ class Asset {
bool isLocal;
bool isArchived;
@ignore
ExifInfo? exifInfo;
@ -168,7 +173,8 @@ class Asset {
fileName == other.fileName &&
livePhotoVideoId == other.livePhotoVideoId &&
isFavorite == other.isFavorite &&
isLocal == other.isLocal;
isLocal == other.isLocal &&
isArchived == other.isArchived;
}
@override
@ -189,7 +195,8 @@ class Asset {
fileName.hashCode ^
livePhotoVideoId.hashCode ^
isFavorite.hashCode ^
isLocal.hashCode;
isLocal.hashCode ^
isArchived.hashCode;
bool updateFromAssetEntity(AssetEntity ae) {
// TODO check more fields;
@ -217,6 +224,9 @@ class Asset {
height ??= a.height;
exifInfo ??= a.exifInfo;
exifInfo?.id = id;
if (!isRemote) {
isArchived = a.isArchived;
}
return this;
}
@ -271,7 +281,8 @@ class Asset {
"isFavorite": $isFavorite,
"isLocal": $isLocal,
"width": ${width ?? "N/A"},
"height": ${height ?? "N/A"}
"height": ${height ?? "N/A"},
"isArchived": $isArchived
}""";
}
}

View file

@ -1,3 +1,4 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
@ -19,6 +20,8 @@ import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
/// State does not contain archived assets.
/// Use database provider if you want to access the isArchived assets
class AssetsState {
final List<Asset> allAssets;
final RenderList? renderList;
@ -76,6 +79,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
GroupAssetsBy
.values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)],
);
state = await AssetsState.fromAssetList(newAssetList)
.withRenderDataStructure(layout);
}
@ -112,6 +116,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
}
final bool newRemote = await _assetService.refreshRemoteAssets();
final bool newLocal = await _albumService.refreshDeviceAlbums();
debugPrint("newRemote: $newRemote, newLocal: $newLocal");
log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
if (!newRemote &&
@ -139,6 +144,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
Future<List<Asset>> _getUserAssets(int userId) => _db.assets
.filter()
.ownerIdEqualTo(userId)
.isArchivedEqualTo(false)
.sortByFileCreatedAtDesc()
.findAll();
@ -224,13 +230,46 @@ class AssetNotifier extends StateNotifier<AssetsState> {
}
final index = state.allAssets.indexWhere((a) => asset.id == a.id);
if (index > 0) {
if (index != -1) {
state.allAssets[index] = newAsset;
_updateAssetsState(state.allAssets);
}
return newAsset.isFavorite;
}
Future<void> toggleArchive(Iterable<Asset> assets, bool status) async {
final newAssets = await Future.wait(
assets.map((a) => _assetService.changeArchiveStatus(a, status)),
);
int i = 0;
bool unArchived = false;
for (Asset oldAsset in assets) {
final newAsset = newAssets[i++];
if (newAsset == null) {
log.severe("Change archive status failed for asset ${oldAsset.id}");
continue;
}
final index = state.allAssets.indexWhere((a) => oldAsset.id == a.id);
if (newAsset.isArchived) {
// remove from state
if (index != -1) {
state.allAssets.removeAt(index);
}
} else {
// add to state is difficult because the list is sorted
unArchived = true;
}
}
if (unArchived) {
final User me = Store.get(StoreKey.currentUser);
await _stateUpdateLock.run(
() async => _updateAssetsState(await _getUserAssets(me.isarId)),
);
} else {
_updateAssetsState(state.allAssets);
}
}
}
final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) {

View file

@ -121,10 +121,21 @@ class AssetService {
) async {
final dto =
await _apiService.assetApi.updateAsset(asset.remoteId!, updateAssetDto);
return dto == null ? null : Asset.remote(dto);
if (dto != null) {
final updated = Asset.remote(dto).updateFromDb(asset);
if (updated.isInDb) {
await _db.writeTxn(() => updated.put(_db));
}
return updated;
}
return null;
}
Future<Asset?> changeFavoriteStatus(Asset asset, bool isFavorite) {
return updateAsset(asset, UpdateAssetDto(isFavorite: isFavorite));
}
Future<Asset?> changeArchiveStatus(Asset asset, bool isArchive) {
return updateAsset(asset, UpdateAssetDto(isArchived: isArchive));
}
}

View file

@ -1,5 +1,5 @@
build:
flutter packages pub run build_runner build
flutter packages pub run build_runner build --delete-conflicting-outputs
watch:
flutter packages pub run build_runner watch --delete-conflicting-outputs

View file

@ -24,6 +24,7 @@ void main() {
fileName: '',
isFavorite: false,
isLocal: false,
isArchived: false,
),
);
}

View file

@ -26,6 +26,7 @@ Asset _getTestAsset(int id, bool favorite) {
type: AssetType.image,
fileName: '',
isFavorite: favorite,
isArchived: false,
);
a.id = id;
return a;

View file

@ -32,6 +32,7 @@ void main() {
fileName: localId,
isFavorite: false,
isLocal: isLocal,
isArchived: false,
);
}

View file

@ -141,7 +141,7 @@ class {{{classname}}} {
{{{name}}}: json[r'{{{baseName}}}'] is List
? (json[r'{{{baseName}}}'] as List).map((e) =>
{{#items.complexType}}
{{items.complexType}}.listFromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}
{{items.complexType}}.listFromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{#uniqueItems}}.toSet(){{/uniqueItems}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}
{{/items.complexType}}
{{^items.complexType}}
e == null ? {{#items.isNullable}}null{{/items.isNullable}}{{^items.isNullable}}const <{{items.items.dataType}}>[]{{/items.isNullable}} : (e as List).cast<{{items.items.dataType}}>()
@ -150,7 +150,7 @@ class {{{classname}}} {
: {{#isNullable}}null{{/isNullable}}{{^isNullable}}const []{{/isNullable}},
{{/items.isArray}}
{{^items.isArray}}
{{{name}}}: {{{complexType}}}.listFromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{{name}}}: {{{complexType}}}.listFromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{#uniqueItems}}.toSet(){{/uniqueItems}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/items.isArray}}
{{/isArray}}
{{^isArray}}
@ -197,7 +197,7 @@ class {{{classname}}} {
{{^complexType}}
{{#isArray}}
{{#isEnum}}
{{{name}}}: {{{items.datatypeWithEnum}}}.listFromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{{name}}}: {{{items.datatypeWithEnum}}}.listFromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{#uniqueItems}}.toSet(){{/uniqueItems}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/isEnum}}
{{^isEnum}}
{{{name}}}: json[r'{{{baseName}}}'] is {{#uniqueItems}}Set{{/uniqueItems}}{{^uniqueItems}}List{{/uniqueItems}}