diff --git a/mobile/lib/entities/album.entity.dart b/mobile/lib/entities/album.entity.dart index 8b466da1db9..f6d53227528 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 327dc606cab..546101baca6 100644 --- a/mobile/lib/entities/album.entity.g.dart +++ b/mobile/lib/entities/album.entity.g.dart @@ -27,49 +27,54 @@ const AlbumSchema = CollectionSchema( name: r'createdAt', type: IsarType.dateTime, ), - r'endDate': PropertySchema( + r'description': PropertySchema( id: 2, + name: r'description', + type: IsarType.string, + ), + r'endDate': PropertySchema( + id: 3, name: r'endDate', type: IsarType.dateTime, ), r'lastModifiedAssetTimestamp': PropertySchema( - id: 3, + id: 4, name: r'lastModifiedAssetTimestamp', type: IsarType.dateTime, ), r'localId': PropertySchema( - id: 4, + id: 5, name: r'localId', type: IsarType.string, ), r'modifiedAt': PropertySchema( - id: 5, + id: 6, name: r'modifiedAt', type: IsarType.dateTime, ), r'name': PropertySchema( - id: 6, + id: 7, name: r'name', type: IsarType.string, ), r'remoteId': PropertySchema( - id: 7, + id: 8, name: r'remoteId', type: IsarType.string, ), r'shared': PropertySchema( - id: 8, + id: 9, name: r'shared', type: IsarType.bool, ), r'sortOrder': PropertySchema( - id: 9, + id: 10, name: r'sortOrder', type: IsarType.byte, enumMap: _AlbumsortOrderEnumValueMap, ), r'startDate': PropertySchema( - id: 10, + id: 11, name: r'startDate', type: IsarType.dateTime, ) @@ -146,6 +151,12 @@ int _albumEstimateSize( Map> allOffsets, ) { var bytesCount = offsets.last; + { + final value = object.description; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } { final value = object.localId; if (value != null) { @@ -170,15 +181,16 @@ void _albumSerialize( ) { writer.writeBool(offsets[0], object.activityEnabled); writer.writeDateTime(offsets[1], object.createdAt); - writer.writeDateTime(offsets[2], object.endDate); - writer.writeDateTime(offsets[3], object.lastModifiedAssetTimestamp); - writer.writeString(offsets[4], object.localId); - writer.writeDateTime(offsets[5], object.modifiedAt); - writer.writeString(offsets[6], object.name); - writer.writeString(offsets[7], object.remoteId); - writer.writeBool(offsets[8], object.shared); - writer.writeByte(offsets[9], object.sortOrder.index); - writer.writeDateTime(offsets[10], object.startDate); + writer.writeString(offsets[2], object.description); + writer.writeDateTime(offsets[3], object.endDate); + writer.writeDateTime(offsets[4], object.lastModifiedAssetTimestamp); + writer.writeString(offsets[5], object.localId); + writer.writeDateTime(offsets[6], object.modifiedAt); + writer.writeString(offsets[7], object.name); + writer.writeString(offsets[8], object.remoteId); + writer.writeBool(offsets[9], object.shared); + writer.writeByte(offsets[10], object.sortOrder.index); + writer.writeDateTime(offsets[11], object.startDate); } Album _albumDeserialize( @@ -190,16 +202,18 @@ Album _albumDeserialize( final object = Album( activityEnabled: reader.readBool(offsets[0]), createdAt: reader.readDateTime(offsets[1]), - endDate: reader.readDateTimeOrNull(offsets[2]), - lastModifiedAssetTimestamp: reader.readDateTimeOrNull(offsets[3]), - localId: reader.readStringOrNull(offsets[4]), - modifiedAt: reader.readDateTime(offsets[5]), - name: reader.readString(offsets[6]), - remoteId: reader.readStringOrNull(offsets[7]), - shared: reader.readBool(offsets[8]), - sortOrder: _AlbumsortOrderValueEnumMap[reader.readByteOrNull(offsets[9])] ?? - SortOrder.desc, - startDate: reader.readDateTimeOrNull(offsets[10]), + description: reader.readStringOrNull(offsets[2]), + endDate: reader.readDateTimeOrNull(offsets[3]), + lastModifiedAssetTimestamp: reader.readDateTimeOrNull(offsets[4]), + localId: reader.readStringOrNull(offsets[5]), + modifiedAt: reader.readDateTime(offsets[6]), + name: reader.readString(offsets[7]), + remoteId: reader.readStringOrNull(offsets[8]), + shared: reader.readBool(offsets[9]), + sortOrder: + _AlbumsortOrderValueEnumMap[reader.readByteOrNull(offsets[10])] ?? + SortOrder.desc, + startDate: reader.readDateTimeOrNull(offsets[11]), ); object.id = id; return object; @@ -217,23 +231,25 @@ P _albumDeserializeProp

( case 1: return (reader.readDateTime(offset)) as P; case 2: - return (reader.readDateTimeOrNull(offset)) as P; + return (reader.readStringOrNull(offset)) as P; case 3: return (reader.readDateTimeOrNull(offset)) as P; case 4: - return (reader.readStringOrNull(offset)) as P; + return (reader.readDateTimeOrNull(offset)) as P; case 5: - return (reader.readDateTime(offset)) as P; - case 6: - return (reader.readString(offset)) as P; - case 7: return (reader.readStringOrNull(offset)) as P; + case 6: + return (reader.readDateTime(offset)) as P; + case 7: + return (reader.readString(offset)) as P; case 8: - return (reader.readBool(offset)) as P; + return (reader.readStringOrNull(offset)) as P; case 9: + return (reader.readBool(offset)) as P; + case 10: return (_AlbumsortOrderValueEnumMap[reader.readByteOrNull(offset)] ?? SortOrder.desc) as P; - case 10: + case 11: return (reader.readDateTimeOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -535,6 +551,152 @@ extension AlbumQueryFilter on QueryBuilder { }); } + QueryBuilder descriptionIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'description', + )); + }); + } + + QueryBuilder descriptionIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'description', + )); + }); + } + + QueryBuilder descriptionEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'description', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder descriptionGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'description', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder descriptionLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'description', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder descriptionBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'description', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder descriptionStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'description', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder descriptionEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'description', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder descriptionContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'description', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder descriptionMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'description', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder descriptionIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'description', + value: '', + )); + }); + } + + QueryBuilder descriptionIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'description', + value: '', + )); + }); + } + QueryBuilder endDateIsNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(const FilterCondition.isNull( @@ -1502,6 +1664,18 @@ extension AlbumQuerySortBy on QueryBuilder { }); } + QueryBuilder sortByDescription() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'description', Sort.asc); + }); + } + + QueryBuilder sortByDescriptionDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'description', Sort.desc); + }); + } + QueryBuilder sortByEndDate() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'endDate', Sort.asc); @@ -1637,6 +1811,18 @@ extension AlbumQuerySortThenBy on QueryBuilder { }); } + QueryBuilder thenByDescription() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'description', Sort.asc); + }); + } + + QueryBuilder thenByDescriptionDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'description', Sort.desc); + }); + } + QueryBuilder thenByEndDate() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'endDate', Sort.asc); @@ -1772,6 +1958,13 @@ extension AlbumQueryWhereDistinct on QueryBuilder { }); } + QueryBuilder distinctByDescription( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'description', caseSensitive: caseSensitive); + }); + } + QueryBuilder distinctByEndDate() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'endDate'); @@ -1849,6 +2042,12 @@ extension AlbumQueryProperty on QueryBuilder { }); } + QueryBuilder descriptionProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'description'); + }); + } + QueryBuilder endDateProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'endDate'); diff --git a/mobile/lib/models/albums/album_viewer_page_state.model.dart b/mobile/lib/models/albums/album_viewer_page_state.model.dart index 1d1cc9f9eca..10a8183ddc1 100644 --- a/mobile/lib/models/albums/album_viewer_page_state.model.dart +++ b/mobile/lib/models/albums/album_viewer_page_state.model.dart @@ -3,18 +3,23 @@ import 'dart:convert'; class AlbumViewerPageState { final bool isEditAlbum; final String editTitleText; + final String editDescriptionText; + AlbumViewerPageState({ required this.isEditAlbum, required this.editTitleText, + required this.editDescriptionText, }); AlbumViewerPageState copyWith({ bool? isEditAlbum, String? editTitleText, + String? editDescriptionText, }) { return AlbumViewerPageState( isEditAlbum: isEditAlbum ?? this.isEditAlbum, editTitleText: editTitleText ?? this.editTitleText, + editDescriptionText: editDescriptionText ?? this.editDescriptionText, ); } @@ -23,6 +28,7 @@ class AlbumViewerPageState { result.addAll({'isEditAlbum': isEditAlbum}); result.addAll({'editTitleText': editTitleText}); + result.addAll({'editDescriptionText': editDescriptionText}); return result; } @@ -31,6 +37,7 @@ class AlbumViewerPageState { return AlbumViewerPageState( isEditAlbum: map['isEditAlbum'] ?? false, editTitleText: map['editTitleText'] ?? '', + editDescriptionText: map['editDescriptionText'] ?? '', ); } @@ -41,7 +48,7 @@ class AlbumViewerPageState { @override String toString() => - '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 54bfa69da4c..b2100946e62 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 5f7ef40d4b1..591be260f6c 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 00000000000..37c5beb2c2e --- /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 47ea4760280..723bb1e252e 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 435e282523e..ccea200f3ac 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 f6c46843db2..f22fc307167 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 c4845620ffd..f5c63214516 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 e4186577821..cf7344d321c 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 a7bbe452e61..e2ac73bd9b4 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 0922f506d55..f1e87210401 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 80950d8c000..e08d7de8b99 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 de73307443b..f5064f499c0 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 2aabf7fbbb3..9d480454594 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 00000000000..06bfbc01864 --- /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 0f0e240f01b..038c9a13d85 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: () {