mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	Implemented editable album title (#130)
* Replace static title text with a text edit field * Implement endpoint for updating album info * Implement changing title * Only the owner can change the title
This commit is contained in:
		| @@ -0,0 +1,53 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| class AlbumViewerPageState { | ||||
|   final bool isEditAlbum; | ||||
|   final String editTitleText; | ||||
|   AlbumViewerPageState({ | ||||
|     required this.isEditAlbum, | ||||
|     required this.editTitleText, | ||||
|   }); | ||||
|  | ||||
|   AlbumViewerPageState copyWith({ | ||||
|     bool? isEditAlbum, | ||||
|     String? editTitleText, | ||||
|   }) { | ||||
|     return AlbumViewerPageState( | ||||
|       isEditAlbum: isEditAlbum ?? this.isEditAlbum, | ||||
|       editTitleText: editTitleText ?? this.editTitleText, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Map<String, dynamic> toMap() { | ||||
|     final result = <String, dynamic>{}; | ||||
|  | ||||
|     result.addAll({'isEditAlbum': isEditAlbum}); | ||||
|     result.addAll({'editTitleText': editTitleText}); | ||||
|  | ||||
|     return result; | ||||
|   } | ||||
|  | ||||
|   factory AlbumViewerPageState.fromMap(Map<String, dynamic> map) { | ||||
|     return AlbumViewerPageState( | ||||
|       isEditAlbum: map['isEditAlbum'] ?? false, | ||||
|       editTitleText: map['editTitleText'] ?? '', | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   String toJson() => json.encode(toMap()); | ||||
|  | ||||
|   factory AlbumViewerPageState.fromJson(String source) => AlbumViewerPageState.fromMap(json.decode(source)); | ||||
|  | ||||
|   @override | ||||
|   String toString() => 'AlbumViewerPageState(isEditAlbum: $isEditAlbum, editTitleText: $editTitleText)'; | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     if (identical(this, other)) return true; | ||||
|  | ||||
|     return other is AlbumViewerPageState && other.isEditAlbum == isEditAlbum && other.editTitleText == editTitleText; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode => isEditAlbum.hashCode ^ editTitleText.hashCode; | ||||
| } | ||||
| @@ -0,0 +1,49 @@ | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/models/album_viewer_page_state.model.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart'; | ||||
|  | ||||
| class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> { | ||||
|   AlbumViewerNotifier(this.ref) : super(AlbumViewerPageState(editTitleText: "", isEditAlbum: false)); | ||||
|  | ||||
|   final Ref ref; | ||||
|  | ||||
|   void enableEditAlbum() { | ||||
|     state = state.copyWith(isEditAlbum: true); | ||||
|   } | ||||
|  | ||||
|   void disableEditAlbum() { | ||||
|     state = state.copyWith(isEditAlbum: false); | ||||
|   } | ||||
|  | ||||
|   void setEditTitleText(String newTitle) { | ||||
|     state = state.copyWith(editTitleText: newTitle); | ||||
|   } | ||||
|  | ||||
|   void remoteEditTitleText() { | ||||
|     state = state.copyWith(editTitleText: ""); | ||||
|   } | ||||
|  | ||||
|   void resetState() { | ||||
|     state = state.copyWith(editTitleText: "", isEditAlbum: false); | ||||
|   } | ||||
|  | ||||
|   Future<bool> changeAlbumTitle(String albumId, String ownerId, String newAlbumTitle) async { | ||||
|     SharedAlbumService service = SharedAlbumService(); | ||||
|  | ||||
|     bool isSuccess = await service.changeTitleAlbum(albumId, ownerId, newAlbumTitle); | ||||
|  | ||||
|     if (isSuccess) { | ||||
|       state = state.copyWith(editTitleText: "", isEditAlbum: false); | ||||
|       ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); | ||||
|  | ||||
|       return true; | ||||
|     } | ||||
|  | ||||
|     return false; | ||||
|   } | ||||
| } | ||||
|  | ||||
| final albumViewerProvider = StateNotifierProvider<AlbumViewerNotifier, AlbumViewerPageState>((ref) { | ||||
|   return AlbumViewerNotifier(ref); | ||||
| }); | ||||
| @@ -138,4 +138,23 @@ class SharedAlbumService { | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<bool> changeTitleAlbum(String albumId, String ownerId, String newAlbumTitle) async { | ||||
|     try { | ||||
|       Response res = await _networkService.patchRequest(url: 'shared/updateInfo', data: { | ||||
|         "albumId": albumId, | ||||
|         "ownerId": ownerId, | ||||
|         "albumName": newAlbumTitle, | ||||
|       }); | ||||
|  | ||||
|       if (res.statusCode != 200) { | ||||
|         return false; | ||||
|       } | ||||
|  | ||||
|       return true; | ||||
|     } catch (e) { | ||||
|       debugPrint("Error deleteAlbum  ${e.toString()}"); | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import 'package:fluttertoast/fluttertoast.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/immich_colors.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/providers/album_viewer.provider.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
| @@ -27,6 +28,8 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget { | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final isMultiSelectionEnable = ref.watch(assetSelectionProvider).isMultiselectEnable; | ||||
|     final selectedAssetsInAlbum = ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer; | ||||
|     final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText; | ||||
|     final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum; | ||||
|  | ||||
|     void _onDeleteAlbumPressed(String albumId) async { | ||||
|       ImmichLoadingOverlayController.appLoader.show(); | ||||
| @@ -152,6 +155,24 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget { | ||||
|           icon: const Icon(Icons.close_rounded), | ||||
|           splashRadius: 25, | ||||
|         ); | ||||
|       } else if (isEditAlbum) { | ||||
|         return IconButton( | ||||
|           onPressed: () async { | ||||
|             bool isSuccess = | ||||
|                 await ref.watch(albumViewerProvider.notifier).changeAlbumTitle(albumId, userId, newAlbumTitle); | ||||
|  | ||||
|             if (!isSuccess) { | ||||
|               ImmichToast.show( | ||||
|                 context: context, | ||||
|                 msg: "Failed to change album title", | ||||
|                 gravity: ToastGravity.BOTTOM, | ||||
|                 toastType: ToastType.error, | ||||
|               ); | ||||
|             } | ||||
|           }, | ||||
|           icon: const Icon(Icons.check_rounded), | ||||
|           splashRadius: 25, | ||||
|         ); | ||||
|       } else { | ||||
|         return IconButton( | ||||
|           onPressed: () async => await AutoRouter.of(context).pop(), | ||||
|   | ||||
| @@ -0,0 +1,76 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/providers/album_viewer.provider.dart'; | ||||
|  | ||||
| class AlbumViewerEditableTitle extends HookConsumerWidget { | ||||
|   final SharedAlbum albumInfo; | ||||
|   final FocusNode titleFocusNode; | ||||
|   const AlbumViewerEditableTitle({Key? key, required this.albumInfo, required this.titleFocusNode}) : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final titleTextEditController = useTextEditingController(text: albumInfo.albumName); | ||||
|  | ||||
|     void onFocusModeChange() { | ||||
|       if (!titleFocusNode.hasFocus && titleTextEditController.text.isEmpty) { | ||||
|         ref.watch(albumViewerProvider.notifier).setEditTitleText("Untitled"); | ||||
|         titleTextEditController.text = "Untitled"; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     useEffect(() { | ||||
|       titleFocusNode.addListener(onFocusModeChange); | ||||
|       return () { | ||||
|         titleFocusNode.removeListener(onFocusModeChange); | ||||
|       }; | ||||
|     }, []); | ||||
|  | ||||
|     return TextField( | ||||
|       onChanged: (value) { | ||||
|         if (value.isEmpty) { | ||||
|         } else { | ||||
|           ref.watch(albumViewerProvider.notifier).setEditTitleText(value); | ||||
|         } | ||||
|       }, | ||||
|       focusNode: titleFocusNode, | ||||
|       style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), | ||||
|       controller: titleTextEditController, | ||||
|       onTap: () { | ||||
|         FocusScope.of(context).requestFocus(titleFocusNode); | ||||
|  | ||||
|         ref.watch(albumViewerProvider.notifier).setEditTitleText(albumInfo.albumName); | ||||
|         ref.watch(albumViewerProvider.notifier).enableEditAlbum(); | ||||
|  | ||||
|         if (titleTextEditController.text == 'Untitled') { | ||||
|           titleTextEditController.clear(); | ||||
|         } | ||||
|       }, | ||||
|       decoration: InputDecoration( | ||||
|         contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), | ||||
|         suffixIcon: titleFocusNode.hasFocus | ||||
|             ? IconButton( | ||||
|                 onPressed: () { | ||||
|                   titleTextEditController.clear(); | ||||
|                 }, | ||||
|                 icon: const Icon(Icons.cancel_rounded), | ||||
|                 splashRadius: 10, | ||||
|               ) | ||||
|             : null, | ||||
|         enabledBorder: OutlineInputBorder( | ||||
|           borderSide: const BorderSide(color: Colors.transparent), | ||||
|           borderRadius: BorderRadius.circular(10), | ||||
|         ), | ||||
|         focusedBorder: OutlineInputBorder( | ||||
|           borderSide: const BorderSide(color: Colors.transparent), | ||||
|           borderRadius: BorderRadius.circular(10), | ||||
|         ), | ||||
|         focusColor: Colors.grey[300], | ||||
|         fillColor: Colors.grey[200], | ||||
|         filled: titleFocusNode.hasFocus, | ||||
|         hintText: 'Add a title', | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -12,6 +12,7 @@ import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.da | ||||
| import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/ui/album_action_outlined_button.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/ui/album_viewer_appbar.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/ui/album_viewer_editable_title.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/ui/album_viewer_thumbnail.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; | ||||
| @@ -26,6 +27,7 @@ class AlbumViewerPage extends HookConsumerWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     FocusNode titleFocusNode = useFocusNode(); | ||||
|     ScrollController _scrollController = useScrollController(); | ||||
|     AsyncValue<SharedAlbum> _albumInfo = ref.watch(sharedAlbumDetailProvider(albumId)); | ||||
|  | ||||
| @@ -83,12 +85,17 @@ class AlbumViewerPage extends HookConsumerWidget { | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Widget _buildTitle(String title) { | ||||
|     Widget _buildTitle(SharedAlbum albumInfo) { | ||||
|       return Padding( | ||||
|         padding: const EdgeInsets.only(left: 16.0, top: 16), | ||||
|         child: Text( | ||||
|           title, | ||||
|           style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), | ||||
|         padding: const EdgeInsets.only(left: 8, right: 8, top: 16), | ||||
|         child: userId == albumInfo.ownerId | ||||
|             ? AlbumViewerEditableTitle( | ||||
|                 albumInfo: albumInfo, | ||||
|                 titleFocusNode: titleFocusNode, | ||||
|               ) | ||||
|             : Padding( | ||||
|                 padding: const EdgeInsets.only(left: 8.0), | ||||
|                 child: Text(albumInfo.albumName, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), | ||||
|               ), | ||||
|       ); | ||||
|     } | ||||
| @@ -124,7 +131,7 @@ class AlbumViewerPage extends HookConsumerWidget { | ||||
|         child: Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             _buildTitle(albumInfo.albumName), | ||||
|             _buildTitle(albumInfo), | ||||
|             _buildAlbumDateRange(albumInfo), | ||||
|             SizedBox( | ||||
|               height: 60, | ||||
| @@ -204,7 +211,11 @@ class AlbumViewerPage extends HookConsumerWidget { | ||||
|     } | ||||
|  | ||||
|     Widget _buildBody(SharedAlbum albumInfo) { | ||||
|       return Stack(children: [ | ||||
|       return GestureDetector( | ||||
|         onTap: () { | ||||
|           titleFocusNode.unfocus(); | ||||
|         }, | ||||
|         child: Stack(children: [ | ||||
|           DraggableScrollbar.semicircle( | ||||
|             backgroundColor: Theme.of(context).primaryColor, | ||||
|             controller: _scrollController, | ||||
| @@ -228,7 +239,8 @@ class AlbumViewerPage extends HookConsumerWidget { | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|       ]); | ||||
|         ]), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return Scaffold( | ||||
|   | ||||
| @@ -14,6 +14,7 @@ import { | ||||
|   Headers, | ||||
|   Delete, | ||||
|   Logger, | ||||
|   Patch, | ||||
| } from '@nestjs/common'; | ||||
| import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; | ||||
| import { AssetService } from './asset.service'; | ||||
|   | ||||
							
								
								
									
										12
									
								
								server/src/api-v1/sharing/dto/update-shared-album.dto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								server/src/api-v1/sharing/dto/update-shared-album.dto.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| import { IsNotEmpty } from 'class-validator'; | ||||
|  | ||||
| export class UpdateShareAlbumDto { | ||||
|   @IsNotEmpty() | ||||
|   albumId: string; | ||||
|  | ||||
|   @IsNotEmpty() | ||||
|   albumName: string; | ||||
|  | ||||
|   @IsNotEmpty() | ||||
|   ownerId: string; | ||||
| } | ||||
| @@ -2,10 +2,11 @@ import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Validatio | ||||
| import { SharingService } from './sharing.service'; | ||||
| import { CreateSharedAlbumDto } from './dto/create-shared-album.dto'; | ||||
| import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; | ||||
| import { GetAuthUser } from '../../decorators/auth-user.decorator'; | ||||
| import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; | ||||
| import { AddAssetsDto } from './dto/add-assets.dto'; | ||||
| import { AddUsersDto } from './dto/add-users.dto'; | ||||
| import { RemoveAssetsDto } from './dto/remove-assets.dto'; | ||||
| import { UpdateShareAlbumDto } from './dto/update-shared-album.dto'; | ||||
|  | ||||
| @UseGuards(JwtAuthGuard) | ||||
| @Controller('shared') | ||||
| @@ -52,4 +53,9 @@ export class SharingController { | ||||
|   async leaveAlbum(@GetAuthUser() authUser, @Param('albumId') albumId: string) { | ||||
|     return await this.sharingService.leaveAlbum(authUser, albumId); | ||||
|   } | ||||
|  | ||||
|   @Patch('/updateInfo') | ||||
|   async updateAlbumInfo(@GetAuthUser() authUser, @Body(ValidationPipe) updateAlbumInfoDto: UpdateShareAlbumDto) { | ||||
|     return await this.sharingService.updateAlbumTitle(authUser, updateAlbumInfoDto); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -12,6 +12,7 @@ import { UserSharedAlbumEntity } from './entities/user-shared-album.entity'; | ||||
| import _ from 'lodash'; | ||||
| import { AddUsersDto } from './dto/add-users.dto'; | ||||
| import { RemoveAssetsDto } from './dto/remove-assets.dto'; | ||||
| import { UpdateShareAlbumDto } from './dto/update-shared-album.dto'; | ||||
|  | ||||
| @Injectable() | ||||
| export class SharingService { | ||||
| @@ -184,4 +185,15 @@ export class SharingService { | ||||
|  | ||||
|     return await this.assetSharedAlbumRepository.save([...newRecords]); | ||||
|   } | ||||
|  | ||||
|   async updateAlbumTitle(authUser: AuthUserDto, updateShareAlbumDto: UpdateShareAlbumDto) { | ||||
|     if (authUser.id != updateShareAlbumDto.ownerId) { | ||||
|       throw new BadRequestException('Unauthorized to change album info'); | ||||
|     } | ||||
|  | ||||
|     const sharedAlbum = await this.sharedAlbumRepository.findOne({ where: { id: updateShareAlbumDto.albumId } }); | ||||
|     sharedAlbum.albumName = updateShareAlbumDto.albumName; | ||||
|  | ||||
|     return await this.sharedAlbumRepository.save(sharedAlbum); | ||||
|   } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user