118 - Implement shared album feature (#124)
* New features - Share album. Users can now create albums to share with existing people on the network. - Owner can delete the album. - Owner can invite the additional users to the album. - Shared users and the owner can add additional assets to the album. * In the asset viewer, the user can swipe up to see detailed information and swip down to dismiss. * Several UI enhancements.
							
								
								
									
										14
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -34,8 +34,9 @@ Loading ~4000 images/videos | ||||
| <p align="left"> | ||||
|   <img src="design/nsc1.png" width="150" title="Login With Custom URL"> | ||||
|   <img src="design/nsc2.png" width="150" title="Backup Setting Info"> | ||||
|   <img src="design/nsc3.png" width="150" title="Multiple select"> | ||||
|   <img src="design/nsc4.jpeg" width="150" title="Curated Search Info"> | ||||
|   <img src="design/home-screen.jpeg" width="150" title="Home Screen"> | ||||
|   <img src="design/search-screen.jpeg" width="150" title="Curated Search Info"> | ||||
|   <img src="design/shared-albums.png" width="150" title="Shared Albums"> | ||||
|   <img src="design/nsc6.png" width="150" title="EXIF Info"> | ||||
|  | ||||
| </p> | ||||
| @@ -63,6 +64,7 @@ This project is under heavy development, there will be continous functions, feat | ||||
| - Show asset's location information on map (OpenStreetMap). | ||||
| - Show curated places on the search page | ||||
| - Show curated objects on the search page | ||||
| - Shared album with users on the same server | ||||
|  | ||||
| # Development | ||||
|  | ||||
| @@ -111,6 +113,14 @@ curl --location --request POST 'http://your-server-ip:2283/auth/signUp' \ | ||||
|  | ||||
| ## Run mobile app | ||||
|  | ||||
| ## F-Droid | ||||
| You can get the app on F-droid by cliking the image below. | ||||
|  | ||||
| [<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" | ||||
|     alt="Get it on F-Droid" | ||||
|     height="80">](https://f-droid.org/packages/app.alextran.immich) | ||||
|  | ||||
|  | ||||
| ## Android | ||||
|  | ||||
| #### Download latest `apk` in release tab and run on your phone. You can follow this guide on how to do that | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								design/home-screen.jpeg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 126 KiB | 
							
								
								
									
										
											BIN
										
									
								
								design/search-screen.jpeg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 376 KiB | 
							
								
								
									
										
											BIN
										
									
								
								design/shared-albums.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 244 KiB | 
| @@ -2,7 +2,7 @@ version: "3.8" | ||||
|  | ||||
| services: | ||||
|   immich_server: | ||||
|     image: immich-server-dev:1.6.0 | ||||
|     image: immich-server-dev:1.7.0 | ||||
|     build: | ||||
|       context: ../server | ||||
|       dockerfile: Dockerfile | ||||
| @@ -24,7 +24,7 @@ services: | ||||
|       - immich_network | ||||
|  | ||||
|   immich_microservices: | ||||
|     image: immich-microservices-dev:1.6.0 | ||||
|     image: immich-microservices-dev:1.7.0 | ||||
|     build: | ||||
|       context: ../microservices | ||||
|       dockerfile: Dockerfile | ||||
|   | ||||
| @@ -2,7 +2,7 @@ version: "3.8" | ||||
|  | ||||
| services: | ||||
|   immich_server: | ||||
|     image: immich-server-dev:1.6.0 | ||||
|     image: immich-server-dev:1.7.0 | ||||
|     build: | ||||
|       context: ../server | ||||
|       dockerfile: Dockerfile | ||||
| @@ -22,7 +22,7 @@ services: | ||||
|       - immich_network | ||||
|  | ||||
|   immich_microservices: | ||||
|     image: immich-microservices-dev:1.6.0 | ||||
|     image: immich-microservices-dev:1.7.0 | ||||
|     build: | ||||
|       context: ../microservices | ||||
|       dockerfile: Dockerfile | ||||
|   | ||||
| @@ -2,7 +2,7 @@ version: "3.8" | ||||
|  | ||||
| services: | ||||
|   immich_server: | ||||
|     image: immich-server:1.6.0 | ||||
|     image: immich-server:1.7.0 | ||||
|     build: | ||||
|       context: ../server | ||||
|       dockerfile: Dockerfile | ||||
| @@ -23,7 +23,7 @@ services: | ||||
|     restart: unless-stopped | ||||
|  | ||||
|   immich_microservices: | ||||
|     image: immich-microservices:1.6.0 | ||||
|     image: immich-microservices:1.7.0 | ||||
|     build: | ||||
|       context: ../microservices | ||||
|       dockerfile: Dockerfile | ||||
|   | ||||
| @@ -10,11 +10,22 @@ map $http_upgrade $connection_upgrade { | ||||
|  | ||||
| server { | ||||
|  | ||||
|   gzip on; | ||||
|   gzip_min_length 1000; | ||||
|   gunzip on; | ||||
|  | ||||
|   client_max_body_size 50000M; | ||||
|  | ||||
|   listen 80; | ||||
|   access_log off; | ||||
|  | ||||
|   location / { | ||||
|  | ||||
|     # Compression | ||||
|     gzip_static         on; | ||||
|     gzip_min_length     1000; | ||||
|     gzip_comp_level     2; | ||||
|  | ||||
|     proxy_buffering off; | ||||
|     proxy_buffer_size 16k; | ||||
|     proxy_busy_buffers_size 24k; | ||||
|   | ||||
| @@ -81,4 +81,5 @@ flutter { | ||||
|  | ||||
| dependencies { | ||||
|     implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" | ||||
|     implementation 'com.android.support:multidex:1.0.3' | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,7 @@ | ||||
| * New features  | ||||
|   - Share album. Users can now create albums to share with existing people on the network. | ||||
|   - Owner can delete the album. | ||||
|   - Owner can invite the additional users to the album. | ||||
|   - Shared users and the owner can add additional assets to the album. | ||||
| * In the asset viewer, the user can swipe up to see detailed information and swip down to dismiss. | ||||
| * Several UI enhancements. | ||||
| Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 1.6 MiB | 
| Before Width: | Height: | Size: 517 KiB After Width: | Height: | Size: 681 KiB | 
| Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 252 KiB | 
| After Width: | Height: | Size: 517 KiB | 
| After Width: | Height: | Size: 1.6 MiB | 
| @@ -19,7 +19,7 @@ platform :ios do | ||||
|   desc "iOS Beta" | ||||
|   lane :beta do | ||||
|     increment_version_number( | ||||
|       version_number: "1.6.0" | ||||
|       version_number: "1.7.0" | ||||
|     ) | ||||
|     increment_build_number( | ||||
|       build_number: latest_testflight_build_number + 1, | ||||
|   | ||||
							
								
								
									
										3
									
								
								mobile/lib/constants/immich_colors.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
|  | ||||
| const immichBackgroundColor = Color(0xFFf6f8fe); | ||||
| @@ -2,20 +2,20 @@ import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:hive_flutter/hive_flutter.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/constants/immich_colors.dart'; | ||||
| import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
| import 'package:immich_mobile/routing/tab_navigation_observer.dart'; | ||||
| import 'package:immich_mobile/shared/providers/app_state.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/backup.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/server_info.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/websocket.provider.dart'; | ||||
| import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; | ||||
| import 'constants/hive_box.dart'; | ||||
|  | ||||
| void main() async { | ||||
|   await Hive.initFlutter(); | ||||
|   await Hive.openBox(userInfoBox); | ||||
|   // Hive.registerAdapter(ImmichBackUpAssetAdapter()); | ||||
|   // Hive.deleteBoxFromDisk(hiveImmichBox); | ||||
|  | ||||
|   SystemChrome.setSystemUIOverlayStyle( | ||||
|     const SystemUiOverlayStyle( | ||||
| @@ -87,20 +87,21 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return MaterialApp.router( | ||||
|     return MaterialApp( | ||||
|       debugShowCheckedModeBanner: false, | ||||
|       home: Stack( | ||||
|         children: [ | ||||
|           MaterialApp.router( | ||||
|             title: 'Immich', | ||||
|             debugShowCheckedModeBanner: false, | ||||
|             theme: ThemeData( | ||||
|               brightness: Brightness.light, | ||||
|               primarySwatch: Colors.indigo, | ||||
|         // textTheme: GoogleFonts.workSansTextTheme( | ||||
|         //   Theme.of(context).textTheme.apply(fontSizeFactor: 1.0), | ||||
|         // ), | ||||
|               fontFamily: 'WorkSans', | ||||
|               snackBarTheme: const SnackBarThemeData(contentTextStyle: TextStyle(fontFamily: 'WorkSans')), | ||||
|         scaffoldBackgroundColor: const Color(0xFFf6f8fe), | ||||
|               scaffoldBackgroundColor: immichBackgroundColor, | ||||
|               appBarTheme: const AppBarTheme( | ||||
|           backgroundColor: Colors.white, | ||||
|                 backgroundColor: immichBackgroundColor, | ||||
|                 foregroundColor: Colors.indigo, | ||||
|                 elevation: 1, | ||||
|                 centerTitle: true, | ||||
| @@ -109,6 +110,10 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv | ||||
|             ), | ||||
|             routeInformationParser: _immichRouter.defaultRouteParser(), | ||||
|             routerDelegate: _immichRouter.delegate(navigatorObservers: () => [TabNavigationObserver(ref: ref)]), | ||||
|           ), | ||||
|           const ImmichLoadingOverlay(), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:cached_network_image/cached_network_image.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:flutter_swipe_detector/flutter_swipe_detector.dart'; | ||||
| import 'package:hive/hive.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| @@ -35,16 +37,7 @@ class ImageViewerPage extends HookConsumerWidget { | ||||
|       assetDetail = await _assetService.getAssetById(asset.id); | ||||
|     } | ||||
|  | ||||
|     useEffect(() { | ||||
|       getAssetExif(); | ||||
|       return null; | ||||
|     }, []); | ||||
|  | ||||
|     return Scaffold( | ||||
|       backgroundColor: Colors.black, | ||||
|       appBar: TopControlAppBar( | ||||
|         asset: asset, | ||||
|         onMoreInfoPressed: () { | ||||
|     showInfo() { | ||||
|       showModalBottomSheet( | ||||
|         backgroundColor: Colors.black, | ||||
|         barrierColor: Colors.transparent, | ||||
| @@ -54,12 +47,30 @@ class ImageViewerPage extends HookConsumerWidget { | ||||
|           return ExifBottomSheet(assetDetail: assetDetail!); | ||||
|         }, | ||||
|       ); | ||||
|         }, | ||||
|     } | ||||
|  | ||||
|     useEffect(() { | ||||
|       getAssetExif(); | ||||
|       return null; | ||||
|     }, []); | ||||
|  | ||||
|     return Scaffold( | ||||
|       backgroundColor: Colors.black, | ||||
|       appBar: TopControlAppBar( | ||||
|         asset: asset, | ||||
|         onMoreInfoPressed: showInfo, | ||||
|         onDownloadPressed: () { | ||||
|           ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context); | ||||
|         }, | ||||
|       ), | ||||
|       body: SafeArea( | ||||
|       body: SwipeDetector( | ||||
|         onSwipeDown: (_) { | ||||
|           AutoRouter.of(context).pop(); | ||||
|         }, | ||||
|         onSwipeUp: (_) { | ||||
|           showInfo(); | ||||
|         }, | ||||
|         child: SafeArea( | ||||
|           child: Stack( | ||||
|             children: [ | ||||
|               Center( | ||||
| @@ -119,6 +130,7 @@ class ImageViewerPage extends HookConsumerWidget { | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:flutter_swipe_detector/flutter_swipe_detector.dart'; | ||||
| import 'package:hive/hive.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| @@ -29,6 +31,18 @@ class VideoViewerPage extends HookConsumerWidget { | ||||
|  | ||||
|     String jwtToken = Hive.box(userInfoBox).get(accessTokenKey); | ||||
|  | ||||
|     void showInfo() { | ||||
|       showModalBottomSheet( | ||||
|         backgroundColor: Colors.black, | ||||
|         barrierColor: Colors.transparent, | ||||
|         isScrollControlled: false, | ||||
|         context: context, | ||||
|         builder: (context) { | ||||
|           return ExifBottomSheet(assetDetail: assetDetail!); | ||||
|         }, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     getAssetExif() async { | ||||
|       assetDetail = await _assetService.getAssetById(asset.id); | ||||
|     } | ||||
| @@ -43,21 +57,20 @@ class VideoViewerPage extends HookConsumerWidget { | ||||
|       appBar: TopControlAppBar( | ||||
|         asset: asset, | ||||
|         onMoreInfoPressed: () { | ||||
|           showModalBottomSheet( | ||||
|             backgroundColor: Colors.black, | ||||
|             barrierColor: Colors.transparent, | ||||
|             isScrollControlled: false, | ||||
|             context: context, | ||||
|             builder: (context) { | ||||
|               return ExifBottomSheet(assetDetail: assetDetail!); | ||||
|             }, | ||||
|           ); | ||||
|           showInfo(); | ||||
|         }, | ||||
|         onDownloadPressed: () { | ||||
|           ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context); | ||||
|         }, | ||||
|       ), | ||||
|       body: SafeArea( | ||||
|       body: SwipeDetector( | ||||
|         onSwipeDown: (_) { | ||||
|           AutoRouter.of(context).pop(); | ||||
|         }, | ||||
|         onSwipeUp: (_) { | ||||
|           showInfo(); | ||||
|         }, | ||||
|         child: SafeArea( | ||||
|           child: Stack( | ||||
|             children: [ | ||||
|               VideoThumbnailPlayer( | ||||
| @@ -71,6 +84,7 @@ class VideoViewerPage extends HookConsumerWidget { | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart'; | ||||
|  | ||||
| class DeleteDialog extends ConsumerWidget { | ||||
|   | ||||
| @@ -11,8 +11,11 @@ class ImageGrid extends ConsumerWidget { | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     return SliverGrid( | ||||
|       gridDelegate: | ||||
|           const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, crossAxisSpacing: 5.0, mainAxisSpacing: 5), | ||||
|       gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( | ||||
|         crossAxisCount: 3, | ||||
|         crossAxisSpacing: 5.0, | ||||
|         mainAxisSpacing: 5, | ||||
|       ), | ||||
|       delegate: SliverChildBuilderDelegate( | ||||
|         (BuildContext context, int index) { | ||||
|           var assetType = assetGroup[index].type; | ||||
| @@ -44,7 +47,8 @@ class ImageGrid extends ConsumerWidget { | ||||
|                         ), | ||||
|                       ) | ||||
|               ], | ||||
|               )); | ||||
|             ), | ||||
|           ); | ||||
|         }, | ||||
|         childCount: assetGroup.length, | ||||
|       ), | ||||
|   | ||||
| @@ -29,7 +29,7 @@ class ImmichSliverAppBar extends ConsumerWidget { | ||||
|       floating: true, | ||||
|       pinned: false, | ||||
|       snap: false, | ||||
|       backgroundColor: Colors.grey[200], | ||||
|       // backgroundColor: Colors.grey[200], | ||||
|       shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))), | ||||
|       leading: Builder( | ||||
|         builder: (BuildContext context) { | ||||
| @@ -40,7 +40,7 @@ class ImmichSliverAppBar extends ConsumerWidget { | ||||
|                 child: IconButton( | ||||
|                   splashRadius: 25, | ||||
|                   icon: const Icon( | ||||
|                     Icons.account_circle_rounded, | ||||
|                     Icons.face_outlined, | ||||
|                     size: 30, | ||||
|                   ), | ||||
|                   onPressed: () { | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; | ||||
| import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; | ||||
| import 'package:immich_mobile/shared/models/server_info_state.model.dart'; | ||||
| @@ -79,7 +79,7 @@ class ProfileDrawer extends HookConsumerWidget { | ||||
|                 ), | ||||
|                 title: const Text( | ||||
|                   "Sign Out", | ||||
|                   style: TextStyle(color: Colors.black54, fontSize: 14), | ||||
|                   style: TextStyle(color: Colors.black54, fontSize: 14, fontWeight: FontWeight.bold), | ||||
|                 ), | ||||
|                 onTap: () async { | ||||
|                   bool res = await ref.read(authenticationProvider.notifier).logout(); | ||||
|   | ||||
| @@ -10,7 +10,7 @@ import 'package:immich_mobile/modules/home/ui/image_grid.dart'; | ||||
| import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart'; | ||||
| import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart'; | ||||
| import 'package:immich_mobile/modules/home/ui/profile_drawer.dart'; | ||||
| import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/server_info.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/websocket.provider.dart'; | ||||
| import 'package:sliver_tools/sliver_tools.dart'; | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/backup.provider.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_toast.dart'; | ||||
| @@ -14,7 +14,7 @@ class LoginForm extends HookConsumerWidget { | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final usernameController = useTextEditingController(text: 'testuser@email.com'); | ||||
|     final passwordController = useTextEditingController(text: 'password'); | ||||
|     final serverEndpointController = useTextEditingController(text: 'http://192.168.1.103:2283'); | ||||
|     final serverEndpointController = useTextEditingController(text: 'http://192.168.1.216:2283'); | ||||
|  | ||||
|     return Center( | ||||
|       child: ConstrainedBox( | ||||
| @@ -119,11 +119,11 @@ class LoginButton extends ConsumerWidget { | ||||
|           // This will remove current cache asset state of previous user login. | ||||
|           ref.watch(assetProvider.notifier).clearAllAsset(); | ||||
|  | ||||
|           var isAuthenicated = await ref | ||||
|           var isAuthenticated = await ref | ||||
|               .read(authenticationProvider.notifier) | ||||
|               .login(emailController.text, passwordController.text, serverEndpointController.text); | ||||
|  | ||||
|           if (isAuthenicated) { | ||||
|           if (isAuthenticated) { | ||||
|             // Resume backup (if enable) then navigate | ||||
|             ref.watch(backupProvider.notifier).resumeBackup(); | ||||
|             // AutoRouter.of(context).pushNamed("/home-page"); | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:flutter_spinkit/flutter_spinkit.dart'; | ||||
| import 'package:hive_flutter/hive_flutter.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| @@ -11,6 +12,7 @@ import 'package:immich_mobile/modules/search/ui/search_bar.dart'; | ||||
| import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart'; | ||||
| import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; | ||||
| import 'package:immich_mobile/utils/capitalize_first_letter.dart'; | ||||
|  | ||||
| // ignore: must_be_immutable | ||||
| @@ -23,8 +25,10 @@ class SearchPage extends HookConsumerWidget { | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     var box = Hive.box(userInfoBox); | ||||
|     final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled; | ||||
|     AsyncValue<List<CuratedLocation>> curatedLocation = ref.watch(getCuratedLocationProvider); | ||||
|     AsyncValue<List<CuratedObject>> curatedObjects = ref.watch(getCuratedObjectProvider); | ||||
|     AsyncValue<List<CuratedLocation>> curatedLocation = | ||||
|         ref.watch(getCuratedLocationProvider); | ||||
|     AsyncValue<List<CuratedObject>> curatedObjects = | ||||
|         ref.watch(getCuratedObjectProvider); | ||||
|  | ||||
|     useEffect(() { | ||||
|       searchFocusNode = FocusNode(); | ||||
| @@ -40,7 +44,10 @@ class SearchPage extends HookConsumerWidget { | ||||
|  | ||||
|     _buildPlaces() { | ||||
|       return curatedLocation.when( | ||||
|         loading: () => const SizedBox(width: 60, height: 60, child: CircularProgressIndicator.adaptive()), | ||||
|         loading: () => const SizedBox( | ||||
|           height: 200, | ||||
|           child: Center(child: ImmichLoadingIndicator()), | ||||
|         ), | ||||
|         error: (err, stack) => Text('Error: $err'), | ||||
|         data: (curatedLocations) { | ||||
|           return curatedLocations.isNotEmpty | ||||
| @@ -59,7 +66,8 @@ class SearchPage extends HookConsumerWidget { | ||||
|                         imageUrl: thumbnailRequestUrl, | ||||
|                         textInfo: locationInfo.city, | ||||
|                         onTap: () { | ||||
|                           AutoRouter.of(context).push(SearchResultRoute(searchTerm: locationInfo.city)); | ||||
|                           AutoRouter.of(context).push( | ||||
|                               SearchResultRoute(searchTerm: locationInfo.city)); | ||||
|                         }, | ||||
|                       ); | ||||
|                     }), | ||||
| @@ -87,7 +95,10 @@ class SearchPage extends HookConsumerWidget { | ||||
|  | ||||
|     _buildThings() { | ||||
|       return curatedObjects.when( | ||||
|         loading: () => const SizedBox(width: 60, height: 60, child: CircularProgressIndicator.adaptive()), | ||||
|         loading: () => const SizedBox( | ||||
|           height: 200, | ||||
|           child: Center(child: ImmichLoadingIndicator()), | ||||
|         ), | ||||
|         error: (err, stack) => Text('Error: $err'), | ||||
|         data: (objects) { | ||||
|           return objects.isNotEmpty | ||||
| @@ -106,8 +117,9 @@ class SearchPage extends HookConsumerWidget { | ||||
|                         imageUrl: thumbnailRequestUrl, | ||||
|                         textInfo: curatedObjectInfo.object, | ||||
|                         onTap: () { | ||||
|                           AutoRouter.of(context) | ||||
|                               .push(SearchResultRoute(searchTerm: curatedObjectInfo.object.capitalizeFirstLetter())); | ||||
|                           AutoRouter.of(context).push(SearchResultRoute( | ||||
|                               searchTerm: curatedObjectInfo.object | ||||
|                                   .capitalizeFirstLetter())); | ||||
|                         }, | ||||
|                       ); | ||||
|                     }), | ||||
| @@ -165,7 +177,9 @@ class SearchPage extends HookConsumerWidget { | ||||
|                 _buildThings() | ||||
|               ], | ||||
|             ), | ||||
|             isSearchEnabled ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(), | ||||
|             isSearchEnabled | ||||
|                 ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) | ||||
|                 : Container(), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|   | ||||
| @@ -0,0 +1,70 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:collection/collection.dart'; | ||||
|  | ||||
| import 'package:immich_mobile/shared/models/immich_asset.model.dart'; | ||||
|  | ||||
| class AssetSelectionPageResult { | ||||
|   final Set<ImmichAsset> selectedNewAsset; | ||||
|   final Set<ImmichAsset> selectedAdditionalAsset; | ||||
|   final bool isAlbumExist; | ||||
|  | ||||
|   AssetSelectionPageResult({ | ||||
|     required this.selectedNewAsset, | ||||
|     required this.selectedAdditionalAsset, | ||||
|     required this.isAlbumExist, | ||||
|   }); | ||||
|  | ||||
|   AssetSelectionPageResult copyWith({ | ||||
|     Set<ImmichAsset>? selectedNewAsset, | ||||
|     Set<ImmichAsset>? selectedAdditionalAsset, | ||||
|     bool? isAlbumExist, | ||||
|   }) { | ||||
|     return AssetSelectionPageResult( | ||||
|       selectedNewAsset: selectedNewAsset ?? this.selectedNewAsset, | ||||
|       selectedAdditionalAsset: selectedAdditionalAsset ?? this.selectedAdditionalAsset, | ||||
|       isAlbumExist: isAlbumExist ?? this.isAlbumExist, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Map<String, dynamic> toMap() { | ||||
|     final result = <String, dynamic>{}; | ||||
|  | ||||
|     result.addAll({'selectedNewAsset': selectedNewAsset.map((x) => x.toMap()).toList()}); | ||||
|     result.addAll({'selectedAdditionalAsset': selectedAdditionalAsset.map((x) => x.toMap()).toList()}); | ||||
|     result.addAll({'isAlbumExist': isAlbumExist}); | ||||
|  | ||||
|     return result; | ||||
|   } | ||||
|  | ||||
|   factory AssetSelectionPageResult.fromMap(Map<String, dynamic> map) { | ||||
|     return AssetSelectionPageResult( | ||||
|       selectedNewAsset: Set<ImmichAsset>.from(map['selectedNewAsset']?.map((x) => ImmichAsset.fromMap(x))), | ||||
|       selectedAdditionalAsset: | ||||
|           Set<ImmichAsset>.from(map['selectedAdditionalAsset']?.map((x) => ImmichAsset.fromMap(x))), | ||||
|       isAlbumExist: map['isAlbumExist'] ?? false, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   String toJson() => json.encode(toMap()); | ||||
|  | ||||
|   factory AssetSelectionPageResult.fromJson(String source) => AssetSelectionPageResult.fromMap(json.decode(source)); | ||||
|  | ||||
|   @override | ||||
|   String toString() => | ||||
|       'AssetSelectionPageResult(selectedNewAsset: $selectedNewAsset, selectedAdditionalAsset: $selectedAdditionalAsset, isAlbumExist: $isAlbumExist)'; | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     if (identical(this, other)) return true; | ||||
|     final setEquals = const DeepCollectionEquality().equals; | ||||
|  | ||||
|     return other is AssetSelectionPageResult && | ||||
|         setEquals(other.selectedNewAsset, selectedNewAsset) && | ||||
|         setEquals(other.selectedAdditionalAsset, selectedAdditionalAsset) && | ||||
|         other.isAlbumExist == isAlbumExist; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode => selectedNewAsset.hashCode ^ selectedAdditionalAsset.hashCode ^ isAlbumExist.hashCode; | ||||
| } | ||||
| @@ -0,0 +1,103 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:collection/collection.dart'; | ||||
|  | ||||
| import 'package:immich_mobile/shared/models/immich_asset.model.dart'; | ||||
|  | ||||
| class AssetSelectionState { | ||||
|   final Set<String> selectedMonths; | ||||
|   final Set<ImmichAsset> selectedNewAssetsForAlbum; | ||||
|   final Set<ImmichAsset> selectedAdditionalAssetsForAlbum; | ||||
|   final Set<ImmichAsset> selectedAssetsInAlbumViewer; | ||||
|   final bool isMultiselectEnable; | ||||
|  | ||||
|   /// Indicate the asset selection page is navigated from existing album | ||||
|   final bool isAlbumExist; | ||||
|   AssetSelectionState({ | ||||
|     required this.selectedMonths, | ||||
|     required this.selectedNewAssetsForAlbum, | ||||
|     required this.selectedAdditionalAssetsForAlbum, | ||||
|     required this.selectedAssetsInAlbumViewer, | ||||
|     required this.isMultiselectEnable, | ||||
|     required this.isAlbumExist, | ||||
|   }); | ||||
|  | ||||
|   AssetSelectionState copyWith({ | ||||
|     Set<String>? selectedMonths, | ||||
|     Set<ImmichAsset>? selectedNewAssetsForAlbum, | ||||
|     Set<ImmichAsset>? selectedAdditionalAssetsForAlbum, | ||||
|     Set<ImmichAsset>? selectedAssetsInAlbumViewer, | ||||
|     bool? isMultiselectEnable, | ||||
|     bool? isAlbumExist, | ||||
|   }) { | ||||
|     return AssetSelectionState( | ||||
|       selectedMonths: selectedMonths ?? this.selectedMonths, | ||||
|       selectedNewAssetsForAlbum: selectedNewAssetsForAlbum ?? this.selectedNewAssetsForAlbum, | ||||
|       selectedAdditionalAssetsForAlbum: selectedAdditionalAssetsForAlbum ?? this.selectedAdditionalAssetsForAlbum, | ||||
|       selectedAssetsInAlbumViewer: selectedAssetsInAlbumViewer ?? this.selectedAssetsInAlbumViewer, | ||||
|       isMultiselectEnable: isMultiselectEnable ?? this.isMultiselectEnable, | ||||
|       isAlbumExist: isAlbumExist ?? this.isAlbumExist, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Map<String, dynamic> toMap() { | ||||
|     final result = <String, dynamic>{}; | ||||
|  | ||||
|     result.addAll({'selectedMonths': selectedMonths.toList()}); | ||||
|     result.addAll({'selectedNewAssetsForAlbum': selectedNewAssetsForAlbum.map((x) => x.toMap()).toList()}); | ||||
|     result | ||||
|         .addAll({'selectedAdditionalAssetsForAlbum': selectedAdditionalAssetsForAlbum.map((x) => x.toMap()).toList()}); | ||||
|     result.addAll({'selectedAssetsInAlbumViewer': selectedAssetsInAlbumViewer.map((x) => x.toMap()).toList()}); | ||||
|     result.addAll({'isMultiselectEnable': isMultiselectEnable}); | ||||
|     result.addAll({'isAlbumExist': isAlbumExist}); | ||||
|  | ||||
|     return result; | ||||
|   } | ||||
|  | ||||
|   factory AssetSelectionState.fromMap(Map<String, dynamic> map) { | ||||
|     return AssetSelectionState( | ||||
|       selectedMonths: Set<String>.from(map['selectedMonths']), | ||||
|       selectedNewAssetsForAlbum: | ||||
|           Set<ImmichAsset>.from(map['selectedNewAssetsForAlbum']?.map((x) => ImmichAsset.fromMap(x))), | ||||
|       selectedAdditionalAssetsForAlbum: | ||||
|           Set<ImmichAsset>.from(map['selectedAdditionalAssetsForAlbum']?.map((x) => ImmichAsset.fromMap(x))), | ||||
|       selectedAssetsInAlbumViewer: | ||||
|           Set<ImmichAsset>.from(map['selectedAssetsInAlbumViewer']?.map((x) => ImmichAsset.fromMap(x))), | ||||
|       isMultiselectEnable: map['isMultiselectEnable'] ?? false, | ||||
|       isAlbumExist: map['isAlbumExist'] ?? false, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   String toJson() => json.encode(toMap()); | ||||
|  | ||||
|   factory AssetSelectionState.fromJson(String source) => AssetSelectionState.fromMap(json.decode(source)); | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'AssetSelectionState(selectedMonths: $selectedMonths, selectedNewAssetsForAlbum: $selectedNewAssetsForAlbum, selectedAdditionalAssetsForAlbum: $selectedAdditionalAssetsForAlbum, selectedAssetsInAlbumViewer: $selectedAssetsInAlbumViewer, isMultiselectEnable: $isMultiselectEnable, isAlbumExist: $isAlbumExist)'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     if (identical(this, other)) return true; | ||||
|     final setEquals = const DeepCollectionEquality().equals; | ||||
|  | ||||
|     return other is AssetSelectionState && | ||||
|         setEquals(other.selectedMonths, selectedMonths) && | ||||
|         setEquals(other.selectedNewAssetsForAlbum, selectedNewAssetsForAlbum) && | ||||
|         setEquals(other.selectedAdditionalAssetsForAlbum, selectedAdditionalAssetsForAlbum) && | ||||
|         setEquals(other.selectedAssetsInAlbumViewer, selectedAssetsInAlbumViewer) && | ||||
|         other.isMultiselectEnable == isMultiselectEnable && | ||||
|         other.isAlbumExist == isAlbumExist; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode { | ||||
|     return selectedMonths.hashCode ^ | ||||
|         selectedNewAssetsForAlbum.hashCode ^ | ||||
|         selectedAdditionalAssetsForAlbum.hashCode ^ | ||||
|         selectedAssetsInAlbumViewer.hashCode ^ | ||||
|         isMultiselectEnable.hashCode ^ | ||||
|         isAlbumExist.hashCode; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										113
									
								
								mobile/lib/modules/sharing/models/shared_album.model.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,113 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:collection/collection.dart'; | ||||
|  | ||||
| import 'package:immich_mobile/modules/sharing/models/shared_asset.model.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/models/shared_user.model.dart'; | ||||
|  | ||||
| class SharedAlbum { | ||||
|   final String id; | ||||
|   final String ownerId; | ||||
|   final String albumName; | ||||
|   final String createdAt; | ||||
|   final String? albumThumbnailAssetId; | ||||
|   final List<SharedUsers> sharedUsers; | ||||
|   final List<SharedAssets>? sharedAssets; | ||||
|  | ||||
|   SharedAlbum({ | ||||
|     required this.id, | ||||
|     required this.ownerId, | ||||
|     required this.albumName, | ||||
|     required this.createdAt, | ||||
|     required this.albumThumbnailAssetId, | ||||
|     required this.sharedUsers, | ||||
|     this.sharedAssets, | ||||
|   }); | ||||
|  | ||||
|   SharedAlbum copyWith({ | ||||
|     String? id, | ||||
|     String? ownerId, | ||||
|     String? albumName, | ||||
|     String? createdAt, | ||||
|     String? albumThumbnailAssetId, | ||||
|     List<SharedUsers>? sharedUsers, | ||||
|     List<SharedAssets>? sharedAssets, | ||||
|   }) { | ||||
|     return SharedAlbum( | ||||
|       id: id ?? this.id, | ||||
|       ownerId: ownerId ?? this.ownerId, | ||||
|       albumName: albumName ?? this.albumName, | ||||
|       createdAt: createdAt ?? this.createdAt, | ||||
|       albumThumbnailAssetId: albumThumbnailAssetId ?? this.albumThumbnailAssetId, | ||||
|       sharedUsers: sharedUsers ?? this.sharedUsers, | ||||
|       sharedAssets: sharedAssets ?? this.sharedAssets, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Map<String, dynamic> toMap() { | ||||
|     final result = <String, dynamic>{}; | ||||
|  | ||||
|     result.addAll({'id': id}); | ||||
|     result.addAll({'ownerId': ownerId}); | ||||
|     result.addAll({'albumName': albumName}); | ||||
|     result.addAll({'createdAt': createdAt}); | ||||
|     if (albumThumbnailAssetId != null) { | ||||
|       result.addAll({'albumThumbnailAssetId': albumThumbnailAssetId}); | ||||
|     } | ||||
|     result.addAll({'sharedUsers': sharedUsers.map((x) => x.toMap()).toList()}); | ||||
|     if (sharedAssets != null) { | ||||
|       result.addAll({'sharedAssets': sharedAssets!.map((x) => x.toMap()).toList()}); | ||||
|     } | ||||
|  | ||||
|     return result; | ||||
|   } | ||||
|  | ||||
|   factory SharedAlbum.fromMap(Map<String, dynamic> map) { | ||||
|     return SharedAlbum( | ||||
|       id: map['id'] ?? '', | ||||
|       ownerId: map['ownerId'] ?? '', | ||||
|       albumName: map['albumName'] ?? '', | ||||
|       createdAt: map['createdAt'] ?? '', | ||||
|       albumThumbnailAssetId: map['albumThumbnailAssetId'], | ||||
|       sharedUsers: List<SharedUsers>.from(map['sharedUsers']?.map((x) => SharedUsers.fromMap(x))), | ||||
|       sharedAssets: map['sharedAssets'] != null | ||||
|           ? List<SharedAssets>.from(map['sharedAssets']?.map((x) => SharedAssets.fromMap(x))) | ||||
|           : null, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   String toJson() => json.encode(toMap()); | ||||
|  | ||||
|   factory SharedAlbum.fromJson(String source) => SharedAlbum.fromMap(json.decode(source)); | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'SharedAlbum(id: $id, ownerId: $ownerId, albumName: $albumName, createdAt: $createdAt, albumThumbnailAssetId: $albumThumbnailAssetId, sharedUsers: $sharedUsers, sharedAssets: $sharedAssets)'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     if (identical(this, other)) return true; | ||||
|     final listEquals = const DeepCollectionEquality().equals; | ||||
|  | ||||
|     return other is SharedAlbum && | ||||
|         other.id == id && | ||||
|         other.ownerId == ownerId && | ||||
|         other.albumName == albumName && | ||||
|         other.createdAt == createdAt && | ||||
|         other.albumThumbnailAssetId == albumThumbnailAssetId && | ||||
|         listEquals(other.sharedUsers, sharedUsers) && | ||||
|         listEquals(other.sharedAssets, sharedAssets); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode { | ||||
|     return id.hashCode ^ | ||||
|         ownerId.hashCode ^ | ||||
|         albumName.hashCode ^ | ||||
|         createdAt.hashCode ^ | ||||
|         albumThumbnailAssetId.hashCode ^ | ||||
|         sharedUsers.hashCode ^ | ||||
|         sharedAssets.hashCode; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										50
									
								
								mobile/lib/modules/sharing/models/shared_asset.model.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,50 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:immich_mobile/shared/models/immich_asset.model.dart'; | ||||
|  | ||||
| class SharedAssets { | ||||
|   final ImmichAsset assetInfo; | ||||
|  | ||||
|   SharedAssets({ | ||||
|     required this.assetInfo, | ||||
|   }); | ||||
|  | ||||
|   SharedAssets copyWith({ | ||||
|     ImmichAsset? assetInfo, | ||||
|   }) { | ||||
|     return SharedAssets( | ||||
|       assetInfo: assetInfo ?? this.assetInfo, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Map<String, dynamic> toMap() { | ||||
|     final result = <String, dynamic>{}; | ||||
|  | ||||
|     result.addAll({'assetInfo': assetInfo.toMap()}); | ||||
|  | ||||
|     return result; | ||||
|   } | ||||
|  | ||||
|   factory SharedAssets.fromMap(Map<String, dynamic> map) { | ||||
|     return SharedAssets( | ||||
|       assetInfo: ImmichAsset.fromMap(map['assetInfo']), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   String toJson() => json.encode(toMap()); | ||||
|  | ||||
|   factory SharedAssets.fromJson(String source) => SharedAssets.fromMap(json.decode(source)); | ||||
|  | ||||
|   @override | ||||
|   String toString() => 'SharedAssets(assetInfo: $assetInfo)'; | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     if (identical(this, other)) return true; | ||||
|  | ||||
|     return other is SharedAssets && other.assetInfo == assetInfo; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode => assetInfo.hashCode; | ||||
| } | ||||
							
								
								
									
										76
									
								
								mobile/lib/modules/sharing/models/shared_user.model.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,76 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:immich_mobile/shared/models/user_info.model.dart'; | ||||
|  | ||||
| class SharedUsers { | ||||
|   final int id; | ||||
|   final String albumId; | ||||
|   final String sharedUserId; | ||||
|   final UserInfo userInfo; | ||||
|  | ||||
|   SharedUsers({ | ||||
|     required this.id, | ||||
|     required this.albumId, | ||||
|     required this.sharedUserId, | ||||
|     required this.userInfo, | ||||
|   }); | ||||
|  | ||||
|   SharedUsers copyWith({ | ||||
|     int? id, | ||||
|     String? albumId, | ||||
|     String? sharedUserId, | ||||
|     UserInfo? userInfo, | ||||
|   }) { | ||||
|     return SharedUsers( | ||||
|       id: id ?? this.id, | ||||
|       albumId: albumId ?? this.albumId, | ||||
|       sharedUserId: sharedUserId ?? this.sharedUserId, | ||||
|       userInfo: userInfo ?? this.userInfo, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Map<String, dynamic> toMap() { | ||||
|     final result = <String, dynamic>{}; | ||||
|  | ||||
|     result.addAll({'id': id}); | ||||
|     result.addAll({'albumId': albumId}); | ||||
|     result.addAll({'sharedUserId': sharedUserId}); | ||||
|     result.addAll({'userInfo': userInfo.toMap()}); | ||||
|  | ||||
|     return result; | ||||
|   } | ||||
|  | ||||
|   factory SharedUsers.fromMap(Map<String, dynamic> map) { | ||||
|     return SharedUsers( | ||||
|       id: map['id']?.toInt() ?? 0, | ||||
|       albumId: map['albumId'] ?? '', | ||||
|       sharedUserId: map['sharedUserId'] ?? '', | ||||
|       userInfo: UserInfo.fromMap(map['userInfo']), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   String toJson() => json.encode(toMap()); | ||||
|  | ||||
|   factory SharedUsers.fromJson(String source) => SharedUsers.fromMap(json.decode(source)); | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'SharedUsers(id: $id, albumId: $albumId, sharedUserId: $sharedUserId, userInfo: $userInfo)'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     if (identical(this, other)) return true; | ||||
|  | ||||
|     return other is SharedUsers && | ||||
|         other.id == id && | ||||
|         other.albumId == albumId && | ||||
|         other.sharedUserId == sharedUserId && | ||||
|         other.userInfo == userInfo; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode { | ||||
|     return id.hashCode ^ albumId.hashCode ^ sharedUserId.hashCode ^ userInfo.hashCode; | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,15 @@ | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
|  | ||||
| class AlbumTitleNotifier extends StateNotifier<String> { | ||||
|   AlbumTitleNotifier() : super(""); | ||||
|  | ||||
|   setAlbumTitle(String title) { | ||||
|     state = title; | ||||
|   } | ||||
|  | ||||
|   clearAlbumTitle() { | ||||
|     state = ""; | ||||
|   } | ||||
| } | ||||
|  | ||||
| final albumTitleProvider = StateNotifierProvider<AlbumTitleNotifier, String>((ref) => AlbumTitleNotifier()); | ||||
| @@ -0,0 +1,113 @@ | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/models/asset_selection_state.model.dart'; | ||||
|  | ||||
| import 'package:immich_mobile/shared/models/immich_asset.model.dart'; | ||||
|  | ||||
| class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> { | ||||
|   AssetSelectionNotifier() | ||||
|       : super(AssetSelectionState( | ||||
|           selectedNewAssetsForAlbum: {}, | ||||
|           selectedMonths: {}, | ||||
|           selectedAdditionalAssetsForAlbum: {}, | ||||
|           selectedAssetsInAlbumViewer: {}, | ||||
|           isAlbumExist: false, | ||||
|           isMultiselectEnable: false, | ||||
|         )); | ||||
|  | ||||
|   void setIsAlbumExist(bool isAlbumExist) { | ||||
|     state = state.copyWith(isAlbumExist: isAlbumExist); | ||||
|   } | ||||
|  | ||||
|   void removeAssetsInMonth(String removedMonth, List<ImmichAsset> assetsInMonth) { | ||||
|     Set<ImmichAsset> currentAssetList = state.selectedNewAssetsForAlbum; | ||||
|     Set<String> currentMonthList = state.selectedMonths; | ||||
|  | ||||
|     currentMonthList.removeWhere((selectedMonth) => selectedMonth == removedMonth); | ||||
|  | ||||
|     for (ImmichAsset asset in assetsInMonth) { | ||||
|       currentAssetList.removeWhere((e) => e.id == asset.id); | ||||
|     } | ||||
|  | ||||
|     state = state.copyWith(selectedNewAssetsForAlbum: currentAssetList, selectedMonths: currentMonthList); | ||||
|   } | ||||
|  | ||||
|   void addAdditionalAssets(List<ImmichAsset> assets) { | ||||
|     state = state.copyWith( | ||||
|       selectedAdditionalAssetsForAlbum: {...state.selectedAdditionalAssetsForAlbum, ...assets}, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void addAllAssetsInMonth(String month, List<ImmichAsset> assetsInMonth) { | ||||
|     state = state.copyWith( | ||||
|       selectedMonths: {...state.selectedMonths, month}, | ||||
|       selectedNewAssetsForAlbum: {...state.selectedNewAssetsForAlbum, ...assetsInMonth}, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void addNewAssets(List<ImmichAsset> assets) { | ||||
|     state = state.copyWith( | ||||
|       selectedNewAssetsForAlbum: {...state.selectedNewAssetsForAlbum, ...assets}, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void removeSelectedNewAssets(List<ImmichAsset> assets) { | ||||
|     Set<ImmichAsset> currentList = state.selectedNewAssetsForAlbum; | ||||
|  | ||||
|     for (ImmichAsset asset in assets) { | ||||
|       currentList.removeWhere((e) => e.id == asset.id); | ||||
|     } | ||||
|  | ||||
|     state = state.copyWith(selectedNewAssetsForAlbum: currentList); | ||||
|   } | ||||
|  | ||||
|   void removeSelectedAdditionalAssets(List<ImmichAsset> assets) { | ||||
|     Set<ImmichAsset> currentList = state.selectedAdditionalAssetsForAlbum; | ||||
|  | ||||
|     for (ImmichAsset asset in assets) { | ||||
|       currentList.removeWhere((e) => e.id == asset.id); | ||||
|     } | ||||
|  | ||||
|     state = state.copyWith(selectedAdditionalAssetsForAlbum: currentList); | ||||
|   } | ||||
|  | ||||
|   void removeAll() { | ||||
|     state = state.copyWith( | ||||
|       selectedNewAssetsForAlbum: {}, | ||||
|       selectedMonths: {}, | ||||
|       selectedAdditionalAssetsForAlbum: {}, | ||||
|       selectedAssetsInAlbumViewer: {}, | ||||
|       isAlbumExist: false, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void enableMultiselection() { | ||||
|     state = state.copyWith(isMultiselectEnable: true); | ||||
|   } | ||||
|  | ||||
|   void disableMultiselection() { | ||||
|     state = state.copyWith( | ||||
|       isMultiselectEnable: false, | ||||
|       selectedAssetsInAlbumViewer: {}, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void addAssetsInAlbumViewer(List<ImmichAsset> assets) { | ||||
|     state = state.copyWith( | ||||
|       selectedAssetsInAlbumViewer: {...state.selectedAssetsInAlbumViewer, ...assets}, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void removeAssetsInAlbumViewer(List<ImmichAsset> assets) { | ||||
|     Set<ImmichAsset> currentList = state.selectedAssetsInAlbumViewer; | ||||
|  | ||||
|     for (ImmichAsset asset in assets) { | ||||
|       currentList.removeWhere((e) => e.id == asset.id); | ||||
|     } | ||||
|  | ||||
|     state = state.copyWith(selectedAssetsInAlbumViewer: currentList); | ||||
|   } | ||||
| } | ||||
|  | ||||
| final assetSelectionProvider = StateNotifierProvider<AssetSelectionNotifier, AssetSelectionState>((ref) { | ||||
|   return AssetSelectionNotifier(); | ||||
| }); | ||||
| @@ -0,0 +1,57 @@ | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart'; | ||||
|  | ||||
| class SharedAlbumNotifier extends StateNotifier<List<SharedAlbum>> { | ||||
|   SharedAlbumNotifier() : super([]); | ||||
|  | ||||
|   final SharedAlbumService _sharedAlbumService = SharedAlbumService(); | ||||
|  | ||||
|   getAllSharedAlbums() async { | ||||
|     List<SharedAlbum> sharedAlbums = await _sharedAlbumService.getAllSharedAlbum(); | ||||
|  | ||||
|     state = sharedAlbums; | ||||
|   } | ||||
|  | ||||
|   Future<bool> deleteAlbum(String albumId) async { | ||||
|     var res = await _sharedAlbumService.deleteAlbum(albumId); | ||||
|  | ||||
|     if (res) { | ||||
|       state = state.where((album) => album.id != albumId).toList(); | ||||
|       return true; | ||||
|     } else { | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<bool> leaveAlbum(String albumId) async { | ||||
|     var res = await _sharedAlbumService.leaveAlbum(albumId); | ||||
|  | ||||
|     if (res) { | ||||
|       state = state.where((album) => album.id != albumId).toList(); | ||||
|       return true; | ||||
|     } else { | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<bool> removeAssetFromAlbum(String albumId, List<String> assetIds) async { | ||||
|     var res = await _sharedAlbumService.removeAssetFromAlbum(albumId, assetIds); | ||||
|  | ||||
|     if (res) { | ||||
|       return true; | ||||
|     } else { | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| final sharedAlbumProvider = StateNotifierProvider<SharedAlbumNotifier, List<SharedAlbum>>((ref) { | ||||
|   return SharedAlbumNotifier(); | ||||
| }); | ||||
|  | ||||
| final sharedAlbumDetailProvider = FutureProvider.autoDispose.family<SharedAlbum, String>((ref, albumId) async { | ||||
|   final SharedAlbumService _sharedAlbumService = SharedAlbumService(); | ||||
|  | ||||
|   return await _sharedAlbumService.getAlbumDetail(albumId); | ||||
| }); | ||||
| @@ -0,0 +1,9 @@ | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/shared/models/user_info.model.dart'; | ||||
| import 'package:immich_mobile/shared/services/user.service.dart'; | ||||
|  | ||||
| final suggestedSharedUsersProvider = FutureProvider.autoDispose<List<UserInfo>>((ref) async { | ||||
|   UserService userService = UserService(); | ||||
|  | ||||
|   return await userService.getAllUsersInfo(); | ||||
| }); | ||||
							
								
								
									
										141
									
								
								mobile/lib/modules/sharing/services/shared_album.service.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,141 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart'; | ||||
| import 'package:immich_mobile/shared/models/immich_asset.model.dart'; | ||||
| import 'package:immich_mobile/shared/services/network.service.dart'; | ||||
|  | ||||
| class SharedAlbumService { | ||||
|   final NetworkService _networkService = NetworkService(); | ||||
|  | ||||
|   Future<List<SharedAlbum>> getAllSharedAlbum() async { | ||||
|     try { | ||||
|       var res = await _networkService.getRequest(url: 'shared/allSharedAlbums'); | ||||
|       List<dynamic> decodedData = jsonDecode(res.toString()); | ||||
|       List<SharedAlbum> result = List.from(decodedData.map((e) => SharedAlbum.fromMap(e))); | ||||
|  | ||||
|       return result; | ||||
|     } catch (e) { | ||||
|       debugPrint("Error getAllSharedAlbum  ${e.toString()}"); | ||||
|     } | ||||
|  | ||||
|     return []; | ||||
|   } | ||||
|  | ||||
|   Future<bool> createSharedAlbum(String albumName, Set<ImmichAsset> assets, List<String> sharedUserIds) async { | ||||
|     try { | ||||
|       var res = await _networkService.postRequest(url: 'shared/createAlbum', data: { | ||||
|         "albumName": albumName, | ||||
|         "sharedWithUserIds": sharedUserIds, | ||||
|         "assetIds": assets.map((asset) => asset.id).toList(), | ||||
|       }); | ||||
|  | ||||
|       if (res == null) { | ||||
|         return false; | ||||
|       } | ||||
|  | ||||
|       return true; | ||||
|     } catch (e) { | ||||
|       debugPrint("Error createSharedAlbum  ${e.toString()}"); | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<SharedAlbum> getAlbumDetail(String albumId) async { | ||||
|     try { | ||||
|       var res = await _networkService.getRequest(url: 'shared/$albumId'); | ||||
|       dynamic decodedData = jsonDecode(res.toString()); | ||||
|       SharedAlbum result = SharedAlbum.fromMap(decodedData); | ||||
|  | ||||
|       return result; | ||||
|     } catch (e) { | ||||
|       throw Exception('Error getAllSharedAlbum  ${e.toString()}'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<bool> addAdditionalAssetToAlbum(Set<ImmichAsset> assets, String albumId) async { | ||||
|     try { | ||||
|       var res = await _networkService.postRequest(url: 'shared/addAssets', data: { | ||||
|         "albumId": albumId, | ||||
|         "assetIds": assets.map((asset) => asset.id).toList(), | ||||
|       }); | ||||
|  | ||||
|       if (res == null) { | ||||
|         return false; | ||||
|       } | ||||
|  | ||||
|       return true; | ||||
|     } catch (e) { | ||||
|       debugPrint("Error addAdditionalAssetToAlbum  ${e.toString()}"); | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<bool> addAdditionalUserToAlbum(List<String> sharedUserIds, String albumId) async { | ||||
|     try { | ||||
|       var res = await _networkService.postRequest(url: 'shared/addUsers', data: { | ||||
|         "albumId": albumId, | ||||
|         "sharedUserIds": sharedUserIds, | ||||
|       }); | ||||
|  | ||||
|       if (res == null) { | ||||
|         return false; | ||||
|       } | ||||
|  | ||||
|       return true; | ||||
|     } catch (e) { | ||||
|       debugPrint("Error addAdditionalUserToAlbum  ${e.toString()}"); | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<bool> deleteAlbum(String albumId) async { | ||||
|     try { | ||||
|       Response res = await _networkService.deleteRequest(url: 'shared/$albumId'); | ||||
|  | ||||
|       if (res.statusCode != 200) { | ||||
|         return false; | ||||
|       } | ||||
|  | ||||
|       return true; | ||||
|     } catch (e) { | ||||
|       debugPrint("Error deleteAlbum  ${e.toString()}"); | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<bool> leaveAlbum(String albumId) async { | ||||
|     try { | ||||
|       Response res = await _networkService.deleteRequest(url: 'shared/leaveAlbum/$albumId'); | ||||
|  | ||||
|       if (res.statusCode != 200) { | ||||
|         return false; | ||||
|       } | ||||
|  | ||||
|       return true; | ||||
|     } catch (e) { | ||||
|       debugPrint("Error deleteAlbum  ${e.toString()}"); | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<bool> removeAssetFromAlbum(String albumId, List<String> assetIds) async { | ||||
|     try { | ||||
|       Response res = await _networkService.deleteRequest(url: 'shared/removeAssets/', data: { | ||||
|         "albumId": albumId, | ||||
|         "assetIds": assetIds, | ||||
|       }); | ||||
|  | ||||
|       if (res.statusCode != 200) { | ||||
|         return false; | ||||
|       } | ||||
|  | ||||
|       return true; | ||||
|     } catch (e) { | ||||
|       debugPrint("Error deleteAlbum  ${e.toString()}"); | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,36 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
|  | ||||
| class AlbumActionOutlinedButton extends StatelessWidget { | ||||
|   final VoidCallback? onPressed; | ||||
|   final String labelText; | ||||
|   final IconData iconData; | ||||
|  | ||||
|   const AlbumActionOutlinedButton({Key? key, this.onPressed, required this.labelText, required this.iconData}) | ||||
|       : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Padding( | ||||
|       padding: const EdgeInsets.only(right: 8.0), | ||||
|       child: OutlinedButton.icon( | ||||
|         style: ButtonStyle( | ||||
|           padding: MaterialStateProperty.all<EdgeInsets>(const EdgeInsets.symmetric(vertical: 0, horizontal: 10)), | ||||
|           shape: MaterialStateProperty.resolveWith<OutlinedBorder>( | ||||
|             (_) => RoundedRectangleBorder( | ||||
|               borderRadius: BorderRadius.circular(25), | ||||
|             ), | ||||
|           ), | ||||
|           side: MaterialStateProperty.resolveWith<BorderSide>( | ||||
|             (_) => const BorderSide(width: 1, color: Color.fromARGB(255, 158, 158, 158)), | ||||
|           ), | ||||
|         ), | ||||
|         icon: Icon(iconData, size: 15), | ||||
|         label: Text( | ||||
|           labelText, | ||||
|           style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.black87), | ||||
|         ), | ||||
|         onPressed: onPressed, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										68
									
								
								mobile/lib/modules/sharing/ui/album_title_text_field.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,68 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/providers/album_title.provider.dart'; | ||||
|  | ||||
| class AlbumTitleTextField extends ConsumerWidget { | ||||
|   const AlbumTitleTextField({ | ||||
|     Key? key, | ||||
|     required this.isAlbumTitleEmpty, | ||||
|     required this.albumTitleTextFieldFocusNode, | ||||
|     required this.albumTitleController, | ||||
|     required this.isAlbumTitleTextFieldFocus, | ||||
|   }) : super(key: key); | ||||
|  | ||||
|   final ValueNotifier<bool> isAlbumTitleEmpty; | ||||
|   final FocusNode albumTitleTextFieldFocusNode; | ||||
|   final TextEditingController albumTitleController; | ||||
|   final ValueNotifier<bool> isAlbumTitleTextFieldFocus; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     return TextField( | ||||
|       onChanged: (v) { | ||||
|         if (v.isEmpty) { | ||||
|           isAlbumTitleEmpty.value = true; | ||||
|         } else { | ||||
|           isAlbumTitleEmpty.value = false; | ||||
|         } | ||||
|  | ||||
|         ref.watch(albumTitleProvider.notifier).setAlbumTitle(v); | ||||
|       }, | ||||
|       focusNode: albumTitleTextFieldFocusNode, | ||||
|       style: TextStyle(fontSize: 28, color: Colors.grey[700], fontWeight: FontWeight.bold), | ||||
|       controller: albumTitleController, | ||||
|       onTap: () { | ||||
|         isAlbumTitleTextFieldFocus.value = true; | ||||
|  | ||||
|         if (albumTitleController.text == 'Untitled') { | ||||
|           albumTitleController.clear(); | ||||
|         } | ||||
|       }, | ||||
|       decoration: InputDecoration( | ||||
|         contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), | ||||
|         suffixIcon: !isAlbumTitleEmpty.value && isAlbumTitleTextFieldFocus.value | ||||
|             ? IconButton( | ||||
|                 onPressed: () { | ||||
|                   albumTitleController.clear(); | ||||
|                   isAlbumTitleEmpty.value = true; | ||||
|                 }, | ||||
|                 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), | ||||
|         ), | ||||
|         hintText: 'Add a title', | ||||
|         focusColor: Colors.grey[300], | ||||
|         fillColor: Colors.grey[200], | ||||
|         filled: isAlbumTitleTextFieldFocus.value, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										181
									
								
								mobile/lib/modules/sharing/ui/album_viewer_appbar.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,181 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| 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/asset_selection.provider.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_toast.dart'; | ||||
| import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; | ||||
|  | ||||
| class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget { | ||||
|   const AlbumViewerAppbar({ | ||||
|     Key? key, | ||||
|     required AsyncValue<SharedAlbum> albumInfo, | ||||
|     required this.userId, | ||||
|     required this.albumId, | ||||
|   })  : _albumInfo = albumInfo, | ||||
|         super(key: key); | ||||
|  | ||||
|   final AsyncValue<SharedAlbum> _albumInfo; | ||||
|   final String userId; | ||||
|   final String albumId; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final isMultiSelectionEnable = ref.watch(assetSelectionProvider).isMultiselectEnable; | ||||
|     final selectedAssetsInAlbum = ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer; | ||||
|  | ||||
|     void _onDeleteAlbumPressed(String albumId) async { | ||||
|       ImmichLoadingOverlayController.appLoader.show(); | ||||
|  | ||||
|       bool isSuccess = await ref.watch(sharedAlbumProvider.notifier).deleteAlbum(albumId); | ||||
|  | ||||
|       if (isSuccess) { | ||||
|         AutoRouter.of(context).navigate(const TabControllerRoute(children: [SharingRoute()])); | ||||
|       } else { | ||||
|         ImmichToast.show( | ||||
|           context: context, | ||||
|           msg: "Failed to delete album", | ||||
|           toastType: ToastType.error, | ||||
|           gravity: ToastGravity.BOTTOM, | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       ImmichLoadingOverlayController.appLoader.hide(); | ||||
|     } | ||||
|  | ||||
|     void _onLeaveAlbumPressed(String albumId) async { | ||||
|       ImmichLoadingOverlayController.appLoader.show(); | ||||
|  | ||||
|       bool isSuccess = await ref.watch(sharedAlbumProvider.notifier).leaveAlbum(albumId); | ||||
|  | ||||
|       if (isSuccess) { | ||||
|         AutoRouter.of(context).navigate(const TabControllerRoute(children: [SharingRoute()])); | ||||
|       } else { | ||||
|         Navigator.pop(context); | ||||
|         ImmichToast.show( | ||||
|           context: context, | ||||
|           msg: "Failed to leave album", | ||||
|           toastType: ToastType.error, | ||||
|           gravity: ToastGravity.BOTTOM, | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       ImmichLoadingOverlayController.appLoader.hide(); | ||||
|     } | ||||
|  | ||||
|     void _onRemoveFromAlbumPressed(String albumId) async { | ||||
|       ImmichLoadingOverlayController.appLoader.show(); | ||||
|  | ||||
|       bool isSuccess = await ref.watch(sharedAlbumProvider.notifier).removeAssetFromAlbum( | ||||
|             albumId, | ||||
|             selectedAssetsInAlbum.map((a) => a.id).toList(), | ||||
|           ); | ||||
|  | ||||
|       if (isSuccess) { | ||||
|         Navigator.pop(context); | ||||
|         ref.watch(assetSelectionProvider.notifier).disableMultiselection(); | ||||
|         ref.refresh(sharedAlbumDetailProvider(albumId)); | ||||
|       } else { | ||||
|         Navigator.pop(context); | ||||
|         ImmichToast.show( | ||||
|           context: context, | ||||
|           msg: "There are problems in removing assets from album", | ||||
|           toastType: ToastType.error, | ||||
|           gravity: ToastGravity.BOTTOM, | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       ImmichLoadingOverlayController.appLoader.hide(); | ||||
|     } | ||||
|  | ||||
|     _buildBottomSheetActionButton() { | ||||
|       if (isMultiSelectionEnable) { | ||||
|         if (_albumInfo.asData?.value.ownerId == userId) { | ||||
|           return ListTile( | ||||
|             leading: const Icon(Icons.delete_sweep_rounded), | ||||
|             title: const Text( | ||||
|               'Remove from album', | ||||
|               style: TextStyle(fontWeight: FontWeight.bold), | ||||
|             ), | ||||
|             onTap: () => _onRemoveFromAlbumPressed(albumId), | ||||
|           ); | ||||
|         } else { | ||||
|           return Container(); | ||||
|         } | ||||
|       } else { | ||||
|         if (_albumInfo.asData?.value.ownerId == userId) { | ||||
|           return ListTile( | ||||
|             leading: const Icon(Icons.delete_forever_rounded), | ||||
|             title: const Text( | ||||
|               'Delete album', | ||||
|               style: TextStyle(fontWeight: FontWeight.bold), | ||||
|             ), | ||||
|             onTap: () => _onDeleteAlbumPressed(albumId), | ||||
|           ); | ||||
|         } else { | ||||
|           return ListTile( | ||||
|             leading: const Icon(Icons.person_remove_rounded), | ||||
|             title: const Text( | ||||
|               'Leave album', | ||||
|               style: TextStyle(fontWeight: FontWeight.bold), | ||||
|             ), | ||||
|             onTap: () => _onLeaveAlbumPressed(albumId), | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     void _buildBottomSheet() { | ||||
|       showModalBottomSheet( | ||||
|         backgroundColor: immichBackgroundColor, | ||||
|         isScrollControlled: false, | ||||
|         context: context, | ||||
|         builder: (context) { | ||||
|           return Column( | ||||
|             mainAxisSize: MainAxisSize.min, | ||||
|             children: [ | ||||
|               _buildBottomSheetActionButton(), | ||||
|             ], | ||||
|           ); | ||||
|         }, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     _buildLeadingButton() { | ||||
|       if (isMultiSelectionEnable) { | ||||
|         return IconButton( | ||||
|           onPressed: () => ref.watch(assetSelectionProvider.notifier).disableMultiselection(), | ||||
|           icon: const Icon(Icons.close_rounded), | ||||
|           splashRadius: 25, | ||||
|         ); | ||||
|       } else { | ||||
|         return IconButton( | ||||
|           onPressed: () async => await AutoRouter.of(context).pop(), | ||||
|           icon: const Icon(Icons.arrow_back_ios_rounded), | ||||
|           splashRadius: 25, | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return AppBar( | ||||
|       elevation: 0, | ||||
|       leading: _buildLeadingButton(), | ||||
|       title: isMultiSelectionEnable ? Text(selectedAssetsInAlbum.length.toString()) : Container(), | ||||
|       centerTitle: false, | ||||
|       actions: [ | ||||
|         IconButton( | ||||
|           splashRadius: 25, | ||||
|           onPressed: _buildBottomSheet, | ||||
|           icon: const Icon(Icons.more_horiz_rounded), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Size get preferredSize => const Size.fromHeight(kToolbarHeight); | ||||
| } | ||||
							
								
								
									
										181
									
								
								mobile/lib/modules/sharing/ui/album_viewer_thumbnail.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,181 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:cached_network_image/cached_network_image.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hive_flutter/hive_flutter.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart'; | ||||
| import 'package:immich_mobile/shared/models/immich_asset.model.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
|  | ||||
| class AlbumViewerThumbnail extends HookConsumerWidget { | ||||
|   final ImmichAsset asset; | ||||
|  | ||||
|   const AlbumViewerThumbnail({Key? key, required this.asset}) : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final cacheKey = useState(1); | ||||
|     var box = Hive.box(userInfoBox); | ||||
|     var thumbnailRequestUrl = | ||||
|         '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true'; | ||||
|     var deviceId = ref.watch(authenticationProvider).deviceId; | ||||
|     final selectedAssetsInAlbumViewer = ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer; | ||||
|     final isMultiSelectionEnable = ref.watch(assetSelectionProvider).isMultiselectEnable; | ||||
|  | ||||
|     _viewAsset() { | ||||
|       if (asset.type == 'IMAGE') { | ||||
|         AutoRouter.of(context).push( | ||||
|           ImageViewerRoute( | ||||
|             imageUrl: | ||||
|                 '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false', | ||||
|             heroTag: asset.id, | ||||
|             thumbnailUrl: thumbnailRequestUrl, | ||||
|             asset: asset, | ||||
|           ), | ||||
|         ); | ||||
|       } else { | ||||
|         AutoRouter.of(context).push( | ||||
|           VideoViewerRoute( | ||||
|               videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}', | ||||
|               asset: asset), | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     BoxBorder drawBorderColor() { | ||||
|       if (selectedAssetsInAlbumViewer.contains(asset)) { | ||||
|         return Border.all( | ||||
|           color: Theme.of(context).primaryColorLight, | ||||
|           width: 10, | ||||
|         ); | ||||
|       } else { | ||||
|         return const Border(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     _enableMultiSelection() { | ||||
|       ref.watch(assetSelectionProvider.notifier).enableMultiselection(); | ||||
|       ref.watch(assetSelectionProvider.notifier).addAssetsInAlbumViewer([asset]); | ||||
|     } | ||||
|  | ||||
|     _disableMultiSelection() { | ||||
|       ref.watch(assetSelectionProvider.notifier).disableMultiselection(); | ||||
|     } | ||||
|  | ||||
|     _buildVideoLabel() { | ||||
|       if (asset.type == 'IMAGE') { | ||||
|         return Container(); | ||||
|       } else { | ||||
|         return Positioned( | ||||
|           top: 5, | ||||
|           right: 5, | ||||
|           child: Row( | ||||
|             children: [ | ||||
|               Text( | ||||
|                 asset.duration.toString().substring(0, 7), | ||||
|                 style: const TextStyle( | ||||
|                   color: Colors.white, | ||||
|                   fontSize: 10, | ||||
|                 ), | ||||
|               ), | ||||
|               const Icon( | ||||
|                 Icons.play_circle_outline_rounded, | ||||
|                 color: Colors.white, | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     _buildAssetStoreLocationIcon() { | ||||
|       return Positioned( | ||||
|         right: 10, | ||||
|         bottom: 5, | ||||
|         child: Icon( | ||||
|           (deviceId != asset.deviceId) ? Icons.cloud_done_outlined : Icons.photo_library_rounded, | ||||
|           color: Colors.white, | ||||
|           size: 18, | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     _buildAssetSelectionIcon() { | ||||
|       bool isSelected = selectedAssetsInAlbumViewer.contains(asset); | ||||
|       if (isMultiSelectionEnable) { | ||||
|         return Positioned( | ||||
|           left: 10, | ||||
|           top: 5, | ||||
|           child: isSelected | ||||
|               ? Icon( | ||||
|                   Icons.check_circle_rounded, | ||||
|                   color: Theme.of(context).primaryColor, | ||||
|                 ) | ||||
|               : const Icon( | ||||
|                   Icons.check_circle_outline_rounded, | ||||
|                   color: Colors.white, | ||||
|                 ), | ||||
|         ); | ||||
|       } else { | ||||
|         return Container(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     _buildThumbnailImage() { | ||||
|       return Container( | ||||
|         decoration: BoxDecoration(border: drawBorderColor()), | ||||
|         child: CachedNetworkImage( | ||||
|           cacheKey: "${asset.id}-${cacheKey.value}", | ||||
|           width: 300, | ||||
|           height: 300, | ||||
|           memCacheHeight: 200, | ||||
|           fit: BoxFit.cover, | ||||
|           imageUrl: thumbnailRequestUrl, | ||||
|           httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, | ||||
|           fadeInDuration: const Duration(milliseconds: 250), | ||||
|           progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale( | ||||
|             scale: 0.2, | ||||
|             child: CircularProgressIndicator(value: downloadProgress.progress), | ||||
|           ), | ||||
|           errorWidget: (context, url, error) { | ||||
|             return Icon( | ||||
|               Icons.image_not_supported_outlined, | ||||
|               color: Theme.of(context).primaryColor, | ||||
|             ); | ||||
|           }, | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     _handleSelectionGesture() { | ||||
|       if (selectedAssetsInAlbumViewer.contains(asset)) { | ||||
|         ref.watch(assetSelectionProvider.notifier).removeAssetsInAlbumViewer([asset]); | ||||
|  | ||||
|         if (selectedAssetsInAlbumViewer.isEmpty) { | ||||
|           _disableMultiSelection(); | ||||
|         } | ||||
|       } else { | ||||
|         ref.watch(assetSelectionProvider.notifier).addAssetsInAlbumViewer([asset]); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return GestureDetector( | ||||
|       onTap: isMultiSelectionEnable ? _handleSelectionGesture : _viewAsset, | ||||
|       onLongPress: _enableMultiSelection, | ||||
|       child: Hero( | ||||
|         tag: asset.id, | ||||
|         child: Stack( | ||||
|           children: [ | ||||
|             _buildThumbnailImage(), | ||||
|             _buildAssetStoreLocationIcon(), | ||||
|             _buildVideoLabel(), | ||||
|             _buildAssetSelectionIcon(), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										25
									
								
								mobile/lib/modules/sharing/ui/asset_grid_by_month.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,25 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/ui/selection_thumbnail_image.dart'; | ||||
| import 'package:immich_mobile/shared/models/immich_asset.model.dart'; | ||||
|  | ||||
| class AssetGridByMonth extends HookConsumerWidget { | ||||
|   final List<ImmichAsset> assetGroup; | ||||
|   const AssetGridByMonth({Key? key, required this.assetGroup}) : super(key: key); | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     return SliverGrid( | ||||
|       gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( | ||||
|         crossAxisCount: 4, | ||||
|         crossAxisSpacing: 5.0, | ||||
|         mainAxisSpacing: 5, | ||||
|       ), | ||||
|       delegate: SliverChildBuilderDelegate( | ||||
|         (BuildContext context, int index) { | ||||
|           return SelectionThumbnailImage(asset: assetGroup[index]); | ||||
|         }, | ||||
|         childCount: assetGroup.length, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										92
									
								
								mobile/lib/modules/sharing/ui/month_group_title.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,92 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart'; | ||||
| import 'package:immich_mobile/shared/models/immich_asset.model.dart'; | ||||
|  | ||||
| class MonthGroupTitle extends HookConsumerWidget { | ||||
|   final String month; | ||||
|   final List<ImmichAsset> assetGroup; | ||||
|  | ||||
|   const MonthGroupTitle({Key? key, required this.month, required this.assetGroup}) : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final selectedDateGroup = ref.watch(assetSelectionProvider).selectedMonths; | ||||
|     final selectedAssets = ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum; | ||||
|     final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist; | ||||
|  | ||||
|     _handleTitleIconClick() { | ||||
|       HapticFeedback.heavyImpact(); | ||||
|  | ||||
|       if (isAlbumExist) { | ||||
|         if (selectedDateGroup.contains(month)) { | ||||
|           ref.watch(assetSelectionProvider.notifier).removeAssetsInMonth(month, []); | ||||
|           ref.watch(assetSelectionProvider.notifier).removeSelectedAdditionalAssets(assetGroup); | ||||
|         } else { | ||||
|           ref.watch(assetSelectionProvider.notifier).addAllAssetsInMonth(month, []); | ||||
|  | ||||
|           // Deep clone assetGroup | ||||
|           var assetGroupWithNewItems = [...assetGroup]; | ||||
|  | ||||
|           for (var selectedAsset in selectedAssets) { | ||||
|             assetGroupWithNewItems.removeWhere((a) => a.id == selectedAsset.id); | ||||
|           } | ||||
|  | ||||
|           ref.watch(assetSelectionProvider.notifier).addAdditionalAssets(assetGroupWithNewItems); | ||||
|         } | ||||
|       } else { | ||||
|         if (selectedDateGroup.contains(month)) { | ||||
|           ref.watch(assetSelectionProvider.notifier).removeAssetsInMonth(month, assetGroup); | ||||
|         } else { | ||||
|           ref.watch(assetSelectionProvider.notifier).addAllAssetsInMonth(month, assetGroup); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     _getSimplifiedMonth() { | ||||
|       var monthAndYear = month.split(','); | ||||
|       var yearText = monthAndYear[1].trim(); | ||||
|       var monthText = monthAndYear[0].trim(); | ||||
|       var currentYear = DateTime.now().year.toString(); | ||||
|  | ||||
|       if (yearText == currentYear) { | ||||
|         return monthText; | ||||
|       } else { | ||||
|         return month; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return SliverToBoxAdapter( | ||||
|       child: Padding( | ||||
|         padding: const EdgeInsets.only(top: 29.0, bottom: 29.0, left: 14.0, right: 8.0), | ||||
|         child: Row( | ||||
|           children: [ | ||||
|             GestureDetector( | ||||
|               onTap: _handleTitleIconClick, | ||||
|               child: selectedDateGroup.contains(month) | ||||
|                   ? Icon( | ||||
|                       Icons.check_circle_rounded, | ||||
|                       color: Theme.of(context).primaryColor, | ||||
|                     ) | ||||
|                   : const Icon( | ||||
|                       Icons.circle_outlined, | ||||
|                       color: Colors.grey, | ||||
|                     ), | ||||
|             ), | ||||
|             Padding( | ||||
|               padding: const EdgeInsets.only(left: 8.0), | ||||
|               child: Text( | ||||
|                 _getSimplifiedMonth(), | ||||
|                 style: TextStyle( | ||||
|                   fontSize: 24, | ||||
|                   color: Theme.of(context).primaryColor, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										149
									
								
								mobile/lib/modules/sharing/ui/selection_thumbnail_image.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,149 @@ | ||||
| import 'package:cached_network_image/cached_network_image.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hive_flutter/hive_flutter.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart'; | ||||
| import 'package:immich_mobile/shared/models/immich_asset.model.dart'; | ||||
|  | ||||
| class SelectionThumbnailImage extends HookConsumerWidget { | ||||
|   final ImmichAsset asset; | ||||
|  | ||||
|   const SelectionThumbnailImage({Key? key, required this.asset}) : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final cacheKey = useState(1); | ||||
|     var box = Hive.box(userInfoBox); | ||||
|     var thumbnailRequestUrl = | ||||
|         '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true'; | ||||
|     var selectedAsset = ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum; | ||||
|     var newAssetsForAlbum = ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum; | ||||
|     var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist; | ||||
|  | ||||
|     Widget _buildSelectionIcon(ImmichAsset asset) { | ||||
|       if (selectedAsset.contains(asset) && !isAlbumExist) { | ||||
|         return Icon( | ||||
|           Icons.check_circle, | ||||
|           color: Theme.of(context).primaryColor, | ||||
|         ); | ||||
|       } else if (selectedAsset.contains(asset) && isAlbumExist) { | ||||
|         return const Icon( | ||||
|           Icons.check_circle, | ||||
|           color: Color.fromARGB(255, 233, 233, 233), | ||||
|         ); | ||||
|       } else if (newAssetsForAlbum.contains(asset) && isAlbumExist) { | ||||
|         return Icon( | ||||
|           Icons.check_circle, | ||||
|           color: Theme.of(context).primaryColor, | ||||
|         ); | ||||
|       } else { | ||||
|         return const Icon( | ||||
|           Icons.circle_outlined, | ||||
|           color: Colors.white, | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     BoxBorder drawBorderColor() { | ||||
|       if (selectedAsset.contains(asset) && !isAlbumExist) { | ||||
|         return Border.all( | ||||
|           color: Theme.of(context).primaryColorLight, | ||||
|           width: 10, | ||||
|         ); | ||||
|       } else if (selectedAsset.contains(asset) && isAlbumExist) { | ||||
|         return Border.all( | ||||
|           color: const Color.fromARGB(255, 190, 190, 190), | ||||
|           width: 10, | ||||
|         ); | ||||
|       } else if (newAssetsForAlbum.contains(asset) && isAlbumExist) { | ||||
|         return Border.all( | ||||
|           color: Theme.of(context).primaryColorLight, | ||||
|           width: 10, | ||||
|         ); | ||||
|       } | ||||
|       return const Border(); | ||||
|     } | ||||
|  | ||||
|     return GestureDetector( | ||||
|       onTap: () { | ||||
|         if (isAlbumExist) { | ||||
|           // Operation for existing album | ||||
|           if (!selectedAsset.contains(asset)) { | ||||
|             if (newAssetsForAlbum.contains(asset)) { | ||||
|               ref.watch(assetSelectionProvider.notifier).removeSelectedAdditionalAssets([asset]); | ||||
|             } else { | ||||
|               ref.watch(assetSelectionProvider.notifier).addAdditionalAssets([asset]); | ||||
|             } | ||||
|           } | ||||
|         } else { | ||||
|           // Operation for new album | ||||
|           if (selectedAsset.contains(asset)) { | ||||
|             ref.watch(assetSelectionProvider.notifier).removeSelectedNewAssets([asset]); | ||||
|           } else { | ||||
|             ref.watch(assetSelectionProvider.notifier).addNewAssets([asset]); | ||||
|           } | ||||
|         } | ||||
|       }, | ||||
|       child: Hero( | ||||
|         tag: asset.id, | ||||
|         child: Stack( | ||||
|           children: [ | ||||
|             Container( | ||||
|               decoration: BoxDecoration(border: drawBorderColor()), | ||||
|               child: CachedNetworkImage( | ||||
|                 cacheKey: "${asset.id}-${cacheKey.value}", | ||||
|                 width: 150, | ||||
|                 height: 150, | ||||
|                 memCacheHeight: asset.type == 'IMAGE' ? 150 : 150, | ||||
|                 fit: BoxFit.cover, | ||||
|                 imageUrl: thumbnailRequestUrl, | ||||
|                 httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, | ||||
|                 fadeInDuration: const Duration(milliseconds: 250), | ||||
|                 progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale( | ||||
|                   scale: 0.2, | ||||
|                   child: CircularProgressIndicator(value: downloadProgress.progress), | ||||
|                 ), | ||||
|                 errorWidget: (context, url, error) { | ||||
|                   return Icon( | ||||
|                     Icons.image_not_supported_outlined, | ||||
|                     color: Theme.of(context).primaryColor, | ||||
|                   ); | ||||
|                 }, | ||||
|               ), | ||||
|             ), | ||||
|             Padding( | ||||
|               padding: const EdgeInsets.all(3.0), | ||||
|               child: Align( | ||||
|                 alignment: Alignment.topLeft, | ||||
|                 child: _buildSelectionIcon(asset), | ||||
|               ), | ||||
|             ), | ||||
|             asset.type == 'IMAGE' | ||||
|                 ? Container() | ||||
|                 : Positioned( | ||||
|                     bottom: 5, | ||||
|                     right: 5, | ||||
|                     child: Row( | ||||
|                       children: [ | ||||
|                         Text( | ||||
|                           asset.duration.toString().substring(0, 7), | ||||
|                           style: const TextStyle( | ||||
|                             color: Colors.white, | ||||
|                             fontSize: 10, | ||||
|                           ), | ||||
|                         ), | ||||
|                         const Icon( | ||||
|                           Icons.play_circle_outline_rounded, | ||||
|                           color: Colors.white, | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ) | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,55 @@ | ||||
| import 'package:cached_network_image/cached_network_image.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hive_flutter/hive_flutter.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/shared/models/immich_asset.model.dart'; | ||||
|  | ||||
| class SharedAlbumThumbnailImage extends HookConsumerWidget { | ||||
|   final ImmichAsset asset; | ||||
|  | ||||
|   const SharedAlbumThumbnailImage({Key? key, required this.asset}) : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final cacheKey = useState(1); | ||||
|  | ||||
|     var box = Hive.box(userInfoBox); | ||||
|     var thumbnailRequestUrl = | ||||
|         '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true'; | ||||
|  | ||||
|     return GestureDetector( | ||||
|       onTap: () { | ||||
|         // debugPrint("View ${asset.id}"); | ||||
|       }, | ||||
|       child: Hero( | ||||
|         tag: asset.id, | ||||
|         child: Stack( | ||||
|           children: [ | ||||
|             CachedNetworkImage( | ||||
|               cacheKey: "${asset.id}-${cacheKey.value}", | ||||
|               width: 500, | ||||
|               height: 500, | ||||
|               memCacheHeight: asset.type == 'IMAGE' ? 500 : 500, | ||||
|               fit: BoxFit.cover, | ||||
|               imageUrl: thumbnailRequestUrl, | ||||
|               httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, | ||||
|               fadeInDuration: const Duration(milliseconds: 250), | ||||
|               progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale( | ||||
|                 scale: 0.2, | ||||
|                 child: CircularProgressIndicator(value: downloadProgress.progress), | ||||
|               ), | ||||
|               errorWidget: (context, url, error) { | ||||
|                 return Icon( | ||||
|                   Icons.image_not_supported_outlined, | ||||
|                   color: Theme.of(context).primaryColor, | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										83
									
								
								mobile/lib/modules/sharing/ui/sharing_sliver_appbar.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,83 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
|  | ||||
| class SharingSliverAppBar extends StatelessWidget { | ||||
|   const SharingSliverAppBar({ | ||||
|     Key? key, | ||||
|   }) : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return SliverAppBar( | ||||
|       centerTitle: true, | ||||
|       floating: false, | ||||
|       pinned: true, | ||||
|       snap: false, | ||||
|       leading: Container(), | ||||
|       // elevation: 0, | ||||
|       title: Text( | ||||
|         'IMMICH', | ||||
|         style: TextStyle( | ||||
|           fontFamily: 'SnowburstOne', | ||||
|           fontWeight: FontWeight.bold, | ||||
|           fontSize: 22, | ||||
|           color: Theme.of(context).primaryColor, | ||||
|         ), | ||||
|       ), | ||||
|       bottom: PreferredSize( | ||||
|         preferredSize: const Size.fromHeight(50.0), | ||||
|         child: Padding( | ||||
|           padding: const EdgeInsets.symmetric(horizontal: 12.0), | ||||
|           child: Row( | ||||
|             mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|             children: [ | ||||
|               Expanded( | ||||
|                 child: Padding( | ||||
|                   padding: const EdgeInsets.only(right: 4.0), | ||||
|                   child: TextButton.icon( | ||||
|                     style: ButtonStyle( | ||||
|                       backgroundColor: MaterialStateProperty.all(Theme.of(context).primaryColor.withAlpha(20)), | ||||
|                       // foregroundColor: MaterialStateProperty.all(Colors.white), | ||||
|                     ), | ||||
|                     onPressed: () { | ||||
|                       AutoRouter.of(context).push(const CreateSharedAlbumRoute()); | ||||
|                     }, | ||||
|                     icon: const Icon( | ||||
|                       Icons.photo_album_outlined, | ||||
|                       size: 20, | ||||
|                     ), | ||||
|                     label: const Text( | ||||
|                       "Create shared album", | ||||
|                       style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|               Expanded( | ||||
|                 child: Padding( | ||||
|                   padding: const EdgeInsets.only(left: 4.0), | ||||
|                   child: TextButton.icon( | ||||
|                     style: ButtonStyle( | ||||
|                       backgroundColor: MaterialStateProperty.all(Theme.of(context).primaryColor.withAlpha(20)), | ||||
|                       // foregroundColor: MaterialStateProperty.all(Colors.white), | ||||
|                     ), | ||||
|                     onPressed: null, | ||||
|                     icon: const Icon( | ||||
|                       Icons.swap_horizontal_circle_outlined, | ||||
|                       size: 20, | ||||
|                     ), | ||||
|                     label: const Text( | ||||
|                       "Share with partner", | ||||
|                       style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ) | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										245
									
								
								mobile/lib/modules/sharing/views/album_viewer_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,245 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/immich_colors.dart'; | ||||
| import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart'; | ||||
| import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/models/asset_selection_page_result.model.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/models/shared_album.model.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/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_thumbnail.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart'; | ||||
| import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; | ||||
| import 'package:intl/intl.dart'; | ||||
|  | ||||
| class AlbumViewerPage extends HookConsumerWidget { | ||||
|   final String albumId; | ||||
|  | ||||
|   const AlbumViewerPage({Key? key, required this.albumId}) : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     ScrollController _scrollController = useScrollController(); | ||||
|     AsyncValue<SharedAlbum> _albumInfo = ref.watch(sharedAlbumDetailProvider(albumId)); | ||||
|  | ||||
|     final userId = ref.watch(authenticationProvider).userId; | ||||
|  | ||||
|     /// Find out if the assets in album exist on the device | ||||
|     /// If they exist, add to selected asset state to show they are already selected. | ||||
|     void _onAddPhotosPressed(SharedAlbum albumInfo) async { | ||||
|       if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) { | ||||
|         ref | ||||
|             .watch(assetSelectionProvider.notifier) | ||||
|             .addNewAssets(albumInfo.sharedAssets!.map((e) => e.assetInfo).toList()); | ||||
|       } | ||||
|  | ||||
|       ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(true); | ||||
|  | ||||
|       AssetSelectionPageResult? returnPayload = | ||||
|           await AutoRouter.of(context).push<AssetSelectionPageResult?>(const AssetSelectionRoute()); | ||||
|  | ||||
|       if (returnPayload != null) { | ||||
|         // Check if there is new assets add | ||||
|         if (returnPayload.selectedAdditionalAsset.isNotEmpty) { | ||||
|           ImmichLoadingOverlayController.appLoader.show(); | ||||
|  | ||||
|           var isSuccess = | ||||
|               await SharedAlbumService().addAdditionalAssetToAlbum(returnPayload.selectedAdditionalAsset, albumId); | ||||
|  | ||||
|           if (isSuccess) { | ||||
|             ref.refresh(sharedAlbumDetailProvider(albumId)); | ||||
|           } | ||||
|  | ||||
|           ImmichLoadingOverlayController.appLoader.hide(); | ||||
|         } | ||||
|  | ||||
|         ref.watch(assetSelectionProvider.notifier).removeAll(); | ||||
|       } else { | ||||
|         ref.watch(assetSelectionProvider.notifier).removeAll(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     void _onAddUsersPressed(SharedAlbum albumInfo) async { | ||||
|       List<String>? sharedUserIds = | ||||
|           await AutoRouter.of(context).push<List<String>?>(SelectAdditionalUserForSharingRoute(albumInfo: albumInfo)); | ||||
|  | ||||
|       if (sharedUserIds != null) { | ||||
|         ImmichLoadingOverlayController.appLoader.show(); | ||||
|  | ||||
|         var isSuccess = await SharedAlbumService().addAdditionalUserToAlbum(sharedUserIds, albumId); | ||||
|  | ||||
|         if (isSuccess) { | ||||
|           ref.refresh(sharedAlbumDetailProvider(albumId)); | ||||
|         } | ||||
|  | ||||
|         ImmichLoadingOverlayController.appLoader.hide(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Widget _buildTitle(String title) { | ||||
|       return Padding( | ||||
|         padding: const EdgeInsets.only(left: 16.0, top: 16), | ||||
|         child: Text( | ||||
|           title, | ||||
|           style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     Widget _buildAlbumDateRange(SharedAlbum albumInfo) { | ||||
|       if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) { | ||||
|         String startDate = ""; | ||||
|         DateTime parsedStartDate = DateTime.parse(albumInfo.sharedAssets!.first.assetInfo.createdAt); | ||||
|         DateTime parsedEndDate = DateTime.parse(albumInfo.sharedAssets!.last.assetInfo.createdAt); | ||||
|  | ||||
|         if (parsedStartDate.year == parsedEndDate.year) { | ||||
|           startDate = DateFormat('LLL d').format(parsedStartDate); | ||||
|         } else { | ||||
|           startDate = DateFormat('LLL d, y').format(parsedStartDate); | ||||
|         } | ||||
|  | ||||
|         String endDate = DateFormat('LLL d, y').format(parsedEndDate); | ||||
|  | ||||
|         return Padding( | ||||
|           padding: const EdgeInsets.only(left: 16.0, top: 8), | ||||
|           child: Text( | ||||
|             "$startDate-$endDate", | ||||
|             style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.grey), | ||||
|           ), | ||||
|         ); | ||||
|       } else { | ||||
|         return Container(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Widget _buildHeader(SharedAlbum albumInfo) { | ||||
|       return SliverToBoxAdapter( | ||||
|         child: Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             _buildTitle(albumInfo.albumName), | ||||
|             _buildAlbumDateRange(albumInfo), | ||||
|             SizedBox( | ||||
|               height: 60, | ||||
|               child: ListView.builder( | ||||
|                 padding: const EdgeInsets.only(left: 16), | ||||
|                 scrollDirection: Axis.horizontal, | ||||
|                 itemBuilder: ((context, index) { | ||||
|                   return Padding( | ||||
|                     padding: const EdgeInsets.only(right: 8.0), | ||||
|                     child: CircleAvatar( | ||||
|                       backgroundColor: Colors.grey[300], | ||||
|                       radius: 18, | ||||
|                       child: Padding( | ||||
|                         padding: const EdgeInsets.all(2.0), | ||||
|                         child: ClipRRect( | ||||
|                           child: Image.asset('assets/immich-logo-no-outline.png'), | ||||
|                           borderRadius: BorderRadius.circular(50.0), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ), | ||||
|                   ); | ||||
|                 }), | ||||
|                 itemCount: albumInfo.sharedUsers.length, | ||||
|               ), | ||||
|             ) | ||||
|           ], | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     Widget _buildImageGrid(SharedAlbum albumInfo) { | ||||
|       if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) { | ||||
|         return SliverPadding( | ||||
|           padding: const EdgeInsets.only(top: 10.0), | ||||
|           sliver: SliverGrid( | ||||
|             gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( | ||||
|               crossAxisCount: 3, | ||||
|               crossAxisSpacing: 5.0, | ||||
|               mainAxisSpacing: 5, | ||||
|             ), | ||||
|             delegate: SliverChildBuilderDelegate( | ||||
|               (BuildContext context, int index) { | ||||
|                 return AlbumViewerThumbnail(asset: albumInfo.sharedAssets![index].assetInfo); | ||||
|               }, | ||||
|               childCount: albumInfo.sharedAssets?.length, | ||||
|             ), | ||||
|           ), | ||||
|         ); | ||||
|       } | ||||
|       return const SliverToBoxAdapter(); | ||||
|     } | ||||
|  | ||||
|     Widget _buildControlButton(SharedAlbum albumInfo) { | ||||
|       return Padding( | ||||
|         padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 8), | ||||
|         child: SizedBox( | ||||
|           height: 40, | ||||
|           child: ListView( | ||||
|             scrollDirection: Axis.horizontal, | ||||
|             children: [ | ||||
|               AlbumActionOutlinedButton( | ||||
|                 iconData: Icons.add_photo_alternate_outlined, | ||||
|                 onPressed: () => _onAddPhotosPressed(albumInfo), | ||||
|                 labelText: "Add photos", | ||||
|               ), | ||||
|               userId == albumInfo.ownerId | ||||
|                   ? AlbumActionOutlinedButton( | ||||
|                       iconData: Icons.person_add_alt_rounded, | ||||
|                       onPressed: () => _onAddUsersPressed(albumInfo), | ||||
|                       labelText: "Add users", | ||||
|                     ) | ||||
|                   : Container(), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     Widget _buildBody(SharedAlbum albumInfo) { | ||||
|       return Stack(children: [ | ||||
|         DraggableScrollbar.semicircle( | ||||
|           backgroundColor: Theme.of(context).primaryColor, | ||||
|           controller: _scrollController, | ||||
|           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) | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ]); | ||||
|     } | ||||
|  | ||||
|     return Scaffold( | ||||
|       appBar: AlbumViewerAppbar(albumInfo: _albumInfo, userId: userId, albumId: albumId), | ||||
|       body: _albumInfo.when( | ||||
|         data: (albumInfo) => _buildBody(albumInfo), | ||||
|         error: (e, _) => Center(child: Text("Error loading album info $e")), | ||||
|         loading: () => const Center( | ||||
|           child: ImmichLoadingIndicator(), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										95
									
								
								mobile/lib/modules/sharing/views/asset_selection_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,95 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| 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/asset_selection_page_result.model.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/ui/asset_grid_by_month.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/ui/month_group_title.dart'; | ||||
| import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart'; | ||||
|  | ||||
| class AssetSelectionPage extends HookConsumerWidget { | ||||
|   const AssetSelectionPage({Key? key}) : super(key: key); | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     ScrollController _scrollController = useScrollController(); | ||||
|     var assetGroupMonthYear = ref.watch(assetGroupByMonthYearProvider); | ||||
|     final selectedAssets = ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum; | ||||
|     final newAssetsForAlbum = ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum; | ||||
|     final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist; | ||||
|  | ||||
|     List<Widget> _imageGridGroup = []; | ||||
|  | ||||
|     String _buildAssetCountText() { | ||||
|       if (isAlbumExist) { | ||||
|         return (selectedAssets.length + newAssetsForAlbum.length).toString(); | ||||
|       } else { | ||||
|         return selectedAssets.length.toString(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Widget _buildBody() { | ||||
|       assetGroupMonthYear.forEach((monthYear, assetGroup) { | ||||
|         _imageGridGroup.add(MonthGroupTitle(month: monthYear, assetGroup: assetGroup)); | ||||
|         _imageGridGroup.add(AssetGridByMonth(assetGroup: assetGroup)); | ||||
|       }); | ||||
|  | ||||
|       return Stack( | ||||
|         children: [ | ||||
|           DraggableScrollbar.semicircle( | ||||
|             backgroundColor: Theme.of(context).primaryColor, | ||||
|             controller: _scrollController, | ||||
|             heightScrollThumb: 48.0, | ||||
|             child: CustomScrollView( | ||||
|               controller: _scrollController, | ||||
|               slivers: [..._imageGridGroup], | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         elevation: 0, | ||||
|         leading: IconButton( | ||||
|           icon: const Icon(Icons.close_rounded), | ||||
|           onPressed: () { | ||||
|             ref.watch(assetSelectionProvider.notifier).removeAll(); | ||||
|             AutoRouter.of(context).pop(null); | ||||
|           }, | ||||
|         ), | ||||
|         title: selectedAssets.isEmpty | ||||
|             ? const Text( | ||||
|                 'Add photos', | ||||
|                 style: TextStyle(fontSize: 18), | ||||
|               ) | ||||
|             : Text( | ||||
|                 _buildAssetCountText(), | ||||
|                 style: const TextStyle(fontSize: 18), | ||||
|               ), | ||||
|         centerTitle: false, | ||||
|         actions: [ | ||||
|           (!isAlbumExist && selectedAssets.isNotEmpty) || (isAlbumExist && newAssetsForAlbum.isNotEmpty) | ||||
|               ? TextButton( | ||||
|                   onPressed: () { | ||||
|                     var payload = AssetSelectionPageResult( | ||||
|                       isAlbumExist: isAlbumExist, | ||||
|                       selectedAdditionalAsset: newAssetsForAlbum, | ||||
|                       selectedNewAsset: selectedAssets, | ||||
|                     ); | ||||
|                     AutoRouter.of(context).pop(payload); | ||||
|                   }, | ||||
|                   child: const Text( | ||||
|                     "Add", | ||||
|                     style: TextStyle(fontWeight: FontWeight.bold), | ||||
|                   ), | ||||
|                 ) | ||||
|               : Container() | ||||
|         ], | ||||
|       ), | ||||
|       body: _buildBody(), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										208
									
								
								mobile/lib/modules/sharing/views/create_shared_album_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,208 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| 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/asset_selection_page_result.model.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/providers/album_title.provider.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/ui/album_action_outlined_button.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/ui/album_title_text_field.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/ui/shared_album_thumbnail_image.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
|  | ||||
| class CreateSharedAlbumPage extends HookConsumerWidget { | ||||
|   const CreateSharedAlbumPage({Key? key}) : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final albumTitleController = useTextEditingController.fromValue(TextEditingValue.empty); | ||||
|     final albumTitleTextFieldFocusNode = useFocusNode(); | ||||
|     final isAlbumTitleTextFieldFocus = useState(false); | ||||
|     final isAlbumTitleEmpty = useState(true); | ||||
|     final selectedAssets = ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum; | ||||
|  | ||||
|     _showSelectUserPage() { | ||||
|       AutoRouter.of(context).push(const SelectUserForSharingRoute()); | ||||
|     } | ||||
|  | ||||
|     void _onBackgroundTapped() { | ||||
|       albumTitleTextFieldFocusNode.unfocus(); | ||||
|       isAlbumTitleTextFieldFocus.value = false; | ||||
|  | ||||
|       if (albumTitleController.text.isEmpty) { | ||||
|         albumTitleController.text = 'Untitled'; | ||||
|         ref.watch(albumTitleProvider.notifier).setAlbumTitle('Untitled'); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     _onSelectPhotosButtonPressed() async { | ||||
|       ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(false); | ||||
|  | ||||
|       AssetSelectionPageResult? selectedAsset = | ||||
|           await AutoRouter.of(context).push<AssetSelectionPageResult?>(const AssetSelectionRoute()); | ||||
|  | ||||
|       if (selectedAsset == null) { | ||||
|         ref.watch(assetSelectionProvider.notifier).removeAll(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     _buildTitleInputField() { | ||||
|       return Padding( | ||||
|         padding: const EdgeInsets.only( | ||||
|           right: 10, | ||||
|           left: 10, | ||||
|         ), | ||||
|         child: AlbumTitleTextField( | ||||
|             isAlbumTitleEmpty: isAlbumTitleEmpty, | ||||
|             albumTitleTextFieldFocusNode: albumTitleTextFieldFocusNode, | ||||
|             albumTitleController: albumTitleController, | ||||
|             isAlbumTitleTextFieldFocus: isAlbumTitleTextFieldFocus), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     _buildTitle() { | ||||
|       if (selectedAssets.isEmpty) { | ||||
|         return const SliverToBoxAdapter( | ||||
|           child: Padding( | ||||
|             padding: EdgeInsets.only(top: 200, left: 18), | ||||
|             child: Text( | ||||
|               'ADD ASSETS', | ||||
|               style: TextStyle(fontSize: 12), | ||||
|             ), | ||||
|           ), | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       return const SliverToBoxAdapter(); | ||||
|     } | ||||
|  | ||||
|     _buildSelectPhotosButton() { | ||||
|       if (selectedAssets.isEmpty) { | ||||
|         return SliverToBoxAdapter( | ||||
|           child: Padding( | ||||
|             padding: const EdgeInsets.only(top: 16, left: 18, right: 18), | ||||
|             child: OutlinedButton.icon( | ||||
|               style: ButtonStyle( | ||||
|                 alignment: Alignment.centerLeft, | ||||
|                 padding: | ||||
|                     MaterialStateProperty.all<EdgeInsets>(const EdgeInsets.symmetric(vertical: 22, horizontal: 16)), | ||||
|               ), | ||||
|               onPressed: _onSelectPhotosButtonPressed, | ||||
|               icon: const Icon(Icons.add_rounded), | ||||
|               label: Padding( | ||||
|                 padding: const EdgeInsets.only(left: 8.0), | ||||
|                 child: Text( | ||||
|                   'Select Photos', | ||||
|                   style: TextStyle(fontSize: 16, color: Colors.grey[700], fontWeight: FontWeight.bold), | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       return const SliverToBoxAdapter(); | ||||
|     } | ||||
|  | ||||
|     _buildControlButton() { | ||||
|       if (selectedAssets.isNotEmpty) { | ||||
|         return Padding( | ||||
|           padding: const EdgeInsets.only(left: 12.0, top: 16, bottom: 16), | ||||
|           child: SizedBox( | ||||
|             height: 30, | ||||
|             child: ListView( | ||||
|               scrollDirection: Axis.horizontal, | ||||
|               children: [ | ||||
|                 AlbumActionOutlinedButton( | ||||
|                   iconData: Icons.add_photo_alternate_outlined, | ||||
|                   onPressed: _onSelectPhotosButtonPressed, | ||||
|                   labelText: "Add photos", | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       return Container(); | ||||
|     } | ||||
|  | ||||
|     _buildSelectedImageGrid() { | ||||
|       if (selectedAssets.isNotEmpty) { | ||||
|         return SliverPadding( | ||||
|           padding: const EdgeInsets.only(top: 16), | ||||
|           sliver: SliverGrid( | ||||
|             gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( | ||||
|               crossAxisCount: 3, | ||||
|               crossAxisSpacing: 5.0, | ||||
|               mainAxisSpacing: 5, | ||||
|             ), | ||||
|             delegate: SliverChildBuilderDelegate( | ||||
|               (BuildContext context, int index) { | ||||
|                 return GestureDetector( | ||||
|                   onTap: _onBackgroundTapped, | ||||
|                   child: SharedAlbumThumbnailImage(asset: selectedAssets.toList()[index]), | ||||
|                 ); | ||||
|               }, | ||||
|               childCount: selectedAssets.length, | ||||
|             ), | ||||
|           ), | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       return const SliverToBoxAdapter(); | ||||
|     } | ||||
|  | ||||
|     return Scaffold( | ||||
|         appBar: AppBar( | ||||
|           elevation: 0, | ||||
|           centerTitle: false, | ||||
|           leading: IconButton( | ||||
|               onPressed: () { | ||||
|                 ref.watch(assetSelectionProvider.notifier).removeAll(); | ||||
|                 AutoRouter.of(context).pop(); | ||||
|               }, | ||||
|               icon: const Icon(Icons.close_rounded)), | ||||
|           title: const Text( | ||||
|             'Create album', | ||||
|             style: TextStyle(color: Colors.black), | ||||
|           ), | ||||
|           actions: [ | ||||
|             TextButton( | ||||
|               onPressed: albumTitleController.text.isNotEmpty ? _showSelectUserPage : null, | ||||
|               child: const Text( | ||||
|                 'Share', | ||||
|                 style: TextStyle( | ||||
|                   fontWeight: FontWeight.bold, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|         body: GestureDetector( | ||||
|           onTap: _onBackgroundTapped, | ||||
|           child: CustomScrollView( | ||||
|             slivers: [ | ||||
|               SliverAppBar( | ||||
|                 elevation: 5, | ||||
|                 leading: Container(), | ||||
|                 pinned: true, | ||||
|                 floating: false, | ||||
|                 bottom: PreferredSize( | ||||
|                   child: Column( | ||||
|                     children: [ | ||||
|                       _buildTitleInputField(), | ||||
|                       _buildControlButton(), | ||||
|                     ], | ||||
|                   ), | ||||
|                   preferredSize: const Size.fromHeight(66.0), | ||||
|                 ), | ||||
|               ), | ||||
|               _buildTitle(), | ||||
|               _buildSelectPhotosButton(), | ||||
|               _buildSelectedImageGrid(), | ||||
|             ], | ||||
|           ), | ||||
|         )); | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,135 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| 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/suggested_shared_users.provider.dart'; | ||||
| import 'package:immich_mobile/shared/models/user_info.model.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; | ||||
|  | ||||
| class SelectAdditionalUserForSharingPage extends HookConsumerWidget { | ||||
|   final SharedAlbum albumInfo; | ||||
|  | ||||
|   const SelectAdditionalUserForSharingPage({Key? key, required this.albumInfo}) : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     AsyncValue<List<UserInfo>> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider); | ||||
|     final sharedUsersList = useState<Set<UserInfo>>({}); | ||||
|  | ||||
|     _addNewUsersHandler() { | ||||
|       AutoRouter.of(context).pop(sharedUsersList.value.map((e) => e.id).toList()); | ||||
|     } | ||||
|  | ||||
|     _buildTileIcon(UserInfo user) { | ||||
|       if (sharedUsersList.value.contains(user)) { | ||||
|         return CircleAvatar( | ||||
|           backgroundColor: Theme.of(context).primaryColor, | ||||
|           child: const Icon( | ||||
|             Icons.check_rounded, | ||||
|             size: 25, | ||||
|           ), | ||||
|         ); | ||||
|       } else { | ||||
|         return CircleAvatar( | ||||
|           backgroundImage: const AssetImage('assets/immich-logo-no-outline.png'), | ||||
|           backgroundColor: Theme.of(context).primaryColor.withAlpha(50), | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     _buildUserList(List<UserInfo> users) { | ||||
|       List<Widget> usersChip = []; | ||||
|  | ||||
|       for (var user in sharedUsersList.value) { | ||||
|         usersChip.add( | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 8.0), | ||||
|             child: Chip( | ||||
|               backgroundColor: Theme.of(context).primaryColor.withOpacity(0.15), | ||||
|               label: Text( | ||||
|                 user.email, | ||||
|                 style: const TextStyle(fontSize: 12, color: Colors.black87, fontWeight: FontWeight.bold), | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|         ); | ||||
|       } | ||||
|       return Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           Wrap( | ||||
|             children: [...usersChip], | ||||
|           ), | ||||
|           const Padding( | ||||
|             padding: EdgeInsets.all(16.0), | ||||
|             child: Text( | ||||
|               'Suggestions', | ||||
|               style: TextStyle(fontSize: 14, color: Colors.grey, fontWeight: FontWeight.bold), | ||||
|             ), | ||||
|           ), | ||||
|           ListView.builder( | ||||
|             shrinkWrap: true, | ||||
|             itemBuilder: ((context, index) { | ||||
|               return ListTile( | ||||
|                 leading: _buildTileIcon(users[index]), | ||||
|                 title: Text( | ||||
|                   users[index].email, | ||||
|                   style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), | ||||
|                 ), | ||||
|                 onTap: () { | ||||
|                   if (sharedUsersList.value.contains(users[index])) { | ||||
|                     sharedUsersList.value = | ||||
|                         sharedUsersList.value.where((selectedUser) => selectedUser.id != users[index].id).toSet(); | ||||
|                   } else { | ||||
|                     sharedUsersList.value = {...sharedUsersList.value, users[index]}; | ||||
|                   } | ||||
|                 }, | ||||
|               ); | ||||
|             }), | ||||
|             itemCount: users.length, | ||||
|           ), | ||||
|         ], | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         title: const Text( | ||||
|           'Invite to album', | ||||
|           style: TextStyle(color: Colors.black), | ||||
|         ), | ||||
|         elevation: 0, | ||||
|         centerTitle: false, | ||||
|         leading: IconButton( | ||||
|           icon: const Icon(Icons.close_rounded), | ||||
|           onPressed: () { | ||||
|             AutoRouter.of(context).pop(null); | ||||
|           }, | ||||
|         ), | ||||
|         actions: [ | ||||
|           TextButton( | ||||
|             onPressed: sharedUsersList.value.isEmpty ? null : _addNewUsersHandler, | ||||
|             child: const Text( | ||||
|               "Add", | ||||
|               style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), | ||||
|             ), | ||||
|           ) | ||||
|         ], | ||||
|       ), | ||||
|       body: suggestedShareUsers.when( | ||||
|         data: (users) { | ||||
|           for (var sharedUsers in albumInfo.sharedUsers) { | ||||
|             users.removeWhere((u) => u.id == sharedUsers.sharedUserId || u.id == albumInfo.ownerId); | ||||
|           } | ||||
|  | ||||
|           return _buildUserList(users); | ||||
|         }, | ||||
|         error: (e, _) => Text("Error loading suggested users $e"), | ||||
|         loading: () => const Center( | ||||
|           child: ImmichLoadingIndicator(), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,145 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| 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/providers/album_title.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/modules/sharing/providers/suggested_shared_users.provider.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
| import 'package:immich_mobile/shared/models/user_info.model.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; | ||||
|  | ||||
| class SelectUserForSharingPage extends HookConsumerWidget { | ||||
|   const SelectUserForSharingPage({Key? key}) : super(key: key); | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final sharedUsersList = useState<Set<UserInfo>>({}); | ||||
|     AsyncValue<List<UserInfo>> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider); | ||||
|  | ||||
|     _createSharedAlbum() async { | ||||
|       var isSuccess = await SharedAlbumService().createSharedAlbum( | ||||
|         ref.watch(albumTitleProvider), | ||||
|         ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum, | ||||
|         sharedUsersList.value.map((userInfo) => userInfo.id).toList(), | ||||
|       ); | ||||
|  | ||||
|       if (isSuccess) { | ||||
|         await ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums(); | ||||
|         ref.watch(assetSelectionProvider.notifier).removeAll(); | ||||
|         ref.watch(albumTitleProvider.notifier).clearAlbumTitle(); | ||||
|  | ||||
|         AutoRouter.of(context).navigate(const TabControllerRoute(children: [SharingRoute()])); | ||||
|       } | ||||
|  | ||||
|       const ScaffoldMessenger(child: SnackBar(content: Text('Failed to create album'))); | ||||
|     } | ||||
|  | ||||
|     _buildTileIcon(UserInfo user) { | ||||
|       if (sharedUsersList.value.contains(user)) { | ||||
|         return CircleAvatar( | ||||
|           backgroundColor: Theme.of(context).primaryColor, | ||||
|           child: const Icon( | ||||
|             Icons.check_rounded, | ||||
|             size: 25, | ||||
|           ), | ||||
|         ); | ||||
|       } else { | ||||
|         return CircleAvatar( | ||||
|           backgroundImage: const AssetImage('assets/immich-logo-no-outline.png'), | ||||
|           backgroundColor: Theme.of(context).primaryColor.withAlpha(50), | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     _buildUserList(List<UserInfo> users) { | ||||
|       List<Widget> usersChip = []; | ||||
|  | ||||
|       for (var user in sharedUsersList.value) { | ||||
|         usersChip.add( | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 8.0), | ||||
|             child: Chip( | ||||
|               backgroundColor: Theme.of(context).primaryColor.withOpacity(0.15), | ||||
|               label: Text( | ||||
|                 user.email, | ||||
|                 style: const TextStyle(fontSize: 12, color: Colors.black87, fontWeight: FontWeight.bold), | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|         ); | ||||
|       } | ||||
|       return Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           Wrap( | ||||
|             children: [...usersChip], | ||||
|           ), | ||||
|           const Padding( | ||||
|             padding: EdgeInsets.all(16.0), | ||||
|             child: Text( | ||||
|               'Suggestions', | ||||
|               style: TextStyle(fontSize: 14, color: Colors.grey, fontWeight: FontWeight.bold), | ||||
|             ), | ||||
|           ), | ||||
|           ListView.builder( | ||||
|             shrinkWrap: true, | ||||
|             itemBuilder: ((context, index) { | ||||
|               return ListTile( | ||||
|                 leading: _buildTileIcon(users[index]), | ||||
|                 title: Text( | ||||
|                   users[index].email, | ||||
|                   style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), | ||||
|                 ), | ||||
|                 onTap: () { | ||||
|                   if (sharedUsersList.value.contains(users[index])) { | ||||
|                     sharedUsersList.value = | ||||
|                         sharedUsersList.value.where((selectedUser) => selectedUser.id != users[index].id).toSet(); | ||||
|                   } else { | ||||
|                     sharedUsersList.value = {...sharedUsersList.value, users[index]}; | ||||
|                   } | ||||
|                 }, | ||||
|               ); | ||||
|             }), | ||||
|             itemCount: users.length, | ||||
|           ), | ||||
|         ], | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         title: const Text( | ||||
|           'Invite to album', | ||||
|           style: TextStyle(color: Colors.black), | ||||
|         ), | ||||
|         elevation: 0, | ||||
|         centerTitle: false, | ||||
|         leading: IconButton( | ||||
|           icon: const Icon(Icons.close_rounded), | ||||
|           onPressed: () async { | ||||
|             AutoRouter.of(context).pop(); | ||||
|           }, | ||||
|         ), | ||||
|         actions: [ | ||||
|           TextButton( | ||||
|               onPressed: sharedUsersList.value.isEmpty ? null : _createSharedAlbum, | ||||
|               child: const Text( | ||||
|                 "Create Album", | ||||
|                 style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), | ||||
|               )) | ||||
|         ], | ||||
|       ), | ||||
|       body: suggestedShareUsers.when( | ||||
|         data: (users) { | ||||
|           return _buildUserList(users); | ||||
|         }, | ||||
|         error: (e, _) => Text("Error loading suggested users $e"), | ||||
|         loading: () => const Center( | ||||
|           child: ImmichLoadingIndicator(), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										142
									
								
								mobile/lib/modules/sharing/views/sharing_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,142 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hive/hive.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/ui/sharing_sliver_appbar.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
| import 'package:transparent_image/transparent_image.dart'; | ||||
|  | ||||
| class SharingPage extends HookConsumerWidget { | ||||
|   const SharingPage({Key? key}) : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     var box = Hive.box(userInfoBox); | ||||
|     var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail'; | ||||
|     final List<SharedAlbum> sharedAlbums = ref.watch(sharedAlbumProvider); | ||||
|  | ||||
|     useEffect(() { | ||||
|       ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); | ||||
|  | ||||
|       return null; | ||||
|     }, []); | ||||
|  | ||||
|     _buildAlbumList() { | ||||
|       return SliverList( | ||||
|         delegate: SliverChildBuilderDelegate( | ||||
|           (BuildContext context, int index) { | ||||
|             String thumbnailUrl = sharedAlbums[index].albumThumbnailAssetId != null | ||||
|                 ? "$thumbnailRequestUrl/${sharedAlbums[index].albumThumbnailAssetId}" | ||||
|                 : "https://images.unsplash.com/photo-1612178537253-bccd437b730e?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8NXx8Ymxhbmt8ZW58MHx8MHx8&auto=format&fit=crop&w=700&q=60"; | ||||
|  | ||||
|             return ListTile( | ||||
|               contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), | ||||
|               leading: ClipRRect( | ||||
|                 borderRadius: BorderRadius.circular(8), | ||||
|                 child: FadeInImage( | ||||
|                   width: 60, | ||||
|                   height: 60, | ||||
|                   fit: BoxFit.cover, | ||||
|                   placeholder: MemoryImage(kTransparentImage), | ||||
|                   image: NetworkImage( | ||||
|                     thumbnailUrl, | ||||
|                     headers: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, | ||||
|                   ), | ||||
|                   fadeInDuration: const Duration(milliseconds: 200), | ||||
|                   fadeOutDuration: const Duration(milliseconds: 200), | ||||
|                 ), | ||||
|               ), | ||||
|               title: Text( | ||||
|                 sharedAlbums[index].albumName, | ||||
|                 maxLines: 1, | ||||
|                 overflow: TextOverflow.ellipsis, | ||||
|                 style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.grey.shade800), | ||||
|               ), | ||||
|               onTap: () { | ||||
|                 AutoRouter.of(context).push(AlbumViewerRoute(albumId: sharedAlbums[index].id)); | ||||
|               }, | ||||
|             ); | ||||
|           }, | ||||
|           childCount: sharedAlbums.length, | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     _buildEmptyListIndication() { | ||||
|       return SliverToBoxAdapter( | ||||
|         child: Padding( | ||||
|           padding: const EdgeInsets.all(8.0), | ||||
|           child: Card( | ||||
|             elevation: 0, | ||||
|             shape: RoundedRectangleBorder( | ||||
|               borderRadius: BorderRadius.circular(10), // if you need this | ||||
|               side: const BorderSide( | ||||
|                 color: Colors.black12, | ||||
|                 width: 1, | ||||
|               ), | ||||
|             ), | ||||
|             color: Colors.transparent, | ||||
|             child: Padding( | ||||
|               padding: const EdgeInsets.all(18.0), | ||||
|               child: Column( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                 children: [ | ||||
|                   Padding( | ||||
|                     padding: const EdgeInsets.only(left: 5.0, bottom: 5), | ||||
|                     child: Icon( | ||||
|                       Icons.offline_share_outlined, | ||||
|                       size: 50, | ||||
|                       color: Theme.of(context).primaryColor.withAlpha(200), | ||||
|                     ), | ||||
|                   ), | ||||
|                   Padding( | ||||
|                     padding: const EdgeInsets.all(8.0), | ||||
|                     child: Text( | ||||
|                       'EMPTY LIST', | ||||
|                       style: TextStyle( | ||||
|                         fontSize: 12, | ||||
|                         color: Theme.of(context).primaryColor, | ||||
|                         fontWeight: FontWeight.bold, | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                   Padding( | ||||
|                     padding: const EdgeInsets.all(8.0), | ||||
|                     child: Text( | ||||
|                       'Create shared albums to share photos and videos with people in your network.', | ||||
|                       style: TextStyle(fontSize: 12, color: Colors.grey[700]), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return Scaffold( | ||||
|       body: CustomScrollView( | ||||
|         slivers: [ | ||||
|           const SharingSliverAppBar(), | ||||
|           const SliverPadding( | ||||
|             padding: EdgeInsets.symmetric(horizontal: 12, vertical: 12), | ||||
|             sliver: SliverToBoxAdapter( | ||||
|               child: Text( | ||||
|                 "Shared albums", | ||||
|                 style: TextStyle( | ||||
|                   fontWeight: FontWeight.bold, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|           sharedAlbums.isNotEmpty ? _buildAlbumList() : _buildEmptyListIndication() | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -1,9 +1,17 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:flutter/widgets.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:immich_mobile/modules/login/views/login_page.dart'; | ||||
| import 'package:immich_mobile/modules/home/views/home_page.dart'; | ||||
| import 'package:immich_mobile/modules/search/views/search_page.dart'; | ||||
| import 'package:immich_mobile/modules/search/views/search_result_page.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/models/asset_selection_page_result.model.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/views/album_viewer_page.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/views/asset_selection_page.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/views/create_shared_album_page.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/views/select_additional_user_for_sharing_page.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/views/select_user_for_sharing_page.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/views/sharing_page.dart'; | ||||
| import 'package:immich_mobile/routing/auth_guard.dart'; | ||||
| import 'package:immich_mobile/shared/models/immich_asset.model.dart'; | ||||
| import 'package:immich_mobile/shared/views/backup_controller_page.dart'; | ||||
| @@ -22,13 +30,31 @@ part 'router.gr.dart'; | ||||
|       guards: [AuthGuard], | ||||
|       children: [ | ||||
|         AutoRoute(page: HomePage, guards: [AuthGuard]), | ||||
|         AutoRoute(page: SearchPage, guards: [AuthGuard]) | ||||
|         AutoRoute(page: SearchPage, guards: [AuthGuard]), | ||||
|         AutoRoute(page: SharingPage, guards: [AuthGuard]) | ||||
|       ], | ||||
|     ), | ||||
|     AutoRoute(page: ImageViewerPage, guards: [AuthGuard]), | ||||
|     AutoRoute(page: VideoViewerPage, guards: [AuthGuard]), | ||||
|     AutoRoute(page: BackupControllerPage, guards: [AuthGuard]), | ||||
|     AutoRoute(page: SearchResultPage, guards: [AuthGuard]), | ||||
|     AutoRoute(page: CreateSharedAlbumPage, guards: [AuthGuard]), | ||||
|     CustomRoute<AssetSelectionPageResult?>( | ||||
|       page: AssetSelectionPage, | ||||
|       guards: [AuthGuard], | ||||
|       transitionsBuilder: TransitionsBuilders.slideBottom, | ||||
|     ), | ||||
|     CustomRoute<List<String>>( | ||||
|       page: SelectUserForSharingPage, | ||||
|       guards: [AuthGuard], | ||||
|       transitionsBuilder: TransitionsBuilders.slideBottom, | ||||
|     ), | ||||
|     AutoRoute(page: AlbumViewerPage, guards: [AuthGuard]), | ||||
|     CustomRoute<List<String>?>( | ||||
|       page: SelectAdditionalUserForSharingPage, | ||||
|       guards: [AuthGuard], | ||||
|       transitionsBuilder: TransitionsBuilders.slideBottom, | ||||
|     ), | ||||
|   ], | ||||
| ) | ||||
| class AppRouter extends _$AppRouter { | ||||
|   | ||||
| @@ -57,6 +57,42 @@ class _$AppRouter extends RootStackRouter { | ||||
|           routeData: routeData, | ||||
|           child: SearchResultPage(key: args.key, searchTerm: args.searchTerm)); | ||||
|     }, | ||||
|     CreateSharedAlbumRoute.name: (routeData) { | ||||
|       return MaterialPageX<dynamic>( | ||||
|           routeData: routeData, child: const CreateSharedAlbumPage()); | ||||
|     }, | ||||
|     AssetSelectionRoute.name: (routeData) { | ||||
|       return CustomPage<AssetSelectionPageResult?>( | ||||
|           routeData: routeData, | ||||
|           child: const AssetSelectionPage(), | ||||
|           transitionsBuilder: TransitionsBuilders.slideBottom, | ||||
|           opaque: true, | ||||
|           barrierDismissible: false); | ||||
|     }, | ||||
|     SelectUserForSharingRoute.name: (routeData) { | ||||
|       return CustomPage<List<String>>( | ||||
|           routeData: routeData, | ||||
|           child: const SelectUserForSharingPage(), | ||||
|           transitionsBuilder: TransitionsBuilders.slideBottom, | ||||
|           opaque: true, | ||||
|           barrierDismissible: false); | ||||
|     }, | ||||
|     AlbumViewerRoute.name: (routeData) { | ||||
|       final args = routeData.argsAs<AlbumViewerRouteArgs>(); | ||||
|       return MaterialPageX<dynamic>( | ||||
|           routeData: routeData, | ||||
|           child: AlbumViewerPage(key: args.key, albumId: args.albumId)); | ||||
|     }, | ||||
|     SelectAdditionalUserForSharingRoute.name: (routeData) { | ||||
|       final args = routeData.argsAs<SelectAdditionalUserForSharingRouteArgs>(); | ||||
|       return CustomPage<List<String>?>( | ||||
|           routeData: routeData, | ||||
|           child: SelectAdditionalUserForSharingPage( | ||||
|               key: args.key, albumInfo: args.albumInfo), | ||||
|           transitionsBuilder: TransitionsBuilders.slideBottom, | ||||
|           opaque: true, | ||||
|           barrierDismissible: false); | ||||
|     }, | ||||
|     HomeRoute.name: (routeData) { | ||||
|       return MaterialPageX<dynamic>( | ||||
|           routeData: routeData, child: const HomePage()); | ||||
| @@ -66,6 +102,10 @@ class _$AppRouter extends RootStackRouter { | ||||
|           orElse: () => const SearchRouteArgs()); | ||||
|       return MaterialPageX<dynamic>( | ||||
|           routeData: routeData, child: SearchPage(key: args.key)); | ||||
|     }, | ||||
|     SharingRoute.name: (routeData) { | ||||
|       return MaterialPageX<dynamic>( | ||||
|           routeData: routeData, child: const SharingPage()); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
| @@ -85,6 +125,10 @@ class _$AppRouter extends RootStackRouter { | ||||
|               RouteConfig(SearchRoute.name, | ||||
|                   path: 'search-page', | ||||
|                   parent: TabControllerRoute.name, | ||||
|                   guards: [authGuard]), | ||||
|               RouteConfig(SharingRoute.name, | ||||
|                   path: 'sharing-page', | ||||
|                   parent: TabControllerRoute.name, | ||||
|                   guards: [authGuard]) | ||||
|             ]), | ||||
|         RouteConfig(ImageViewerRoute.name, | ||||
| @@ -94,7 +138,18 @@ class _$AppRouter extends RootStackRouter { | ||||
|         RouteConfig(BackupControllerRoute.name, | ||||
|             path: '/backup-controller-page', guards: [authGuard]), | ||||
|         RouteConfig(SearchResultRoute.name, | ||||
|             path: '/search-result-page', guards: [authGuard]) | ||||
|             path: '/search-result-page', guards: [authGuard]), | ||||
|         RouteConfig(CreateSharedAlbumRoute.name, | ||||
|             path: '/create-shared-album-page', guards: [authGuard]), | ||||
|         RouteConfig(AssetSelectionRoute.name, | ||||
|             path: '/asset-selection-page', guards: [authGuard]), | ||||
|         RouteConfig(SelectUserForSharingRoute.name, | ||||
|             path: '/select-user-for-sharing-page', guards: [authGuard]), | ||||
|         RouteConfig(AlbumViewerRoute.name, | ||||
|             path: '/album-viewer-page', guards: [authGuard]), | ||||
|         RouteConfig(SelectAdditionalUserForSharingRoute.name, | ||||
|             path: '/select-additional-user-for-sharing-page', | ||||
|             guards: [authGuard]) | ||||
|       ]; | ||||
| } | ||||
|  | ||||
| @@ -223,6 +278,86 @@ class SearchResultRouteArgs { | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// generated route for | ||||
| /// [CreateSharedAlbumPage] | ||||
| class CreateSharedAlbumRoute extends PageRouteInfo<void> { | ||||
|   const CreateSharedAlbumRoute() | ||||
|       : super(CreateSharedAlbumRoute.name, path: '/create-shared-album-page'); | ||||
|  | ||||
|   static const String name = 'CreateSharedAlbumRoute'; | ||||
| } | ||||
|  | ||||
| /// generated route for | ||||
| /// [AssetSelectionPage] | ||||
| class AssetSelectionRoute extends PageRouteInfo<void> { | ||||
|   const AssetSelectionRoute() | ||||
|       : super(AssetSelectionRoute.name, path: '/asset-selection-page'); | ||||
|  | ||||
|   static const String name = 'AssetSelectionRoute'; | ||||
| } | ||||
|  | ||||
| /// generated route for | ||||
| /// [SelectUserForSharingPage] | ||||
| class SelectUserForSharingRoute extends PageRouteInfo<void> { | ||||
|   const SelectUserForSharingRoute() | ||||
|       : super(SelectUserForSharingRoute.name, | ||||
|             path: '/select-user-for-sharing-page'); | ||||
|  | ||||
|   static const String name = 'SelectUserForSharingRoute'; | ||||
| } | ||||
|  | ||||
| /// generated route for | ||||
| /// [AlbumViewerPage] | ||||
| class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> { | ||||
|   AlbumViewerRoute({Key? key, required String albumId}) | ||||
|       : super(AlbumViewerRoute.name, | ||||
|             path: '/album-viewer-page', | ||||
|             args: AlbumViewerRouteArgs(key: key, albumId: albumId)); | ||||
|  | ||||
|   static const String name = 'AlbumViewerRoute'; | ||||
| } | ||||
|  | ||||
| class AlbumViewerRouteArgs { | ||||
|   const AlbumViewerRouteArgs({this.key, required this.albumId}); | ||||
|  | ||||
|   final Key? key; | ||||
|  | ||||
|   final String albumId; | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'AlbumViewerRouteArgs{key: $key, albumId: $albumId}'; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// generated route for | ||||
| /// [SelectAdditionalUserForSharingPage] | ||||
| class SelectAdditionalUserForSharingRoute | ||||
|     extends PageRouteInfo<SelectAdditionalUserForSharingRouteArgs> { | ||||
|   SelectAdditionalUserForSharingRoute( | ||||
|       {Key? key, required SharedAlbum albumInfo}) | ||||
|       : super(SelectAdditionalUserForSharingRoute.name, | ||||
|             path: '/select-additional-user-for-sharing-page', | ||||
|             args: SelectAdditionalUserForSharingRouteArgs( | ||||
|                 key: key, albumInfo: albumInfo)); | ||||
|  | ||||
|   static const String name = 'SelectAdditionalUserForSharingRoute'; | ||||
| } | ||||
|  | ||||
| class SelectAdditionalUserForSharingRouteArgs { | ||||
|   const SelectAdditionalUserForSharingRouteArgs( | ||||
|       {this.key, required this.albumInfo}); | ||||
|  | ||||
|   final Key? key; | ||||
|  | ||||
|   final SharedAlbum albumInfo; | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'SelectAdditionalUserForSharingRouteArgs{key: $key, albumInfo: $albumInfo}'; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// generated route for | ||||
| /// [HomePage] | ||||
| class HomeRoute extends PageRouteInfo<void> { | ||||
| @@ -251,3 +386,11 @@ class SearchRouteArgs { | ||||
|     return 'SearchRouteArgs{key: $key}'; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// generated route for | ||||
| /// [SharingPage] | ||||
| class SharingRoute extends PageRouteInfo<void> { | ||||
|   const SharingRoute() : super(SharingRoute.name, path: 'sharing-page'); | ||||
|  | ||||
|   static const String name = 'SharingRoute'; | ||||
| } | ||||
|   | ||||
| @@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
|  | ||||
| import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart'; | ||||
| import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/server_info.provider.dart'; | ||||
|  | ||||
| class TabNavigationObserver extends AutoRouterObserver { | ||||
| @@ -21,7 +22,8 @@ class TabNavigationObserver extends AutoRouterObserver { | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<void> didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) async { | ||||
|   Future<void> didChangeTabRoute( | ||||
|       TabPageRoute route, TabPageRoute previousRoute) async { | ||||
|     // Perform tasks on re-visit to SearchRoute | ||||
|     if (route.name == 'SearchRoute') { | ||||
|       // Refresh Location State | ||||
| @@ -29,6 +31,10 @@ class TabNavigationObserver extends AutoRouterObserver { | ||||
|       ref.refresh(getCuratedObjectProvider); | ||||
|     } | ||||
|  | ||||
|     if (route.name == 'SharingRoute') { | ||||
|       ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); | ||||
|     } | ||||
|  | ||||
|     ref.watch(serverInfoProvider.notifier).getServerVersion(); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										60
									
								
								mobile/lib/shared/models/user_info.model.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,60 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| class UserInfo { | ||||
|   final String id; | ||||
|   final String email; | ||||
|   final String createdAt; | ||||
|  | ||||
|   UserInfo({ | ||||
|     required this.id, | ||||
|     required this.email, | ||||
|     required this.createdAt, | ||||
|   }); | ||||
|  | ||||
|   UserInfo copyWith({ | ||||
|     String? id, | ||||
|     String? email, | ||||
|     String? createdAt, | ||||
|   }) { | ||||
|     return UserInfo( | ||||
|       id: id ?? this.id, | ||||
|       email: email ?? this.email, | ||||
|       createdAt: createdAt ?? this.createdAt, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Map<String, dynamic> toMap() { | ||||
|     final result = <String, dynamic>{}; | ||||
|  | ||||
|     result.addAll({'id': id}); | ||||
|     result.addAll({'email': email}); | ||||
|     result.addAll({'createdAt': createdAt}); | ||||
|  | ||||
|     return result; | ||||
|   } | ||||
|  | ||||
|   factory UserInfo.fromMap(Map<String, dynamic> map) { | ||||
|     return UserInfo( | ||||
|       id: map['id'] ?? '', | ||||
|       email: map['email'] ?? '', | ||||
|       createdAt: map['createdAt'] ?? '', | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   String toJson() => json.encode(toMap()); | ||||
|  | ||||
|   factory UserInfo.fromJson(String source) => UserInfo.fromMap(json.decode(source)); | ||||
|  | ||||
|   @override | ||||
|   String toString() => 'UserInfo(id: $id, email: $email, createdAt: $createdAt)'; | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     if (identical(this, other)) return true; | ||||
|  | ||||
|     return other is UserInfo && other.id == id && other.email == email && other.createdAt == createdAt; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode => id.hashCode ^ email.hashCode ^ createdAt.hashCode; | ||||
| } | ||||
| @@ -72,3 +72,11 @@ final assetGroupByDateTimeProvider = StateProvider((ref) { | ||||
|   assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a)); | ||||
|   return assets.groupListsBy((element) => DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt))); | ||||
| }); | ||||
| 
 | ||||
| final assetGroupByMonthYearProvider = StateProvider((ref) { | ||||
|   var assets = ref.watch(assetProvider); | ||||
| 
 | ||||
|   assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a)); | ||||
| 
 | ||||
|   return assets.groupListsBy((element) => DateFormat('MMMM, y').format(DateTime.parse(element.createdAt))); | ||||
| }); | ||||
| @@ -3,7 +3,7 @@ import 'dart:convert'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:hive/hive.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/shared/models/immich_asset.model.dart'; | ||||
| import 'package:socket_io_client/socket_io_client.dart'; | ||||
|  | ||||
|   | ||||
| @@ -76,9 +76,10 @@ class NetworkService { | ||||
|       return res; | ||||
|     } on DioError catch (e) { | ||||
|       debugPrint("DioError: ${e.response}"); | ||||
|       return false; | ||||
|       return null; | ||||
|     } catch (e) { | ||||
|       debugPrint("ERROR BackupService: $e"); | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
							
								
								
									
										24
									
								
								mobile/lib/shared/services/user.service.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,24 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:immich_mobile/shared/models/user_info.model.dart'; | ||||
| import 'package:immich_mobile/shared/services/network.service.dart'; | ||||
|  | ||||
| class UserService { | ||||
|   final NetworkService _networkService = NetworkService(); | ||||
|  | ||||
|   Future<List<UserInfo>> getAllUsersInfo() async { | ||||
|     try { | ||||
|       Response res = await _networkService.getRequest(url: 'user'); | ||||
|       List<dynamic> decodedData = jsonDecode(res.toString()); | ||||
|       List<UserInfo> result = List.from(decodedData.map((e) => UserInfo.fromMap(e))); | ||||
|  | ||||
|       return result; | ||||
|     } catch (e) { | ||||
|       debugPrint("Error getAllUsersInfo  ${e.toString()}"); | ||||
|     } | ||||
|  | ||||
|     return []; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										24
									
								
								mobile/lib/shared/ui/immich_loading_indicator.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,24 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_spinkit/flutter_spinkit.dart'; | ||||
|  | ||||
| class ImmichLoadingIndicator extends StatelessWidget { | ||||
|   const ImmichLoadingIndicator({ | ||||
|     Key? key, | ||||
|   }) : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Container( | ||||
|       height: 60, | ||||
|       width: 60, | ||||
|       decoration: BoxDecoration( | ||||
|         color: Theme.of(context).primaryColor.withAlpha(200), | ||||
|         borderRadius: BorderRadius.circular(10), | ||||
|       ), | ||||
|       child: const SpinKitDancingSquare( | ||||
|         color: Colors.white, | ||||
|         size: 30.0, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,31 @@ | ||||
| import 'dart:math'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
|  | ||||
| class ImmichSliverPersistentAppBarDelegate extends SliverPersistentHeaderDelegate { | ||||
|   final double minHeight; | ||||
|   final double maxHeight; | ||||
|   final Widget child; | ||||
|  | ||||
|   ImmichSliverPersistentAppBarDelegate({ | ||||
|     required this.minHeight, | ||||
|     required this.maxHeight, | ||||
|     required this.child, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   double get minExtent => minHeight; | ||||
|  | ||||
|   @override | ||||
|   double get maxExtent => max(maxHeight, minHeight); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { | ||||
|     return SizedBox.expand(child: child); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool shouldRebuild(ImmichSliverPersistentAppBarDelegate oldDelegate) { | ||||
|     return maxHeight != oldDelegate.maxHeight || minHeight != oldDelegate.minHeight || child != oldDelegate.child; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										41
									
								
								mobile/lib/shared/views/immich_loading_overlay.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,41 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; | ||||
|  | ||||
| class ImmichLoadingOverlay extends StatelessWidget { | ||||
|   const ImmichLoadingOverlay({ | ||||
|     Key? key, | ||||
|   }) : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return ValueListenableBuilder<bool>( | ||||
|       valueListenable: ImmichLoadingOverlayController.appLoader.loaderShowingNotifier, | ||||
|       builder: (context, shouldShow, child) { | ||||
|         if (shouldShow) { | ||||
|           return const Scaffold( | ||||
|             backgroundColor: Colors.black54, | ||||
|             body: Center( | ||||
|               child: ImmichLoadingIndicator(), | ||||
|             ), | ||||
|           ); | ||||
|         } else { | ||||
|           return Container(); | ||||
|         } | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class ImmichLoadingOverlayController { | ||||
|   static final ImmichLoadingOverlayController appLoader = ImmichLoadingOverlayController(); | ||||
|   ValueNotifier<bool> loaderShowingNotifier = ValueNotifier(false); | ||||
|   ValueNotifier<String> loaderTextNotifier = ValueNotifier('error message'); | ||||
|  | ||||
|   void show() { | ||||
|     loaderShowingNotifier.value = true; | ||||
|   } | ||||
|  | ||||
|   void hide() { | ||||
|     loaderShowingNotifier.value = false; | ||||
|   } | ||||
| } | ||||
| @@ -15,6 +15,7 @@ class TabControllerPage extends ConsumerWidget { | ||||
|       routes: [ | ||||
|         const HomeRoute(), | ||||
|         SearchRoute(), | ||||
|         const SharingRoute(), | ||||
|       ], | ||||
|       builder: (context, child, animation) { | ||||
|         final tabsRouter = AutoTabsRouter.of(context); | ||||
| @@ -34,7 +35,8 @@ class TabControllerPage extends ConsumerWidget { | ||||
|                   }, | ||||
|                   items: const [ | ||||
|                     BottomNavigationBarItem(label: 'Photos', icon: Icon(Icons.photo)), | ||||
|                     BottomNavigationBarItem(label: 'Seach', icon: Icon(Icons.search)), | ||||
|                     BottomNavigationBarItem(label: 'Search', icon: Icon(Icons.search)), | ||||
|                     BottomNavigationBarItem(label: 'Sharing', icon: Icon(Icons.group_outlined)), | ||||
|                   ], | ||||
|                 ), | ||||
|         ); | ||||
|   | ||||
| @@ -335,6 +335,13 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "5.1.0" | ||||
|   flutter_swipe_detector: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: flutter_swipe_detector | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.0.0" | ||||
|   flutter_test: | ||||
|     dependency: "direct dev" | ||||
|     description: flutter | ||||
|   | ||||
| @@ -2,7 +2,7 @@ name: immich_mobile | ||||
| description: Immich - selfhosted backup media file on mobile phone | ||||
|  | ||||
| publish_to: "none" | ||||
| version: 1.6.0+10 | ||||
| version: 1.7.0+11 | ||||
|  | ||||
| environment: | ||||
|   sdk: ">=2.15.1 <3.0.0" | ||||
| @@ -18,7 +18,6 @@ dependencies: | ||||
|   hive_flutter: | ||||
|   dio: ^4.0.4 | ||||
|   cached_network_image: ^3.2.0 | ||||
|   # google_fonts: ^2.2.0 | ||||
|   percent_indicator: ^3.4.0 | ||||
|   intl: ^0.17.0 | ||||
|   auto_route: ^3.2.2 | ||||
| @@ -37,6 +36,7 @@ dependencies: | ||||
|   flutter_udid: ^2.0.0 | ||||
|   package_info_plus: ^1.4.0 | ||||
|   flutter_spinkit: ^5.1.0 | ||||
|   flutter_swipe_detector: ^2.0.0 | ||||
|  | ||||
| dev_dependencies: | ||||
|   flutter_test: | ||||
|   | ||||
| @@ -82,7 +82,7 @@ export class AssetController { | ||||
|     @Response({ passthrough: true }) res: Res, | ||||
|     @Query(ValidationPipe) query: ServeFileDto, | ||||
|   ) { | ||||
|     return this.assetService.downloadFile(authUser, query, res); | ||||
|     return this.assetService.downloadFile(query, res); | ||||
|   } | ||||
|  | ||||
|   @Get('/file') | ||||
| @@ -95,6 +95,11 @@ export class AssetController { | ||||
|     return this.assetService.serveFile(authUser, query, res, headers); | ||||
|   } | ||||
|  | ||||
|   @Get('/thumbnail/:assetId') | ||||
|   async getAssetThumbnail(@Param('assetId') assetId: string): Promise<StreamableFile> { | ||||
|     return await this.assetService.getAssetThumbnail(assetId); | ||||
|   } | ||||
|  | ||||
|   @Get('/allObjects') | ||||
|   async getCuratedObject(@GetAuthUser() authUser: AuthUserDto) { | ||||
|     return this.assetService.getCuratedObject(authUser); | ||||
|   | ||||
| @@ -76,10 +76,10 @@ export class AssetService { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public async findOne(authUser: AuthUserDto, deviceId: string, assetId: string): Promise<AssetEntity> { | ||||
|   public async findOne(deviceId: string, assetId: string): Promise<AssetEntity> { | ||||
|     const rows = await this.assetRepository.query( | ||||
|       'SELECT * FROM assets a WHERE a."deviceAssetId" = $1 AND a."userId" = $2 AND a."deviceId" = $3', | ||||
|       [assetId, authUser.id, deviceId], | ||||
|       'SELECT * FROM assets a WHERE a."deviceAssetId" = $1 AND a."deviceId" = $2', | ||||
|       [assetId, deviceId], | ||||
|     ); | ||||
|  | ||||
|     if (rows.lengh == 0) { | ||||
| @@ -92,16 +92,15 @@ export class AssetService { | ||||
|   public async getAssetById(authUser: AuthUserDto, assetId: string) { | ||||
|     return await this.assetRepository.findOne({ | ||||
|       where: { | ||||
|         userId: authUser.id, | ||||
|         id: assetId, | ||||
|       }, | ||||
|       relations: ['exifInfo'], | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   public async downloadFile(authUser: AuthUserDto, query: ServeFileDto, res: Res) { | ||||
|   public async downloadFile(query: ServeFileDto, res: Res) { | ||||
|     let file = null; | ||||
|     const asset = await this.findOne(authUser, query.did, query.aid); | ||||
|     const asset = await this.findOne(query.did, query.aid); | ||||
|  | ||||
|     if (query.isThumb === 'false' || !query.isThumb) { | ||||
|       file = createReadStream(asset.originalPath); | ||||
| @@ -112,10 +111,15 @@ export class AssetService { | ||||
|     return new StreamableFile(file); | ||||
|   } | ||||
|  | ||||
|   public async getAssetThumbnail(assetId: string) { | ||||
|     const asset = await this.assetRepository.findOne({ id: assetId }); | ||||
|  | ||||
|     return new StreamableFile(createReadStream(asset.resizePath)); | ||||
|   } | ||||
|  | ||||
|   public async serveFile(authUser: AuthUserDto, query: ServeFileDto, res: Res, headers: any) { | ||||
|     let file = null; | ||||
|     const asset = await this.findOne(authUser, query.did, query.aid); | ||||
|  | ||||
|     const asset = await this.findOne(query.did, query.aid); | ||||
|     if (!asset) { | ||||
|       throw new BadRequestException('Asset does not exist'); | ||||
|     } | ||||
|   | ||||
							
								
								
									
										10
									
								
								server/src/api-v1/sharing/dto/add-assets.dto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,10 @@ | ||||
| import { IsNotEmpty } from 'class-validator'; | ||||
| import { AssetEntity } from '../../asset/entities/asset.entity'; | ||||
|  | ||||
| export class AddAssetsDto { | ||||
|   @IsNotEmpty() | ||||
|   albumId: string; | ||||
|  | ||||
|   @IsNotEmpty() | ||||
|   assetIds: string[]; | ||||
| } | ||||
							
								
								
									
										9
									
								
								server/src/api-v1/sharing/dto/add-users.dto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | ||||
| import { IsNotEmpty } from 'class-validator'; | ||||
|  | ||||
| export class AddUsersDto { | ||||
|   @IsNotEmpty() | ||||
|   albumId: string; | ||||
|  | ||||
|   @IsNotEmpty() | ||||
|   sharedUserIds: string[]; | ||||
| } | ||||
							
								
								
									
										13
									
								
								server/src/api-v1/sharing/dto/create-shared-album.dto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,13 @@ | ||||
| import { IsNotEmpty, IsOptional } from 'class-validator'; | ||||
| import { AssetEntity } from '../../asset/entities/asset.entity'; | ||||
|  | ||||
| export class CreateSharedAlbumDto { | ||||
|   @IsNotEmpty() | ||||
|   albumName: string; | ||||
|  | ||||
|   @IsNotEmpty() | ||||
|   sharedWithUserIds: string[]; | ||||
|  | ||||
|   @IsOptional() | ||||
|   assetIds: string[]; | ||||
| } | ||||
							
								
								
									
										9
									
								
								server/src/api-v1/sharing/dto/remove-assets.dto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | ||||
| import { IsNotEmpty } from 'class-validator'; | ||||
|  | ||||
| export class RemoveAssetsDto { | ||||
|   @IsNotEmpty() | ||||
|   albumId: string; | ||||
|  | ||||
|   @IsNotEmpty() | ||||
|   assetIds: string[]; | ||||
| } | ||||
| @@ -0,0 +1,30 @@ | ||||
| import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; | ||||
| import { AssetEntity } from '../../asset/entities/asset.entity'; | ||||
| import { SharedAlbumEntity } from './shared-album.entity'; | ||||
|  | ||||
| @Entity('asset_shared_album') | ||||
| @Unique('PK_unique_asset_in_album', ['albumId', 'assetId']) | ||||
| export class AssetSharedAlbumEntity { | ||||
|   @PrimaryGeneratedColumn() | ||||
|   id: string; | ||||
|  | ||||
|   @Column() | ||||
|   albumId: string; | ||||
|  | ||||
|   @Column() | ||||
|   assetId: string; | ||||
|  | ||||
|   @ManyToOne(() => SharedAlbumEntity, (sharedAlbum) => sharedAlbum.sharedAssets, { | ||||
|     onDelete: 'CASCADE', | ||||
|     nullable: true, | ||||
|   }) | ||||
|   @JoinColumn({ name: 'albumId' }) | ||||
|   albumInfo: SharedAlbumEntity; | ||||
|  | ||||
|   @ManyToOne(() => AssetEntity, { | ||||
|     onDelete: 'CASCADE', | ||||
|     nullable: true, | ||||
|   }) | ||||
|   @JoinColumn({ name: 'assetId' }) | ||||
|   assetInfo: AssetEntity; | ||||
| } | ||||
							
								
								
									
										27
									
								
								server/src/api-v1/sharing/entities/shared-album.entity.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,27 @@ | ||||
| import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; | ||||
| import { AssetSharedAlbumEntity } from './asset-shared-album.entity'; | ||||
| import { UserSharedAlbumEntity } from './user-shared-album.entity'; | ||||
|  | ||||
| @Entity('shared_albums') | ||||
| export class SharedAlbumEntity { | ||||
|   @PrimaryGeneratedColumn('uuid') | ||||
|   id: string; | ||||
|  | ||||
|   @Column() | ||||
|   ownerId: string; | ||||
|  | ||||
|   @Column({ default: 'Untitled Album' }) | ||||
|   albumName: string; | ||||
|  | ||||
|   @CreateDateColumn({ type: 'timestamptz' }) | ||||
|   createdAt: string; | ||||
|  | ||||
|   @Column({ comment: 'Asset ID to be used as thumbnail', nullable: true }) | ||||
|   albumThumbnailAssetId: string; | ||||
|  | ||||
|   @OneToMany(() => UserSharedAlbumEntity, (userSharedAlbums) => userSharedAlbums.albumInfo) | ||||
|   sharedUsers: UserSharedAlbumEntity[]; | ||||
|  | ||||
|   @OneToMany(() => AssetSharedAlbumEntity, (assetSharedAlbumEntity) => assetSharedAlbumEntity.albumInfo) | ||||
|   sharedAssets: AssetSharedAlbumEntity[]; | ||||
| } | ||||
| @@ -0,0 +1,27 @@ | ||||
| import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; | ||||
| import { UserEntity } from '../../user/entities/user.entity'; | ||||
| import { SharedAlbumEntity } from './shared-album.entity'; | ||||
|  | ||||
| @Entity('user_shared_album') | ||||
| @Unique('PK_unique_user_in_album', ['albumId', 'sharedUserId']) | ||||
| export class UserSharedAlbumEntity { | ||||
|   @PrimaryGeneratedColumn() | ||||
|   id: string; | ||||
|  | ||||
|   @Column() | ||||
|   albumId: string; | ||||
|  | ||||
|   @Column() | ||||
|   sharedUserId: string; | ||||
|  | ||||
|   @ManyToOne(() => SharedAlbumEntity, (sharedAlbum) => sharedAlbum.sharedUsers, { | ||||
|     onDelete: 'CASCADE', | ||||
|     nullable: true, | ||||
|   }) | ||||
|   @JoinColumn({ name: 'albumId' }) | ||||
|   albumInfo: SharedAlbumEntity; | ||||
|  | ||||
|   @ManyToOne(() => UserEntity) | ||||
|   @JoinColumn({ name: 'sharedUserId' }) | ||||
|   userInfo: UserEntity; | ||||
| } | ||||
							
								
								
									
										55
									
								
								server/src/api-v1/sharing/sharing.controller.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,55 @@ | ||||
| import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, ValidationPipe, Query } from '@nestjs/common'; | ||||
| 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 { AddAssetsDto } from './dto/add-assets.dto'; | ||||
| import { AddUsersDto } from './dto/add-users.dto'; | ||||
| import { RemoveAssetsDto } from './dto/remove-assets.dto'; | ||||
|  | ||||
| @UseGuards(JwtAuthGuard) | ||||
| @Controller('shared') | ||||
| export class SharingController { | ||||
|   constructor(private readonly sharingService: SharingService) {} | ||||
|  | ||||
|   @Post('/createAlbum') | ||||
|   async create(@GetAuthUser() authUser, @Body(ValidationPipe) createSharedAlbumDto: CreateSharedAlbumDto) { | ||||
|     return await this.sharingService.create(authUser, createSharedAlbumDto); | ||||
|   } | ||||
|  | ||||
|   @Post('/addUsers') | ||||
|   async addUsers(@Body(ValidationPipe) addUsersDto: AddUsersDto) { | ||||
|     return await this.sharingService.addUsersToAlbum(addUsersDto); | ||||
|   } | ||||
|  | ||||
|   @Post('/addAssets') | ||||
|   async addAssets(@Body(ValidationPipe) addAssetsDto: AddAssetsDto) { | ||||
|     return await this.sharingService.addAssetsToAlbum(addAssetsDto); | ||||
|   } | ||||
|  | ||||
|   @Get('/allSharedAlbums') | ||||
|   async getAllSharedAlbums(@GetAuthUser() authUser) { | ||||
|     return await this.sharingService.getAllSharedAlbums(authUser); | ||||
|   } | ||||
|  | ||||
|   @Get('/:albumId') | ||||
|   async getAlbumInfo(@GetAuthUser() authUser, @Param('albumId') albumId: string) { | ||||
|     return await this.sharingService.getAlbumInfo(authUser, albumId); | ||||
|   } | ||||
|  | ||||
|   @Delete('/removeAssets') | ||||
|   async removeAssetFromAlbum(@GetAuthUser() authUser, @Body(ValidationPipe) removeAssetsDto: RemoveAssetsDto) { | ||||
|     console.log('removeAssets'); | ||||
|     return await this.sharingService.removeAssetsFromAlbum(authUser, removeAssetsDto); | ||||
|   } | ||||
|  | ||||
|   @Delete('/:albumId') | ||||
|   async deleteAlbum(@GetAuthUser() authUser, @Param('albumId') albumId: string) { | ||||
|     return await this.sharingService.deleteAlbum(authUser, albumId); | ||||
|   } | ||||
|  | ||||
|   @Delete('/leaveAlbum/:albumId') | ||||
|   async leaveAlbum(@GetAuthUser() authUser, @Param('albumId') albumId: string) { | ||||
|     return await this.sharingService.leaveAlbum(authUser, albumId); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										24
									
								
								server/src/api-v1/sharing/sharing.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,24 @@ | ||||
| import { Module } from '@nestjs/common'; | ||||
| import { SharingService } from './sharing.service'; | ||||
| import { SharingController } from './sharing.controller'; | ||||
| import { TypeOrmModule } from '@nestjs/typeorm'; | ||||
| import { AssetEntity } from '../asset/entities/asset.entity'; | ||||
| import { UserEntity } from '../user/entities/user.entity'; | ||||
| import { SharedAlbumEntity } from './entities/shared-album.entity'; | ||||
| import { AssetSharedAlbumEntity } from './entities/asset-shared-album.entity'; | ||||
| import { UserSharedAlbumEntity } from './entities/user-shared-album.entity'; | ||||
|  | ||||
| @Module({ | ||||
|   imports: [ | ||||
|     TypeOrmModule.forFeature([ | ||||
|       AssetEntity, | ||||
|       UserEntity, | ||||
|       SharedAlbumEntity, | ||||
|       AssetSharedAlbumEntity, | ||||
|       UserSharedAlbumEntity, | ||||
|     ]), | ||||
|   ], | ||||
|   controllers: [SharingController], | ||||
|   providers: [SharingService], | ||||
| }) | ||||
| export class SharingModule {} | ||||
							
								
								
									
										187
									
								
								server/src/api-v1/sharing/sharing.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,187 @@ | ||||
| import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; | ||||
| import { InjectRepository } from '@nestjs/typeorm'; | ||||
| import { getConnection, Repository } from 'typeorm'; | ||||
| import { AuthUserDto } from '../../decorators/auth-user.decorator'; | ||||
| import { AssetEntity } from '../asset/entities/asset.entity'; | ||||
| import { UserEntity } from '../user/entities/user.entity'; | ||||
| import { AddAssetsDto } from './dto/add-assets.dto'; | ||||
| import { CreateSharedAlbumDto } from './dto/create-shared-album.dto'; | ||||
| import { AssetSharedAlbumEntity } from './entities/asset-shared-album.entity'; | ||||
| import { SharedAlbumEntity } from './entities/shared-album.entity'; | ||||
| 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'; | ||||
|  | ||||
| @Injectable() | ||||
| export class SharingService { | ||||
|   constructor( | ||||
|     @InjectRepository(AssetEntity) | ||||
|     private assetRepository: Repository<AssetEntity>, | ||||
|  | ||||
|     @InjectRepository(UserEntity) | ||||
|     private userRepository: Repository<UserEntity>, | ||||
|  | ||||
|     @InjectRepository(SharedAlbumEntity) | ||||
|     private sharedAlbumRepository: Repository<SharedAlbumEntity>, | ||||
|  | ||||
|     @InjectRepository(AssetSharedAlbumEntity) | ||||
|     private assetSharedAlbumRepository: Repository<AssetSharedAlbumEntity>, | ||||
|  | ||||
|     @InjectRepository(UserSharedAlbumEntity) | ||||
|     private userSharedAlbumRepository: Repository<UserSharedAlbumEntity>, | ||||
|   ) {} | ||||
|  | ||||
|   async create(authUser: AuthUserDto, createSharedAlbumDto: CreateSharedAlbumDto) { | ||||
|     return await getConnection().transaction(async (transactionalEntityManager) => { | ||||
|       // Create album entity | ||||
|       const newSharedAlbum = new SharedAlbumEntity(); | ||||
|       newSharedAlbum.ownerId = authUser.id; | ||||
|       newSharedAlbum.albumName = createSharedAlbumDto.albumName; | ||||
|  | ||||
|       const sharedAlbum = await transactionalEntityManager.save(newSharedAlbum); | ||||
|  | ||||
|       // Add shared users | ||||
|       for (const sharedUserId of createSharedAlbumDto.sharedWithUserIds) { | ||||
|         const newSharedUser = new UserSharedAlbumEntity(); | ||||
|         newSharedUser.albumId = sharedAlbum.id; | ||||
|         newSharedUser.sharedUserId = sharedUserId; | ||||
|  | ||||
|         await transactionalEntityManager.save(newSharedUser); | ||||
|       } | ||||
|  | ||||
|       // Add shared assets | ||||
|       const newRecords: AssetSharedAlbumEntity[] = []; | ||||
|  | ||||
|       for (const assetId of createSharedAlbumDto.assetIds) { | ||||
|         const newAssetSharedAlbum = new AssetSharedAlbumEntity(); | ||||
|         newAssetSharedAlbum.assetId = assetId; | ||||
|         newAssetSharedAlbum.albumId = sharedAlbum.id; | ||||
|  | ||||
|         newRecords.push(newAssetSharedAlbum); | ||||
|       } | ||||
|  | ||||
|       if (!sharedAlbum.albumThumbnailAssetId && newRecords.length > 0) { | ||||
|         sharedAlbum.albumThumbnailAssetId = newRecords[0].assetId; | ||||
|         await transactionalEntityManager.save(sharedAlbum); | ||||
|       } | ||||
|  | ||||
|       await transactionalEntityManager.save([...newRecords]); | ||||
|  | ||||
|       return sharedAlbum; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get all shared album, including owned and shared one. | ||||
|    * @param authUser AuthUserDto | ||||
|    * @returns All Shared Album And Its Members | ||||
|    */ | ||||
|   async getAllSharedAlbums(authUser: AuthUserDto) { | ||||
|     const ownedAlbums = await this.sharedAlbumRepository.find({ | ||||
|       where: { ownerId: authUser.id }, | ||||
|       relations: ['sharedUsers', 'sharedUsers.userInfo'], | ||||
|     }); | ||||
|  | ||||
|     const isSharedWithAlbums = await this.userSharedAlbumRepository.find({ | ||||
|       where: { | ||||
|         sharedUserId: authUser.id, | ||||
|       }, | ||||
|       relations: ['albumInfo', 'albumInfo.sharedUsers', 'albumInfo.sharedUsers.userInfo'], | ||||
|       select: ['albumInfo'], | ||||
|     }); | ||||
|  | ||||
|     return [...ownedAlbums, ...isSharedWithAlbums.map((o) => o.albumInfo)].sort( | ||||
|       (a, b) => new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf(), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   async getAlbumInfo(authUser: AuthUserDto, albumId: string) { | ||||
|     const albumOwner = await this.sharedAlbumRepository.findOne({ where: { ownerId: authUser.id } }); | ||||
|     const personShared = await this.userSharedAlbumRepository.findOne({ | ||||
|       where: { albumId: albumId, sharedUserId: authUser.id }, | ||||
|     }); | ||||
|  | ||||
|     if (!(albumOwner || personShared)) { | ||||
|       throw new UnauthorizedException('Unauthorized Album Access'); | ||||
|     } | ||||
|  | ||||
|     const albumInfo = await this.sharedAlbumRepository.findOne({ | ||||
|       where: { id: albumId }, | ||||
|       relations: ['sharedUsers', 'sharedUsers.userInfo', 'sharedAssets', 'sharedAssets.assetInfo'], | ||||
|     }); | ||||
|  | ||||
|     if (!albumInfo) { | ||||
|       throw new NotFoundException('Album Not Found'); | ||||
|     } | ||||
|     const sortedSharedAsset = albumInfo.sharedAssets.sort( | ||||
|       (a, b) => new Date(a.assetInfo.createdAt).valueOf() - new Date(b.assetInfo.createdAt).valueOf(), | ||||
|     ); | ||||
|  | ||||
|     albumInfo.sharedAssets = sortedSharedAsset; | ||||
|  | ||||
|     return albumInfo; | ||||
|   } | ||||
|  | ||||
|   async addUsersToAlbum(addUsersDto: AddUsersDto) { | ||||
|     const newRecords: UserSharedAlbumEntity[] = []; | ||||
|  | ||||
|     for (const sharedUserId of addUsersDto.sharedUserIds) { | ||||
|       const newEntity = new UserSharedAlbumEntity(); | ||||
|       newEntity.albumId = addUsersDto.albumId; | ||||
|       newEntity.sharedUserId = sharedUserId; | ||||
|  | ||||
|       newRecords.push(newEntity); | ||||
|     } | ||||
|  | ||||
|     return await this.userSharedAlbumRepository.save([...newRecords]); | ||||
|   } | ||||
|  | ||||
|   async deleteAlbum(authUser: AuthUserDto, albumId: string) { | ||||
|     return await this.sharedAlbumRepository.delete({ id: albumId, ownerId: authUser.id }); | ||||
|   } | ||||
|  | ||||
|   async leaveAlbum(authUser: AuthUserDto, albumId: string) { | ||||
|     return await this.userSharedAlbumRepository.delete({ albumId: albumId, sharedUserId: authUser.id }); | ||||
|   } | ||||
|  | ||||
|   async removeUsersFromAlbum() {} | ||||
|  | ||||
|   async removeAssetsFromAlbum(authUser: AuthUserDto, removeAssetsDto: RemoveAssetsDto) { | ||||
|     let deleteAssetCount = 0; | ||||
|     const album = await this.sharedAlbumRepository.findOne({ id: removeAssetsDto.albumId }); | ||||
|  | ||||
|     if (album.ownerId != authUser.id) { | ||||
|       throw new BadRequestException("You don't have permission to remove assets in this album"); | ||||
|     } | ||||
|  | ||||
|     for (const assetId of removeAssetsDto.assetIds) { | ||||
|       const res = await this.assetSharedAlbumRepository.delete({ albumId: removeAssetsDto.albumId, assetId: assetId }); | ||||
|       if (res.affected == 1) deleteAssetCount++; | ||||
|     } | ||||
|  | ||||
|     return deleteAssetCount == removeAssetsDto.assetIds.length; | ||||
|   } | ||||
|  | ||||
|   async addAssetsToAlbum(addAssetsDto: AddAssetsDto) { | ||||
|     const newRecords: AssetSharedAlbumEntity[] = []; | ||||
|  | ||||
|     for (const assetId of addAssetsDto.assetIds) { | ||||
|       const newAssetSharedAlbum = new AssetSharedAlbumEntity(); | ||||
|       newAssetSharedAlbum.assetId = assetId; | ||||
|       newAssetSharedAlbum.albumId = addAssetsDto.albumId; | ||||
|  | ||||
|       newRecords.push(newAssetSharedAlbum); | ||||
|     } | ||||
|  | ||||
|     // Add album thumbnail if not exist. | ||||
|     const album = await this.sharedAlbumRepository.findOne({ id: addAssetsDto.albumId }); | ||||
|  | ||||
|     if (!album.albumThumbnailAssetId && newRecords.length > 0) { | ||||
|       album.albumThumbnailAssetId = newRecords[0].assetId; | ||||
|       await this.sharedAlbumRepository.save(album); | ||||
|     } | ||||
|  | ||||
|     return await this.assetSharedAlbumRepository.save([...newRecords]); | ||||
|   } | ||||
| } | ||||
| @@ -1,9 +1,15 @@ | ||||
| import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common'; | ||||
| import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common'; | ||||
| import { UserService } from './user.service'; | ||||
| import { CreateUserDto } from './dto/create-user.dto'; | ||||
| import { UpdateUserDto } from './dto/update-user.dto'; | ||||
| import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; | ||||
| import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; | ||||
|  | ||||
| @UseGuards(JwtAuthGuard) | ||||
| @Controller('user') | ||||
| export class UserController { | ||||
|   constructor(private readonly userService: UserService) {} | ||||
|  | ||||
|   @Get() | ||||
|   async getAllUsers(@GetAuthUser() authUser: AuthUserDto) { | ||||
|     return await this.userService.getAllUsers(authUser); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { InjectRepository } from '@nestjs/typeorm'; | ||||
| import { Repository } from 'typeorm'; | ||||
| import { Not, Repository } from 'typeorm'; | ||||
| import { AuthUserDto } from '../../decorators/auth-user.decorator'; | ||||
| import { CreateUserDto } from './dto/create-user.dto'; | ||||
| import { UpdateUserDto } from './dto/update-user.dto'; | ||||
| import { UserEntity } from './entities/user.entity'; | ||||
| @@ -11,4 +12,10 @@ export class UserService { | ||||
|     @InjectRepository(UserEntity) | ||||
|     private userRepository: Repository<UserEntity>, | ||||
|   ) {} | ||||
|  | ||||
|   async getAllUsers(authUser: AuthUserDto) { | ||||
|     return await this.userRepository.find({ | ||||
|       where: { id: Not(authUser.id) }, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -14,6 +14,7 @@ import { ImageOptimizeModule } from './modules/image-optimize/image-optimize.mod | ||||
| import { ServerInfoModule } from './api-v1/server-info/server-info.module'; | ||||
| import { BackgroundTaskModule } from './modules/background-task/background-task.module'; | ||||
| import { CommunicationModule } from './api-v1/communication/communication.module'; | ||||
| import { SharingModule } from './api-v1/sharing/sharing.module'; | ||||
|  | ||||
| @Module({ | ||||
|   imports: [ | ||||
| @@ -40,6 +41,8 @@ import { CommunicationModule } from './api-v1/communication/communication.module | ||||
|     BackgroundTaskModule, | ||||
|  | ||||
|     CommunicationModule, | ||||
|  | ||||
|     SharingModule, | ||||
|   ], | ||||
|   controllers: [], | ||||
|   providers: [], | ||||
|   | ||||
| @@ -1,11 +1,5 @@ | ||||
| import { TypeOrmModuleOptions } from '@nestjs/typeorm'; | ||||
| // import dotenv from 'dotenv'; | ||||
|  | ||||
| // const result = dotenv.config(); | ||||
|  | ||||
| // if (result.error) { | ||||
| //   console.log(result.error); | ||||
| // } | ||||
| export const databaseConfig: TypeOrmModuleOptions = { | ||||
|   type: 'postgres', | ||||
|   host: 'immich_postgres', | ||||
|   | ||||
| @@ -47,7 +47,6 @@ export const multerOption: MulterOptions = { | ||||
|     }, | ||||
|  | ||||
|     filename: (req: Request, file: Express.Multer.File, cb: any) => { | ||||
|       // console.log(req, file); | ||||
|       const fileNameUUID = randomUUID(); | ||||
|       if (file.fieldname == 'assetData') { | ||||
|         cb(null, `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`); | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
|  | ||||
| export const serverVersion = { | ||||
|   major: 1, | ||||
|   minor: 6, | ||||
|   minor: 7, | ||||
|   patch: 0, | ||||
|   build: 10, | ||||
|   build: 11, | ||||
| }; | ||||
|   | ||||
| @@ -4,13 +4,13 @@ export class AddRegionCityToExIf1646709533213 implements MigrationInterface { | ||||
|   public async up(queryRunner: QueryRunner): Promise<void> { | ||||
|     await queryRunner.query(` | ||||
|       ALTER TABLE exif | ||||
|         ADD COLUMN city varchar; | ||||
|         ADD COLUMN if not exists city varchar; | ||||
|  | ||||
|       ALTER TABLE exif | ||||
|         ADD COLUMN state varchar; | ||||
|         ADD COLUMN if not exists state varchar; | ||||
|        | ||||
|       ALTER TABLE exif | ||||
|         ADD COLUMN country varchar; | ||||
|         ADD COLUMN if not exists country varchar; | ||||
|     `); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -1,12 +1,10 @@ | ||||
| import { MigrationInterface, QueryRunner } from "typeorm"; | ||||
| import { MigrationInterface, QueryRunner } from 'typeorm'; | ||||
|  | ||||
| export class AddObjectColumnToSmartInfo1648317474768 | ||||
|   implements MigrationInterface | ||||
| { | ||||
| export class AddObjectColumnToSmartInfo1648317474768 implements MigrationInterface { | ||||
|   public async up(queryRunner: QueryRunner): Promise<void> { | ||||
|     await queryRunner.query(` | ||||
|       ALTER TABLE smart_info | ||||
|         ADD COLUMN objects text[]; | ||||
|         ADD COLUMN if not exists objects text[]; | ||||
|  | ||||
|     `); | ||||
|   } | ||||
|   | ||||
| @@ -0,0 +1,70 @@ | ||||
| import { MigrationInterface, QueryRunner } from 'typeorm'; | ||||
|  | ||||
| export class CreateSharedAlbumAndRelatedTables1649643216111 implements MigrationInterface { | ||||
|   public async up(queryRunner: QueryRunner): Promise<void> { | ||||
|     // Create shared_albums | ||||
|     await queryRunner.query(` | ||||
|     create table if not exists shared_albums | ||||
|     ( | ||||
|         id                      uuid                     default uuid_generate_v4()                  not null | ||||
|             constraint "PK_7f71c7b5bc7c87b8f94c9a93a00" | ||||
|                 primary key, | ||||
|         "ownerId"               varchar                                                              not null, | ||||
|         "albumName"             varchar                  default 'Untitled Album'::character varying not null, | ||||
|         "createdAt"             timestamp with time zone default now()                               not null, | ||||
|         "albumThumbnailAssetId" varchar | ||||
|     ); | ||||
|      | ||||
|     comment on column shared_albums."albumThumbnailAssetId" is 'Asset ID to be used as thumbnail'; | ||||
|     `); | ||||
|  | ||||
|     // Create user_shared_album | ||||
|     await queryRunner.query(` | ||||
|     create table if not exists user_shared_album | ||||
|     ( | ||||
|         id             serial | ||||
|             constraint "PK_b6562316a98845a7b3e9a25cdd0" | ||||
|                 primary key, | ||||
|         "albumId"      uuid not null | ||||
|             constraint "FK_7b3bf0f5f8da59af30519c25f18" | ||||
|                 references shared_albums | ||||
|                 on delete cascade, | ||||
|         "sharedUserId" uuid not null | ||||
|             constraint "FK_543c31211653e63e080ba882eb5" | ||||
|                 references users, | ||||
|         constraint "PK_unique_user_in_album" | ||||
|             unique ("albumId", "sharedUserId") | ||||
|     ); | ||||
|     `); | ||||
|  | ||||
|     // Create asset_shared_album | ||||
|     await queryRunner.query( | ||||
|       ` | ||||
|       create table if not exists asset_shared_album | ||||
|       ( | ||||
|           id        serial | ||||
|               constraint "PK_a34e076afbc601d81938e2c2277" | ||||
|                   primary key, | ||||
|           "albumId" uuid not null | ||||
|               constraint "FK_a8b79a84996cef6ba6a3662825d" | ||||
|                   references shared_albums | ||||
|                   on delete cascade, | ||||
|           "assetId" uuid not null | ||||
|               constraint "FK_64f2e7d68d1d1d8417acc844a4a" | ||||
|                   references assets | ||||
|                   on delete cascade, | ||||
|           constraint "UQ_a1e2734a1ce361e7a26f6b28288" | ||||
|               unique ("albumId", "assetId") | ||||
|       ); | ||||
|       `, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   public async down(queryRunner: QueryRunner): Promise<void> { | ||||
|     await queryRunner.query(` | ||||
|       drop table asset_shared_album; | ||||
|       drop table user_shared_album; | ||||
|       drop table shared_albums; | ||||
|     `); | ||||
|   } | ||||
| } | ||||