diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 443fa82d7e..906433adff 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -9,6 +9,8 @@ PODS: - FMDB (2.7.5): - FMDB/standard (= 2.7.5) - FMDB/standard (2.7.5) + - image_picker_ios (0.0.1): + - Flutter - package_info_plus (0.4.5): - Flutter - path_provider_ios (0.0.1): @@ -30,6 +32,7 @@ DEPENDENCIES: - Flutter (from `Flutter`) - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`) @@ -50,6 +53,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_udid/ios" fluttertoast: :path: ".symlinks/plugins/fluttertoast/ios" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_ios: @@ -68,6 +73,7 @@ SPEC CHECKSUMS: flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a + image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604 diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index dd1c0b4840..902925c967 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -43,6 +43,12 @@ NSPhotoLibraryAddUsageDescription We need to manage backup your photos album + NSCameraUsageDescription + We need to access the camera to let you take beautiful video using this app + + NSMicrophoneUsageDescription + We need to access the microphone to let you take beautiful video using this app + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -68,7 +74,7 @@ ITSAppUsesNonExemptEncryption - CADisableMinimumFrameDurationOnPhone - - - + CADisableMinimumFrameDurationOnPhone + + + \ No newline at end of file diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 69fdf359a5..9597930587 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -76,7 +76,7 @@ class _ImmichAppState extends ConsumerState with WidgetsBindingObserv } Future initApp() async { - WidgetsBinding.instance?.addObserver(this); + WidgetsBinding.instance.addObserver(this); } @override @@ -87,7 +87,7 @@ class _ImmichAppState extends ConsumerState with WidgetsBindingObserv @override void dispose() { - WidgetsBinding.instance?.removeObserver(this); + WidgetsBinding.instance.removeObserver(this); super.dispose(); } diff --git a/mobile/lib/modules/backup/models/hive_backup_albums.model.g.dart b/mobile/lib/modules/backup/models/hive_backup_albums.model.g.dart index d64ce4e7ba..ada7306db9 100644 Binary files a/mobile/lib/modules/backup/models/hive_backup_albums.model.g.dart and b/mobile/lib/modules/backup/models/hive_backup_albums.model.g.dart differ diff --git a/mobile/lib/modules/backup/views/backup_controller_page.dart b/mobile/lib/modules/backup/views/backup_controller_page.dart index d195b81aa5..028273e0d4 100644 --- a/mobile/lib/modules/backup/views/backup_controller_page.dart +++ b/mobile/lib/modules/backup/views/backup_controller_page.dart @@ -45,12 +45,16 @@ class BackupControllerPage extends HookConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - LinearPercentIndicator( + Padding( padding: const EdgeInsets.only(top: 8.0), - lineHeight: 5.0, - percent: backupState.serverInfo.diskUsagePercentage / 100.0, - backgroundColor: Colors.grey, - progressColor: Theme.of(context).primaryColor, + child: LinearPercentIndicator( + padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0), + barRadius: const Radius.circular(2), + lineHeight: 6.0, + percent: backupState.serverInfo.diskUsagePercentage / 100.0, + backgroundColor: Colors.grey, + progressColor: Theme.of(context).primaryColor, + ), ), Padding( padding: const EdgeInsets.only(top: 12.0), diff --git a/mobile/lib/modules/home/providers/upload_profile_image.provider.dart b/mobile/lib/modules/home/providers/upload_profile_image.provider.dart new file mode 100644 index 0000000000..d0f3103c56 --- /dev/null +++ b/mobile/lib/modules/home/providers/upload_profile_image.provider.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; + +import 'package:immich_mobile/shared/services/user.service.dart'; + +enum UploadProfileStatus { + idle, + loading, + success, + failure, +} + +class UploadProfileImageState { + // enum + final UploadProfileStatus status; + final String profileImagePath; + UploadProfileImageState({ + required this.status, + required this.profileImagePath, + }); + + UploadProfileImageState copyWith({ + UploadProfileStatus? status, + String? profileImagePath, + }) { + return UploadProfileImageState( + status: status ?? this.status, + profileImagePath: profileImagePath ?? this.profileImagePath, + ); + } + + Map toMap() { + final result = {}; + + result.addAll({'status': status.index}); + result.addAll({'profileImagePath': profileImagePath}); + + return result; + } + + factory UploadProfileImageState.fromMap(Map map) { + return UploadProfileImageState( + status: UploadProfileStatus.values[map['status'] ?? 0], + profileImagePath: map['profileImagePath'] ?? '', + ); + } + + String toJson() => json.encode(toMap()); + + factory UploadProfileImageState.fromJson(String source) => UploadProfileImageState.fromMap(json.decode(source)); + + @override + String toString() => 'UploadProfileImageState(status: $status, profileImagePath: $profileImagePath)'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is UploadProfileImageState && other.status == status && other.profileImagePath == profileImagePath; + } + + @override + int get hashCode => status.hashCode ^ profileImagePath.hashCode; +} + +class UploadProfileImageNotifier extends StateNotifier { + UploadProfileImageNotifier() + : super(UploadProfileImageState( + profileImagePath: '', + status: UploadProfileStatus.idle, + )); + + Future upload(XFile file) async { + state = state.copyWith(status: UploadProfileStatus.loading); + + var res = await UserService().uploadProfileImage(file); + + if (res != null) { + debugPrint("Succesfully upload profile image"); + state = state.copyWith(status: UploadProfileStatus.success, profileImagePath: res.profileImagePath); + return true; + } + + state = state.copyWith(status: UploadProfileStatus.failure); + return false; + } +} + +final uploadProfileImageProvider = + StateNotifierProvider(((ref) => UploadProfileImageNotifier())); diff --git a/mobile/lib/modules/home/ui/profile_drawer.dart b/mobile/lib/modules/home/ui/profile_drawer.dart index 73af9d73aa..391529413c 100644 --- a/mobile/lib/modules/home/ui/profile_drawer.dart +++ b/mobile/lib/modules/home/ui/profile_drawer.dart @@ -1,7 +1,11 @@ import 'package:auto_route/auto_route.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:image_picker/image_picker.dart'; +import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/modules/home/providers/upload_profile_image.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'; @@ -9,17 +13,21 @@ import 'package:immich_mobile/shared/models/server_info_state.model.dart'; import 'package:immich_mobile/modules/backup/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/ui/immich_loading_indicator.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'dart:math'; class ProfileDrawer extends HookConsumerWidget { const ProfileDrawer({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { + String endpoint = Hive.box(userInfoBox).get(serverEndpointKey); AuthenticationState _authState = ref.watch(authenticationProvider); ServerInfoState _serverInfoState = ref.watch(serverInfoProvider); - + final uploadProfileImageStatus = ref.watch(uploadProfileImageProvider).status; final appInfo = useState({}); + var dummmy = Random().nextInt(1024); _getPackageInfo() async { PackageInfo packageInfo = await PackageInfo.fromPlatform(); @@ -30,19 +38,74 @@ class ProfileDrawer extends HookConsumerWidget { }; } + _buildUserProfileImage() { + if (_authState.profileImagePath.isEmpty) { + return const CircleAvatar( + radius: 35, + backgroundImage: AssetImage('assets/immich-logo-no-outline.png'), + backgroundColor: Colors.transparent, + ); + } + + if (uploadProfileImageStatus == UploadProfileStatus.idle) { + if (_authState.profileImagePath.isNotEmpty) { + return CircleAvatar( + radius: 35, + backgroundImage: NetworkImage('$endpoint/user/profile-image/${_authState.userId}?d=${dummmy++}'), + backgroundColor: Colors.transparent, + ); + } else { + return const CircleAvatar( + radius: 35, + backgroundImage: AssetImage('assets/immich-logo-no-outline.png'), + backgroundColor: Colors.transparent, + ); + } + } + + if (uploadProfileImageStatus == UploadProfileStatus.success) { + return CircleAvatar( + radius: 35, + backgroundImage: NetworkImage('$endpoint/user/profile-image/${_authState.userId}?d=${dummmy++}'), + backgroundColor: Colors.transparent, + ); + } + + if (uploadProfileImageStatus == UploadProfileStatus.failure) { + return const CircleAvatar( + radius: 35, + backgroundImage: AssetImage('assets/immich-logo-no-outline.png'), + backgroundColor: Colors.transparent, + ); + } + + if (uploadProfileImageStatus == UploadProfileStatus.loading) { + return const ImmichLoadingIndicator(); + } + + return Container(); + } + + _pickUserProfileImage() async { + final XFile? image = await ImagePicker().pickImage(source: ImageSource.gallery, maxHeight: 1024, maxWidth: 1024); + + if (image != null) { + var success = await ref.watch(uploadProfileImageProvider.notifier).upload(image); + + if (success) { + ref + .watch(authenticationProvider.notifier) + .updateUserProfileImagePath(ref.read(uploadProfileImageProvider).profileImagePath); + } + } + } + useEffect(() { _getPackageInfo(); - + _buildUserProfileImage(); return null; }, []); - return Drawer( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topRight: Radius.circular(5), - bottomRight: Radius.circular(5), - ), - ), child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -51,22 +114,60 @@ class ProfileDrawer extends HookConsumerWidget { padding: EdgeInsets.zero, children: [ DrawerHeader( - decoration: BoxDecoration( - color: Colors.grey[200], + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color.fromARGB(255, 216, 219, 238), Color.fromARGB(255, 226, 230, 231)], + begin: Alignment.centerRight, + end: Alignment.centerLeft, + ), ), child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Image( - image: AssetImage('assets/immich-logo-no-outline.png'), - width: 50, - filterQuality: FilterQuality.high, + Stack( + clipBehavior: Clip.none, + children: [ + _buildUserProfileImage(), + Positioned( + bottom: 0, + right: -5, + child: GestureDetector( + onTap: _pickUserProfileImage, + child: Material( + color: Colors.grey[50], + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(50.0), + ), + child: Padding( + padding: const EdgeInsets.all(5.0), + child: Icon( + Icons.edit, + color: Theme.of(context).primaryColor, + size: 14, + ), + ), + ), + ), + ), + ], ), const Padding(padding: EdgeInsets.all(8)), Text( - _authState.userEmail, - style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold), + "${_authState.firstName} ${_authState.lastName}", + style: TextStyle( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + _authState.userEmail, + style: TextStyle(color: Colors.grey[800], fontSize: 12), + ), ) ], ), @@ -97,7 +198,15 @@ class ProfileDrawer extends HookConsumerWidget { Padding( padding: const EdgeInsets.all(8.0), child: Card( + elevation: 0, color: Colors.grey[100], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), // if you need this + side: const BorderSide( + color: Color.fromARGB(101, 201, 201, 201), + width: 1, + ), + ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8), child: Column( diff --git a/mobile/lib/modules/login/models/authentication_state.model.dart b/mobile/lib/modules/login/models/authentication_state.model.dart index bc0c868e22..87459e8c33 100644 --- a/mobile/lib/modules/login/models/authentication_state.model.dart +++ b/mobile/lib/modules/login/models/authentication_state.model.dart @@ -8,6 +8,11 @@ class AuthenticationState { final String userId; final String userEmail; final bool isAuthenticated; + final String firstName; + final String lastName; + final bool isAdmin; + final bool isFirstLogin; + final String profileImagePath; final DeviceInfoRemote deviceInfo; AuthenticationState({ @@ -16,6 +21,11 @@ class AuthenticationState { required this.userId, required this.userEmail, required this.isAuthenticated, + required this.firstName, + required this.lastName, + required this.isAdmin, + required this.isFirstLogin, + required this.profileImagePath, required this.deviceInfo, }); @@ -25,6 +35,11 @@ class AuthenticationState { String? userId, String? userEmail, bool? isAuthenticated, + String? firstName, + String? lastName, + bool? isAdmin, + bool? isFirstLoggedIn, + String? profileImagePath, DeviceInfoRemote? deviceInfo, }) { return AuthenticationState( @@ -33,24 +48,36 @@ class AuthenticationState { userId: userId ?? this.userId, userEmail: userEmail ?? this.userEmail, isAuthenticated: isAuthenticated ?? this.isAuthenticated, + firstName: firstName ?? this.firstName, + lastName: lastName ?? this.lastName, + isAdmin: isAdmin ?? this.isAdmin, + isFirstLogin: isFirstLoggedIn ?? isFirstLogin, + profileImagePath: profileImagePath ?? this.profileImagePath, deviceInfo: deviceInfo ?? this.deviceInfo, ); } @override String toString() { - return 'AuthenticationState(deviceId: $deviceId, deviceType: $deviceType, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, deviceInfo: $deviceInfo)'; + return 'AuthenticationState(deviceId: $deviceId, deviceType: $deviceType, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, firstName: $firstName, lastName: $lastName, isAdmin: $isAdmin, isFirstLoggedIn: $isFirstLogin, profileImagePath: $profileImagePath, deviceInfo: $deviceInfo)'; } Map toMap() { - return { - 'deviceId': deviceId, - 'deviceType': deviceType, - 'userId': userId, - 'userEmail': userEmail, - 'isAuthenticated': isAuthenticated, - 'deviceInfo': deviceInfo.toMap(), - }; + final result = {}; + + result.addAll({'deviceId': deviceId}); + result.addAll({'deviceType': deviceType}); + result.addAll({'userId': userId}); + result.addAll({'userEmail': userEmail}); + result.addAll({'isAuthenticated': isAuthenticated}); + result.addAll({'firstName': firstName}); + result.addAll({'lastName': lastName}); + result.addAll({'isAdmin': isAdmin}); + result.addAll({'isFirstLogin': isFirstLogin}); + result.addAll({'profileImagePath': profileImagePath}); + result.addAll({'deviceInfo': deviceInfo.toMap()}); + + return result; } factory AuthenticationState.fromMap(Map map) { @@ -60,6 +87,11 @@ class AuthenticationState { userId: map['userId'] ?? '', userEmail: map['userEmail'] ?? '', isAuthenticated: map['isAuthenticated'] ?? false, + firstName: map['firstName'] ?? '', + lastName: map['lastName'] ?? '', + isAdmin: map['isAdmin'] ?? false, + isFirstLogin: map['isFirstLogin'] ?? false, + profileImagePath: map['profileImagePath'] ?? '', deviceInfo: DeviceInfoRemote.fromMap(map['deviceInfo']), ); } @@ -78,6 +110,11 @@ class AuthenticationState { other.userId == userId && other.userEmail == userEmail && other.isAuthenticated == isAuthenticated && + other.firstName == firstName && + other.lastName == lastName && + other.isAdmin == isAdmin && + other.isFirstLogin == isFirstLogin && + other.profileImagePath == profileImagePath && other.deviceInfo == deviceInfo; } @@ -88,6 +125,11 @@ class AuthenticationState { userId.hashCode ^ userEmail.hashCode ^ isAuthenticated.hashCode ^ + firstName.hashCode ^ + lastName.hashCode ^ + isAdmin.hashCode ^ + isFirstLogin.hashCode ^ + profileImagePath.hashCode ^ deviceInfo.hashCode; } } diff --git a/mobile/lib/modules/login/models/login_response.model.dart b/mobile/lib/modules/login/models/login_response.model.dart index 3c2032a842..0b4e833a82 100644 --- a/mobile/lib/modules/login/models/login_response.model.dart +++ b/mobile/lib/modules/login/models/login_response.model.dart @@ -4,31 +4,58 @@ class LogInReponse { final String accessToken; final String userId; final String userEmail; + final String firstName; + final String lastName; + final String profileImagePath; + final bool isAdmin; + final bool isFirstLogin; LogInReponse({ required this.accessToken, required this.userId, required this.userEmail, + required this.firstName, + required this.lastName, + required this.profileImagePath, + required this.isAdmin, + required this.isFirstLogin, }); LogInReponse copyWith({ String? accessToken, String? userId, String? userEmail, + String? firstName, + String? lastName, + String? profileImagePath, + bool? isAdmin, + bool? isFirstLogin, }) { return LogInReponse( accessToken: accessToken ?? this.accessToken, userId: userId ?? this.userId, userEmail: userEmail ?? this.userEmail, + firstName: firstName ?? this.firstName, + lastName: lastName ?? this.lastName, + profileImagePath: profileImagePath ?? this.profileImagePath, + isAdmin: isAdmin ?? this.isAdmin, + isFirstLogin: isFirstLogin ?? this.isFirstLogin, ); } Map toMap() { - return { - 'accessToken': accessToken, - 'userId': userId, - 'userEmail': userEmail, - }; + final result = {}; + + result.addAll({'accessToken': accessToken}); + result.addAll({'userId': userId}); + result.addAll({'userEmail': userEmail}); + result.addAll({'firstName': firstName}); + result.addAll({'lastName': lastName}); + result.addAll({'profileImagePath': profileImagePath}); + result.addAll({'isAdmin': isAdmin}); + result.addAll({'isFirstLogin': isFirstLogin}); + + return result; } factory LogInReponse.fromMap(Map map) { @@ -36,6 +63,11 @@ class LogInReponse { accessToken: map['accessToken'] ?? '', userId: map['userId'] ?? '', userEmail: map['userEmail'] ?? '', + firstName: map['firstName'] ?? '', + lastName: map['lastName'] ?? '', + profileImagePath: map['profileImagePath'] ?? '', + isAdmin: map['isAdmin'] ?? false, + isFirstLogin: map['isFirstLogin'] ?? false, ); } @@ -44,7 +76,9 @@ class LogInReponse { factory LogInReponse.fromJson(String source) => LogInReponse.fromMap(json.decode(source)); @override - String toString() => 'LogInReponse(accessToken: $accessToken, userId: $userId, userEmail: $userEmail)'; + String toString() { + return 'LogInReponse(accessToken: $accessToken, userId: $userId, userEmail: $userEmail, firstName: $firstName, lastName: $lastName, profileImagePath: $profileImagePath, isAdmin: $isAdmin, isFirstLogin: $isFirstLogin)'; + } @override bool operator ==(Object other) { @@ -53,9 +87,23 @@ class LogInReponse { return other is LogInReponse && other.accessToken == accessToken && other.userId == userId && - other.userEmail == userEmail; + other.userEmail == userEmail && + other.firstName == firstName && + other.lastName == lastName && + other.profileImagePath == profileImagePath && + other.isAdmin == isAdmin && + other.isFirstLogin == isFirstLogin; } @override - int get hashCode => accessToken.hashCode ^ userId.hashCode ^ userEmail.hashCode; + int get hashCode { + return accessToken.hashCode ^ + userId.hashCode ^ + userEmail.hashCode ^ + firstName.hashCode ^ + lastName.hashCode ^ + profileImagePath.hashCode ^ + isAdmin.hashCode ^ + isFirstLogin.hashCode; + } } diff --git a/mobile/lib/modules/login/providers/authentication.provider.dart b/mobile/lib/modules/login/providers/authentication.provider.dart index 9882a8659c..df475b36d5 100644 --- a/mobile/lib/modules/login/providers/authentication.provider.dart +++ b/mobile/lib/modules/login/providers/authentication.provider.dart @@ -17,9 +17,14 @@ class AuthenticationNotifier extends StateNotifier { AuthenticationState( deviceId: "", deviceType: "", - isAuthenticated: false, userId: "", userEmail: "", + firstName: '', + lastName: '', + profileImagePath: '', + isAdmin: false, + isFirstLogin: false, + isAuthenticated: false, deviceInfo: DeviceInfoRemote( id: 0, userId: "", @@ -76,6 +81,11 @@ class AuthenticationNotifier extends StateNotifier { isAuthenticated: true, userId: payload.userId, userEmail: payload.userEmail, + firstName: payload.firstName, + lastName: payload.lastName, + profileImagePath: payload.profileImagePath, + isAdmin: payload.isAdmin, + isFirstLoggedIn: payload.isFirstLogin, ); if (isSavedLoginInfo) { @@ -114,9 +124,14 @@ class AuthenticationNotifier extends StateNotifier { state = AuthenticationState( deviceId: "", deviceType: "", - isAuthenticated: false, userId: "", userEmail: "", + firstName: '', + lastName: '', + profileImagePath: '', + isFirstLogin: false, + isAuthenticated: false, + isAdmin: false, deviceInfo: DeviceInfoRemote( id: 0, userId: "", @@ -139,6 +154,10 @@ class AuthenticationNotifier extends StateNotifier { DeviceInfoRemote deviceInfoRemote = await _backupService.setAutoBackup(backupState, deviceId, deviceType); state = state.copyWith(deviceInfo: deviceInfoRemote); } + + updateUserProfileImagePath(String path) { + state = state.copyWith(profileImagePath: path); + } } final authenticationProvider = StateNotifierProvider((ref) { diff --git a/mobile/lib/shared/models/upload_profile_image_repsonse.model.dart b/mobile/lib/shared/models/upload_profile_image_repsonse.model.dart new file mode 100644 index 0000000000..da4a2f6bf3 --- /dev/null +++ b/mobile/lib/shared/models/upload_profile_image_repsonse.model.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; + +class UploadProfileImageResponse { + final String userId; + final String profileImagePath; + UploadProfileImageResponse({ + required this.userId, + required this.profileImagePath, + }); + + UploadProfileImageResponse copyWith({ + String? userId, + String? profileImagePath, + }) { + return UploadProfileImageResponse( + userId: userId ?? this.userId, + profileImagePath: profileImagePath ?? this.profileImagePath, + ); + } + + Map toMap() { + final result = {}; + + result.addAll({'userId': userId}); + result.addAll({'profileImagePath': profileImagePath}); + + return result; + } + + factory UploadProfileImageResponse.fromMap(Map map) { + return UploadProfileImageResponse( + userId: map['userId'] ?? '', + profileImagePath: map['profileImagePath'] ?? '', + ); + } + + String toJson() => json.encode(toMap()); + + factory UploadProfileImageResponse.fromJson(String source) => UploadProfileImageResponse.fromMap(json.decode(source)); + + @override + String toString() => 'UploadProfileImageReponse(userId: $userId, profileImagePath: $profileImagePath)'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is UploadProfileImageResponse && other.userId == userId && other.profileImagePath == profileImagePath; + } + + @override + int get hashCode => userId.hashCode ^ profileImagePath.hashCode; +} diff --git a/mobile/lib/shared/services/user.service.dart b/mobile/lib/shared/services/user.service.dart index cbc5f7d94a..ca259c5f0f 100644 --- a/mobile/lib/shared/services/user.service.dart +++ b/mobile/lib/shared/services/user.service.dart @@ -2,8 +2,15 @@ import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/shared/models/upload_profile_image_repsonse.model.dart'; import 'package:immich_mobile/shared/models/user_info.model.dart'; import 'package:immich_mobile/shared/services/network.service.dart'; +import 'package:immich_mobile/utils/dio_http_interceptor.dart'; +import 'package:immich_mobile/utils/files_helper.dart'; +import 'package:http_parser/http_parser.dart'; class UserService { final NetworkService _networkService = NetworkService(); @@ -21,4 +28,39 @@ class UserService { return []; } + + Future uploadProfileImage(XFile image) async { + var dio = Dio(); + dio.interceptors.add(AuthenticatedRequestInterceptor()); + String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); + var mimeType = FileHelper.getMimeType(image.path); + + final imageData = MultipartFile.fromBytes( + await image.readAsBytes(), + filename: image.name, + contentType: MediaType( + mimeType["type"], + mimeType["subType"], + ), + ); + + final formData = FormData.fromMap({'file': imageData}); + + try { + Response res = await dio.post( + '$savedEndpoint/user/profile-image', + data: formData, + ); + + var payload = UploadProfileImageResponse.fromJson(res.toString()); + + return payload; + } on DioError catch (e) { + debugPrint("Error uploading file: ${e.response}"); + return null; + } catch (e) { + debugPrint("Error uploading file: $e"); + return null; + } + } } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 938bfbad57..f63caf2fc2 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -42,14 +42,14 @@ packages: name: auto_route url: "https://pub.dartlang.org" source: hosted - version: "3.2.4" + version: "4.0.1" auto_route_generator: dependency: "direct dev" description: name: auto_route_generator url: "https://pub.dartlang.org" source: hosted - version: "3.2.3" + version: "4.0.0" badges: dependency: "direct main" description: @@ -126,7 +126,7 @@ packages: name: cached_network_image url: "https://pub.dartlang.org" source: hosted - version: "3.2.0" + version: "3.2.1" cached_network_image_platform_interface: dependency: transitive description: @@ -197,6 +197,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.1" + cross_file: + dependency: transitive + description: + name: cross_file + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.3+1" crypto: dependency: transitive description: @@ -321,6 +328,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.14.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.6" flutter_riverpod: dependency: transitive description: @@ -393,7 +407,7 @@ packages: name: hive url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.2.1" hive_flutter: dependency: "direct main" description: @@ -450,6 +464,41 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.1.3" + image_picker: + dependency: "direct main" + description: + name: image_picker + url: "https://pub.dartlang.org" + source: hosted + version: "0.8.5+3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + url: "https://pub.dartlang.org" + source: hosted + version: "0.8.4+13" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.8" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + url: "https://pub.dartlang.org" + source: hosted + version: "0.8.5+5" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.5.0" intl: dependency: "direct main" description: @@ -673,7 +722,7 @@ packages: name: percent_indicator url: "https://pub.dartlang.org" source: hosted - version: "3.4.0" + version: "4.2.2" petitparser: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 268e84ce95..c0c840bbc8 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: "none" -version: 1.9.1+14 +version: 1.10.0+15 environment: sdk: ">=2.15.1 <3.0.0" @@ -14,13 +14,13 @@ dependencies: photo_manager: ^2.0.6 flutter_hooks: ^0.18.0 hooks_riverpod: ^2.0.0-dev.0 - hive: - hive_flutter: + hive: ^2.2.1 + hive_flutter: ^1.1.0 dio: ^4.0.4 - cached_network_image: ^3.2.0 - percent_indicator: ^3.4.0 + cached_network_image: ^3.2.1 + percent_indicator: ^4.2.2 intl: ^0.17.0 - auto_route: ^3.2.2 + auto_route: ^4.0.1 exif: ^3.1.1 transparent_image: ^2.0.0 visibility_detector: ^0.2.2 @@ -38,6 +38,7 @@ dependencies: flutter_spinkit: ^5.1.0 flutter_swipe_detector: ^2.0.0 equatable: ^2.0.3 + image_picker: ^0.8.5+3 dev_dependencies: flutter_test: @@ -45,7 +46,7 @@ dev_dependencies: flutter_lints: ^1.0.0 hive_generator: ^1.1.2 build_runner: ^2.1.7 - auto_route_generator: ^3.2.1 + auto_route_generator: ^4.0.0 flutter: uses-material-design: true diff --git a/server/src/api-v1/user/user.service.ts b/server/src/api-v1/user/user.service.ts index 2cf0fac725..e747b521f0 100644 --- a/server/src/api-v1/user/user.service.ts +++ b/server/src/api-v1/user/user.service.ts @@ -7,7 +7,7 @@ import { UpdateUserDto } from './dto/update-user.dto'; import { UserEntity } from './entities/user.entity'; import * as bcrypt from 'bcrypt'; import sharp from 'sharp'; -import { createReadStream } from 'fs'; +import { createReadStream, unlink, unlinkSync } from 'fs'; import { Response as Res } from 'express'; @Injectable() @@ -129,25 +129,14 @@ export class UserService { async createProfileImage(authUser: AuthUserDto, fileInfo: Express.Multer.File) { try { - // Convert file to jpeg - let filePath = '' - const convertImageInfo = await sharp(fileInfo.path).webp().resize(512, 512).toFile(fileInfo.path + '.webp') + await this.userRepository.update(authUser.id, { + profileImagePath: fileInfo.path + }) - if (convertImageInfo) { - filePath = fileInfo.path + '.webp'; - await this.userRepository.update(authUser.id, { - profileImagePath: filePath - }) - } else { - filePath = fileInfo.path; - await this.userRepository.update(authUser.id, { - profileImagePath: filePath - }) - } return { userId: authUser.id, - profileImagePath: filePath + profileImagePath: fileInfo.path }; } catch (e) { Logger.error(e, 'Create User Profile Image'); @@ -156,10 +145,22 @@ export class UserService { } async getUserProfileImage(userId: string, res: Res) { - const user = await this.userRepository.findOne({ id: userId }) - res.set({ - 'Content-Type': 'image/webp', - }); - return new StreamableFile(createReadStream(user.profileImagePath)); + try { + const user = await this.userRepository.findOne({ id: userId }) + if (!user.profileImagePath) { + console.log("empty return") + throw new BadRequestException('User does not have a profile image'); + } + + res.set({ + 'Content-Type': 'image/jpeg', + }); + + const fileStream = createReadStream(user.profileImagePath) + return new StreamableFile(fileStream); + } catch (e) { + console.log("error getting user profile") + } + } } diff --git a/server/src/config/profile-image-upload.config.ts b/server/src/config/profile-image-upload.config.ts index 7ce3e648b7..29d8be5d56 100644 --- a/server/src/config/profile-image-upload.config.ts +++ b/server/src/config/profile-image-upload.config.ts @@ -19,6 +19,7 @@ export const profileImageUploadOption: MulterOptions = { destination: (req: Request, file: Express.Multer.File, cb: any) => { const basePath = APP_UPLOAD_LOCATION; const profileImageLocation = `${basePath}/${req.user['id']}/profile`; + if (!existsSync(profileImageLocation)) { mkdirSync(profileImageLocation, { recursive: true }); } @@ -28,9 +29,10 @@ export const profileImageUploadOption: MulterOptions = { }, filename: (req: Request, file: Express.Multer.File, cb: any) => { + const userId = req.user['id']; - cb(null, `${userId}`); + cb(null, `${userId}${extname(file.originalname)}`); }, }), }; diff --git a/web/src/lib/components/shared/navigation-bar.svelte b/web/src/lib/components/shared/navigation-bar.svelte index 2bf8425330..8e3cf47798 100644 --- a/web/src/lib/components/shared/navigation-bar.svelte +++ b/web/src/lib/components/shared/navigation-bar.svelte @@ -1,12 +1,20 @@