From 38e0178c81a3811ad7e20e0a73e9c61c8002bf6d Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 28 Apr 2022 23:46:37 -0500 Subject: [PATCH] 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 --- .../models/album_viewer_page_state.model.dart | 53 +++++++++++++ .../providers/album_viewer.provider.dart | 49 ++++++++++++ .../services/shared_album.service.dart | 19 +++++ .../sharing/ui/album_viewer_appbar.dart | 21 +++++ .../ui/album_viewer_editable_title.dart | 76 +++++++++++++++++++ .../sharing/views/album_viewer_page.dart | 68 ++++++++++------- server/src/api-v1/asset/asset.controller.ts | 1 + .../sharing/dto/update-shared-album.dto.ts | 12 +++ .../src/api-v1/sharing/sharing.controller.ts | 8 +- server/src/api-v1/sharing/sharing.service.ts | 12 +++ 10 files changed, 290 insertions(+), 29 deletions(-) create mode 100644 mobile/lib/modules/sharing/models/album_viewer_page_state.model.dart create mode 100644 mobile/lib/modules/sharing/providers/album_viewer.provider.dart create mode 100644 mobile/lib/modules/sharing/ui/album_viewer_editable_title.dart create mode 100644 server/src/api-v1/sharing/dto/update-shared-album.dto.ts diff --git a/mobile/lib/modules/sharing/models/album_viewer_page_state.model.dart b/mobile/lib/modules/sharing/models/album_viewer_page_state.model.dart new file mode 100644 index 000000000..bf2420e32 --- /dev/null +++ b/mobile/lib/modules/sharing/models/album_viewer_page_state.model.dart @@ -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 toMap() { + final result = {}; + + result.addAll({'isEditAlbum': isEditAlbum}); + result.addAll({'editTitleText': editTitleText}); + + return result; + } + + factory AlbumViewerPageState.fromMap(Map 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; +} diff --git a/mobile/lib/modules/sharing/providers/album_viewer.provider.dart b/mobile/lib/modules/sharing/providers/album_viewer.provider.dart new file mode 100644 index 000000000..90cf18a6a --- /dev/null +++ b/mobile/lib/modules/sharing/providers/album_viewer.provider.dart @@ -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 { + 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 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((ref) { + return AlbumViewerNotifier(ref); +}); diff --git a/mobile/lib/modules/sharing/services/shared_album.service.dart b/mobile/lib/modules/sharing/services/shared_album.service.dart index 88e4398a5..0af8788d4 100644 --- a/mobile/lib/modules/sharing/services/shared_album.service.dart +++ b/mobile/lib/modules/sharing/services/shared_album.service.dart @@ -138,4 +138,23 @@ class SharedAlbumService { return false; } } + + Future 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; + } + } } diff --git a/mobile/lib/modules/sharing/ui/album_viewer_appbar.dart b/mobile/lib/modules/sharing/ui/album_viewer_appbar.dart index 83a627542..a88e3ce16 100644 --- a/mobile/lib/modules/sharing/ui/album_viewer_appbar.dart +++ b/mobile/lib/modules/sharing/ui/album_viewer_appbar.dart @@ -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(), diff --git a/mobile/lib/modules/sharing/ui/album_viewer_editable_title.dart b/mobile/lib/modules/sharing/ui/album_viewer_editable_title.dart new file mode 100644 index 000000000..191b5de1a --- /dev/null +++ b/mobile/lib/modules/sharing/ui/album_viewer_editable_title.dart @@ -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', + ), + ); + } +} diff --git a/mobile/lib/modules/sharing/views/album_viewer_page.dart b/mobile/lib/modules/sharing/views/album_viewer_page.dart index 3077eb89e..0e21bb06c 100644 --- a/mobile/lib/modules/sharing/views/album_viewer_page.dart +++ b/mobile/lib/modules/sharing/views/album_viewer_page.dart @@ -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 _albumInfo = ref.watch(sharedAlbumDetailProvider(albumId)); @@ -83,13 +85,18 @@ 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,31 +211,36 @@ class AlbumViewerPage extends HookConsumerWidget { } Widget _buildBody(SharedAlbum albumInfo) { - return Stack(children: [ - DraggableScrollbar.semicircle( - backgroundColor: Theme.of(context).primaryColor, - controller: _scrollController, - heightScrollThumb: 48.0, - child: CustomScrollView( + return GestureDetector( + onTap: () { + titleFocusNode.unfocus(); + }, + child: Stack(children: [ + DraggableScrollbar.semicircle( + backgroundColor: Theme.of(context).primaryColor, controller: _scrollController, - slivers: [ - _buildHeader(albumInfo), - SliverPersistentHeader( - pinned: true, - delegate: ImmichSliverPersistentAppBarDelegate( - minHeight: 50, - maxHeight: 50, - child: Container( - color: immichBackgroundColor, - child: _buildControlButton(albumInfo), + heightScrollThumb: 48.0, + child: CustomScrollView( + controller: _scrollController, + slivers: [ + _buildHeader(albumInfo), + SliverPersistentHeader( + pinned: true, + delegate: ImmichSliverPersistentAppBarDelegate( + minHeight: 50, + maxHeight: 50, + child: Container( + color: immichBackgroundColor, + child: _buildControlButton(albumInfo), + ), ), ), - ), - _buildImageGrid(albumInfo) - ], + _buildImageGrid(albumInfo) + ], + ), ), - ), - ]); + ]), + ); } return Scaffold( diff --git a/server/src/api-v1/asset/asset.controller.ts b/server/src/api-v1/asset/asset.controller.ts index 4d7b45a7d..6d7ec40b8 100644 --- a/server/src/api-v1/asset/asset.controller.ts +++ b/server/src/api-v1/asset/asset.controller.ts @@ -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'; diff --git a/server/src/api-v1/sharing/dto/update-shared-album.dto.ts b/server/src/api-v1/sharing/dto/update-shared-album.dto.ts new file mode 100644 index 000000000..c67adfb08 --- /dev/null +++ b/server/src/api-v1/sharing/dto/update-shared-album.dto.ts @@ -0,0 +1,12 @@ +import { IsNotEmpty } from 'class-validator'; + +export class UpdateShareAlbumDto { + @IsNotEmpty() + albumId: string; + + @IsNotEmpty() + albumName: string; + + @IsNotEmpty() + ownerId: string; +} diff --git a/server/src/api-v1/sharing/sharing.controller.ts b/server/src/api-v1/sharing/sharing.controller.ts index 4d83fb869..4a9543b04 100644 --- a/server/src/api-v1/sharing/sharing.controller.ts +++ b/server/src/api-v1/sharing/sharing.controller.ts @@ -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); + } } diff --git a/server/src/api-v1/sharing/sharing.service.ts b/server/src/api-v1/sharing/sharing.service.ts index a249223aa..6a1ffa07e 100644 --- a/server/src/api-v1/sharing/sharing.service.ts +++ b/server/src/api-v1/sharing/sharing.service.ts @@ -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); + } }