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.
+
+[](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;
+ `);
+ }
+}