From 34620e1e9ae214b9d9c2392f6a4bde99c18771b5 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 15 Jul 2025 20:37:44 -0500 Subject: [PATCH] feat: album edit (#19936) --- i18n/en.json | 1 + .../lib/domain/models/album/album.model.dart | 28 + .../domain/services/remote_album.service.dart | 72 ++ .../lib/extensions/datetime_extensions.dart | 56 ++ .../repositories/remote_album.repository.dart | 164 +++++ .../repositories/timeline.repository.dart | 101 ++- .../lib/pages/album/album_control_button.dart | 50 +- mobile/lib/pages/album/album_viewer.dart | 16 +- .../presentation/pages/drift_album.page.dart | 27 +- .../pages/drift_create_album.page.dart | 2 + .../pages/drift_remote_album.page.dart | 422 ++++++++++- .../pages/drift_user_selection.page.dart | 215 ++++++ .../drift_album_option.widget.dart | 118 +++ .../current_album.provider.dart | 39 + .../infrastructure/remote_album.provider.dart | 107 ++- .../drift_album_api_repository.dart | 71 ++ mobile/lib/routing/router.dart | 6 + mobile/lib/routing/router.gr.dart | 38 + .../album/remote_album_shared_user_icons.dart | 50 ++ .../common/remote_album_sliver_app_bar.dart | 687 ++++++++++++++++++ .../widgets/common/user_circle_avatar.dart | 54 +- .../extensions/datetime_extensions_test.dart | 49 ++ 22 files changed, 2271 insertions(+), 102 deletions(-) create mode 100644 mobile/lib/presentation/pages/drift_user_selection.page.dart create mode 100644 mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart create mode 100644 mobile/lib/providers/infrastructure/current_album.provider.dart create mode 100644 mobile/lib/widgets/album/remote_album_shared_user_icons.dart create mode 100644 mobile/lib/widgets/common/remote_album_sliver_app_bar.dart create mode 100644 mobile/test/modules/extensions/datetime_extensions_test.dart diff --git a/i18n/en.json b/i18n/en.json index f547a4e48d..d2acfcc58f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1938,6 +1938,7 @@ "user_usage_stats_description": "View account usage statistics", "username": "Username", "users": "Users", + "users_added_to_album_count": "Added {count, plural, one {# user} other {# users}} to the album", "utilities": "Utilities", "validate": "Validate", "validate_endpoint_error": "Please enter a valid URL", diff --git a/mobile/lib/domain/models/album/album.model.dart b/mobile/lib/domain/models/album/album.model.dart index 7cafca9116..a199bce129 100644 --- a/mobile/lib/domain/models/album/album.model.dart +++ b/mobile/lib/domain/models/album/album.model.dart @@ -86,4 +86,32 @@ class RemoteAlbum { assetCount.hashCode ^ ownerName.hashCode; } + + RemoteAlbum copyWith({ + String? id, + String? name, + String? ownerId, + String? description, + DateTime? createdAt, + DateTime? updatedAt, + String? thumbnailAssetId, + bool? isActivityEnabled, + AlbumAssetOrder? order, + int? assetCount, + String? ownerName, + }) { + return RemoteAlbum( + id: id ?? this.id, + name: name ?? this.name, + ownerId: ownerId ?? this.ownerId, + description: description ?? this.description, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + thumbnailAssetId: thumbnailAssetId ?? this.thumbnailAssetId, + isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled, + order: order ?? this.order, + assetCount: assetCount ?? this.assetCount, + ownerName: ownerName ?? this.ownerName, + ); + } } diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index ae9e8b5336..ebb24d5fe5 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -1,4 +1,8 @@ +import 'dart:async'; + import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; @@ -10,6 +14,10 @@ class RemoteAlbumService { const RemoteAlbumService(this._repository, this._albumApiRepository); + Stream watchAlbum(String albumId) { + return _repository.watchAlbum(albumId); + } + Future> getAll() { return _repository.getAll(); } @@ -75,4 +83,68 @@ class RemoteAlbumService { return album; } + + Future updateAlbum( + String albumId, { + String? name, + String? description, + String? thumbnailAssetId, + bool? isActivityEnabled, + AlbumAssetOrder? order, + }) async { + final updatedAlbum = await _albumApiRepository.updateAlbum( + albumId, + name: name, + description: description, + thumbnailAssetId: thumbnailAssetId, + isActivityEnabled: isActivityEnabled, + order: order, + ); + + // Update the local database + await _repository.update(updatedAlbum); + + return updatedAlbum; + } + + FutureOr<(DateTime, DateTime)> getDateRange(String albumId) { + return _repository.getDateRange(albumId); + } + + Future> getSharedUsers(String albumId) { + return _repository.getSharedUsers(albumId); + } + + Future> getAssets(String albumId) { + return _repository.getAssets(albumId); + } + + Future addAssets({ + required String albumId, + required List assetIds, + }) async { + final album = await _albumApiRepository.addAssets( + albumId, + assetIds, + ); + + await _repository.addAssets(albumId, album.added); + + return album.added.length; + } + + Future deleteAlbum(String albumId) async { + await _albumApiRepository.deleteAlbum(albumId); + + await _repository.deleteAlbum(albumId); + } + + Future addUsers({ + required String albumId, + required List userIds, + }) async { + await _albumApiRepository.addUsers(albumId, userIds); + + return _repository.addUsers(albumId, userIds); + } } diff --git a/mobile/lib/extensions/datetime_extensions.dart b/mobile/lib/extensions/datetime_extensions.dart index 14d89e2755..e23bf5210f 100644 --- a/mobile/lib/extensions/datetime_extensions.dart +++ b/mobile/lib/extensions/datetime_extensions.dart @@ -1,3 +1,6 @@ +import 'dart:ui'; +import 'package:easy_localization/easy_localization.dart'; + extension TimeAgoExtension on DateTime { /// Displays the time difference of this [DateTime] object to the current time as a [String] String timeAgo({bool numericDates = true}) { @@ -35,3 +38,56 @@ extension TimeAgoExtension on DateTime { return '${(difference.inDays / 365).floor()} years ago'; } } + +/// Extension to format date ranges according to UI requirements +extension DateRangeFormatting on DateTime { + /// Formats a date range according to specific rules: + /// - Single date of this year: "Aug 28" + /// - Single date of other year: "Aug 28, 2023" + /// - Date range of this year: "Mar 23-May 31" + /// - Date range of other year: "Aug 28 - Sep 30, 2023" + /// - Date range over multiple years: "Apr 17, 2021 - Apr 9, 2022" + static String formatDateRange( + DateTime startDate, + DateTime endDate, + Locale? locale, + ) { + final now = DateTime.now(); + final currentYear = now.year; + final localeString = locale?.toString() ?? 'en_US'; + + // Check if it's a single date (same day) + if (startDate.year == endDate.year && + startDate.month == endDate.month && + startDate.day == endDate.day) { + if (startDate.year == currentYear) { + // Single date of this year: "Aug 28" + return DateFormat.MMMd(localeString).format(startDate); + } else { + // Single date of other year: "Aug 28, 2023" + return DateFormat.yMMMd(localeString).format(startDate); + } + } + + // It's a date range + if (startDate.year == endDate.year) { + // Same year + if (startDate.year == currentYear) { + // Date range of this year: "Mar 23-May 31" + final startFormatted = DateFormat.MMMd(localeString).format(startDate); + final endFormatted = DateFormat.MMMd(localeString).format(endDate); + return '$startFormatted - $endFormatted'; + } else { + // Date range of other year: "Aug 28 - Sep 30, 2023" + final startFormatted = DateFormat.MMMd(localeString).format(startDate); + final endFormatted = DateFormat.MMMd(localeString).format(endDate); + return '$startFormatted - $endFormatted, ${startDate.year}'; + } + } else { + // Date range over multiple years: "Apr 17, 2021 - Apr 9, 2022" + final startFormatted = DateFormat.yMMMd(localeString).format(startDate); + final endFormatted = DateFormat.yMMMd(localeString).format(endDate); + return '$startFormatted - $endFormatted'; + } + } +} diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index b77184bce0..c3c4570559 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -1,7 +1,13 @@ +import 'dart:async'; + import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; enum SortRemoteAlbumsBy { id, updatedAt } @@ -99,11 +105,169 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { }); } + Future update(RemoteAlbum album) async { + await _db.remoteAlbumEntity.update().replace( + RemoteAlbumEntityCompanion( + id: Value(album.id), + name: Value(album.name), + ownerId: Value(album.ownerId), + createdAt: Value(album.createdAt), + updatedAt: Value(album.updatedAt), + description: Value(album.description), + thumbnailAssetId: Value(album.thumbnailAssetId), + isActivityEnabled: Value(album.isActivityEnabled), + order: Value(album.order), + ), + ); + } + Future removeAssets(String albumId, List assetIds) { return _db.remoteAlbumAssetEntity.deleteWhere( (tbl) => tbl.albumId.equals(albumId) & tbl.assetId.isIn(assetIds), ); } + + FutureOr<(DateTime, DateTime)> getDateRange(String albumId) { + final query = _db.remoteAlbumAssetEntity.selectOnly() + ..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId)) + ..addColumns([ + _db.remoteAssetEntity.createdAt.min(), + _db.remoteAssetEntity.createdAt.max(), + ]) + ..join([ + innerJoin( + _db.remoteAssetEntity, + _db.remoteAssetEntity.id + .equalsExp(_db.remoteAlbumAssetEntity.assetId), + ), + ]); + + return query.map((row) { + final minDate = row.read(_db.remoteAssetEntity.createdAt.min()); + final maxDate = row.read(_db.remoteAssetEntity.createdAt.max()); + return (minDate ?? DateTime.now(), maxDate ?? DateTime.now()); + }).getSingle(); + } + + Future> getSharedUsers(String albumId) async { + final albumUserRows = await (_db.select(_db.remoteAlbumUserEntity) + ..where((row) => row.albumId.equals(albumId))) + .get(); + + if (albumUserRows.isEmpty) { + return []; + } + + final userIds = albumUserRows.map((row) => row.userId); + + return (_db.select(_db.userEntity)..where((row) => row.id.isIn(userIds))) + .map( + (user) => UserDto( + id: user.id, + email: user.email, + name: user.name, + profileImagePath: user.profileImagePath?.isEmpty == true + ? null + : user.profileImagePath, + isAdmin: user.isAdmin, + updatedAt: user.updatedAt, + quotaSizeInBytes: user.quotaSizeInBytes ?? 0, + quotaUsageInBytes: user.quotaUsageInBytes, + memoryEnabled: true, + inTimeline: false, + isPartnerSharedBy: false, + isPartnerSharedWith: false, + ), + ) + .get(); + } + + Future> getAssets(String albumId) { + final query = _db.remoteAlbumAssetEntity.select().join([ + innerJoin( + _db.remoteAssetEntity, + _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId), + ), + ]) + ..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId)); + + return query + .map((row) => row.readTable(_db.remoteAssetEntity).toDto()) + .get(); + } + + Future addAssets(String albumId, List assetIds) async { + final albumAssets = assetIds.map( + (assetId) => RemoteAlbumAssetEntityCompanion( + albumId: Value(albumId), + assetId: Value(assetId), + ), + ); + + await _db.batch((batch) { + batch.insertAll( + _db.remoteAlbumAssetEntity, + albumAssets, + ); + }); + + return assetIds.length; + } + + Future addUsers(String albumId, List userIds) { + final albumUsers = userIds.map( + (assetId) => RemoteAlbumUserEntityCompanion( + albumId: Value(albumId), + userId: Value(assetId), + role: const Value(AlbumUserRole.editor), + ), + ); + + return _db.batch((batch) { + batch.insertAll( + _db.remoteAlbumUserEntity, + albumUsers, + ); + }); + } + + Future deleteAlbum(String albumId) async { + return _db.transaction(() async { + await _db.remoteAlbumEntity.deleteWhere( + (table) => table.id.equals(albumId), + ); + }); + } + + Stream watchAlbum(String albumId) { + final query = _db.remoteAlbumEntity.select().join([ + leftOuterJoin( + _db.remoteAlbumAssetEntity, + _db.remoteAlbumAssetEntity.albumId.equalsExp(_db.remoteAlbumEntity.id), + useColumns: false, + ), + leftOuterJoin( + _db.remoteAssetEntity, + _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId), + useColumns: false, + ), + leftOuterJoin( + _db.userEntity, + _db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId), + useColumns: false, + ), + ]) + ..where(_db.remoteAlbumEntity.id.equals(albumId)) + ..addColumns([_db.userEntity.name]) + ..groupBy([_db.remoteAlbumEntity.id]); + + return query.map((row) { + final album = row.readTable(_db.remoteAlbumEntity).toDto( + ownerName: row.read(_db.userEntity.name)!, + ); + return album; + }).watchSingleOrNull(); + } } extension on RemoteAlbumEntityData { diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index c3c7fc71ab..a125d87d8d 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:drift/drift.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; @@ -195,41 +196,75 @@ class DriftTimelineRepository extends DriftDatabaseRepository { return _db.remoteAlbumAssetEntity .count(where: (row) => row.albumId.equals(albumId)) .map(_generateBuckets) - .watchSingle(); + .watch() + .map((results) => results.isNotEmpty ? results.first : []) + .handleError((error) { + return []; + }); } - final assetCountExp = _db.remoteAssetEntity.id.count(); - final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy); + return (_db.remoteAlbumEntity.select() + ..where((row) => row.id.equals(albumId))) + .watch() + .switchMap((albums) { + if (albums.isEmpty) { + return Stream.value([]); + } - final query = _db.remoteAssetEntity.selectOnly() - ..addColumns([assetCountExp, dateExp]) - ..join([ - innerJoin( - _db.remoteAlbumAssetEntity, - _db.remoteAlbumAssetEntity.assetId - .equalsExp(_db.remoteAssetEntity.id), - useColumns: false, - ), - ]) - ..where( - _db.remoteAssetEntity.deletedAt.isNull() & - _db.remoteAlbumAssetEntity.albumId.equals(albumId), - ) - ..groupBy([dateExp]) - ..orderBy([OrderingTerm.desc(dateExp)]); + final album = albums.first; + final isAscending = album.order == AlbumAssetOrder.asc; + final assetCountExp = _db.remoteAssetEntity.id.count(); + final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy); - return query.map((row) { - final timeline = row.read(dateExp)!.dateFmt(groupBy); - final assetCount = row.read(assetCountExp)!; - return TimeBucket(date: timeline, assetCount: assetCount); - }).watch(); + final query = _db.remoteAssetEntity.selectOnly() + ..addColumns([assetCountExp, dateExp]) + ..join([ + innerJoin( + _db.remoteAlbumAssetEntity, + _db.remoteAlbumAssetEntity.assetId + .equalsExp(_db.remoteAssetEntity.id), + useColumns: false, + ), + ]) + ..where( + _db.remoteAssetEntity.deletedAt.isNull() & + _db.remoteAlbumAssetEntity.albumId.equals(albumId), + ) + ..groupBy([dateExp]); + + if (isAscending) { + query.orderBy([OrderingTerm.asc(dateExp)]); + } else { + query.orderBy([OrderingTerm.desc(dateExp)]); + } + + return query.map((row) { + final timeline = row.read(dateExp)!.dateFmt(groupBy); + final assetCount = row.read(assetCountExp)!; + return TimeBucket(date: timeline, assetCount: assetCount); + }).watch(); + }).handleError((error) { + // If there's an error (e.g., album was deleted), return empty buckets + return []; + }); } Future> _getRemoteAlbumBucketAssets( String albumId, { required int offset, required int count, - }) { + }) async { + final albumData = await (_db.remoteAlbumEntity.select() + ..where((row) => row.id.equals(albumId))) + .getSingleOrNull(); + + // If album doesn't exist (was deleted), return empty list + if (albumData == null) { + return []; + } + + final isAscending = albumData.order == AlbumAssetOrder.asc; + final query = _db.remoteAssetEntity.select().join( [ innerJoin( @@ -239,13 +274,19 @@ class DriftTimelineRepository extends DriftDatabaseRepository { useColumns: false, ), ], - ) - ..where( + )..where( _db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAlbumAssetEntity.albumId.equals(albumId), - ) - ..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)]) - ..limit(count, offset: offset); + ); + + if (isAscending) { + query.orderBy([OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]); + } else { + query.orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)]); + } + + query.limit(count, offset: offset); + return query .map((row) => row.readTable(_db.remoteAssetEntity).toDto()) .get(); diff --git a/mobile/lib/pages/album/album_control_button.dart b/mobile/lib/pages/album/album_control_button.dart index b2100946e6..c453ace618 100644 --- a/mobile/lib/pages/album/album_control_button.dart +++ b/mobile/lib/pages/album/album_control_button.dart @@ -1,52 +1,40 @@ 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; + final void Function()? onAddPhotosPressed; + final void Function()? onAddUsersPressed; - AlbumControlButton({ + const AlbumControlButton({ super.key, - required this.onAddPhotosPressed, - required this.onAddUsersPressed, + this.onAddPhotosPressed, + 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), - child: SizedBox( - height: 36, - child: ListView( - scrollDirection: Axis.horizontal, - children: [ + return SizedBox( + height: 36, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + if (onAddPhotosPressed != null) AlbumActionFilledButton( key: const ValueKey('add_photos_button'), iconData: Icons.add_photo_alternate_outlined, onPressed: onAddPhotosPressed, labelText: "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(), - ), - ], - ), + if (onAddUsersPressed != null) + AlbumActionFilledButton( + key: const ValueKey('add_users_button'), + iconData: Icons.person_add_alt_rounded, + onPressed: onAddUsersPressed, + labelText: "album_viewer_page_share_add_users".tr(), + ), + ], ), ); } diff --git a/mobile/lib/pages/album/album_viewer.dart b/mobile/lib/pages/album/album_viewer.dart index 86b23fba30..2edf6082ac 100644 --- a/mobile/lib/pages/album/album_viewer.dart +++ b/mobile/lib/pages/album/album_viewer.dart @@ -41,6 +41,11 @@ class AlbumViewer extends HookConsumerWidget { final userId = ref.watch(authProvider).userId; final isMultiselecting = ref.watch(multiselectProvider); final isProcessing = useProcessingOverlay(); + final isOwner = ref.watch( + currentAlbumProvider.select((album) { + return album?.ownerId == userId; + }), + ); Future onRemoveFromAlbumPressed(Iterable assets) async { final bool isSuccess = @@ -138,10 +143,13 @@ class AlbumViewer extends HookConsumerWidget { ), const AlbumSharedUserIcons(), if (album.isRemote) - AlbumControlButton( - key: const ValueKey("albumControlButton"), - onAddPhotosPressed: onAddPhotosPressed, - onAddUsersPressed: onAddUsersPressed, + Padding( + padding: const EdgeInsets.only(left: 16.0), + child: AlbumControlButton( + key: const ValueKey("albumControlButton"), + onAddPhotosPressed: onAddPhotosPressed, + onAddUsersPressed: isOwner ? onAddUsersPressed : null, + ), ), const SizedBox(height: 8), ], diff --git a/mobile/lib/presentation/pages/drift_album.page.dart b/mobile/lib/presentation/pages/drift_album.page.dart index e6d3d796a4..d59f734c79 100644 --- a/mobile/lib/presentation/pages/drift_album.page.dart +++ b/mobile/lib/presentation/pages/drift_album.page.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/remote_album.utils.dart'; @@ -478,7 +479,7 @@ class _QuickSortAndViewMode extends StatelessWidget { } } -class _AlbumList extends StatelessWidget { +class _AlbumList extends ConsumerWidget { const _AlbumList({ required this.isLoading, required this.error, @@ -492,7 +493,7 @@ class _AlbumList extends StatelessWidget { final String? userId; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { if (isLoading) { return const SliverToBoxAdapter( child: Center( @@ -567,9 +568,12 @@ class _AlbumList extends StatelessWidget { color: context.colorScheme.onSurfaceSecondary, ), ), - onTap: () => context.router.push( - RemoteAlbumRoute(album: album), - ), + onTap: () { + ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album); + context.router.push( + RemoteAlbumRoute(album: album), + ); + }, leadingPadding: const EdgeInsets.only( right: 16, ), @@ -692,7 +696,7 @@ class _AlbumGrid extends StatelessWidget { } } -class _GridAlbumCard extends StatelessWidget { +class _GridAlbumCard extends ConsumerWidget { const _GridAlbumCard({ required this.album, required this.userId, @@ -702,11 +706,14 @@ class _GridAlbumCard extends StatelessWidget { final String? userId; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return GestureDetector( - onTap: () => context.router.push( - RemoteAlbumRoute(album: album), - ), + onTap: () { + ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album); + context.router.push( + RemoteAlbumRoute(album: album), + ); + }, child: Card( elevation: 0, color: context.colorScheme.surfaceBright, diff --git a/mobile/lib/presentation/pages/drift_create_album.page.dart b/mobile/lib/presentation/pages/drift_create_album.page.dart index e06321413e..f6ba98f61c 100644 --- a/mobile/lib/presentation/pages/drift_create_album.page.dart +++ b/mobile/lib/presentation/pages/drift_create_album.page.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/album/album_action_filled_button.dart'; @@ -202,6 +203,7 @@ class _DriftCreateAlbumPageState extends ConsumerState { ); if (album != null) { + ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album); context.replaceRoute( RemoteAlbumRoute(album: album), ); diff --git a/mobile/lib/presentation/pages/drift_remote_album.page.dart b/mobile/lib/presentation/pages/drift_remote_album.page.dart index bbfe6ddc74..6b68bfcd4e 100644 --- a/mobile/lib/presentation/pages/drift_remote_album.page.dart +++ b/mobile/lib/presentation/pages/drift_remote_album.page.dart @@ -1,17 +1,237 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; + import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart'; +import 'package:immich_mobile/presentation/widgets/remote_album/drift_album_option.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; -import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/widgets/common/remote_album_sliver_app_bar.dart'; @RoutePage() -class RemoteAlbumPage extends StatelessWidget { +class RemoteAlbumPage extends ConsumerStatefulWidget { final RemoteAlbum album; - const RemoteAlbumPage({super.key, required this.album}); + const RemoteAlbumPage({ + super.key, + required this.album, + }); + + @override + ConsumerState createState() => _RemoteAlbumPageState(); +} + +class _RemoteAlbumPageState extends ConsumerState { + @override + void initState() { + super.initState(); + } + + Future addAssets(BuildContext context) async { + final albumAssets = + await ref.read(remoteAlbumProvider.notifier).getAssets(widget.album.id); + + final newAssets = await context.pushRoute>( + DriftAssetSelectionTimelineRoute( + lockedSelectionAssets: albumAssets.toSet(), + ), + ); + + if (newAssets == null || newAssets.isEmpty) { + return; + } + + final added = await ref.read(remoteAlbumProvider.notifier).addAssets( + widget.album.id, + newAssets.map((asset) { + final remoteAsset = asset as RemoteAsset; + return remoteAsset.id; + }).toList(), + ); + + if (added > 0) { + ImmichToast.show( + context: context, + msg: "assets_added_to_album_count".t( + context: context, + args: { + 'count': added.toString(), + }, + ), + toastType: ToastType.success, + ); + } + } + + Future addUsers(BuildContext context) async { + final newUsers = await context.pushRoute>( + DriftUserSelectionRoute(album: widget.album), + ); + + if (newUsers == null || newUsers.isEmpty) { + return; + } + + try { + await ref + .read(remoteAlbumProvider.notifier) + .addUsers(widget.album.id, newUsers); + + if (newUsers.isNotEmpty) { + ImmichToast.show( + context: context, + msg: "users_added_to_album_count".t( + context: context, + args: { + 'count': newUsers.length, + }, + ), + toastType: ToastType.success, + ); + } + + ref.invalidate(remoteAlbumSharedUsersProvider(widget.album.id)); + } catch (e) { + ImmichToast.show( + context: context, + msg: "Failed to add users to album: ${e.toString()}", + toastType: ToastType.error, + ); + } + } + + Future toggleAlbumOrder() async { + await ref.read(remoteAlbumProvider.notifier).toggleAlbumOrder( + widget.album.id, + ); + + ref.invalidate(timelineServiceProvider); + } + + Future deleteAlbum(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text('delete_album'.t(context: context)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'album_delete_confirmation'.t( + context: context, + args: {'album': widget.album.name}, + ), + ), + const SizedBox(height: 8), + Text( + 'album_delete_confirmation_description'.t(context: context), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text('cancel'.t(context: context)), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text('delete_album'.t(context: context)), + ), + ], + ); + }, + ); + + if (confirmed == true) { + try { + await ref + .read(remoteAlbumProvider.notifier) + .deleteAlbum(widget.album.id); + + ImmichToast.show( + context: context, + msg: 'library_deleted' + .t(context: context), // Using existing success message + toastType: ToastType.success, + ); + + context.pushRoute(const DriftAlbumsRoute()); + } catch (e) { + ImmichToast.show( + context: context, + msg: 'album_viewer_appbar_share_err_delete'.t(context: context), + toastType: ToastType.error, + ); + } + } + } + + Future showEditTitleAndDescription(BuildContext context) async { + final result = await showDialog<_EditAlbumData?>( + context: context, + barrierDismissible: true, + builder: (context) => _EditAlbumDialog(album: widget.album), + ); + + if (result != null && context.mounted) { + HapticFeedback.mediumImpact(); + } + } + + void showOptionSheet(BuildContext context) { + final user = ref.watch(currentUserProvider); + final isOwner = user != null ? user.id == widget.album.ownerId : false; + + showModalBottomSheet( + context: context, + backgroundColor: context.colorScheme.surface, + isScrollControlled: false, + builder: (context) { + return DriftRemoteAlbumOption( + onDeleteAlbum: isOwner + ? () async { + await deleteAlbum(context); + if (context.mounted) { + context.pop(); + } + } + : null, + onAddUsers: isOwner + ? () async { + await addUsers(context); + context.pop(); + } + : null, + onAddPhotos: () async { + await addAssets(context); + context.pop(); + }, + onToggleAlbumOrder: () async { + await toggleAlbumOrder(); + context.pop(); + }, + onEditAlbum: () async { + context.pop(); + await showEditTitleAndDescription(context); + }, + ); + }, + ); + } @override Widget build(BuildContext context) { @@ -21,19 +241,207 @@ class RemoteAlbumPage extends StatelessWidget { (ref) { final timelineService = ref .watch(timelineFactoryProvider) - .remoteAlbum(albumId: album.id); + .remoteAlbum(albumId: widget.album.id); ref.onDispose(timelineService.dispose); return timelineService; }, ), ], child: Timeline( - appBar: MesmerizingSliverAppBar( - title: album.name, + appBar: RemoteAlbumSliverAppBar( icon: Icons.photo_album_outlined, + onShowOptions: () => showOptionSheet(context), + onToggleAlbumOrder: () => toggleAlbumOrder(), + onEditTitle: () => showEditTitleAndDescription(context), ), bottomSheet: RemoteAlbumBottomSheet( - album: album, + album: widget.album, + ), + ), + ); + } +} + +class _EditAlbumData { + final String name; + final String? description; + + const _EditAlbumData({ + required this.name, + this.description, + }); +} + +class _EditAlbumDialog extends ConsumerStatefulWidget { + final RemoteAlbum album; + + const _EditAlbumDialog({ + required this.album, + }); + + @override + ConsumerState<_EditAlbumDialog> createState() => _EditAlbumDialogState(); +} + +class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> { + late final TextEditingController titleController; + late final TextEditingController descriptionController; + final formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + titleController = TextEditingController(text: widget.album.name); + descriptionController = TextEditingController( + text: widget.album.description.isEmpty ? '' : widget.album.description, + ); + } + + @override + void dispose() { + titleController.dispose(); + descriptionController.dispose(); + super.dispose(); + } + + Future _handleSave() async { + if (formKey.currentState?.validate() != true) return; + + try { + final newTitle = titleController.text.trim(); + final newDescription = descriptionController.text.trim(); + + await ref.read(remoteAlbumProvider.notifier).updateAlbum( + widget.album.id, + name: newTitle, + description: newDescription.isEmpty ? null : newDescription, + ); + + if (mounted) { + Navigator.of(context).pop( + _EditAlbumData( + name: newTitle, + description: newDescription.isEmpty ? null : newDescription, + ), + ); + } + } catch (e) { + if (mounted) { + ImmichToast.show( + context: context, + msg: 'album_update_error'.t(context: context), + toastType: ToastType.error, + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Dialog( + insetPadding: const EdgeInsets.all(24), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(16), + ), + ), + child: SingleChildScrollView( + child: Container( + padding: const EdgeInsets.all(16), + constraints: const BoxConstraints(maxWidth: 550), + child: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + Icons.edit_outlined, + color: context.colorScheme.primary, + size: 24, + ), + const SizedBox(width: 12), + Text( + 'edit_album'.t(context: context), + style: context.textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: 24), + + // Album Name + Text( + 'album_name'.t(context: context).toUpperCase(), + style: context.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + TextFormField( + controller: titleController, + maxLines: 1, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + filled: true, + fillColor: context.colorScheme.surface, + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'album_name_required'.t(context: context); + } + + return null; + }, + ), + const SizedBox(height: 18), + + // Description + Text( + 'description'.t(context: context).toUpperCase(), + style: context.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + TextFormField( + controller: descriptionController, + maxLines: 4, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + border: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(12), + ), + ), + filled: true, + fillColor: context.colorScheme.surface, + ), + ), + const SizedBox(height: 24), + + // Action Buttons + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(null), + child: Text('cancel'.t(context: context)), + ), + const SizedBox(width: 12), + FilledButton( + onPressed: _handleSave, + child: Text('save'.t(context: context)), + ), + ], + ), + ], + ), + ), ), ), ); diff --git a/mobile/lib/presentation/pages/drift_user_selection.page.dart b/mobile/lib/presentation/pages/drift_user_selection.page.dart new file mode 100644 index 0000000000..6f5c4c3e2b --- /dev/null +++ b/mobile/lib/presentation/pages/drift_user_selection.page.dart @@ -0,0 +1,215 @@ +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:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/user_metadata.model.dart'; +import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; + +// TODO: Refactor this provider when we have user provider/service/repository pattern in place +final driftUsersProvider = + FutureProvider.autoDispose>((ref) async { + final drift = ref.watch(driftProvider); + final currentUser = ref.watch(currentUserProvider); + + final userEntities = await drift.managers.userEntity.get(); + + final users = userEntities + .map( + (entity) => UserDto( + id: entity.id, + name: entity.name, + email: entity.email, + isAdmin: entity.isAdmin, + profileImagePath: entity.profileImagePath, + updatedAt: entity.updatedAt, + quotaSizeInBytes: entity.quotaSizeInBytes ?? 0, + quotaUsageInBytes: entity.quotaUsageInBytes, + isPartnerSharedBy: false, + isPartnerSharedWith: false, + avatarColor: AvatarColor.primary, + memoryEnabled: true, + inTimeline: true, + ), + ) + .toList(); + + users.removeWhere((u) => currentUser?.id == u.id); + + return users; +}); + +@RoutePage() +class DriftUserSelectionPage extends HookConsumerWidget { + final RemoteAlbum album; + + const DriftUserSelectionPage({ + super.key, + required this.album, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final AsyncValue> suggestedShareUsers = + ref.watch(driftUsersProvider); + final sharedUsersList = useState>({}); + + addNewUsersHandler() { + context.maybePop(sharedUsersList.value.map((e) => e.id).toList()); + } + + buildTileIcon(UserDto user) { + if (sharedUsersList.value.contains(user)) { + return CircleAvatar( + backgroundColor: context.primaryColor, + child: const Icon( + Icons.check_rounded, + size: 25, + ), + ); + } else { + return UserCircleAvatar( + user: user, + ); + } + } + + buildUserList(List users) { + List usersChip = []; + + for (var user in sharedUsersList.value) { + usersChip.add( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Chip( + backgroundColor: context.primaryColor.withValues(alpha: 0.15), + label: Text( + user.name, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ); + } + return ListView( + children: [ + Wrap( + children: [...usersChip], + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'suggestions'.tr(), + style: const TextStyle( + fontSize: 14, + color: Colors.grey, + fontWeight: FontWeight.bold, + ), + ), + ), + ListView.builder( + primary: false, + shrinkWrap: true, + itemBuilder: ((context, index) { + return ListTile( + leading: buildTileIcon(users[index]), + dense: true, + title: Text( + users[index].name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + subtitle: Text( + users[index].email, + style: const TextStyle( + fontSize: 12, + ), + ), + onTap: () { + if (sharedUsersList.value.contains(users[index])) { + sharedUsersList.value = sharedUsersList.value + .where( + (selectedUser) => selectedUser.id != users[index].id, + ) + .toSet(); + } else { + sharedUsersList.value = { + ...sharedUsersList.value, + users[index], + }; + } + }, + ); + }), + itemCount: users.length, + ), + ], + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text( + 'invite_to_album', + ).tr(), + elevation: 0, + centerTitle: false, + leading: IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () { + context.maybePop(null); + }, + ), + actions: [ + TextButton( + onPressed: + sharedUsersList.value.isEmpty ? null : addNewUsersHandler, + child: const Text( + "add", + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ).tr(), + ), + ], + ), + body: suggestedShareUsers.widgetWhen( + onData: (users) { + // Get shared users for this album from the database + final sharedUsers = + ref.watch(remoteAlbumSharedUsersProvider(album.id)); + + return sharedUsers.when( + data: (albumSharedUsers) { + // Filter out users that are already shared with this album and the owner + final filteredUsers = users.where((user) { + return !albumSharedUsers + .any((sharedUser) => sharedUser.id == user.id) && + user.id != album.ownerId; + }).toList(); + + return buildUserList(filteredUsers); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) { + // If we can't load shared users, just filter out the owner + final filteredUsers = + users.where((user) => user.id != album.ownerId).toList(); + return buildUserList(filteredUsers); + }, + ); + }, + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart b/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart new file mode 100644 index 0000000000..d32608a8b4 --- /dev/null +++ b/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; + +class DriftRemoteAlbumOption extends ConsumerWidget { + const DriftRemoteAlbumOption({ + super.key, + this.onAddPhotos, + this.onAddUsers, + this.onDeleteAlbum, + this.onLeaveAlbum, + this.onCreateSharedLink, + this.onToggleAlbumOrder, + this.onEditAlbum, + }); + + final VoidCallback? onAddPhotos; + final VoidCallback? onAddUsers; + final VoidCallback? onDeleteAlbum; + final VoidCallback? onLeaveAlbum; + final VoidCallback? onCreateSharedLink; + final VoidCallback? onToggleAlbumOrder; + final VoidCallback? onEditAlbum; + + @override + Widget build(BuildContext context, WidgetRef ref) { + TextStyle textStyle = Theme.of(context).textTheme.bodyLarge!.copyWith( + fontWeight: FontWeight.w600, + ); + + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: ListView( + shrinkWrap: true, + children: [ + if (onEditAlbum != null) + ListTile( + leading: const Icon(Icons.edit), + title: Text( + 'edit_album'.t(context: context), + style: textStyle, + ), + onTap: onEditAlbum, + ), + if (onAddPhotos != null) + ListTile( + leading: const Icon(Icons.add_a_photo), + title: Text( + 'add_photos'.t(context: context), + style: textStyle, + ), + onTap: onAddPhotos, + ), + if (onAddUsers != null) + ListTile( + leading: const Icon(Icons.group_add), + title: Text( + 'album_viewer_page_share_add_users'.t(context: context), + style: textStyle, + ), + onTap: onAddUsers, + ), + if (onLeaveAlbum != null) + ListTile( + leading: const Icon(Icons.person_remove_rounded), + title: Text( + 'leave_album'.t(context: context), + style: textStyle, + ), + onTap: onLeaveAlbum, + ), + if (onToggleAlbumOrder != null) + ListTile( + leading: const Icon(Icons.swap_vert_rounded), + title: Text( + 'change_display_order'.t(context: context), + style: textStyle, + ), + onTap: onToggleAlbumOrder, + ), + if (onCreateSharedLink != null) + ListTile( + leading: const Icon(Icons.link), + title: Text( + 'create_shared_link'.t(context: context), + style: textStyle, + ), + onTap: onCreateSharedLink, + ), + if (onDeleteAlbum != null) ...[ + const Divider( + indent: 16, + endIndent: 16, + ), + ListTile( + leading: Icon( + Icons.delete, + color: + context.isDarkTheme ? Colors.red[400] : Colors.red[800], + ), + title: Text( + 'delete_album'.t(context: context), + style: textStyle.copyWith( + color: + context.isDarkTheme ? Colors.red[400] : Colors.red[800], + ), + ), + onTap: onDeleteAlbum, + ), + ], + ], + ), + ), + ); + } +} diff --git a/mobile/lib/providers/infrastructure/current_album.provider.dart b/mobile/lib/providers/infrastructure/current_album.provider.dart new file mode 100644 index 0000000000..ece188ee15 --- /dev/null +++ b/mobile/lib/providers/infrastructure/current_album.provider.dart @@ -0,0 +1,39 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; + +final currentRemoteAlbumProvider = + AutoDisposeNotifierProvider( + CurrentAlbumNotifier.new, +); + +class CurrentAlbumNotifier extends AutoDisposeNotifier { + KeepAliveLink? _keepAliveLink; + StreamSubscription? _assetSubscription; + + @override + RemoteAlbum? build() => null; + + void setAlbum(RemoteAlbum album) { + _keepAliveLink?.close(); + _assetSubscription?.cancel(); + state = album; + + _assetSubscription = ref + .watch(remoteAlbumServiceProvider) + .watchAlbum(album.id) + .listen((updatedAlbum) { + if (updatedAlbum != null) { + state = updatedAlbum; + } + }); + _keepAliveLink = ref.keepAlive(); + } + + void dispose() { + _keepAliveLink?.close(); + _assetSubscription?.cancel(); + } +} diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart index 84db53ab9f..14badd58ed 100644 --- a/mobile/lib/providers/infrastructure/remote_album.provider.dart +++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart @@ -1,5 +1,8 @@ import 'package:collection/collection.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/utils/remote_album.utils.dart'; @@ -58,7 +61,7 @@ class RemoteAlbumState { } class RemoteAlbumNotifier extends Notifier { - late final RemoteAlbumService _remoteAlbumService; + late RemoteAlbumService _remoteAlbumService; @override RemoteAlbumState build() { @@ -145,4 +148,106 @@ class RemoteAlbumNotifier extends Notifier { rethrow; } } + + Future updateAlbum( + String albumId, { + String? name, + String? description, + String? thumbnailAssetId, + bool? isActivityEnabled, + AlbumAssetOrder? order, + }) async { + state = state.copyWith(isLoading: true, error: null); + + try { + final updatedAlbum = await _remoteAlbumService.updateAlbum( + albumId, + name: name, + description: description, + thumbnailAssetId: thumbnailAssetId, + isActivityEnabled: isActivityEnabled, + order: order, + ); + + final updatedAlbums = state.albums.map((album) { + return album.id == albumId ? updatedAlbum : album; + }).toList(); + + final updatedFilteredAlbums = state.filteredAlbums.map((album) { + return album.id == albumId ? updatedAlbum : album; + }).toList(); + + state = state.copyWith( + albums: updatedAlbums, + filteredAlbums: updatedFilteredAlbums, + isLoading: false, + ); + + return updatedAlbum; + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + rethrow; + } + } + + Future toggleAlbumOrder(String albumId) async { + final currentAlbum = + state.albums.firstWhere((album) => album.id == albumId); + + final newOrder = currentAlbum.order == AlbumAssetOrder.asc + ? AlbumAssetOrder.desc + : AlbumAssetOrder.asc; + + return updateAlbum(albumId, order: newOrder); + } + + Future deleteAlbum(String albumId) async { + await _remoteAlbumService.deleteAlbum(albumId); + + final updatedAlbums = + state.albums.where((album) => album.id != albumId).toList(); + final updatedFilteredAlbums = + state.filteredAlbums.where((album) => album.id != albumId).toList(); + + state = state.copyWith( + albums: updatedAlbums, + filteredAlbums: updatedFilteredAlbums, + ); + } + + Future> getAssets(String albumId) { + return _remoteAlbumService.getAssets(albumId); + } + + Future addAssets(String albumId, List assetIds) { + return _remoteAlbumService.addAssets( + albumId: albumId, + assetIds: assetIds, + ); + } + + Future addUsers(String albumId, List userIds) { + return _remoteAlbumService.addUsers( + albumId: albumId, + userIds: userIds, + ); + } } + +final remoteAlbumDateRangeProvider = + FutureProvider.family<(DateTime, DateTime), String>( + (ref, albumId) async { + final service = ref.watch(remoteAlbumServiceProvider); + return service.getDateRange(albumId); + }, +); + +final remoteAlbumSharedUsersProvider = + FutureProvider.autoDispose.family, String>( + (ref, albumId) async { + final link = ref.keepAlive(); + ref.onDispose(() => link.close()); + final service = ref.watch(remoteAlbumServiceProvider); + return service.getSharedUsers(albumId); + }, +); diff --git a/mobile/lib/repositories/drift_album_api_repository.dart b/mobile/lib/repositories/drift_album_api_repository.dart index 7ef24f1e7c..26b55fbef6 100644 --- a/mobile/lib/repositories/drift_album_api_repository.dart +++ b/mobile/lib/repositories/drift_album_api_repository.dart @@ -52,6 +52,77 @@ class DriftAlbumApiRepository extends ApiRepository { } return (removed: removed, failed: failed); } + + Future<({List added, List failed})> addAssets( + String albumId, + Iterable assetIds, + ) async { + final response = await checkNull( + _api.addAssetsToAlbum( + albumId, + BulkIdsDto(ids: assetIds.toList()), + ), + ); + final List added = [], failed = []; + for (final dto in response) { + if (dto.success) { + added.add(dto.id); + } else { + failed.add(dto.id); + } + } + + return (added: added, failed: failed); + } + + Future updateAlbum( + String albumId, { + String? name, + String? description, + String? thumbnailAssetId, + bool? isActivityEnabled, + AlbumAssetOrder? order, + }) async { + AssetOrder? apiOrder; + if (order != null) { + apiOrder = + order == AlbumAssetOrder.asc ? AssetOrder.asc : AssetOrder.desc; + } + + final responseDto = await checkNull( + _api.updateAlbumInfo( + albumId, + UpdateAlbumDto( + albumName: name, + description: description, + albumThumbnailAssetId: thumbnailAssetId, + isActivityEnabled: isActivityEnabled, + order: apiOrder, + ), + ), + ); + + return responseDto.toRemoteAlbum(); + } + + Future deleteAlbum(String albumId) { + return _api.deleteAlbum(albumId); + } + + Future addUsers( + String albumId, + Iterable userIds, + ) async { + final albumUsers = + userIds.map((userId) => AlbumUserAddDto(userId: userId)).toList(); + final response = await checkNull( + _api.addUsersToAlbum( + albumId, + AddUsersDto(albumUsers: albumUsers), + ), + ); + return response.toRemoteAlbum(); + } } extension on AlbumResponseDto { diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 7cd628606b..ddd630727d 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -75,6 +75,7 @@ import 'package:immich_mobile/presentation/pages/drift_local_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_place.page.dart'; import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart'; import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; import 'package:immich_mobile/presentation/pages/drift_archive.page.dart'; @@ -463,6 +464,11 @@ class AppRouter extends RootStackRouter { page: DriftPlaceDetailRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: DriftUserSelectionRoute.page, + guards: [_authGuard, _duplicateGuard], + ), + // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index e4719697c2..a8a26836c7 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -964,6 +964,44 @@ class DriftTrashRoute extends PageRouteInfo { ); } +/// generated route for +/// [DriftUserSelectionPage] +class DriftUserSelectionRoute + extends PageRouteInfo { + DriftUserSelectionRoute({ + Key? key, + required RemoteAlbum album, + List? children, + }) : super( + DriftUserSelectionRoute.name, + args: DriftUserSelectionRouteArgs(key: key, album: album), + initialChildren: children, + ); + + static const String name = 'DriftUserSelectionRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return DriftUserSelectionPage(key: args.key, album: args.album); + }, + ); +} + +class DriftUserSelectionRouteArgs { + const DriftUserSelectionRouteArgs({this.key, required this.album}); + + final Key? key; + + final RemoteAlbum album; + + @override + String toString() { + return 'DriftUserSelectionRouteArgs{key: $key, album: $album}'; + } +} + /// generated route for /// [DriftVideoPage] class DriftVideoRoute extends PageRouteInfo { diff --git a/mobile/lib/widgets/album/remote_album_shared_user_icons.dart b/mobile/lib/widgets/album/remote_album_shared_user_icons.dart new file mode 100644 index 0000000000..dd1a64abe0 --- /dev/null +++ b/mobile/lib/widgets/album/remote_album_shared_user_icons.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; +import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; + +class RemoteAlbumSharedUserIcons extends ConsumerWidget { + const RemoteAlbumSharedUserIcons({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currentAlbum = ref.watch(currentRemoteAlbumProvider); + if (currentAlbum == null) { + return const SizedBox(); + } + + final sharedUsersAsync = + ref.watch(remoteAlbumSharedUsersProvider(currentAlbum.id)); + + return sharedUsersAsync.maybeWhen( + data: (sharedUsers) { + if (sharedUsers.isEmpty) { + return const SizedBox(); + } + + return SizedBox( + height: 50, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemBuilder: ((context, index) { + return Padding( + padding: const EdgeInsets.only(right: 4.0), + child: UserCircleAvatar( + user: sharedUsers[index], + radius: 18, + size: 36, + hasBorder: true, + ), + ); + }), + itemCount: sharedUsers.length, + ), + ); + }, + orElse: () => const SizedBox(), + ); + } +} diff --git a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart new file mode 100644 index 0000000000..2d35a6702b --- /dev/null +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -0,0 +1,687 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/datetime_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/album/remote_album_shared_user_icons.dart'; + +class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget { + const RemoteAlbumSliverAppBar({ + super.key, + this.icon = Icons.camera, + this.onShowOptions, + this.onToggleAlbumOrder, + this.onEditTitle, + }); + + final IconData icon; + final void Function()? onShowOptions; + final void Function()? onToggleAlbumOrder; + final void Function()? onEditTitle; + + @override + ConsumerState createState() => + _MesmerizingSliverAppBarState(); +} + +class _MesmerizingSliverAppBarState + extends ConsumerState { + double _scrollProgress = 0.0; + + double _calculateScrollProgress(FlexibleSpaceBarSettings? settings) { + if (settings?.maxExtent == null || settings?.minExtent == null) { + return 1.0; + } + + final deltaExtent = settings!.maxExtent - settings.minExtent; + if (deltaExtent <= 0.0) { + return 1.0; + } + + return (1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent) + .clamp(0.0, 1.0); + } + + @override + Widget build(BuildContext context) { + final isMultiSelectEnabled = + ref.watch(multiSelectProvider.select((s) => s.isEnabled)); + + final currentAlbum = ref.watch(currentRemoteAlbumProvider); + if (currentAlbum == null) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + Color? actionIconColor = Color.lerp( + Colors.white, + context.primaryColor, + _scrollProgress, + ); + + List actionIconShadows = [ + if (_scrollProgress < 0.95) + Shadow( + offset: const Offset(0, 2), + blurRadius: 5, + color: Colors.black.withValues(alpha: 0.5), + ) + else + const Shadow( + offset: Offset(0, 2), + blurRadius: 0, + color: Colors.transparent, + ), + ]; + + return isMultiSelectEnabled + ? SliverToBoxAdapter( + child: switch (_scrollProgress) { + < 0.8 => const SizedBox(height: 120), + _ => const SizedBox(height: 452), + }, + ) + : SliverAppBar( + expandedHeight: 400.0, + floating: false, + pinned: true, + snap: false, + elevation: 0, + leading: IconButton( + icon: Icon( + Platform.isIOS + ? Icons.arrow_back_ios_new_rounded + : Icons.arrow_back, + color: actionIconColor, + shadows: actionIconShadows, + ), + onPressed: () { + ref.read(remoteAlbumProvider.notifier).refresh(); + context.pop(); + }, + ), + actions: [ + if (widget.onToggleAlbumOrder != null) + IconButton( + icon: Icon( + Icons.swap_vert_rounded, + color: actionIconColor, + shadows: actionIconShadows, + ), + onPressed: widget.onToggleAlbumOrder, + ), + if (widget.onShowOptions != null) + IconButton( + icon: Icon( + Icons.more_vert, + color: actionIconColor, + shadows: actionIconShadows, + ), + onPressed: widget.onShowOptions, + ), + ], + flexibleSpace: Builder( + builder: (context) { + final settings = context.dependOnInheritedWidgetOfExactType< + FlexibleSpaceBarSettings>(); + final scrollProgress = _calculateScrollProgress(settings); + + // Update scroll progress for the leading button + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _scrollProgress != scrollProgress) { + setState(() { + _scrollProgress = scrollProgress; + }); + } + }); + + return FlexibleSpaceBar( + centerTitle: true, + title: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: scrollProgress > 0.95 + ? Text( + currentAlbum.name, + style: TextStyle( + color: context.primaryColor, + fontWeight: FontWeight.w600, + fontSize: 18, + ), + ) + : null, + ), + background: _ExpandedBackground( + scrollProgress: scrollProgress, + icon: widget.icon, + onEditTitle: widget.onEditTitle, + ), + ); + }, + ), + ); + } +} + +class _ExpandedBackground extends ConsumerStatefulWidget { + final double scrollProgress; + final IconData icon; + final void Function()? onEditTitle; + + const _ExpandedBackground({ + required this.scrollProgress, + required this.icon, + this.onEditTitle, + }); + + @override + ConsumerState<_ExpandedBackground> createState() => + _ExpandedBackgroundState(); +} + +class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> + with SingleTickerProviderStateMixin { + late AnimationController _slideController; + late Animation _slideAnimation; + + @override + void initState() { + super.initState(); + + _slideController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + ); + + _slideAnimation = Tween( + begin: const Offset(0, 1.5), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: _slideController, + curve: Curves.easeOutCubic, + ), + ); + + Future.delayed(const Duration(milliseconds: 100), () { + if (mounted) { + _slideController.forward(); + } + }); + } + + @override + void dispose() { + _slideController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final timelineService = ref.watch(timelineServiceProvider); + final currentAlbum = ref.watch(currentRemoteAlbumProvider); + + if (currentAlbum == null) { + return const SizedBox.shrink(); + } + + final dateRange = ref.watch( + remoteAlbumDateRangeProvider(currentAlbum.id), + ); + return Stack( + fit: StackFit.expand, + children: [ + Transform.translate( + offset: Offset(0, widget.scrollProgress * 50), + child: Transform.scale( + scale: 1.4 - (widget.scrollProgress * 0.2), + child: _RandomAssetBackground( + timelineService: timelineService, + icon: widget.icon, + ), + ), + ), + ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: widget.scrollProgress * 2.0, + sigmaY: widget.scrollProgress * 2.0, + ), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.05), + Colors.transparent, + Colors.black.withValues(alpha: 0.3), + Colors.black.withValues( + alpha: 0.6 + (widget.scrollProgress * 0.25), + ), + ], + stops: const [0.0, 0.15, 0.55, 1.0], + ), + ), + ), + ), + ), + Positioned( + bottom: 16, + left: 16, + right: 16, + child: SlideTransition( + position: _slideAnimation, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + if (dateRange.hasValue) + Text( + DateRangeFormatting.formatDateRange( + dateRange.value!.$1.toLocal(), + dateRange.value!.$2.toLocal(), + context.locale, + ), + style: const TextStyle( + color: Colors.white, + shadows: [ + Shadow( + offset: Offset(0, 2), + blurRadius: 12, + color: Colors.black87, + ), + ], + ), + ), + const Text( + " • ", + style: TextStyle( + color: Colors.white, + shadows: [ + Shadow( + offset: Offset(0, 2), + blurRadius: 12, + color: Colors.black87, + ), + ], + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 300), + child: const _ItemCountText(), + ), + ], + ), + GestureDetector( + onTap: widget.onEditTitle, + child: SizedBox( + width: double.infinity, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text( + currentAlbum.name, + maxLines: 1, + style: const TextStyle( + color: Colors.white, + fontSize: 36, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + shadows: [ + Shadow( + offset: Offset(0, 2), + blurRadius: 12, + color: Colors.black54, + ), + ], + ), + ), + ), + ), + ), + if (currentAlbum.description.isNotEmpty) + GestureDetector( + onTap: widget.onEditTitle, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 80, + ), + child: SingleChildScrollView( + child: Text( + currentAlbum.description, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + shadows: [ + Shadow( + offset: Offset(0, 2), + blurRadius: 8, + color: Colors.black54, + ), + ], + ), + ), + ), + ), + ), + const Padding( + padding: EdgeInsets.only(top: 8.0), + child: RemoteAlbumSharedUserIcons(), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _ItemCountText extends ConsumerStatefulWidget { + const _ItemCountText(); + + @override + ConsumerState<_ItemCountText> createState() => _ItemCountTextState(); +} + +class _ItemCountTextState extends ConsumerState<_ItemCountText> { + StreamSubscription? _reloadSubscription; + + @override + void initState() { + super.initState(); + _reloadSubscription = + EventStream.shared.listen((_) => setState(() {})); + } + + @override + void dispose() { + _reloadSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final assetCount = ref.watch( + timelineServiceProvider.select((s) => s.totalAssets), + ); + + return Text( + 'items_count'.t( + context: context, + args: {"count": assetCount}, + ), + style: context.textTheme.labelLarge?.copyWith( + color: Colors.white, + shadows: [ + const Shadow( + offset: Offset(0, 2), + blurRadius: 12, + color: Colors.black87, + ), + ], + ), + ); + } +} + +class _RandomAssetBackground extends StatefulWidget { + final TimelineService timelineService; + final IconData icon; + + const _RandomAssetBackground({ + required this.timelineService, + required this.icon, + }); + + @override + State<_RandomAssetBackground> createState() => _RandomAssetBackgroundState(); +} + +class _RandomAssetBackgroundState extends State<_RandomAssetBackground> + with TickerProviderStateMixin { + late AnimationController _zoomController; + late AnimationController _crossFadeController; + late Animation _zoomAnimation; + late Animation _panAnimation; + late Animation _crossFadeAnimation; + BaseAsset? _currentAsset; + BaseAsset? _nextAsset; + bool _isZoomingIn = true; + + @override + void initState() { + super.initState(); + + _zoomController = AnimationController( + duration: const Duration(seconds: 12), + vsync: this, + ); + + _crossFadeController = AnimationController( + duration: const Duration(milliseconds: 1200), + vsync: this, + ); + + _zoomAnimation = Tween( + begin: 1.0, + end: 1.2, + ).animate( + CurvedAnimation( + parent: _zoomController, + curve: Curves.easeInOut, + ), + ); + + _panAnimation = Tween( + begin: Offset.zero, + end: const Offset(0.5, -0.5), + ).animate( + CurvedAnimation( + parent: _zoomController, + curve: Curves.easeInOut, + ), + ); + + _crossFadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate( + CurvedAnimation( + parent: _crossFadeController, + curve: Curves.easeInOutCubic, + ), + ); + + Future.delayed( + Durations.medium1, + () => _loadFirstAsset(), + ); + } + + @override + void dispose() { + _zoomController.dispose(); + _crossFadeController.dispose(); + super.dispose(); + } + + void _startAnimationCycle() { + if (_isZoomingIn) { + _zoomController.forward().then((_) { + _loadNextAsset(); + }); + } else { + _zoomController.reverse().then((_) { + _loadNextAsset(); + }); + } + } + + Future _loadFirstAsset() async { + if (!mounted) { + return; + } + + if (widget.timelineService.totalAssets == 0) { + setState(() { + _currentAsset = null; + }); + + return; + } + + setState(() { + _currentAsset = widget.timelineService.getRandomAsset(); + }); + + await _crossFadeController.forward(); + + if (_zoomController.status == AnimationStatus.dismissed) { + if (_isZoomingIn) { + _zoomController.reset(); + } else { + _zoomController.value = 1.0; + } + _startAnimationCycle(); + } + } + + Future _loadNextAsset() async { + if (!mounted) { + return; + } + + try { + if (widget.timelineService.totalAssets > 1) { + // Load next asset while keeping current one visible + final nextAsset = widget.timelineService.getRandomAsset(); + + setState(() { + _nextAsset = nextAsset; + }); + + await _crossFadeController.reverse(); + setState(() { + _currentAsset = _nextAsset; + _nextAsset = null; + }); + + _crossFadeController.value = 1.0; + + _isZoomingIn = !_isZoomingIn; + + _startAnimationCycle(); + } + } catch (e) { + _zoomController.reset(); + _startAnimationCycle(); + } + } + + @override + Widget build(BuildContext context) { + if (widget.timelineService.totalAssets == 0) { + return const SizedBox.shrink(); + } + + return AnimatedBuilder( + animation: Listenable.merge( + [_zoomAnimation, _panAnimation, _crossFadeAnimation], + ), + builder: (context, child) { + return Transform.scale( + scale: _zoomAnimation.value, + filterQuality: Platform.isAndroid ? FilterQuality.low : null, + child: Transform.translate( + offset: _panAnimation.value, + filterQuality: Platform.isAndroid ? FilterQuality.low : null, + child: Stack( + fit: StackFit.expand, + children: [ + // Current image + if (_currentAsset != null) + Opacity( + opacity: _crossFadeAnimation.value, + child: SizedBox( + width: double.infinity, + height: double.infinity, + child: Image( + alignment: Alignment.topRight, + image: getFullImageProvider(_currentAsset!), + fit: BoxFit.cover, + frameBuilder: + (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return Container(); + }, + errorBuilder: (context, error, stackTrace) { + return SizedBox( + width: double.infinity, + height: double.infinity, + child: Icon( + Icons.error_outline_rounded, + size: 24, + color: Colors.red[300], + ), + ); + }, + ), + ), + ), + + if (_nextAsset != null) + Opacity( + opacity: 1.0 - _crossFadeAnimation.value, + child: SizedBox( + width: double.infinity, + height: double.infinity, + child: Image( + alignment: Alignment.topRight, + image: getFullImageProvider(_nextAsset!), + fit: BoxFit.cover, + frameBuilder: + (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return const SizedBox.shrink(); + }, + errorBuilder: (context, error, stackTrace) { + return SizedBox( + width: double.infinity, + height: double.infinity, + child: Icon( + Icons.error_outline_rounded, + size: 24, + color: Colors.red[300], + ), + ); + }, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart index e8501f1184..479c30d6da 100644 --- a/mobile/lib/widgets/common/user_circle_avatar.dart +++ b/mobile/lib/widgets/common/user_circle_avatar.dart @@ -14,11 +14,13 @@ class UserCircleAvatar extends ConsumerWidget { final UserDto user; double radius; double size; + bool hasBorder; UserCircleAvatar({ super.key, this.radius = 22, this.size = 44, + this.hasBorder = false, required this.user, }); @@ -38,25 +40,39 @@ class UserCircleAvatar extends ConsumerWidget { ), child: Text(user.name[0].toUpperCase()), ); - return CircleAvatar( - backgroundColor: userAvatarColor, - radius: radius, - child: user.profileImagePath == null - ? textIcon - : ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(50)), - child: CachedNetworkImage( - fit: BoxFit.cover, - cacheKey: user.profileImagePath, - width: size, - height: size, - placeholder: (_, __) => Image.memory(kTransparentImage), - imageUrl: profileImageUrl, - httpHeaders: ApiService.getRequestHeaders(), - fadeInDuration: const Duration(milliseconds: 300), - errorWidget: (context, error, stackTrace) => textIcon, - ), - ), + return Tooltip( + message: user.name, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: hasBorder + ? Border.all( + color: Colors.grey[500]!, + width: 1, + ) + : null, + ), + child: CircleAvatar( + backgroundColor: userAvatarColor, + radius: radius, + child: user.profileImagePath == null + ? textIcon + : ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(50)), + child: CachedNetworkImage( + fit: BoxFit.cover, + cacheKey: user.profileImagePath, + width: size, + height: size, + placeholder: (_, __) => Image.memory(kTransparentImage), + imageUrl: profileImageUrl, + httpHeaders: ApiService.getRequestHeaders(), + fadeInDuration: const Duration(milliseconds: 300), + errorWidget: (context, error, stackTrace) => textIcon, + ), + ), + ), + ), ); } } diff --git a/mobile/test/modules/extensions/datetime_extensions_test.dart b/mobile/test/modules/extensions/datetime_extensions_test.dart new file mode 100644 index 0000000000..412d946b1f --- /dev/null +++ b/mobile/test/modules/extensions/datetime_extensions_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/extensions/datetime_extensions.dart'; +import 'package:intl/date_symbol_data_local.dart'; + +void main() { + setUpAll(() async { + await initializeDateFormatting(); + }); + + group('DateRangeFormatting.formatDateRange', () { + final currentYear = DateTime.now().year; + + test('returns single date format for this year', () { + final date = DateTime(currentYear, 8, 28); // Aug 28 this year + final result = DateRangeFormatting.formatDateRange(date, date, null); + expect(result, 'Aug 28'); + }); + + test('returns single date format for other year', () { + final date = DateTime(2023, 8, 28); // Aug 28, 2023 + final result = DateRangeFormatting.formatDateRange(date, date, null); + expect(result, 'Aug 28, 2023'); + }); + + test('returns date range format for this year', () { + final startDate = DateTime(currentYear, 3, 23); // Mar 23 + final endDate = DateTime(currentYear, 5, 31); // May 31 + final result = + DateRangeFormatting.formatDateRange(startDate, endDate, null); + expect(result, 'Mar 23 - May 31'); + }); + + test('returns date range format for other year (same year)', () { + final startDate = DateTime(2023, 8, 28); // Aug 28 + final endDate = DateTime(2023, 9, 30); // Sep 30 + final result = + DateRangeFormatting.formatDateRange(startDate, endDate, null); + expect(result, 'Aug 28 - Sep 30, 2023'); + }); + + test('returns date range format over multiple years', () { + final startDate = DateTime(2021, 4, 17); // Apr 17, 2021 + final endDate = DateTime(2022, 4, 9); // Apr 9, 2022 + final result = + DateRangeFormatting.formatDateRange(startDate, endDate, null); + expect(result, 'Apr 17, 2021 - Apr 9, 2022'); + }); + }); +}