浏览代码

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
Alex 3 年之前
父节点
当前提交
38e0178c81

+ 53 - 0
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<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;
+}

+ 49 - 0
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<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);
+});

+ 19 - 0
mobile/lib/modules/sharing/services/shared_album.service.dart

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

+ 21 - 0
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(),

+ 76 - 0
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',
+      ),
+    );
+  }
+}

+ 40 - 28
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<SharedAlbum> _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(

+ 1 - 0
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';

+ 12 - 0
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;
+}

+ 7 - 1
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);
+  }
 }

+ 12 - 0
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);
+  }
 }