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 <alex.tran1502@gmail.com>
This commit is contained in:
JobiJoba 2025-06-05 00:41:28 +07:00 committed by GitHub
parent 19ff39c2b9
commit 5d0ad853f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 598 additions and 98 deletions

View File

@ -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<Album> {

View File

@ -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<Type, List<int>> 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<P>(
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<Album, Album, QFilterCondition> {
});
}
QueryBuilder<Album, Album, QAfterFilterCondition> descriptionIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'description',
));
});
}
QueryBuilder<Album, Album, QAfterFilterCondition> descriptionIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'description',
));
});
}
QueryBuilder<Album, Album, QAfterFilterCondition> descriptionEqualTo(
String? value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'description',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Album, Album, QAfterFilterCondition> 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<Album, Album, QAfterFilterCondition> 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<Album, Album, QAfterFilterCondition> 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<Album, Album, QAfterFilterCondition> descriptionStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'description',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Album, Album, QAfterFilterCondition> descriptionEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'description',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Album, Album, QAfterFilterCondition> descriptionContains(
String value,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'description',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Album, Album, QAfterFilterCondition> descriptionMatches(
String pattern,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'description',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Album, Album, QAfterFilterCondition> descriptionIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'description',
value: '',
));
});
}
QueryBuilder<Album, Album, QAfterFilterCondition> descriptionIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'description',
value: '',
));
});
}
QueryBuilder<Album, Album, QAfterFilterCondition> endDateIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
@ -1502,6 +1664,18 @@ extension AlbumQuerySortBy on QueryBuilder<Album, Album, QSortBy> {
});
}
QueryBuilder<Album, Album, QAfterSortBy> sortByDescription() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'description', Sort.asc);
});
}
QueryBuilder<Album, Album, QAfterSortBy> sortByDescriptionDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'description', Sort.desc);
});
}
QueryBuilder<Album, Album, QAfterSortBy> sortByEndDate() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'endDate', Sort.asc);
@ -1637,6 +1811,18 @@ extension AlbumQuerySortThenBy on QueryBuilder<Album, Album, QSortThenBy> {
});
}
QueryBuilder<Album, Album, QAfterSortBy> thenByDescription() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'description', Sort.asc);
});
}
QueryBuilder<Album, Album, QAfterSortBy> thenByDescriptionDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'description', Sort.desc);
});
}
QueryBuilder<Album, Album, QAfterSortBy> thenByEndDate() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'endDate', Sort.asc);
@ -1772,6 +1958,13 @@ extension AlbumQueryWhereDistinct on QueryBuilder<Album, Album, QDistinct> {
});
}
QueryBuilder<Album, Album, QDistinct> distinctByDescription(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'description', caseSensitive: caseSensitive);
});
}
QueryBuilder<Album, Album, QDistinct> distinctByEndDate() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'endDate');
@ -1849,6 +2042,12 @@ extension AlbumQueryProperty on QueryBuilder<Album, Album, QQueryProperty> {
});
}
QueryBuilder<Album, String?, QQueryOperations> descriptionProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'description');
});
}
QueryBuilder<Album, DateTime?, QQueryOperations> endDateProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'endDate');

View File

@ -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;
}

View File

@ -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: [

View File

@ -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,
),
),
);
}

View File

@ -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,
),
);
}
}

View File

@ -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(

View File

@ -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,
),
),
);
}
}

View File

@ -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,

View File

@ -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<Set<Asset>>(
@ -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<void> 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(),
],
),

View File

@ -5,7 +5,13 @@ import 'package:immich_mobile/entities/album.entity.dart';
class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> {
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<AlbumViewerPageState> {
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<bool> changeAlbumTitle(
@ -46,6 +64,28 @@ class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> {
state = state.copyWith(editTitleText: "", isEditAlbum: false);
return false;
}
Future<bool> 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 =

View File

@ -36,6 +36,7 @@ class AlbumApiRepository extends ApiRepository implements IAlbumApiRepository {
String name, {
required Iterable<String> assetIds,
Iterable<String> 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;
}
}

View File

@ -422,6 +422,25 @@ class AlbumService {
}
}
Future<bool> 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<Album?> getAlbumByName(
String name, {
bool? remote,

View File

@ -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 ||

View File

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

View File

@ -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,

View File

@ -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(),
),
),
);
}
}

View File

@ -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: () {