diff --git a/README.md b/README.md index cd5c644a3..6d7957629 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,9 @@ Loading ~4000 images/videos

- - + + +

@@ -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. + +[Get it on F-Droid](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 diff --git a/design/home-screen.jpeg b/design/home-screen.jpeg new file mode 100644 index 000000000..7871ed6f9 Binary files /dev/null and b/design/home-screen.jpeg differ diff --git a/design/search-screen.jpeg b/design/search-screen.jpeg new file mode 100644 index 000000000..e43fb897e Binary files /dev/null and b/design/search-screen.jpeg differ diff --git a/design/shared-albums.png b/design/shared-albums.png new file mode 100644 index 000000000..a0b139b84 Binary files /dev/null and b/design/shared-albums.png differ diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 16938140f..a70078f31 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -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 diff --git a/docker/docker-compose.gpu.yml b/docker/docker-compose.gpu.yml index bbdaf497c..e37c6f8a0 100644 --- a/docker/docker-compose.gpu.yml +++ b/docker/docker-compose.gpu.yml @@ -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 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 74fc4fd28..2e292ed8f 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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 diff --git a/docker/settings/nginx-conf/nginx.conf b/docker/settings/nginx-conf/nginx.conf index 8d53113a0..73fdd77ec 100644 --- a/docker/settings/nginx-conf/nginx.conf +++ b/docker/settings/nginx-conf/nginx.conf @@ -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; diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 2f7ca021f..2a79dedc7 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -81,4 +81,5 @@ flutter { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'com.android.support:multidex:1.0.3' } diff --git a/mobile/android/fastlane/metadata/android/en-US/changelogs/11.txt b/mobile/android/fastlane/metadata/android/en-US/changelogs/11.txt new file mode 100644 index 000000000..f398e9b56 --- /dev/null +++ b/mobile/android/fastlane/metadata/android/en-US/changelogs/11.txt @@ -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. \ No newline at end of file diff --git a/mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/2_en-US.png b/mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/2_en-US.png index 83350376c..36f586014 100644 Binary files a/mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/2_en-US.png and b/mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/2_en-US.png differ diff --git a/mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/4_en-US.png b/mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/4_en-US.png index f5a9e490b..0e2151cc1 100644 Binary files a/mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/4_en-US.png and b/mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/4_en-US.png differ diff --git a/mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/5_en-US.png b/mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/5_en-US.png index dabf1f16c..4111a86c9 100644 Binary files a/mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/5_en-US.png and b/mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/5_en-US.png differ diff --git a/mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/6_en-US.png b/mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/6_en-US.png new file mode 100644 index 000000000..f5a9e490b Binary files /dev/null and b/mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/6_en-US.png differ diff --git a/mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/7_en-US.png b/mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/7_en-US.png new file mode 100644 index 000000000..dabf1f16c Binary files /dev/null and b/mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/7_en-US.png differ diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 232a109eb..98b94617c 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -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, diff --git a/mobile/lib/constants/immich_colors.dart b/mobile/lib/constants/immich_colors.dart new file mode 100644 index 000000000..f93ae3736 --- /dev/null +++ b/mobile/lib/constants/immich_colors.dart @@ -0,0 +1,3 @@ +import 'package:flutter/material.dart'; + +const immichBackgroundColor = Color(0xFFf6f8fe); diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 9a6254f49..3d2d71e58 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -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,28 +87,33 @@ class _ImmichAppState extends ConsumerState with WidgetsBindingObserv @override Widget build(BuildContext context) { - return MaterialApp.router( - title: 'Immich', + return MaterialApp( 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), - appBarTheme: const AppBarTheme( - backgroundColor: Colors.white, - foregroundColor: Colors.indigo, - elevation: 1, - centerTitle: true, - systemOverlayStyle: SystemUiOverlayStyle.dark, - ), + home: Stack( + children: [ + MaterialApp.router( + title: 'Immich', + debugShowCheckedModeBanner: false, + theme: ThemeData( + brightness: Brightness.light, + primarySwatch: Colors.indigo, + fontFamily: 'WorkSans', + snackBarTheme: const SnackBarThemeData(contentTextStyle: TextStyle(fontFamily: 'WorkSans')), + scaffoldBackgroundColor: immichBackgroundColor, + appBarTheme: const AppBarTheme( + backgroundColor: immichBackgroundColor, + foregroundColor: Colors.indigo, + elevation: 1, + centerTitle: true, + systemOverlayStyle: SystemUiOverlayStyle.dark, + ), + ), + routeInformationParser: _immichRouter.defaultRouteParser(), + routerDelegate: _immichRouter.delegate(navigatorObservers: () => [TabNavigationObserver(ref: ref)]), + ), + const ImmichLoadingOverlay(), + ], ), - routeInformationParser: _immichRouter.defaultRouteParser(), - routerDelegate: _immichRouter.delegate(navigatorObservers: () => [TabNavigationObserver(ref: ref)]), ); } } diff --git a/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart b/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart index 856e4c376..6dd7fd145 100644 --- a/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart +++ b/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart @@ -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,6 +37,18 @@ class ImageViewerPage extends HookConsumerWidget { assetDetail = await _assetService.getAssetById(asset.id); } + showInfo() { + showModalBottomSheet( + backgroundColor: Colors.black, + barrierColor: Colors.transparent, + isScrollControlled: false, + context: context, + builder: (context) { + return ExifBottomSheet(assetDetail: assetDetail!); + }, + ); + } + useEffect(() { getAssetExif(); return null; @@ -44,79 +58,77 @@ class ImageViewerPage extends HookConsumerWidget { backgroundColor: Colors.black, appBar: TopControlAppBar( asset: asset, - onMoreInfoPressed: () { - showModalBottomSheet( - backgroundColor: Colors.black, - barrierColor: Colors.transparent, - isScrollControlled: false, - context: context, - builder: (context) { - return ExifBottomSheet(assetDetail: assetDetail!); - }, - ); - }, + onMoreInfoPressed: showInfo, onDownloadPressed: () { ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context); }, ), - body: SafeArea( - child: Stack( - children: [ - Center( - child: Hero( - tag: heroTag, - child: CachedNetworkImage( - fit: BoxFit.cover, - imageUrl: imageUrl, - httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, - fadeInDuration: const Duration(milliseconds: 250), - errorWidget: (context, url, error) => ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 300), - child: Wrap( - spacing: 32, - runSpacing: 32, - alignment: WrapAlignment.center, - children: [ - const Text( - "Failed To Render Image - Possibly Corrupted Data", - textAlign: TextAlign.center, - style: TextStyle(fontSize: 16, color: Colors.white), - ), - SingleChildScrollView( - child: Text( - error.toString(), + body: SwipeDetector( + onSwipeDown: (_) { + AutoRouter.of(context).pop(); + }, + onSwipeUp: (_) { + showInfo(); + }, + child: SafeArea( + child: Stack( + children: [ + Center( + child: Hero( + tag: heroTag, + child: CachedNetworkImage( + fit: BoxFit.cover, + imageUrl: imageUrl, + httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, + fadeInDuration: const Duration(milliseconds: 250), + errorWidget: (context, url, error) => ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: Wrap( + spacing: 32, + runSpacing: 32, + alignment: WrapAlignment.center, + children: [ + const Text( + "Failed To Render Image - Possibly Corrupted Data", textAlign: TextAlign.center, - style: TextStyle(fontSize: 12, color: Colors.grey[400]), + style: TextStyle(fontSize: 16, color: Colors.white), ), - ), - ], + SingleChildScrollView( + child: Text( + error.toString(), + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Colors.grey[400]), + ), + ), + ], + ), ), + placeholder: (context, url) { + return CachedNetworkImage( + cacheKey: thumbnailUrl, + fit: BoxFit.cover, + imageUrl: thumbnailUrl, + httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, + placeholderFadeInDuration: const Duration(milliseconds: 0), + progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale( + scale: 0.2, + child: CircularProgressIndicator(value: downloadProgress.progress), + ), + errorWidget: (context, url, error) => Icon( + Icons.error, + color: Colors.grey[300], + ), + ); + }, ), - placeholder: (context, url) { - return CachedNetworkImage( - cacheKey: thumbnailUrl, - fit: BoxFit.cover, - imageUrl: thumbnailUrl, - httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, - placeholderFadeInDuration: const Duration(milliseconds: 0), - progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale( - scale: 0.2, - child: CircularProgressIndicator(value: downloadProgress.progress), - ), - errorWidget: (context, url, error) => Icon( - Icons.error, - color: Colors.grey[300], - ), - ); - }, ), ), - ), - if (downloadAssetStatus == DownloadAssetStatus.loading) - const Center( - child: DownloadLoadingIndicator(), - ), - ], + if (downloadAssetStatus == DownloadAssetStatus.loading) + const Center( + child: DownloadLoadingIndicator(), + ), + ], + ), ), ), ); diff --git a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart index 891128313..e93e83d22 100644 --- a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart +++ b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart @@ -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,32 +57,32 @@ 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( - child: Stack( - children: [ - VideoThumbnailPlayer( - url: videoUrl, - jwtToken: jwtToken, - ), - if (downloadAssetStatus == DownloadAssetStatus.loading) - const Center( - child: DownloadLoadingIndicator(), + body: SwipeDetector( + onSwipeDown: (_) { + AutoRouter.of(context).pop(); + }, + onSwipeUp: (_) { + showInfo(); + }, + child: SafeArea( + child: Stack( + children: [ + VideoThumbnailPlayer( + url: videoUrl, + jwtToken: jwtToken, ), - ], + if (downloadAssetStatus == DownloadAssetStatus.loading) + const Center( + child: DownloadLoadingIndicator(), + ), + ], + ), ), ), ); diff --git a/mobile/lib/modules/home/ui/delete_diaglog.dart b/mobile/lib/modules/home/ui/delete_diaglog.dart index 5294c7e62..af6040da8 100644 --- a/mobile/lib/modules/home/ui/delete_diaglog.dart +++ b/mobile/lib/modules/home/ui/delete_diaglog.dart @@ -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 { diff --git a/mobile/lib/modules/home/ui/image_grid.dart b/mobile/lib/modules/home/ui/image_grid.dart index 32233a794..1ac6d61ba 100644 --- a/mobile/lib/modules/home/ui/image_grid.dart +++ b/mobile/lib/modules/home/ui/image_grid.dart @@ -11,40 +11,44 @@ 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; return GestureDetector( - onTap: () {}, - child: Stack( - children: [ - ThumbnailImage(asset: assetGroup[index]), - assetType == 'IMAGE' - ? Container() - : Positioned( - top: 5, - right: 5, - child: Row( - children: [ - Text( - assetGroup[index].duration.toString().substring(0, 7), - style: const TextStyle( - color: Colors.white, - fontSize: 10, - ), - ), - const Icon( - Icons.play_circle_outline_rounded, + onTap: () {}, + child: Stack( + children: [ + ThumbnailImage(asset: assetGroup[index]), + assetType == 'IMAGE' + ? Container() + : Positioned( + top: 5, + right: 5, + child: Row( + children: [ + Text( + assetGroup[index].duration.toString().substring(0, 7), + style: const TextStyle( color: Colors.white, + fontSize: 10, ), - ], - ), - ) - ], - )); + ), + const Icon( + Icons.play_circle_outline_rounded, + color: Colors.white, + ), + ], + ), + ) + ], + ), + ); }, childCount: assetGroup.length, ), diff --git a/mobile/lib/modules/home/ui/immich_sliver_appbar.dart b/mobile/lib/modules/home/ui/immich_sliver_appbar.dart index 18fee2450..409425731 100644 --- a/mobile/lib/modules/home/ui/immich_sliver_appbar.dart +++ b/mobile/lib/modules/home/ui/immich_sliver_appbar.dart @@ -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: () { diff --git a/mobile/lib/modules/home/ui/profile_drawer.dart b/mobile/lib/modules/home/ui/profile_drawer.dart index c9a586fc1..cafab9e37 100644 --- a/mobile/lib/modules/home/ui/profile_drawer.dart +++ b/mobile/lib/modules/home/ui/profile_drawer.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/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(); diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index c96c578c9..9152c06a7 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -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'; diff --git a/mobile/lib/modules/login/ui/login_form.dart b/mobile/lib/modules/login/ui/login_form.dart index 213ca9268..9ea9adfbc 100644 --- a/mobile/lib/modules/login/ui/login_form.dart +++ b/mobile/lib/modules/login/ui/login_form.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"); diff --git a/mobile/lib/modules/search/views/search_page.dart b/mobile/lib/modules/search/views/search_page.dart index 9e8b3cd78..59866b605 100644 --- a/mobile/lib/modules/search/views/search_page.dart +++ b/mobile/lib/modules/search/views/search_page.dart @@ -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> curatedLocation = ref.watch(getCuratedLocationProvider); - AsyncValue> curatedObjects = ref.watch(getCuratedObjectProvider); + AsyncValue> curatedLocation = + ref.watch(getCuratedLocationProvider); + AsyncValue> 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(), ], ), ), diff --git a/mobile/lib/modules/sharing/models/asset_selection_page_result.model.dart b/mobile/lib/modules/sharing/models/asset_selection_page_result.model.dart new file mode 100644 index 000000000..e4c53e37f --- /dev/null +++ b/mobile/lib/modules/sharing/models/asset_selection_page_result.model.dart @@ -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 selectedNewAsset; + final Set selectedAdditionalAsset; + final bool isAlbumExist; + + AssetSelectionPageResult({ + required this.selectedNewAsset, + required this.selectedAdditionalAsset, + required this.isAlbumExist, + }); + + AssetSelectionPageResult copyWith({ + Set? selectedNewAsset, + Set? selectedAdditionalAsset, + bool? isAlbumExist, + }) { + return AssetSelectionPageResult( + selectedNewAsset: selectedNewAsset ?? this.selectedNewAsset, + selectedAdditionalAsset: selectedAdditionalAsset ?? this.selectedAdditionalAsset, + isAlbumExist: isAlbumExist ?? this.isAlbumExist, + ); + } + + Map toMap() { + final result = {}; + + 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 map) { + return AssetSelectionPageResult( + selectedNewAsset: Set.from(map['selectedNewAsset']?.map((x) => ImmichAsset.fromMap(x))), + selectedAdditionalAsset: + Set.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; +} diff --git a/mobile/lib/modules/sharing/models/asset_selection_state.model.dart b/mobile/lib/modules/sharing/models/asset_selection_state.model.dart new file mode 100644 index 000000000..33e312362 --- /dev/null +++ b/mobile/lib/modules/sharing/models/asset_selection_state.model.dart @@ -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 selectedMonths; + final Set selectedNewAssetsForAlbum; + final Set selectedAdditionalAssetsForAlbum; + final Set 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? selectedMonths, + Set? selectedNewAssetsForAlbum, + Set? selectedAdditionalAssetsForAlbum, + Set? 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 toMap() { + final result = {}; + + 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 map) { + return AssetSelectionState( + selectedMonths: Set.from(map['selectedMonths']), + selectedNewAssetsForAlbum: + Set.from(map['selectedNewAssetsForAlbum']?.map((x) => ImmichAsset.fromMap(x))), + selectedAdditionalAssetsForAlbum: + Set.from(map['selectedAdditionalAssetsForAlbum']?.map((x) => ImmichAsset.fromMap(x))), + selectedAssetsInAlbumViewer: + Set.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; + } +} diff --git a/mobile/lib/modules/sharing/models/shared_album.model.dart b/mobile/lib/modules/sharing/models/shared_album.model.dart new file mode 100644 index 000000000..e1323dbcb --- /dev/null +++ b/mobile/lib/modules/sharing/models/shared_album.model.dart @@ -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; + final List? 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, + List? 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 toMap() { + final result = {}; + + 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 map) { + return SharedAlbum( + id: map['id'] ?? '', + ownerId: map['ownerId'] ?? '', + albumName: map['albumName'] ?? '', + createdAt: map['createdAt'] ?? '', + albumThumbnailAssetId: map['albumThumbnailAssetId'], + sharedUsers: List.from(map['sharedUsers']?.map((x) => SharedUsers.fromMap(x))), + sharedAssets: map['sharedAssets'] != null + ? List.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; + } +} diff --git a/mobile/lib/modules/sharing/models/shared_asset.model.dart b/mobile/lib/modules/sharing/models/shared_asset.model.dart new file mode 100644 index 000000000..e74584ce7 --- /dev/null +++ b/mobile/lib/modules/sharing/models/shared_asset.model.dart @@ -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 toMap() { + final result = {}; + + result.addAll({'assetInfo': assetInfo.toMap()}); + + return result; + } + + factory SharedAssets.fromMap(Map 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; +} diff --git a/mobile/lib/modules/sharing/models/shared_user.model.dart b/mobile/lib/modules/sharing/models/shared_user.model.dart new file mode 100644 index 000000000..78e0398f7 --- /dev/null +++ b/mobile/lib/modules/sharing/models/shared_user.model.dart @@ -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 toMap() { + final result = {}; + + result.addAll({'id': id}); + result.addAll({'albumId': albumId}); + result.addAll({'sharedUserId': sharedUserId}); + result.addAll({'userInfo': userInfo.toMap()}); + + return result; + } + + factory SharedUsers.fromMap(Map 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; + } +} diff --git a/mobile/lib/modules/sharing/providers/album_title.provider.dart b/mobile/lib/modules/sharing/providers/album_title.provider.dart new file mode 100644 index 000000000..bf812a01d --- /dev/null +++ b/mobile/lib/modules/sharing/providers/album_title.provider.dart @@ -0,0 +1,15 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class AlbumTitleNotifier extends StateNotifier { + AlbumTitleNotifier() : super(""); + + setAlbumTitle(String title) { + state = title; + } + + clearAlbumTitle() { + state = ""; + } +} + +final albumTitleProvider = StateNotifierProvider((ref) => AlbumTitleNotifier()); diff --git a/mobile/lib/modules/sharing/providers/asset_selection.provider.dart b/mobile/lib/modules/sharing/providers/asset_selection.provider.dart new file mode 100644 index 000000000..bd8dcc4a8 --- /dev/null +++ b/mobile/lib/modules/sharing/providers/asset_selection.provider.dart @@ -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 { + 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 assetsInMonth) { + Set currentAssetList = state.selectedNewAssetsForAlbum; + Set 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 assets) { + state = state.copyWith( + selectedAdditionalAssetsForAlbum: {...state.selectedAdditionalAssetsForAlbum, ...assets}, + ); + } + + void addAllAssetsInMonth(String month, List assetsInMonth) { + state = state.copyWith( + selectedMonths: {...state.selectedMonths, month}, + selectedNewAssetsForAlbum: {...state.selectedNewAssetsForAlbum, ...assetsInMonth}, + ); + } + + void addNewAssets(List assets) { + state = state.copyWith( + selectedNewAssetsForAlbum: {...state.selectedNewAssetsForAlbum, ...assets}, + ); + } + + void removeSelectedNewAssets(List assets) { + Set currentList = state.selectedNewAssetsForAlbum; + + for (ImmichAsset asset in assets) { + currentList.removeWhere((e) => e.id == asset.id); + } + + state = state.copyWith(selectedNewAssetsForAlbum: currentList); + } + + void removeSelectedAdditionalAssets(List assets) { + Set 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 assets) { + state = state.copyWith( + selectedAssetsInAlbumViewer: {...state.selectedAssetsInAlbumViewer, ...assets}, + ); + } + + void removeAssetsInAlbumViewer(List assets) { + Set currentList = state.selectedAssetsInAlbumViewer; + + for (ImmichAsset asset in assets) { + currentList.removeWhere((e) => e.id == asset.id); + } + + state = state.copyWith(selectedAssetsInAlbumViewer: currentList); + } +} + +final assetSelectionProvider = StateNotifierProvider((ref) { + return AssetSelectionNotifier(); +}); diff --git a/mobile/lib/modules/sharing/providers/shared_album.provider.dart b/mobile/lib/modules/sharing/providers/shared_album.provider.dart new file mode 100644 index 000000000..be9f47581 --- /dev/null +++ b/mobile/lib/modules/sharing/providers/shared_album.provider.dart @@ -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> { + SharedAlbumNotifier() : super([]); + + final SharedAlbumService _sharedAlbumService = SharedAlbumService(); + + getAllSharedAlbums() async { + List sharedAlbums = await _sharedAlbumService.getAllSharedAlbum(); + + state = sharedAlbums; + } + + Future 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 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 removeAssetFromAlbum(String albumId, List assetIds) async { + var res = await _sharedAlbumService.removeAssetFromAlbum(albumId, assetIds); + + if (res) { + return true; + } else { + return false; + } + } +} + +final sharedAlbumProvider = StateNotifierProvider>((ref) { + return SharedAlbumNotifier(); +}); + +final sharedAlbumDetailProvider = FutureProvider.autoDispose.family((ref, albumId) async { + final SharedAlbumService _sharedAlbumService = SharedAlbumService(); + + return await _sharedAlbumService.getAlbumDetail(albumId); +}); diff --git a/mobile/lib/modules/sharing/providers/suggested_shared_users.provider.dart b/mobile/lib/modules/sharing/providers/suggested_shared_users.provider.dart new file mode 100644 index 000000000..64a82dfe7 --- /dev/null +++ b/mobile/lib/modules/sharing/providers/suggested_shared_users.provider.dart @@ -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>((ref) async { + UserService userService = UserService(); + + return await userService.getAllUsersInfo(); +}); diff --git a/mobile/lib/modules/sharing/services/shared_album.service.dart b/mobile/lib/modules/sharing/services/shared_album.service.dart new file mode 100644 index 000000000..88e4398a5 --- /dev/null +++ b/mobile/lib/modules/sharing/services/shared_album.service.dart @@ -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> getAllSharedAlbum() async { + try { + var res = await _networkService.getRequest(url: 'shared/allSharedAlbums'); + List decodedData = jsonDecode(res.toString()); + List result = List.from(decodedData.map((e) => SharedAlbum.fromMap(e))); + + return result; + } catch (e) { + debugPrint("Error getAllSharedAlbum ${e.toString()}"); + } + + return []; + } + + Future createSharedAlbum(String albumName, Set assets, List 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 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 addAdditionalAssetToAlbum(Set 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 addAdditionalUserToAlbum(List 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 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 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 removeAssetFromAlbum(String albumId, List 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; + } + } +} diff --git a/mobile/lib/modules/sharing/ui/album_action_outlined_button.dart b/mobile/lib/modules/sharing/ui/album_action_outlined_button.dart new file mode 100644 index 000000000..62be8e855 --- /dev/null +++ b/mobile/lib/modules/sharing/ui/album_action_outlined_button.dart @@ -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(const EdgeInsets.symmetric(vertical: 0, horizontal: 10)), + shape: MaterialStateProperty.resolveWith( + (_) => RoundedRectangleBorder( + borderRadius: BorderRadius.circular(25), + ), + ), + side: MaterialStateProperty.resolveWith( + (_) => 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, + ), + ); + } +} diff --git a/mobile/lib/modules/sharing/ui/album_title_text_field.dart b/mobile/lib/modules/sharing/ui/album_title_text_field.dart new file mode 100644 index 000000000..f777e2863 --- /dev/null +++ b/mobile/lib/modules/sharing/ui/album_title_text_field.dart @@ -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 isAlbumTitleEmpty; + final FocusNode albumTitleTextFieldFocusNode; + final TextEditingController albumTitleController; + final ValueNotifier 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, + ), + ); + } +} diff --git a/mobile/lib/modules/sharing/ui/album_viewer_appbar.dart b/mobile/lib/modules/sharing/ui/album_viewer_appbar.dart new file mode 100644 index 000000000..83a627542 --- /dev/null +++ b/mobile/lib/modules/sharing/ui/album_viewer_appbar.dart @@ -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 albumInfo, + required this.userId, + required this.albumId, + }) : _albumInfo = albumInfo, + super(key: key); + + final AsyncValue _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); +} diff --git a/mobile/lib/modules/sharing/ui/album_viewer_thumbnail.dart b/mobile/lib/modules/sharing/ui/album_viewer_thumbnail.dart new file mode 100644 index 000000000..cf7e99b71 --- /dev/null +++ b/mobile/lib/modules/sharing/ui/album_viewer_thumbnail.dart @@ -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(), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/modules/sharing/ui/asset_grid_by_month.dart b/mobile/lib/modules/sharing/ui/asset_grid_by_month.dart new file mode 100644 index 000000000..acc4749d7 --- /dev/null +++ b/mobile/lib/modules/sharing/ui/asset_grid_by_month.dart @@ -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 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, + ), + ); + } +} diff --git a/mobile/lib/modules/sharing/ui/month_group_title.dart b/mobile/lib/modules/sharing/ui/month_group_title.dart new file mode 100644 index 000000000..297127aa5 --- /dev/null +++ b/mobile/lib/modules/sharing/ui/month_group_title.dart @@ -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 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, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/modules/sharing/ui/selection_thumbnail_image.dart b/mobile/lib/modules/sharing/ui/selection_thumbnail_image.dart new file mode 100644 index 000000000..33831a658 --- /dev/null +++ b/mobile/lib/modules/sharing/ui/selection_thumbnail_image.dart @@ -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, + ), + ], + ), + ) + ], + ), + ), + ); + } +} diff --git a/mobile/lib/modules/sharing/ui/shared_album_thumbnail_image.dart b/mobile/lib/modules/sharing/ui/shared_album_thumbnail_image.dart new file mode 100644 index 000000000..01971a649 --- /dev/null +++ b/mobile/lib/modules/sharing/ui/shared_album_thumbnail_image.dart @@ -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, + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/modules/sharing/ui/sharing_sliver_appbar.dart b/mobile/lib/modules/sharing/ui/sharing_sliver_appbar.dart new file mode 100644 index 000000000..baf002927 --- /dev/null +++ b/mobile/lib/modules/sharing/ui/sharing_sliver_appbar.dart @@ -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), + ), + ), + ), + ) + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/modules/sharing/views/album_viewer_page.dart b/mobile/lib/modules/sharing/views/album_viewer_page.dart new file mode 100644 index 000000000..3077eb89e --- /dev/null +++ b/mobile/lib/modules/sharing/views/album_viewer_page.dart @@ -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 _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(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? sharedUserIds = + await AutoRouter.of(context).push?>(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(), + ), + ), + ); + } +} diff --git a/mobile/lib/modules/sharing/views/asset_selection_page.dart b/mobile/lib/modules/sharing/views/asset_selection_page.dart new file mode 100644 index 000000000..5147e71f1 --- /dev/null +++ b/mobile/lib/modules/sharing/views/asset_selection_page.dart @@ -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 _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(), + ); + } +} diff --git a/mobile/lib/modules/sharing/views/create_shared_album_page.dart b/mobile/lib/modules/sharing/views/create_shared_album_page.dart new file mode 100644 index 000000000..747ee8e9b --- /dev/null +++ b/mobile/lib/modules/sharing/views/create_shared_album_page.dart @@ -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(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(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(), + ], + ), + )); + } +} diff --git a/mobile/lib/modules/sharing/views/select_additional_user_for_sharing_page.dart b/mobile/lib/modules/sharing/views/select_additional_user_for_sharing_page.dart new file mode 100644 index 000000000..388af8180 --- /dev/null +++ b/mobile/lib/modules/sharing/views/select_additional_user_for_sharing_page.dart @@ -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> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider); + final sharedUsersList = useState>({}); + + _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 users) { + List 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(), + ), + ), + ); + } +} diff --git a/mobile/lib/modules/sharing/views/select_user_for_sharing_page.dart b/mobile/lib/modules/sharing/views/select_user_for_sharing_page.dart new file mode 100644 index 000000000..0c1168376 --- /dev/null +++ b/mobile/lib/modules/sharing/views/select_user_for_sharing_page.dart @@ -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>({}); + AsyncValue> 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 users) { + List 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(), + ), + ), + ); + } +} diff --git a/mobile/lib/modules/sharing/views/sharing_page.dart b/mobile/lib/modules/sharing/views/sharing_page.dart new file mode 100644 index 000000000..cad49e4ad --- /dev/null +++ b/mobile/lib/modules/sharing/views/sharing_page.dart @@ -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 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() + ], + ), + ); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index ee38bff71..a25d45c7b 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -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( + page: AssetSelectionPage, + guards: [AuthGuard], + transitionsBuilder: TransitionsBuilders.slideBottom, + ), + CustomRoute>( + page: SelectUserForSharingPage, + guards: [AuthGuard], + transitionsBuilder: TransitionsBuilders.slideBottom, + ), + AutoRoute(page: AlbumViewerPage, guards: [AuthGuard]), + CustomRoute?>( + page: SelectAdditionalUserForSharingPage, + guards: [AuthGuard], + transitionsBuilder: TransitionsBuilders.slideBottom, + ), ], ) class AppRouter extends _$AppRouter { diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 06c0c599b..0d4f76074 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -57,6 +57,42 @@ class _$AppRouter extends RootStackRouter { routeData: routeData, child: SearchResultPage(key: args.key, searchTerm: args.searchTerm)); }, + CreateSharedAlbumRoute.name: (routeData) { + return MaterialPageX( + routeData: routeData, child: const CreateSharedAlbumPage()); + }, + AssetSelectionRoute.name: (routeData) { + return CustomPage( + routeData: routeData, + child: const AssetSelectionPage(), + transitionsBuilder: TransitionsBuilders.slideBottom, + opaque: true, + barrierDismissible: false); + }, + SelectUserForSharingRoute.name: (routeData) { + return CustomPage>( + routeData: routeData, + child: const SelectUserForSharingPage(), + transitionsBuilder: TransitionsBuilders.slideBottom, + opaque: true, + barrierDismissible: false); + }, + AlbumViewerRoute.name: (routeData) { + final args = routeData.argsAs(); + return MaterialPageX( + routeData: routeData, + child: AlbumViewerPage(key: args.key, albumId: args.albumId)); + }, + SelectAdditionalUserForSharingRoute.name: (routeData) { + final args = routeData.argsAs(); + return CustomPage?>( + routeData: routeData, + child: SelectAdditionalUserForSharingPage( + key: args.key, albumInfo: args.albumInfo), + transitionsBuilder: TransitionsBuilders.slideBottom, + opaque: true, + barrierDismissible: false); + }, HomeRoute.name: (routeData) { return MaterialPageX( routeData: routeData, child: const HomePage()); @@ -66,6 +102,10 @@ class _$AppRouter extends RootStackRouter { orElse: () => const SearchRouteArgs()); return MaterialPageX( routeData: routeData, child: SearchPage(key: args.key)); + }, + SharingRoute.name: (routeData) { + return MaterialPageX( + 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 { + const CreateSharedAlbumRoute() + : super(CreateSharedAlbumRoute.name, path: '/create-shared-album-page'); + + static const String name = 'CreateSharedAlbumRoute'; +} + +/// generated route for +/// [AssetSelectionPage] +class AssetSelectionRoute extends PageRouteInfo { + const AssetSelectionRoute() + : super(AssetSelectionRoute.name, path: '/asset-selection-page'); + + static const String name = 'AssetSelectionRoute'; +} + +/// generated route for +/// [SelectUserForSharingPage] +class SelectUserForSharingRoute extends PageRouteInfo { + const SelectUserForSharingRoute() + : super(SelectUserForSharingRoute.name, + path: '/select-user-for-sharing-page'); + + static const String name = 'SelectUserForSharingRoute'; +} + +/// generated route for +/// [AlbumViewerPage] +class AlbumViewerRoute extends PageRouteInfo { + 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 { + 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 { @@ -251,3 +386,11 @@ class SearchRouteArgs { return 'SearchRouteArgs{key: $key}'; } } + +/// generated route for +/// [SharingPage] +class SharingRoute extends PageRouteInfo { + const SharingRoute() : super(SharingRoute.name, path: 'sharing-page'); + + static const String name = 'SharingRoute'; +} diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart index 6b813bd16..5eecc7ec3 100644 --- a/mobile/lib/routing/tab_navigation_observer.dart +++ b/mobile/lib/routing/tab_navigation_observer.dart @@ -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 didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) async { + Future 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(); } } diff --git a/mobile/lib/shared/models/user_info.model.dart b/mobile/lib/shared/models/user_info.model.dart new file mode 100644 index 000000000..dc5203f9e --- /dev/null +++ b/mobile/lib/shared/models/user_info.model.dart @@ -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 toMap() { + final result = {}; + + result.addAll({'id': id}); + result.addAll({'email': email}); + result.addAll({'createdAt': createdAt}); + + return result; + } + + factory UserInfo.fromMap(Map 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; +} diff --git a/mobile/lib/modules/home/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart similarity index 88% rename from mobile/lib/modules/home/providers/asset.provider.dart rename to mobile/lib/shared/providers/asset.provider.dart index f1553575a..401ef5f54 100644 --- a/mobile/lib/modules/home/providers/asset.provider.dart +++ b/mobile/lib/shared/providers/asset.provider.dart @@ -72,3 +72,11 @@ final assetGroupByDateTimeProvider = StateProvider((ref) { assets.sortByCompare((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((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a)); + + return assets.groupListsBy((element) => DateFormat('MMMM, y').format(DateTime.parse(element.createdAt))); +}); diff --git a/mobile/lib/shared/providers/websocket.provider.dart b/mobile/lib/shared/providers/websocket.provider.dart index 23565feb3..e5bf907f7 100644 --- a/mobile/lib/shared/providers/websocket.provider.dart +++ b/mobile/lib/shared/providers/websocket.provider.dart @@ -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'; diff --git a/mobile/lib/shared/services/network.service.dart b/mobile/lib/shared/services/network.service.dart index 45d740e4f..52260e494 100644 --- a/mobile/lib/shared/services/network.service.dart +++ b/mobile/lib/shared/services/network.service.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; } } diff --git a/mobile/lib/shared/services/user.service.dart b/mobile/lib/shared/services/user.service.dart new file mode 100644 index 000000000..cbc5f7d94 --- /dev/null +++ b/mobile/lib/shared/services/user.service.dart @@ -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> getAllUsersInfo() async { + try { + Response res = await _networkService.getRequest(url: 'user'); + List decodedData = jsonDecode(res.toString()); + List result = List.from(decodedData.map((e) => UserInfo.fromMap(e))); + + return result; + } catch (e) { + debugPrint("Error getAllUsersInfo ${e.toString()}"); + } + + return []; + } +} diff --git a/mobile/lib/shared/ui/immich_loading_indicator.dart b/mobile/lib/shared/ui/immich_loading_indicator.dart new file mode 100644 index 000000000..f5735811a --- /dev/null +++ b/mobile/lib/shared/ui/immich_loading_indicator.dart @@ -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, + ), + ); + } +} diff --git a/mobile/lib/shared/ui/immich_sliver_persistent_app_bar_delegate.dart b/mobile/lib/shared/ui/immich_sliver_persistent_app_bar_delegate.dart new file mode 100644 index 000000000..6ca358dad --- /dev/null +++ b/mobile/lib/shared/ui/immich_sliver_persistent_app_bar_delegate.dart @@ -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; + } +} diff --git a/mobile/lib/shared/views/immich_loading_overlay.dart b/mobile/lib/shared/views/immich_loading_overlay.dart new file mode 100644 index 000000000..70bfdb922 --- /dev/null +++ b/mobile/lib/shared/views/immich_loading_overlay.dart @@ -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( + 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 loaderShowingNotifier = ValueNotifier(false); + ValueNotifier loaderTextNotifier = ValueNotifier('error message'); + + void show() { + loaderShowingNotifier.value = true; + } + + void hide() { + loaderShowingNotifier.value = false; + } +} diff --git a/mobile/lib/shared/views/tab_controller_page.dart b/mobile/lib/shared/views/tab_controller_page.dart index f699e1cca..2ff1d04de 100644 --- a/mobile/lib/shared/views/tab_controller_page.dart +++ b/mobile/lib/shared/views/tab_controller_page.dart @@ -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)), ], ), ); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index d759ed6b5..44d267cc7 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -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 diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 4d939bd6a..751ec6c00 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -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: diff --git a/server/src/api-v1/asset/asset.controller.ts b/server/src/api-v1/asset/asset.controller.ts index 6e7f9b0ad..4d7b45a7d 100644 --- a/server/src/api-v1/asset/asset.controller.ts +++ b/server/src/api-v1/asset/asset.controller.ts @@ -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 { + return await this.assetService.getAssetThumbnail(assetId); + } + @Get('/allObjects') async getCuratedObject(@GetAuthUser() authUser: AuthUserDto) { return this.assetService.getCuratedObject(authUser); diff --git a/server/src/api-v1/asset/asset.service.ts b/server/src/api-v1/asset/asset.service.ts index 40d8664e0..1c9e2cc2d 100644 --- a/server/src/api-v1/asset/asset.service.ts +++ b/server/src/api-v1/asset/asset.service.ts @@ -76,10 +76,10 @@ export class AssetService { } } - public async findOne(authUser: AuthUserDto, deviceId: string, assetId: string): Promise { + public async findOne(deviceId: string, assetId: string): Promise { 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'); } diff --git a/server/src/api-v1/sharing/dto/add-assets.dto.ts b/server/src/api-v1/sharing/dto/add-assets.dto.ts new file mode 100644 index 000000000..a64a602fc --- /dev/null +++ b/server/src/api-v1/sharing/dto/add-assets.dto.ts @@ -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[]; +} diff --git a/server/src/api-v1/sharing/dto/add-users.dto.ts b/server/src/api-v1/sharing/dto/add-users.dto.ts new file mode 100644 index 000000000..1014218bd --- /dev/null +++ b/server/src/api-v1/sharing/dto/add-users.dto.ts @@ -0,0 +1,9 @@ +import { IsNotEmpty } from 'class-validator'; + +export class AddUsersDto { + @IsNotEmpty() + albumId: string; + + @IsNotEmpty() + sharedUserIds: string[]; +} diff --git a/server/src/api-v1/sharing/dto/create-shared-album.dto.ts b/server/src/api-v1/sharing/dto/create-shared-album.dto.ts new file mode 100644 index 000000000..5aa59cd38 --- /dev/null +++ b/server/src/api-v1/sharing/dto/create-shared-album.dto.ts @@ -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[]; +} diff --git a/server/src/api-v1/sharing/dto/remove-assets.dto.ts b/server/src/api-v1/sharing/dto/remove-assets.dto.ts new file mode 100644 index 000000000..158139c08 --- /dev/null +++ b/server/src/api-v1/sharing/dto/remove-assets.dto.ts @@ -0,0 +1,9 @@ +import { IsNotEmpty } from 'class-validator'; + +export class RemoveAssetsDto { + @IsNotEmpty() + albumId: string; + + @IsNotEmpty() + assetIds: string[]; +} diff --git a/server/src/api-v1/sharing/entities/asset-shared-album.entity.ts b/server/src/api-v1/sharing/entities/asset-shared-album.entity.ts new file mode 100644 index 000000000..69f4f7804 --- /dev/null +++ b/server/src/api-v1/sharing/entities/asset-shared-album.entity.ts @@ -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; +} diff --git a/server/src/api-v1/sharing/entities/shared-album.entity.ts b/server/src/api-v1/sharing/entities/shared-album.entity.ts new file mode 100644 index 000000000..e8f27fcbc --- /dev/null +++ b/server/src/api-v1/sharing/entities/shared-album.entity.ts @@ -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[]; +} diff --git a/server/src/api-v1/sharing/entities/user-shared-album.entity.ts b/server/src/api-v1/sharing/entities/user-shared-album.entity.ts new file mode 100644 index 000000000..b3041e07c --- /dev/null +++ b/server/src/api-v1/sharing/entities/user-shared-album.entity.ts @@ -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; +} diff --git a/server/src/api-v1/sharing/sharing.controller.ts b/server/src/api-v1/sharing/sharing.controller.ts new file mode 100644 index 000000000..4d83fb869 --- /dev/null +++ b/server/src/api-v1/sharing/sharing.controller.ts @@ -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); + } +} diff --git a/server/src/api-v1/sharing/sharing.module.ts b/server/src/api-v1/sharing/sharing.module.ts new file mode 100644 index 000000000..04b511e7d --- /dev/null +++ b/server/src/api-v1/sharing/sharing.module.ts @@ -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 {} diff --git a/server/src/api-v1/sharing/sharing.service.ts b/server/src/api-v1/sharing/sharing.service.ts new file mode 100644 index 000000000..a249223aa --- /dev/null +++ b/server/src/api-v1/sharing/sharing.service.ts @@ -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, + + @InjectRepository(UserEntity) + private userRepository: Repository, + + @InjectRepository(SharedAlbumEntity) + private sharedAlbumRepository: Repository, + + @InjectRepository(AssetSharedAlbumEntity) + private assetSharedAlbumRepository: Repository, + + @InjectRepository(UserSharedAlbumEntity) + private userSharedAlbumRepository: Repository, + ) {} + + 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]); + } +} diff --git a/server/src/api-v1/user/user.controller.ts b/server/src/api-v1/user/user.controller.ts index 4b8420e6b..473eb84c9 100644 --- a/server/src/api-v1/user/user.controller.ts +++ b/server/src/api-v1/user/user.controller.ts @@ -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); + } } diff --git a/server/src/api-v1/user/user.service.ts b/server/src/api-v1/user/user.service.ts index 6ca580934..614ee4cd8 100644 --- a/server/src/api-v1/user/user.service.ts +++ b/server/src/api-v1/user/user.service.ts @@ -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, ) {} + + async getAllUsers(authUser: AuthUserDto) { + return await this.userRepository.find({ + where: { id: Not(authUser.id) }, + }); + } } diff --git a/server/src/app.module.ts b/server/src/app.module.ts index f62b38de6..a768bca92 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -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: [], diff --git a/server/src/config/database.config.ts b/server/src/config/database.config.ts index 71c94064c..dec963812 100644 --- a/server/src/config/database.config.ts +++ b/server/src/config/database.config.ts @@ -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', diff --git a/server/src/config/multer-option.config.ts b/server/src/config/multer-option.config.ts index fea33e5dc..263e162b4 100644 --- a/server/src/config/multer-option.config.ts +++ b/server/src/config/multer-option.config.ts @@ -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()}`); diff --git a/server/src/constants/server_version.constant.ts b/server/src/constants/server_version.constant.ts index 031140ec0..21c3be576 100644 --- a/server/src/constants/server_version.constant.ts +++ b/server/src/constants/server_version.constant.ts @@ -3,7 +3,7 @@ export const serverVersion = { major: 1, - minor: 6, + minor: 7, patch: 0, - build: 10, + build: 11, }; diff --git a/server/src/migration/1646709533213-AddRegionCityToExIf.ts b/server/src/migration/1646709533213-AddRegionCityToExIf.ts index 9b753cdcf..e2d226cfa 100644 --- a/server/src/migration/1646709533213-AddRegionCityToExIf.ts +++ b/server/src/migration/1646709533213-AddRegionCityToExIf.ts @@ -4,13 +4,13 @@ export class AddRegionCityToExIf1646709533213 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { 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; `); } diff --git a/server/src/migration/1648317474768-AddObjectColumnToSmartInfo.ts b/server/src/migration/1648317474768-AddObjectColumnToSmartInfo.ts index 6016eb055..bdf3dff5d 100644 --- a/server/src/migration/1648317474768-AddObjectColumnToSmartInfo.ts +++ b/server/src/migration/1648317474768-AddObjectColumnToSmartInfo.ts @@ -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 { await queryRunner.query(` ALTER TABLE smart_info - ADD COLUMN objects text[]; + ADD COLUMN if not exists objects text[]; `); } diff --git a/server/src/migration/1649643216111-CreateSharedAlbumAndRelatedTables.ts b/server/src/migration/1649643216111-CreateSharedAlbumAndRelatedTables.ts new file mode 100644 index 000000000..ef633d6f1 --- /dev/null +++ b/server/src/migration/1649643216111-CreateSharedAlbumAndRelatedTables.ts @@ -0,0 +1,70 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateSharedAlbumAndRelatedTables1649643216111 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // 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 { + await queryRunner.query(` + drop table asset_shared_album; + drop table user_shared_album; + drop table shared_albums; + `); + } +}