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