0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-31 00:43:56 -05:00

feat(album): album view sort order (#14648)

* feat(mobile): album view sort order

* feat: add error message

* refactor(mobile): album page (#14659)

* refactor album page

* update lint rule

* const record

* fix: updating sort order when pull to refresh

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>

* Move sort toggle button to bottom sheet menu

* chore: revert multiselectgrid loading status

* chore: revert multiselectgrid loading status

---------

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>
This commit is contained in:
Alex 2024-12-16 10:11:48 -06:00 committed by GitHub
parent 364b717fde
commit 9503bf479b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 597 additions and 332 deletions

View file

@ -69,7 +69,7 @@ custom_lint:
# acceptable exceptions for the time being (until Isar is fully replaced)
- integration_test/test_utils/general_helper.dart
- lib/main.dart
- lib/pages/common/album_asset_selection.page.dart
- lib/pages/album/album_asset_selection.page.dart
- lib/routing/router.dart
- lib/services/immich_logger.service.dart # not really a service... more a util
- lib/utils/{db,migration,renderlist_generator}.dart

View file

@ -1,4 +1,6 @@
{
"change_display_order": "Change display order",
"error_change_sort_album": "Failed to change album sort order",
"action_common_back": "Back",
"action_common_cancel": "Cancel",
"action_common_clear": "Clear",

View file

@ -86,7 +86,7 @@ class BackgroundSyncWorker {
result(false)
break
default:
result(FlutterError())
result(FlutterError())
self.complete(UIBackgroundFetchResult.failed)
}
}

View file

@ -0,0 +1,4 @@
enum SortOrder {
asc,
desc,
}

View file

@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/utils/datetime_comparison.dart';
@ -23,6 +24,7 @@ class Album {
this.lastModifiedAssetTimestamp,
required this.shared,
required this.activityEnabled,
this.sortOrder = SortOrder.desc,
});
// fields stored in DB
@ -39,6 +41,8 @@ class Album {
DateTime? lastModifiedAssetTimestamp;
bool shared;
bool activityEnabled;
@enumerated
SortOrder sortOrder;
final IsarLink<User> owner = IsarLink<User>();
final IsarLink<Asset> thumbnail = IsarLink<Asset>();
final IsarLinks<User> sharedUsers = IsarLinks<User>();
@ -154,6 +158,11 @@ class Album {
);
a.remoteAssetCount = dto.assetCount;
a.owner.value = await db.users.getById(dto.ownerId);
if (dto.order != null) {
a.sortOrder =
dto.order == AssetOrder.asc ? SortOrder.asc : SortOrder.desc;
}
if (dto.albumThumbnailAssetId != null) {
a.thumbnail.value = await db.assets
.where()

Binary file not shown.

View file

@ -1,3 +1,4 @@
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/entities/album.entity.dart';
abstract interface class IAlbumApiRepository {
@ -17,6 +18,7 @@ abstract interface class IAlbumApiRepository {
String? thumbnailAssetId,
String? description,
bool? activityEnabled,
SortOrder? sortOrder,
});
Future<void> delete(String albumId);

View file

@ -0,0 +1,53 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
// ignore: must_be_immutable
class AlbumControlButton extends ConsumerWidget {
void Function() onAddPhotosPressed;
void Function() onAddUsersPressed;
AlbumControlButton({
super.key,
required this.onAddPhotosPressed,
required this.onAddUsersPressed,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userId = ref.watch(authProvider).userId;
final isOwner = ref.watch(
currentAlbumProvider.select((album) {
return album?.ownerId == userId;
}),
);
return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 16),
child: SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
AlbumActionFilledButton(
key: const ValueKey('add_photos_button'),
iconData: Icons.add_photo_alternate_outlined,
onPressed: onAddPhotosPressed,
labelText: "share_add_photos".tr(),
),
if (isOwner)
AlbumActionFilledButton(
key: const ValueKey('add_users_button'),
iconData: Icons.person_add_alt_rounded,
onPressed: onAddUsersPressed,
labelText: "album_viewer_page_share_add_users".tr(),
),
],
),
),
);
}
}

View file

@ -0,0 +1,61 @@
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/providers/album/current_album.provider.dart';
class AlbumDateRange extends ConsumerWidget {
const AlbumDateRange({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final data = ref.watch(
currentAlbumProvider.select((album) {
if (album == null || album.assets.isEmpty) {
return null;
}
final startDate = album.startDate;
final endDate = album.endDate;
if (startDate == null || endDate == null) {
return null;
}
return (startDate, endDate, album.shared);
}),
);
if (data == null) {
return const SizedBox();
}
final (startDate, endDate, shared) = data;
return Padding(
padding: shared
? const EdgeInsets.only(
left: 16.0,
bottom: 0.0,
)
: const EdgeInsets.only(left: 16.0, bottom: 8.0),
child: Text(
_getDateRangeText(startDate, endDate),
style: context.textTheme.labelLarge,
),
);
}
@pragma('vm:prefer-inline')
String _getDateRangeText(DateTime startDate, DateTime endDate) {
if (startDate.day == endDate.day &&
startDate.month == endDate.month &&
startDate.year == endDate.year) {
return DateFormat.yMMMd().format(startDate);
}
final String startDateText = (startDate.year == endDate.year
? DateFormat.MMMd()
: DateFormat.yMMMd())
.format(startDate);
final String endDateText = DateFormat.yMMMd().format(endDate);
return "$startDateText - $endDateText";
}
}

View file

@ -7,22 +7,25 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
@RoutePage()
class AlbumOptionsPage extends HookConsumerWidget {
final Album album;
const AlbumOptionsPage({super.key, required this.album});
const AlbumOptionsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentAlbumProvider);
if (album == null) {
return const SizedBox();
}
final sharedUsers = useState(album.sharedUsers.toList());
final owner = album.owner.value;
final userId = ref.watch(authProvider).userId;

View file

@ -0,0 +1,56 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
class AlbumSharedUserIcons extends HookConsumerWidget {
const AlbumSharedUserIcons({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final sharedUsers = useRef<List<User>>(const []);
sharedUsers.value = ref.watch(
currentAlbumProvider.select((album) {
if (album == null) {
return const [];
}
if (album.sharedUsers.length == sharedUsers.value.length) {
return sharedUsers.value;
}
return album.sharedUsers.toList(growable: false);
}),
);
if (sharedUsers.value.isEmpty) {
return const SizedBox();
}
return GestureDetector(
onTap: () => context.pushRoute(AlbumOptionsRoute()),
child: SizedBox(
height: 50,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
itemBuilder: ((context, index) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: UserCircleAvatar(
user: sharedUsers.value[index],
radius: 18,
size: 36,
),
);
}),
itemCount: sharedUsers.value.length,
),
),
);
}
}

View file

@ -0,0 +1,41 @@
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/providers/album/current_album.provider.dart';
import 'package:immich_mobile/widgets/album/album_viewer_editable_title.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
class AlbumTitle extends ConsumerWidget {
const AlbumTitle({super.key, required this.titleFocusNode});
final FocusNode titleFocusNode;
@override
Widget build(BuildContext context, WidgetRef ref) {
final userId = ref.watch(authProvider).userId;
final (isOwner, isRemote, albumName) = ref.watch(
currentAlbumProvider.select((album) {
if (album == null) {
return const (false, false, '');
}
return (album.ownerId == userId, album.isRemote, album.name);
}),
);
if (isOwner && isRemote) {
return Padding(
padding: const EdgeInsets.only(left: 8, right: 8),
child: AlbumViewerEditableTitle(
albumName: albumName,
titleFocusNode: titleFocusNode,
),
);
}
return Padding(
padding: const EdgeInsets.only(left: 16, right: 8),
child: Text(albumName, style: context.textTheme.headlineMedium),
);
}
}

View file

@ -0,0 +1,147 @@
import 'dart:async';
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';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/albums/asset_selection_page_result.model.dart';
import 'package:immich_mobile/pages/album/album_control_button.dart';
import 'package:immich_mobile/pages/album/album_date_range.dart';
import 'package:immich_mobile/pages/album/album_shared_user_icons.dart';
import 'package:immich_mobile/pages/album/album_title.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/widgets/album/album_viewer_appbar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class AlbumViewer extends HookConsumerWidget {
const AlbumViewer({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentAlbumProvider);
if (album == null) {
return const SizedBox();
}
final titleFocusNode = useFocusNode();
final userId = ref.watch(authProvider).userId;
final isMultiselecting = ref.watch(multiselectProvider);
final isProcessing = useProcessingOverlay();
Future<bool> onRemoveFromAlbumPressed(Iterable<Asset> assets) async {
final bool isSuccess =
await ref.read(albumProvider.notifier).removeAsset(album, assets);
if (!isSuccess) {
ImmichToast.show(
context: context,
msg: "album_viewer_appbar_share_err_remove".tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
return isSuccess;
}
/// Find out if the assets in album exist on the device
/// If they exist, add to selected asset state to show they are already selected.
void onAddPhotosPressed() async {
AssetSelectionPageResult? returnPayload =
await context.pushRoute<AssetSelectionPageResult?>(
AlbumAssetSelectionRoute(
existingAssets: album.assets,
canDeselect: false,
query: getRemoteAssetQuery(ref),
),
);
if (returnPayload != null && returnPayload.selectedAssets.isNotEmpty) {
// Check if there is new assets add
isProcessing.value = true;
await ref
.watch(albumProvider.notifier)
.addAssets(album, returnPayload.selectedAssets);
isProcessing.value = false;
}
}
void onAddUsersPressed() async {
List<String>? sharedUserIds = await context.pushRoute<List<String>?>(
AlbumAdditionalSharedUserSelectionRoute(album: album),
);
if (sharedUserIds != null) {
isProcessing.value = true;
await ref.watch(albumProvider.notifier).addUsers(album, sharedUserIds);
isProcessing.value = false;
}
}
onActivitiesPressed() {
if (album.remoteId != null) {
context.pushRoute(
const ActivitiesRoute(),
);
}
}
return Stack(
children: [
MultiselectGrid(
key: const ValueKey("albumViewerMultiselectGrid"),
renderListProvider: albumRenderlistProvider(album.id),
topWidget: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AlbumTitle(
key: const ValueKey("albumTitle"),
titleFocusNode: titleFocusNode,
),
const AlbumDateRange(),
const AlbumSharedUserIcons(),
if (album.isRemote)
AlbumControlButton(
key: const ValueKey("albumControlButton"),
onAddPhotosPressed: onAddPhotosPressed,
onAddUsersPressed: onAddUsersPressed,
),
],
),
onRemoveFromAlbum: onRemoveFromAlbumPressed,
editEnabled: album.ownerId == userId,
),
AnimatedPositioned(
key: const ValueKey("albumViewerAppbarPositioned"),
duration: const Duration(milliseconds: 300),
top: isMultiselecting ? -(kToolbarHeight + context.padding.top) : 0,
left: 0,
right: 0,
child: AlbumViewerAppbar(
key: const ValueKey("albumViewerAppbar"),
titleFocusNode: titleFocusNode,
userId: userId,
onAddPhotos: onAddPhotosPressed,
onAddUsers: onAddUsersPressed,
onActivities: onActivitiesPressed,
),
),
],
);
}
}

View file

@ -0,0 +1,27 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/pages/album/album_viewer.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
@RoutePage()
class AlbumViewerPage extends HookConsumerWidget {
final int albumId;
const AlbumViewerPage({super.key, required this.albumId});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Listen provider to prevent autoDispose when navigating to other routes from within the viewer page
ref.listen(currentAlbumProvider, (_, __) {});
ref.listen(albumWatcher(albumId), (_, albumFuture) {
albumFuture.whenData(
(value) => ref.read(currentAlbumProvider.notifier).set(value),
);
});
return const Scaffold(body: AlbumViewer());
}
}

View file

@ -1,267 +0,0 @@
import 'dart:async';
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';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/albums/asset_selection_page_result.model.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
import 'package:immich_mobile/widgets/album/album_viewer_editable_title.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/widgets/album/album_viewer_appbar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
@RoutePage()
class AlbumViewerPage extends HookConsumerWidget {
final int albumId;
const AlbumViewerPage({super.key, required this.albumId});
@override
Widget build(BuildContext context, WidgetRef ref) {
FocusNode titleFocusNode = useFocusNode();
final album = ref.watch(albumWatcher(albumId));
// Listen provider to prevent autoDispose when navigating to other routes from within the viewer page
ref.listen(currentAlbumProvider, (_, __) {});
album.whenData(
(value) => Future.microtask(
() => ref.read(currentAlbumProvider.notifier).set(value),
),
);
final userId = ref.watch(authProvider).userId;
final isProcessing = useProcessingOverlay();
Future<bool> onRemoveFromAlbumPressed(Iterable<Asset> assets) async {
final a = album.valueOrNull;
final bool isSuccess = a != null &&
await ref.read(albumProvider.notifier).removeAsset(a, assets);
if (!isSuccess) {
ImmichToast.show(
context: context,
msg: "album_viewer_appbar_share_err_remove".tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
return isSuccess;
}
/// Find out if the assets in album exist on the device
/// If they exist, add to selected asset state to show they are already selected.
void onAddPhotosPressed(Album albumInfo) async {
AssetSelectionPageResult? returnPayload =
await context.pushRoute<AssetSelectionPageResult?>(
AlbumAssetSelectionRoute(
existingAssets: albumInfo.assets,
canDeselect: false,
query: getRemoteAssetQuery(ref),
),
);
if (returnPayload != null && returnPayload.selectedAssets.isNotEmpty) {
// Check if there is new assets add
isProcessing.value = true;
await ref.watch(albumProvider.notifier).addAssets(
albumInfo,
returnPayload.selectedAssets,
);
isProcessing.value = false;
}
}
void onAddUsersPressed(Album album) async {
List<String>? sharedUserIds = await context.pushRoute<List<String>?>(
AlbumAdditionalSharedUserSelectionRoute(album: album),
);
if (sharedUserIds != null) {
isProcessing.value = true;
await ref.watch(albumProvider.notifier).addUsers(album, sharedUserIds);
isProcessing.value = false;
}
}
Widget buildControlButton(Album album) {
return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 16),
child: SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
AlbumActionFilledButton(
iconData: Icons.add_photo_alternate_outlined,
onPressed: () => onAddPhotosPressed(album),
labelText: "share_add_photos".tr(),
),
if (userId == album.ownerId)
AlbumActionFilledButton(
iconData: Icons.person_add_alt_rounded,
onPressed: () => onAddUsersPressed(album),
labelText: "album_viewer_page_share_add_users".tr(),
),
],
),
),
);
}
Widget buildTitle(Album album) {
return Padding(
padding: const EdgeInsets.only(left: 8, right: 8),
child: userId == album.ownerId && album.isRemote
? AlbumViewerEditableTitle(
album: album,
titleFocusNode: titleFocusNode,
)
: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
album.name,
style: context.textTheme.headlineMedium,
),
),
);
}
Widget buildAlbumDateRange(Album album) {
final DateTime? startDate = album.startDate;
final DateTime? endDate = album.endDate;
if (startDate == null || endDate == null) {
return const SizedBox();
}
final String dateRangeText;
if (startDate.day == endDate.day &&
startDate.month == endDate.month &&
startDate.year == endDate.year) {
dateRangeText = DateFormat.yMMMd().format(startDate);
} else {
final String startDateText = (startDate.year == endDate.year
? DateFormat.MMMd()
: DateFormat.yMMMd())
.format(startDate);
final String endDateText = DateFormat.yMMMd().format(endDate);
dateRangeText = "$startDateText - $endDateText";
}
return Padding(
padding: EdgeInsets.only(
left: 16.0,
bottom: album.shared ? 0.0 : 8.0,
),
child: Text(
dateRangeText,
style: context.textTheme.labelLarge,
),
);
}
Widget buildSharedUserIconsRow(Album album) {
return album.sharedUsers.isNotEmpty
? GestureDetector(
onTap: () => context.pushRoute(AlbumOptionsRoute(album: album)),
child: SizedBox(
height: 50,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
itemBuilder: ((context, index) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: UserCircleAvatar(
user: album.sharedUsers.toList()[index],
radius: 18,
size: 36,
),
);
}),
itemCount: album.sharedUsers.length,
),
),
)
: const SizedBox.shrink();
}
Widget buildHeader(Album album) {
return Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildTitle(album),
if (album.assets.isNotEmpty == true) buildAlbumDateRange(album),
buildSharedUserIconsRow(album),
],
);
}
onActivitiesPressed(Album album) {
if (album.remoteId != null) {
context.pushRoute(
const ActivitiesRoute(),
);
}
}
return Scaffold(
body: Stack(
children: [
album.widgetWhen(
onData: (albumInfo) => MultiselectGrid(
renderListProvider: albumRenderlistProvider(albumId),
topWidget: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildHeader(albumInfo),
if (albumInfo.isRemote) buildControlButton(albumInfo),
],
),
onRemoveFromAlbum: onRemoveFromAlbumPressed,
editEnabled: albumInfo.ownerId == userId,
),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
top: ref.watch(multiselectProvider)
? -(kToolbarHeight + context.padding.top)
: 0,
left: 0,
right: 0,
child: album.when(
data: (data) => AlbumViewerAppbar(
titleFocusNode: titleFocusNode,
album: data,
userId: userId,
onAddPhotos: onAddPhotosPressed,
onAddUsers: onAddUsersPressed,
onActivities: onActivitiesPressed,
),
error: (error, stackTrace) => AppBar(title: const Text("Error")),
loading: () => AppBar(),
),
),
],
),
);
}
}

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/services/album.service.dart';
@ -106,6 +107,13 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
return _albumService.setActivityStatus(album, enabled);
}
Future<Album?> toggleSortOrder(Album album) {
final order =
album.sortOrder == SortOrder.asc ? SortOrder.desc : SortOrder.asc;
return _albumService.updateSortOrder(album, order);
}
@override
void dispose() {
_streamSub.cancel();
@ -135,11 +143,22 @@ final albumWatcher =
final albumRenderlistProvider =
StreamProvider.autoDispose.family<RenderList, int>((ref, albumId) {
final album = ref.watch(albumWatcher(albumId)).value;
if (album != null) {
final query =
album.assets.filter().isTrashedEqualTo(false).sortByFileCreatedAtDesc();
return renderListGeneratorWithGroupBy(query, GroupAssetsBy.none);
final query = album.assets.filter().isTrashedEqualTo(false);
if (album.sortOrder == SortOrder.asc) {
return renderListGeneratorWithGroupBy(
query.sortByFileCreatedAt(),
GroupAssetsBy.none,
);
} else if (album.sortOrder == SortOrder.desc) {
return renderListGeneratorWithGroupBy(
query.sortByFileCreatedAtDesc(),
GroupAssetsBy.none,
);
}
}
return const Stream.empty();
});

View file

@ -0,0 +1,20 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
enum RenderListStatusEnum { complete, empty, error, loading }
final renderListStatusProvider =
StateNotifierProvider<RenderListStatus, RenderListStatusEnum>((ref) {
return RenderListStatus(ref);
});
class RenderListStatus extends StateNotifier<RenderListStatusEnum> {
RenderListStatus(this.ref) : super(RenderListStatusEnum.complete);
final Ref ref;
RenderListStatusEnum get status => state;
set status(RenderListStatusEnum value) {
state = value;
}
}

View file

@ -1,4 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
@ -56,7 +57,13 @@ class AlbumApiRepository extends ApiRepository implements IAlbumApiRepository {
String? thumbnailAssetId,
String? description,
bool? activityEnabled,
SortOrder? sortOrder,
}) async {
AssetOrder? order;
if (sortOrder != null) {
order = sortOrder == SortOrder.asc ? AssetOrder.asc : AssetOrder.desc;
}
final response = await checkNull(
_api.updateAlbumInfo(
albumId,
@ -65,9 +72,11 @@ class AlbumApiRepository extends ApiRepository implements IAlbumApiRepository {
albumThumbnailAssetId: thumbnailAssetId,
description: description,
isActivityEnabled: activityEnabled,
order: order,
),
),
);
return _toAlbum(response);
}
@ -152,6 +161,7 @@ class AlbumApiRepository extends ApiRepository implements IAlbumApiRepository {
startDate: dto.startDate,
endDate: dto.endDate,
activityEnabled: dto.isActivityEnabled,
sortOrder: dto.order == AssetOrder.asc ? SortOrder.asc : SortOrder.desc,
);
album.remoteAssetCount = dto.assetCount;
album.owner.value = User.fromSimpleUserDto(dto.owner);

View file

@ -20,11 +20,11 @@ import 'package:immich_mobile/pages/library/people/people_collection.page.dart';
import 'package:immich_mobile/pages/library/places/places_collection.page.dart';
import 'package:immich_mobile/pages/library/library.page.dart';
import 'package:immich_mobile/pages/common/activities.page.dart';
import 'package:immich_mobile/pages/common/album_additional_shared_user_selection.page.dart';
import 'package:immich_mobile/pages/common/album_asset_selection.page.dart';
import 'package:immich_mobile/pages/common/album_options.page.dart';
import 'package:immich_mobile/pages/common/album_shared_user_selection.page.dart';
import 'package:immich_mobile/pages/common/album_viewer.page.dart';
import 'package:immich_mobile/pages/album/album_additional_shared_user_selection.page.dart';
import 'package:immich_mobile/pages/album/album_asset_selection.page.dart';
import 'package:immich_mobile/pages/album/album_options.page.dart';
import 'package:immich_mobile/pages/album/album_shared_user_selection.page.dart';
import 'package:immich_mobile/pages/album/album_viewer.page.dart';
import 'package:immich_mobile/pages/common/app_log.page.dart';
import 'package:immich_mobile/pages/common/app_log_detail.page.dart';
import 'package:immich_mobile/pages/common/create_album.page.dart';

View file

@ -139,13 +139,11 @@ class AlbumAssetSelectionRouteArgs {
class AlbumOptionsRoute extends PageRouteInfo<AlbumOptionsRouteArgs> {
AlbumOptionsRoute({
Key? key,
required Album album,
List<PageRouteInfo>? children,
}) : super(
AlbumOptionsRoute.name,
args: AlbumOptionsRouteArgs(
key: key,
album: album,
),
initialChildren: children,
);
@ -158,25 +156,19 @@ class AlbumOptionsRoute extends PageRouteInfo<AlbumOptionsRouteArgs> {
final args = data.argsAs<AlbumOptionsRouteArgs>();
return AlbumOptionsPage(
key: args.key,
album: args.album,
);
},
);
}
class AlbumOptionsRouteArgs {
const AlbumOptionsRouteArgs({
this.key,
required this.album,
});
const AlbumOptionsRouteArgs({this.key});
final Key? key;
final Album album;
@override
String toString() {
return 'AlbumOptionsRouteArgs{key: $key, album: $album}';
return 'AlbumOptionsRouteArgs{key: $key}';
}
}

View file

@ -5,6 +5,7 @@ import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/interfaces/album.interface.dart';
import 'package:immich_mobile/interfaces/album_api.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
@ -436,4 +437,17 @@ class AlbumService {
) async {
return _albumRepository.search(searchTerm, filterMode);
}
Future<Album?> updateSortOrder(Album album, SortOrder order) async {
try {
final updateAlbum =
await _albumApiRepository.update(album.remoteId!, sortOrder: order);
album.sortOrder = updateAlbum.sortOrder;
return _albumRepository.update(album);
} catch (error, stackTrace) {
_log.severe("Error updating album sort order", error, stackTrace);
}
return null;
}
}

View file

@ -403,6 +403,8 @@ class SyncService {
album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp;
album.shared = dto.shared;
album.activityEnabled = dto.activityEnabled;
album.sortOrder = dto.sortOrder;
final remoteThumbnailAssetId = dto.remoteThumbnailAssetId;
if (remoteThumbnailAssetId != null &&
album.thumbnail.value?.remoteId != remoteThumbnailAssetId) {

View file

@ -4,7 +4,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/db.dart';
import 'package:isar/isar.dart';
const int targetVersion = 7;
const int targetVersion = 8;
Future<void> migrateDatabaseIfNeeded(Isar db) async {
final int version = Store.get(StoreKey.version, 1);

View file

@ -1,22 +1,21 @@
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';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/activity_statistics.provider.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/album_viewer.provider.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class AlbumViewerAppbar extends HookConsumerWidget
implements PreferredSizeWidget {
const AlbumViewerAppbar({
super.key,
required this.album,
required this.userId,
required this.titleFocusNode,
this.onAddPhotos,
@ -24,34 +23,48 @@ class AlbumViewerAppbar extends HookConsumerWidget
required this.onActivities,
});
final Album album;
final String userId;
final FocusNode titleFocusNode;
final Function(Album album)? onAddPhotos;
final Function(Album album)? onAddUsers;
final Function(Album album) onActivities;
final void Function()? onAddPhotos;
final void Function()? onAddUsers;
final void Function() onActivities;
@override
Widget build(BuildContext context, WidgetRef ref) {
final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
final isProcessing = useProcessingOverlay();
final albumState = useState(ref.read(currentAlbumProvider));
final album = albumState.value;
ref.listen(currentAlbumProvider, (_, newAlbum) {
final oldAlbum = albumState.value;
if (oldAlbum != null && newAlbum != null && oldAlbum.id == newAlbum.id) {
return;
}
albumState.value = newAlbum;
});
if (album == null) {
return const SizedBox();
}
final albumViewer = ref.watch(albumViewerProvider);
final newAlbumTitle = albumViewer.editTitleText;
final isEditAlbum = albumViewer.isEditAlbum;
final comments = album.shared
? ref.watch(activityStatisticsProvider(album.remoteId!))
: 0;
deleteAlbum() async {
isProcessing.value = true;
final bool success =
await ref.watch(albumProvider.notifier).deleteAlbum(album);
final bool success;
if (album.shared) {
success = await ref.watch(albumProvider.notifier).deleteAlbum(album);
context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()]));
} else {
success = await ref.watch(albumProvider.notifier).deleteAlbum(album);
context
.navigateTo(const TabControllerRoute(children: [LibraryRoute()]));
}
if (!success) {
ImmichToast.show(
context: context,
@ -60,11 +73,9 @@ class AlbumViewerAppbar extends HookConsumerWidget
gravity: ToastGravity.BOTTOM,
);
}
isProcessing.value = false;
}
Future<void> showConfirmationDialog() async {
Future<void> onDeleteAlbumPressed() {
return showDialog<void>(
context: context,
barrierDismissible: false, // user must tap button!
@ -102,13 +113,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
);
}
void onDeleteAlbumPressed() async {
showConfirmationDialog();
}
void onLeaveAlbumPressed() async {
isProcessing.value = true;
bool isSuccess =
await ref.watch(albumProvider.notifier).leaveAlbum(album);
@ -123,8 +128,6 @@ class AlbumViewerAppbar extends HookConsumerWidget
gravity: ToastGravity.BOTTOM,
);
}
isProcessing.value = false;
}
buildBottomSheetActions() {
@ -136,7 +139,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
'album_viewer_appbar_share_delete',
style: TextStyle(fontWeight: FontWeight.w500),
).tr(),
onTap: () => onDeleteAlbumPressed(),
onTap: onDeleteAlbumPressed,
)
: ListTile(
leading: const Icon(Icons.person_remove_rounded),
@ -144,25 +147,52 @@ class AlbumViewerAppbar extends HookConsumerWidget
'album_viewer_appbar_share_leave',
style: TextStyle(fontWeight: FontWeight.w500),
).tr(),
onTap: () => onLeaveAlbumPressed(),
onTap: onLeaveAlbumPressed,
),
];
// }
}
void onSortOrderToggled() async {
final updatedAlbum =
await ref.read(albumProvider.notifier).toggleSortOrder(album);
if (updatedAlbum == null) {
ImmichToast.show(
context: context,
msg: "error_change_sort_album".tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
context.pop();
}
void buildBottomSheet() {
final ownerActions = [
ListTile(
leading: const Icon(Icons.person_add_alt_rounded),
onTap: () {
context.pop();
onAddUsers!(album);
final onAddUsers = this.onAddUsers;
if (onAddUsers != null) {
onAddUsers();
}
},
title: const Text(
"album_viewer_page_share_add_users",
style: TextStyle(fontWeight: FontWeight.w500),
).tr(),
),
ListTile(
leading: const Icon(Icons.swap_vert_rounded),
onTap: onSortOrderToggled,
title: const Text(
"change_display_order",
style: TextStyle(fontWeight: FontWeight.w500),
).tr(),
),
ListTile(
leading: const Icon(Icons.share_rounded),
onTap: () {
@ -176,7 +206,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
),
ListTile(
leading: const Icon(Icons.settings_rounded),
onTap: () => context.navigateTo(AlbumOptionsRoute(album: album)),
onTap: () => context.navigateTo(AlbumOptionsRoute()),
title: const Text(
"translated_text_options",
style: TextStyle(fontWeight: FontWeight.w500),
@ -189,7 +219,10 @@ class AlbumViewerAppbar extends HookConsumerWidget
leading: const Icon(Icons.add_photo_alternate_outlined),
onTap: () {
context.pop();
onAddPhotos!(album);
final onAddPhotos = this.onAddPhotos;
if (onAddPhotos != null) {
onAddPhotos();
}
},
title: const Text(
"share_add_photos",
@ -222,9 +255,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
Widget buildActivitiesButton() {
return IconButton(
onPressed: () {
onActivities(album);
},
onPressed: onActivities,
icon: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@ -271,7 +302,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
);
} else {
return IconButton(
onPressed: () async => await context.maybePop(),
onPressed: context.maybePop,
icon: const Icon(Icons.arrow_back_ios_rounded),
splashRadius: 25,
);
@ -285,12 +316,13 @@ class AlbumViewerAppbar extends HookConsumerWidget
actions: [
if (album.shared && (album.activityEnabled || comments != 0))
buildActivitiesButton(),
if (album.isRemote)
if (album.isRemote) ...[
IconButton(
splashRadius: 25,
onPressed: buildBottomSheet,
icon: const Icon(Icons.more_horiz_rounded),
),
],
],
);
}

View file

@ -4,20 +4,19 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album_viewer.provider.dart';
import 'package:immich_mobile/entities/album.entity.dart';
class AlbumViewerEditableTitle extends HookConsumerWidget {
final Album album;
final String albumName;
final FocusNode titleFocusNode;
const AlbumViewerEditableTitle({
super.key,
required this.album,
required this.albumName,
required this.titleFocusNode,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final titleTextEditController = useTextEditingController(text: album.name);
final titleTextEditController = useTextEditingController(text: albumName);
void onFocusModeChange() {
if (!titleFocusNode.hasFocus && titleTextEditController.text.isEmpty) {
@ -51,7 +50,7 @@ class AlbumViewerEditableTitle extends HookConsumerWidget {
onTap: () {
context.focusScope.requestFocus(titleFocusNode);
ref.watch(albumViewerProvider.notifier).setEditTitleText(album.name);
ref.watch(albumViewerProvider.notifier).setEditTitleText(albumName);
ref.watch(albumViewerProvider.notifier).enableEditAlbum();
if (titleTextEditController.text == 'Untitled') {

View file

@ -433,6 +433,7 @@ class MultiselectGrid extends HookConsumerWidget {
),
if (selectionEnabledHook.value)
ControlBottomAppBar(
key: const ValueKey("controlBottomAppBar"),
onShare: onShareAssets,
onFavorite: favoriteEnabled ? onFavoriteAssets : null,
onArchive: archiveEnabled ? onArchiveAsset : null,

View file

@ -0,0 +1,35 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/asset_viewer/render_list_status_provider.dart';
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
class MultiselectGridStatusIndicator extends HookConsumerWidget {
const MultiselectGridStatusIndicator({
super.key,
this.buildLoadingIndicator,
this.emptyIndicator,
});
final Widget Function()? buildLoadingIndicator;
final Widget? emptyIndicator;
@override
Widget build(BuildContext context, WidgetRef ref) {
final renderListStatus = ref.watch(renderListStatusProvider);
return switch (renderListStatus) {
RenderListStatusEnum.loading => buildLoadingIndicator == null
? const Center(
child: DelayedLoadingIndicator(
delay: Duration(milliseconds: 500),
),
)
: buildLoadingIndicator!(),
RenderListStatusEnum.empty =>
emptyIndicator ?? Center(child: const Text("no_assets_to_show").tr()),
RenderListStatusEnum.error =>
Center(child: const Text("error_loading_assets").tr()),
RenderListStatusEnum.complete => const SizedBox()
};
}
}

View file

@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions: