Browse Source

Implemented user profile upload and show on web/mobile (#191)

* Update mobile dependencies

* Added image picker

* Added mechanism to upload profile image

* Added image type to send to web

* Added styling for circle avatar

* Fixxed issue with sharp cannot resize image properly

* Finished displaying and uploading user profile

* Added user profile to web
Alex 3 years ago
parent
commit
d476b15312

+ 6 - 0
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

+ 10 - 4
mobile/ios/Runner/Info.plist

@@ -43,6 +43,12 @@
     <key>NSPhotoLibraryAddUsageDescription</key>
     <string>We need to manage backup your photos album</string>
 
+    <key>NSCameraUsageDescription</key>
+    <string>We need to access the camera to let you take beautiful video using this app</string>
+
+    <key>NSMicrophoneUsageDescription</key>
+    <string>We need to access the microphone to let you take beautiful video using this app</string>
+
     <key>UILaunchStoryboardName</key>
     <string>LaunchScreen</string>
     <key>UIMainStoryboardFile</key>
@@ -68,7 +74,7 @@
     <true />
     <key>ITSAppUsesNonExemptEncryption</key>
     <false />
-  	<key>CADisableMinimumFrameDurationOnPhone</key>
-	<true/>
-</dict>
-</plist>
+    <key>CADisableMinimumFrameDurationOnPhone</key>
+    <true />
+  </dict>
+</plist>

+ 2 - 2
mobile/lib/main.dart

@@ -76,7 +76,7 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
   }
 
   Future<void> initApp() async {
-    WidgetsBinding.instance?.addObserver(this);
+    WidgetsBinding.instance.addObserver(this);
   }
 
   @override
@@ -87,7 +87,7 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
 
   @override
   void dispose() {
-    WidgetsBinding.instance?.removeObserver(this);
+    WidgetsBinding.instance.removeObserver(this);
     super.dispose();
   }
 

+ 3 - 1
mobile/lib/modules/backup/models/hive_backup_albums.model.g.dart

@@ -38,5 +38,7 @@ class HiveBackupAlbumsAdapter extends TypeAdapter<HiveBackupAlbums> {
   @override
   bool operator ==(Object other) =>
       identical(this, other) ||
-      other is HiveBackupAlbumsAdapter && runtimeType == other.runtimeType && typeId == other.typeId;
+      other is HiveBackupAlbumsAdapter &&
+          runtimeType == other.runtimeType &&
+          typeId == other.typeId;
 }

+ 9 - 5
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),

+ 93 - 0
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<String, dynamic> toMap() {
+    final result = <String, dynamic>{};
+
+    result.addAll({'status': status.index});
+    result.addAll({'profileImagePath': profileImagePath});
+
+    return result;
+  }
+
+  factory UploadProfileImageState.fromMap(Map<String, dynamic> 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<UploadProfileImageState> {
+  UploadProfileImageNotifier()
+      : super(UploadProfileImageState(
+          profileImagePath: '',
+          status: UploadProfileStatus.idle,
+        ));
+
+  Future<bool> 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<UploadProfileImageNotifier, UploadProfileImageState>(((ref) => UploadProfileImageNotifier()));

+ 128 - 19
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(

+ 51 - 9
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<String, dynamic> toMap() {
-    return {
-      'deviceId': deviceId,
-      'deviceType': deviceType,
-      'userId': userId,
-      'userEmail': userEmail,
-      'isAuthenticated': isAuthenticated,
-      'deviceInfo': deviceInfo.toMap(),
-    };
+    final result = <String, dynamic>{};
+
+    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<String, dynamic> 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;
   }
 }

+ 56 - 8
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<String, dynamic> toMap() {
-    return {
-      'accessToken': accessToken,
-      'userId': userId,
-      'userEmail': userEmail,
-    };
+    final result = <String, dynamic>{};
+
+    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<String, dynamic> 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;
+  }
 }

+ 21 - 2
mobile/lib/modules/login/providers/authentication.provider.dart

@@ -17,9 +17,14 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
           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<AuthenticationState> {
         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<AuthenticationState> {
     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<AuthenticationState> {
     DeviceInfoRemote deviceInfoRemote = await _backupService.setAutoBackup(backupState, deviceId, deviceType);
     state = state.copyWith(deviceInfo: deviceInfoRemote);
   }
+
+  updateUserProfileImagePath(String path) {
+    state = state.copyWith(profileImagePath: path);
+  }
 }
 
 final authenticationProvider = StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) {

+ 53 - 0
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<String, dynamic> toMap() {
+    final result = <String, dynamic>{};
+
+    result.addAll({'userId': userId});
+    result.addAll({'profileImagePath': profileImagePath});
+
+    return result;
+  }
+
+  factory UploadProfileImageResponse.fromMap(Map<String, dynamic> 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;
+}

+ 42 - 0
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<UploadProfileImageResponse?> 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;
+    }
+  }
 }

+ 54 - 5
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:

+ 8 - 7
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

+ 23 - 22
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')
-
-      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
-        })
-      }
+      await this.userRepository.update(authUser.id, {
+        profileImagePath: fileInfo.path
+      })
+
 
       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")
+    }
+
   }
 }

+ 3 - 1
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)}`);
     },
   }),
 };

+ 18 - 2
web/src/lib/components/shared/navigation-bar.svelte

@@ -1,12 +1,20 @@
 <script lang="ts">
 	import { page } from '$app/stores';
 	import type { ImmichUser } from '$lib/models/immich-user';
+	import { onMount } from 'svelte';
 	import { fade } from 'svelte/transition';
+	import { serverEndpoint } from '../../constants';
 
 	export let user: ImmichUser;
 
 	let shouldShowAccountInfo = false;
+	let shouldShowProfileImage = false;
 
+	onMount(async () => {
+		const res = await fetch(`${serverEndpoint}/user/profile-image/${user.id}`);
+
+		if (res.status == 200) shouldShowProfileImage = true;
+	});
 	const getFirstLetter = (text?: string) => {
 		return text?.charAt(0).toUpperCase();
 	};
@@ -39,9 +47,17 @@
 				on:mouseleave={() => (shouldShowAccountInfo = false)}
 			>
 				<button
-					class="flex place-items-center place-content-center rounded-full bg-immich-primary/80 h-10 w-10 text-gray-100 hover:bg-immich-primary"
+					class="flex place-items-center place-content-center rounded-full bg-immich-primary/80 h-12 w-12 text-gray-100 hover:bg-immich-primary"
 				>
-					{getFirstLetter(user.firstName)}{getFirstLetter(user.lastName)}
+					{#if shouldShowProfileImage}
+						<img
+							src={`${serverEndpoint}/user/profile-image/${user.id}`}
+							alt="profile-img"
+							class="inline rounded-full h-12 w-12 object-cover shadow-md"
+						/>
+					{:else}
+						{getFirstLetter(user.firstName)}{getFirstLetter(user.lastName)}
+					{/if}
 				</button>
 
 				{#if shouldShowAccountInfo}