From 5d0ad853f4721a4944e4ffe41772f652c5a01245 Mon Sep 17 00:00:00 2001 From: JobiJoba <6494791+JobiJoba@users.noreply.github.com> Date: Thu, 5 Jun 2025 00:41:28 +0700 Subject: [PATCH] feat(mobile): add album description functionality (#18886) * feat(mobile): add album description functionality - Introduced a new optional `description` field in the `Album` entity. - Updated `AlbumViewerPageState` to manage `editDescriptionText`. - Created `AlbumDescription` and `AlbumViewerEditableDescription` widgets for displaying and editing album descriptions. - Enhanced `CreateAlbumPage` to include a description input field. - Implemented backend support for updating album descriptions in `AlbumApiRepository` and `AlbumService`. - Updated sync logic to handle album descriptions during data synchronization. - Adjusted UI components to accommodate the new description feature. * fix dart analysis error * remove comment that shouldn't be there * Album header styling * fix: disable edit after album creation --------- Co-authored-by: Alex --- mobile/lib/entities/album.entity.dart | 8 +- mobile/lib/entities/album.entity.g.dart | Bin 54315 -> 60066 bytes .../albums/album_viewer_page_state.model.dart | 17 ++- .../lib/pages/album/album_control_button.dart | 4 +- mobile/lib/pages/album/album_date_range.dart | 11 +- mobile/lib/pages/album/album_description.dart | 45 ++++++++ .../pages/album/album_shared_user_icons.dart | 2 +- mobile/lib/pages/album/album_title.dart | 13 ++- mobile/lib/pages/album/album_viewer.dart | 54 +++++++--- .../lib/pages/common/create_album.page.dart | 35 ++++-- .../album/album_viewer.provider.dart | 44 +++++++- .../repositories/album_api.repository.dart | 4 + mobile/lib/services/album.service.dart | 19 ++++ mobile/lib/services/sync.service.dart | 5 + .../album/album_action_filled_button.dart | 6 +- .../widgets/album/album_viewer_appbar.dart | 46 +++++--- .../album_viewer_editable_description.dart | 102 ++++++++++++++++++ .../album/album_viewer_editable_title.dart | 10 +- 18 files changed, 363 insertions(+), 62 deletions(-) create mode 100644 mobile/lib/pages/album/album_description.dart create mode 100644 mobile/lib/widgets/album/album_viewer_editable_description.dart diff --git a/mobile/lib/entities/album.entity.dart b/mobile/lib/entities/album.entity.dart index 8b466da1d..f6d532275 100644 --- a/mobile/lib/entities/album.entity.dart +++ b/mobile/lib/entities/album.entity.dart @@ -19,6 +19,7 @@ class Album { required this.name, required this.createdAt, required this.modifiedAt, + this.description, this.startDate, this.endDate, this.lastModifiedAssetTimestamp, @@ -34,6 +35,7 @@ class Album { @Index(unique: false, replace: false, type: IndexType.hash) String? localId; String name; + String? description; DateTime createdAt; DateTime modifiedAt; DateTime? startDate; @@ -108,6 +110,7 @@ class Album { remoteId == other.remoteId && localId == other.localId && name == other.name && + description == other.description && createdAt.isAtSameMomentAs(other.createdAt) && modifiedAt.isAtSameMomentAs(other.modifiedAt) && isAtSameMomentAs(startDate, other.startDate) && @@ -135,6 +138,7 @@ class Album { modifiedAt.hashCode ^ startDate.hashCode ^ endDate.hashCode ^ + description.hashCode ^ lastModifiedAssetTimestamp.hashCode ^ shared.hashCode ^ activityEnabled.hashCode ^ @@ -150,6 +154,7 @@ class Album { name: dto.albumName, createdAt: dto.createdAt, modifiedAt: dto.updatedAt, + description: dto.description, lastModifiedAssetTimestamp: dto.lastModifiedAssetTimestamp, shared: dto.shared, startDate: dto.startDate, @@ -184,7 +189,8 @@ class Album { } @override - String toString() => name; + String toString() => + 'remoteId: $remoteId name: $name description: $description'; } extension AssetsHelper on IsarCollection { diff --git a/mobile/lib/entities/album.entity.g.dart b/mobile/lib/entities/album.entity.g.dart index 327dc606caba239c7f01b461aa741b8933038947..546101baca6add4070c3d97cbcc55cd3064ac788 100644 GIT binary patch delta 1178 zcmaKrT}V@59L70rJ4c;5&+*Ki#l8$>4wh`XQfDSfhM!HX7HBKY5B!>&+ma|EE$G6C zoX^y8Dlh-D>9cw3BsWm=0VWj3l+EWRrJ_@Lr&QPl?*&7xMFj%`M> z%cRm)j7wex-2|Fp5u?2bNau7CyT`1h&}d|g*km{iGhXy3X5a^NmvV~1#CtR`IziJQ z#?uT5h_jl^e4pJCxYE&c~aK0#$*}W>3b`S`&Can{uI; zcTz{u%R8j_-hy^}hs{YOA8j5#Vi}Zy9p@=8c-Vt)|${hI{3HLNMT4W)@Behmj-baZA#F`4<9tlD?_2<{2Q9EP z-NtCCfxrhkN90;;Jxft?1N#6(yLhCX|9%i7-uLokNsId?V!ge@Pv*WQLhm1R!@qc7 zexZ5cz?>Z-^C91ZblJsJg)96)P^&eFmzNSnnmet8Hb1V%dQA~(+$n@FanKx37VRab z9`z~N(`=Y*B*5XB)A%__6HNS>mt>~v#OldS4FUaMR7>RhOPtT^urkA7X}$_?Cz8bL yGgV~a+WTwrhhw!P+G)g>#d7i8JfDQsbsGLPEwNZ^qlM7XF)KzkjN-4ALi!Ic?YC_J delta 432 zcmXwwOG_J37=}4DGn1K+rsqs%%*0epj5eWBG>MwTOK{gH)vmmSv=o|9QZ$eV?nGP^ z+$i-0cdej!fua=-6rmJxsdUkeh`~jXxGw&HqNkL8o9DfKo9A_(_v#M!Y_Oyte%dHT zr5OxJ6h8#NNessh7pCPABxQ=P@-(&u{mM8VDHKf_6zemQ- zu>OhSrPRm+>WIv1F7g0hC?!#}vlt@Gfp;uL;@Dv^(v2#M<5Fan9>hc4foFOfNuZ>6 zkR&$re$vUWbdA7gJe8uka74r5R3nN;ki@WRbYaC%acYE|-G94gakH;y^0;;DTBuYg zRPu9`Y!4dDDCxzh8PXH~XO&WUZmj$!UrynJ*=JAY!nx*jTEl_Y#BVdMcK#cN`*7zo z_)z@-!LjEFwmh$};AutJ@(FLpie;fF^nypmsnEMt0E4CzlNQ6Z$kX1(Xz^0adfy@^ z_@~|>{sP?H!2NK(P - 'AlbumViewerPageState(isEditAlbum: $isEditAlbum, editTitleText: $editTitleText)'; + 'AlbumViewerPageState(isEditAlbum: $isEditAlbum, editTitleText: $editTitleText, editDescriptionText: $editDescriptionText)'; @override bool operator ==(Object other) { @@ -49,9 +56,13 @@ class AlbumViewerPageState { return other is AlbumViewerPageState && other.isEditAlbum == isEditAlbum && - other.editTitleText == editTitleText; + other.editTitleText == editTitleText && + other.editDescriptionText == editDescriptionText; } @override - int get hashCode => isEditAlbum.hashCode ^ editTitleText.hashCode; + int get hashCode => + isEditAlbum.hashCode ^ + editTitleText.hashCode ^ + editDescriptionText.hashCode; } diff --git a/mobile/lib/pages/album/album_control_button.dart b/mobile/lib/pages/album/album_control_button.dart index 54bfa69da..b2100946e 100644 --- a/mobile/lib/pages/album/album_control_button.dart +++ b/mobile/lib/pages/album/album_control_button.dart @@ -26,9 +26,9 @@ class AlbumControlButton extends ConsumerWidget { ); return Padding( - padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 16), + padding: const EdgeInsets.only(left: 16.0), child: SizedBox( - height: 40, + height: 36, child: ListView( scrollDirection: Axis.horizontal, children: [ diff --git a/mobile/lib/pages/album/album_date_range.dart b/mobile/lib/pages/album/album_date_range.dart index 5f7ef40d4..591be260f 100644 --- a/mobile/lib/pages/album/album_date_range.dart +++ b/mobile/lib/pages/album/album_date_range.dart @@ -30,15 +30,12 @@ class AlbumDateRange extends ConsumerWidget { 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), + padding: const EdgeInsets.only(left: 16.0), child: Text( _getDateRangeText(startDate, endDate), - style: context.textTheme.labelLarge, + style: context.textTheme.labelLarge?.copyWith( + color: context.colorScheme.onSurfaceVariant, + ), ), ); } diff --git a/mobile/lib/pages/album/album_description.dart b/mobile/lib/pages/album/album_description.dart new file mode 100644 index 000000000..37c5beb2c --- /dev/null +++ b/mobile/lib/pages/album/album_description.dart @@ -0,0 +1,45 @@ +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'; +import 'package:immich_mobile/widgets/album/album_viewer_editable_description.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; + +class AlbumDescription extends ConsumerWidget { + const AlbumDescription({super.key, required this.descriptionFocusNode}); + + final FocusNode descriptionFocusNode; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final userId = ref.watch(authProvider).userId; + final (isOwner, isRemote, albumDescription) = ref.watch( + currentAlbumProvider.select((album) { + if (album == null) { + return const (false, false, ''); + } + + return (album.ownerId == userId, album.isRemote, album.description); + }), + ); + + if (isOwner && isRemote) { + return Padding( + padding: const EdgeInsets.only(left: 8, right: 8), + child: AlbumViewerEditableDescription( + albumDescription: albumDescription ?? 'add_a_description'.tr(), + descriptionFocusNode: descriptionFocusNode, + ), + ); + } + + return Padding( + padding: const EdgeInsets.only(left: 16, right: 8), + child: Text( + albumDescription ?? 'add_a_description'.tr(), + style: context.textTheme.bodyLarge, + ), + ); + } +} diff --git a/mobile/lib/pages/album/album_shared_user_icons.dart b/mobile/lib/pages/album/album_shared_user_icons.dart index 47ea47602..723bb1e25 100644 --- a/mobile/lib/pages/album/album_shared_user_icons.dart +++ b/mobile/lib/pages/album/album_shared_user_icons.dart @@ -36,7 +36,7 @@ class AlbumSharedUserIcons extends HookConsumerWidget { child: SizedBox( height: 50, child: ListView.builder( - padding: const EdgeInsets.only(left: 16), + padding: const EdgeInsets.only(left: 16, bottom: 8), scrollDirection: Axis.horizontal, itemBuilder: ((context, index) { return Padding( diff --git a/mobile/lib/pages/album/album_title.dart b/mobile/lib/pages/album/album_title.dart index 435e28252..ccea200f3 100644 --- a/mobile/lib/pages/album/album_title.dart +++ b/mobile/lib/pages/album/album_title.dart @@ -19,7 +19,11 @@ class AlbumTitle extends ConsumerWidget { return const (false, false, ''); } - return (album.ownerId == userId, album.isRemote, album.name); + return ( + album.ownerId == userId, + album.isRemote, + album.name, + ); }), ); @@ -35,7 +39,12 @@ class AlbumTitle extends ConsumerWidget { return Padding( padding: const EdgeInsets.only(left: 16, right: 8), - child: Text(albumName, style: context.textTheme.headlineMedium), + child: Text( + albumName, + style: context.textTheme.headlineLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), ); } } diff --git a/mobile/lib/pages/album/album_viewer.dart b/mobile/lib/pages/album/album_viewer.dart index f6c46843d..f22fc3071 100644 --- a/mobile/lib/pages/album/album_viewer.dart +++ b/mobile/lib/pages/album/album_viewer.dart @@ -10,6 +10,7 @@ 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_description.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'; @@ -36,6 +37,7 @@ class AlbumViewer extends HookConsumerWidget { } final titleFocusNode = useFocusNode(); + final descriptionFocusNode = useFocusNode(); final userId = ref.watch(authProvider).userId; final isMultiselecting = ref.watch(multiselectProvider); final isProcessing = useProcessingOverlay(); @@ -106,23 +108,44 @@ class AlbumViewer extends HookConsumerWidget { MultiselectGrid( key: const ValueKey("albumViewerMultiselectGrid"), renderListProvider: albumTimelineProvider(album.id), - topWidget: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AlbumTitle( - key: const ValueKey("albumTitle"), - titleFocusNode: titleFocusNode, + topWidget: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + context.primaryColor.withValues(alpha: 0.04), + context.primaryColor.withValues(alpha: 0.02), + Colors.orange.withValues(alpha: 0.02), + Colors.transparent, + ], + stops: const [0.0, 0.3, 0.7, 1.0], ), - const AlbumDateRange(), - const AlbumSharedUserIcons(), - if (album.isRemote) - AlbumControlButton( - key: const ValueKey("albumControlButton"), - onAddPhotosPressed: onAddPhotosPressed, - onAddUsersPressed: onAddUsersPressed, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 32), + const AlbumDateRange(), + AlbumTitle( + key: const ValueKey("albumTitle"), + titleFocusNode: titleFocusNode, ), - ], + AlbumDescription( + key: const ValueKey("albumDescription"), + descriptionFocusNode: descriptionFocusNode, + ), + const AlbumSharedUserIcons(), + if (album.isRemote) + AlbumControlButton( + key: const ValueKey("albumControlButton"), + onAddPhotosPressed: onAddPhotosPressed, + onAddUsersPressed: onAddUsersPressed, + ), + const SizedBox(height: 8), + ], + ), ), onRemoveFromAlbum: onRemoveFromAlbumPressed, editEnabled: album.ownerId == userId, @@ -136,6 +159,7 @@ class AlbumViewer extends HookConsumerWidget { child: AlbumViewerAppbar( key: const ValueKey("albumViewerAppbar"), titleFocusNode: titleFocusNode, + descriptionFocusNode: descriptionFocusNode, userId: userId, onAddPhotos: onAddPhotosPressed, onAddUsers: onAddUsersPressed, diff --git a/mobile/lib/pages/common/create_album.page.dart b/mobile/lib/pages/common/create_album.page.dart index c4845620f..f5c632145 100644 --- a/mobile/lib/pages/common/create_album.page.dart +++ b/mobile/lib/pages/common/create_album.page.dart @@ -8,9 +8,11 @@ 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/album_title.provider.dart'; +import 'package:immich_mobile/providers/album/album_viewer.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/album/album_action_filled_button.dart'; import 'package:immich_mobile/widgets/album/album_title_text_field.dart'; +import 'package:immich_mobile/widgets/album/album_viewer_editable_description.dart'; import 'package:immich_mobile/widgets/album/shared_album_thumbnail_image.dart'; @RoutePage() @@ -28,6 +30,7 @@ class CreateAlbumPage extends HookConsumerWidget { final albumTitleController = useTextEditingController.fromValue(TextEditingValue.empty); final albumTitleTextFieldFocusNode = useFocusNode(); + final albumDescriptionTextFieldFocusNode = useFocusNode(); final isAlbumTitleTextFieldFocus = useState(false); final isAlbumTitleEmpty = useState(true); final selectedAssets = useState>( @@ -36,6 +39,7 @@ class CreateAlbumPage extends HookConsumerWidget { void onBackgroundTapped() { albumTitleTextFieldFocusNode.unfocus(); + albumDescriptionTextFieldFocusNode.unfocus(); isAlbumTitleTextFieldFocus.value = false; if (albumTitleController.text.isEmpty) { @@ -77,6 +81,19 @@ class CreateAlbumPage extends HookConsumerWidget { ); } + buildDescriptionInputField() { + return Padding( + padding: const EdgeInsets.only( + right: 10, + left: 10, + ), + child: AlbumViewerEditableDescription( + albumDescription: '', + descriptionFocusNode: albumDescriptionTextFieldFocusNode, + ), + ); + } + buildTitle() { if (selectedAssets.value.isEmpty) { return SliverToBoxAdapter( @@ -178,18 +195,18 @@ class CreateAlbumPage extends HookConsumerWidget { return const SliverToBoxAdapter(); } - createNonSharedAlbum() async { + Future createAlbum() async { onBackgroundTapped(); var newAlbum = await ref.watch(albumProvider.notifier).createAlbum( - ref.watch(albumTitleProvider), + ref.read(albumTitleProvider), selectedAssets.value, ); if (newAlbum != null) { - ref.watch(albumProvider.notifier).refreshRemoteAlbums(); + ref.read(albumProvider.notifier).refreshRemoteAlbums(); selectedAssets.value = {}; - ref.watch(albumTitleProvider.notifier).clearAlbumTitle(); - + ref.read(albumTitleProvider.notifier).clearAlbumTitle(); + ref.read(albumViewerProvider.notifier).disableEditAlbum(); context.replaceRoute(AlbumViewerRoute(albumId: newAlbum.id)); } } @@ -211,9 +228,8 @@ class CreateAlbumPage extends HookConsumerWidget { ).tr(), actions: [ TextButton( - onPressed: albumTitleController.text.isNotEmpty - ? createNonSharedAlbum - : null, + onPressed: + albumTitleController.text.isNotEmpty ? createAlbum : null, child: Text( 'create'.tr(), style: TextStyle( @@ -237,10 +253,11 @@ class CreateAlbumPage extends HookConsumerWidget { pinned: true, floating: false, bottom: PreferredSize( - preferredSize: const Size.fromHeight(96.0), + preferredSize: const Size.fromHeight(125.0), child: Column( children: [ buildTitleInputField(), + buildDescriptionInputField(), if (selectedAssets.value.isNotEmpty) buildControlButton(), ], ), diff --git a/mobile/lib/providers/album/album_viewer.provider.dart b/mobile/lib/providers/album/album_viewer.provider.dart index e41865778..cf7344d32 100644 --- a/mobile/lib/providers/album/album_viewer.provider.dart +++ b/mobile/lib/providers/album/album_viewer.provider.dart @@ -5,7 +5,13 @@ import 'package:immich_mobile/entities/album.entity.dart'; class AlbumViewerNotifier extends StateNotifier { AlbumViewerNotifier(this.ref) - : super(AlbumViewerPageState(editTitleText: "", isEditAlbum: false)); + : super( + AlbumViewerPageState( + editTitleText: "", + isEditAlbum: false, + editDescriptionText: "", + ), + ); final Ref ref; @@ -21,12 +27,24 @@ class AlbumViewerNotifier extends StateNotifier { state = state.copyWith(editTitleText: newTitle); } + void setEditDescriptionText(String newDescription) { + state = state.copyWith(editDescriptionText: newDescription); + } + void remoteEditTitleText() { state = state.copyWith(editTitleText: ""); } + void remoteEditDescriptionText() { + state = state.copyWith(editDescriptionText: ""); + } + void resetState() { - state = state.copyWith(editTitleText: "", isEditAlbum: false); + state = state.copyWith( + editTitleText: "", + isEditAlbum: false, + editDescriptionText: "", + ); } Future changeAlbumTitle( @@ -46,6 +64,28 @@ class AlbumViewerNotifier extends StateNotifier { state = state.copyWith(editTitleText: "", isEditAlbum: false); return false; } + + Future changeAlbumDescription( + Album album, + String newAlbumDescription, + ) async { + AlbumService service = ref.watch(albumServiceProvider); + + bool isSuccess = await service.changeDescriptionAlbum( + album, + newAlbumDescription, + ); + + if (isSuccess) { + state = state.copyWith(editDescriptionText: "", isEditAlbum: false); + + return true; + } + + state = state.copyWith(editDescriptionText: "", isEditAlbum: false); + + return false; + } } final albumViewerProvider = diff --git a/mobile/lib/repositories/album_api.repository.dart b/mobile/lib/repositories/album_api.repository.dart index a7bbe452e..e2ac73bd9 100644 --- a/mobile/lib/repositories/album_api.repository.dart +++ b/mobile/lib/repositories/album_api.repository.dart @@ -36,6 +36,7 @@ class AlbumApiRepository extends ApiRepository implements IAlbumApiRepository { String name, { required Iterable assetIds, Iterable sharedUserIds = const [], + String? description, }) async { final users = sharedUserIds.map( (id) => AlbumUserCreateDto(userId: id, role: AlbumUserRole.editor), @@ -44,6 +45,7 @@ class AlbumApiRepository extends ApiRepository implements IAlbumApiRepository { _api.createAlbum( CreateAlbumDto( albumName: name, + description: description, assetIds: assetIds.toList(), albumUsers: users.toList(), ), @@ -161,6 +163,7 @@ class AlbumApiRepository extends ApiRepository implements IAlbumApiRepository { lastModifiedAssetTimestamp: dto.lastModifiedAssetTimestamp, shared: dto.shared, startDate: dto.startDate, + description: dto.description, endDate: dto.endDate, activityEnabled: dto.isActivityEnabled, sortOrder: dto.order == AssetOrder.asc ? SortOrder.asc : SortOrder.desc, @@ -174,6 +177,7 @@ class AlbumApiRepository extends ApiRepository implements IAlbumApiRepository { album.sharedUsers.addAll(users.map(entity.User.fromDto)); final assets = dto.assets.map(Asset.remote).toList(); album.assets.addAll(assets); + return album; } } diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index 0922f506d..f1e872104 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -422,6 +422,25 @@ class AlbumService { } } + Future changeDescriptionAlbum( + Album album, + String newAlbumDescription, + ) async { + try { + final updatedAlbum = await _albumApiRepository.update( + album.remoteId!, + description: newAlbumDescription, + ); + + album.description = updatedAlbum.description; + await _albumRepository.update(album); + return true; + } catch (e) { + debugPrint("Error changeDescriptionAlbum ${e.toString()}"); + return false; + } + } + Future getAlbumByName( String name, { bool? remote, diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index 80950d8c0..e08d7de8b 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -451,6 +451,7 @@ class SyncService { final usersToLink = await _userRepository.getByUserIds(userIdsToAdd); album.name = dto.name; + album.description = dto.description; album.shared = dto.shared; album.createdAt = dto.createdAt; album.modifiedAt = dto.modifiedAt; @@ -643,6 +644,7 @@ class SyncService { toUpdate.isEmpty && toDelete.isEmpty && dbAlbum.name == deviceAlbum.name && + dbAlbum.description == deviceAlbum.description && dbAlbum.modifiedAt.isAtSameMomentAs(deviceAlbum.modifiedAt)) { // changes only affeted excluded albums _log.info( @@ -670,6 +672,7 @@ class SyncService { deleteCandidates.addAll(toDelete); existing.addAll(existingInDb); dbAlbum.name = deviceAlbum.name; + dbAlbum.description = deviceAlbum.description; dbAlbum.modifiedAt = deviceAlbum.modifiedAt; if (dbAlbum.thumbnail.value != null && toDelete.contains(dbAlbum.thumbnail.value)) { @@ -943,6 +946,7 @@ class SyncService { Album dbAlbum, ) async { return deviceAlbum.name != dbAlbum.name || + deviceAlbum.description != dbAlbum.description || !deviceAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) || await _albumMediaRepository.getAssetCount(deviceAlbum.localId!) != (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount)) @@ -1101,6 +1105,7 @@ class SyncService { bool _hasRemoteAlbumChanged(Album remoteAlbum, Album dbAlbum) { return remoteAlbum.remoteAssetCount != dbAlbum.assetCount || remoteAlbum.name != dbAlbum.name || + remoteAlbum.description != dbAlbum.description || remoteAlbum.remoteThumbnailAssetId != dbAlbum.thumbnail.value?.remoteId || remoteAlbum.shared != dbAlbum.shared || remoteAlbum.remoteUsers.length != dbAlbum.sharedUsers.length || diff --git a/mobile/lib/widgets/album/album_action_filled_button.dart b/mobile/lib/widgets/album/album_action_filled_button.dart index de7330744..f5064f499 100644 --- a/mobile/lib/widgets/album/album_action_filled_button.dart +++ b/mobile/lib/widgets/album/album_action_filled_button.dart @@ -16,7 +16,7 @@ class AlbumActionFilledButton extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.only(right: 16.0), + padding: const EdgeInsets.only(right: 8.0), child: FilledButton.icon( style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16), @@ -32,9 +32,7 @@ class AlbumActionFilledButton extends StatelessWidget { ), label: Text( labelText, - style: context.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + style: context.textTheme.labelLarge?.copyWith(), ), onPressed: onPressed, ), diff --git a/mobile/lib/widgets/album/album_viewer_appbar.dart b/mobile/lib/widgets/album/album_viewer_appbar.dart index 2aabf7fbb..9d4804545 100644 --- a/mobile/lib/widgets/album/album_viewer_appbar.dart +++ b/mobile/lib/widgets/album/album_viewer_appbar.dart @@ -18,6 +18,7 @@ class AlbumViewerAppbar extends HookConsumerWidget super.key, required this.userId, required this.titleFocusNode, + required this.descriptionFocusNode, this.onAddPhotos, this.onAddUsers, required this.onActivities, @@ -25,6 +26,7 @@ class AlbumViewerAppbar extends HookConsumerWidget final String userId; final FocusNode titleFocusNode; + final FocusNode descriptionFocusNode; final void Function()? onAddPhotos; final void Function()? onAddUsers; final void Function() onActivities; @@ -48,6 +50,7 @@ class AlbumViewerAppbar extends HookConsumerWidget final albumViewer = ref.watch(albumViewerProvider); final newAlbumTitle = albumViewer.editTitleText; + final newAlbumDescription = albumViewer.editDescriptionText; final isEditAlbum = albumViewer.isEditAlbum; final comments = album.shared @@ -277,20 +280,37 @@ class AlbumViewerAppbar extends HookConsumerWidget if (isEditAlbum) { return IconButton( onPressed: () async { - bool isSuccess = await ref - .watch(albumViewerProvider.notifier) - .changeAlbumTitle(album, newAlbumTitle); - - if (!isSuccess) { - ImmichToast.show( - context: context, - msg: "album_viewer_appbar_share_err_title".tr(), - gravity: ToastGravity.BOTTOM, - toastType: ToastType.error, - ); + if (newAlbumTitle.isNotEmpty) { + bool isSuccess = await ref + .watch(albumViewerProvider.notifier) + .changeAlbumTitle(album, newAlbumTitle); + if (!isSuccess) { + ImmichToast.show( + context: context, + msg: "album_viewer_appbar_share_err_title".tr(), + gravity: ToastGravity.BOTTOM, + toastType: ToastType.error, + ); + } + titleFocusNode.unfocus(); + } else if (newAlbumDescription.isNotEmpty) { + bool isSuccessDescription = await ref + .watch(albumViewerProvider.notifier) + .changeAlbumDescription(album, newAlbumDescription); + if (!isSuccessDescription) { + ImmichToast.show( + context: context, + msg: "album_viewer_appbar_share_err_description".tr(), + gravity: ToastGravity.BOTTOM, + toastType: ToastType.error, + ); + } + descriptionFocusNode.unfocus(); + } else { + titleFocusNode.unfocus(); + descriptionFocusNode.unfocus(); + ref.read(albumViewerProvider.notifier).disableEditAlbum(); } - - titleFocusNode.unfocus(); }, icon: const Icon(Icons.check_rounded), splashRadius: 25, diff --git a/mobile/lib/widgets/album/album_viewer_editable_description.dart b/mobile/lib/widgets/album/album_viewer_editable_description.dart new file mode 100644 index 000000000..06bfbc018 --- /dev/null +++ b/mobile/lib/widgets/album/album_viewer_editable_description.dart @@ -0,0 +1,102 @@ +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/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/album/album_viewer.provider.dart'; + +class AlbumViewerEditableDescription extends HookConsumerWidget { + final String albumDescription; + final FocusNode descriptionFocusNode; + const AlbumViewerEditableDescription({ + super.key, + required this.albumDescription, + required this.descriptionFocusNode, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final albumViewerState = ref.watch(albumViewerProvider); + + final descriptionTextEditController = useTextEditingController( + text: albumViewerState.isEditAlbum && + albumViewerState.editDescriptionText.isNotEmpty + ? albumViewerState.editDescriptionText + : albumDescription, + ); + + void onFocusModeChange() { + if (!descriptionFocusNode.hasFocus && + descriptionTextEditController.text.isEmpty) { + ref.watch(albumViewerProvider.notifier).setEditDescriptionText(""); + descriptionTextEditController.text = ""; + } + } + + useEffect( + () { + descriptionFocusNode.addListener(onFocusModeChange); + return () { + descriptionFocusNode.removeListener(onFocusModeChange); + }; + }, + [], + ); + + return Material( + color: Colors.transparent, + child: TextField( + onChanged: (value) { + if (value.isEmpty) { + } else { + ref + .watch(albumViewerProvider.notifier) + .setEditDescriptionText(value); + } + }, + focusNode: descriptionFocusNode, + style: context.textTheme.bodyMedium, + maxLines: 3, + minLines: 1, + controller: descriptionTextEditController, + onTap: () { + context.focusScope.requestFocus(descriptionFocusNode); + + ref + .watch(albumViewerProvider.notifier) + .setEditDescriptionText(albumDescription); + ref.watch(albumViewerProvider.notifier).enableEditAlbum(); + + if (descriptionTextEditController.text == '') { + descriptionTextEditController.clear(); + } + }, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8), + suffixIcon: descriptionFocusNode.hasFocus + ? IconButton( + onPressed: () { + descriptionTextEditController.clear(); + }, + icon: Icon( + Icons.cancel_rounded, + color: context.primaryColor, + ), + splashRadius: 10, + ) + : null, + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.transparent), + ), + focusedBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.transparent), + ), + focusColor: Colors.grey[300], + fillColor: context.scaffoldBackgroundColor, + filled: descriptionFocusNode.hasFocus, + hintText: 'add_a_description'.tr(), + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/album/album_viewer_editable_title.dart b/mobile/lib/widgets/album/album_viewer_editable_title.dart index 0f0e240f0..038c9a13d 100644 --- a/mobile/lib/widgets/album/album_viewer_editable_title.dart +++ b/mobile/lib/widgets/album/album_viewer_editable_title.dart @@ -52,7 +52,9 @@ class AlbumViewerEditableTitle extends HookConsumerWidget { } }, focusNode: titleFocusNode, - style: context.textTheme.headlineMedium, + style: context.textTheme.headlineLarge?.copyWith( + fontWeight: FontWeight.w700, + ), controller: titleTextEditController, onTap: () { context.focusScope.requestFocus(titleFocusNode); @@ -65,8 +67,10 @@ class AlbumViewerEditableTitle extends HookConsumerWidget { } }, decoration: InputDecoration( - contentPadding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 0, + ), suffixIcon: titleFocusNode.hasFocus ? IconButton( onPressed: () {