1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 15:11:58 +00:00

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
This commit is contained in:
Alex 2022-05-28 22:35:45 -05:00 committed by GitHub
parent bdf38e7668
commit d476b15312
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 576 additions and 85 deletions

View file

@ -9,6 +9,8 @@ PODS:
- FMDB (2.7.5): - FMDB (2.7.5):
- FMDB/standard (= 2.7.5) - FMDB/standard (= 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): - package_info_plus (0.4.5):
- Flutter - Flutter
- path_provider_ios (0.0.1): - path_provider_ios (0.0.1):
@ -30,6 +32,7 @@ DEPENDENCIES:
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/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`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`)
@ -50,6 +53,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_udid/ios" :path: ".symlinks/plugins/flutter_udid/ios"
fluttertoast: fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios" :path: ".symlinks/plugins/fluttertoast/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
package_info_plus: package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios" :path: ".symlinks/plugins/package_info_plus/ios"
path_provider_ios: path_provider_ios:
@ -68,6 +73,7 @@ SPEC CHECKSUMS:
flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c
fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037 fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604 photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604

View file

@ -43,6 +43,12 @@
<key>NSPhotoLibraryAddUsageDescription</key> <key>NSPhotoLibraryAddUsageDescription</key>
<string>We need to manage backup your photos album</string> <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> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
@ -68,7 +74,7 @@
<true /> <true />
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false /> <false />
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true />
</dict> </dict>
</plist> </plist>

View file

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

View file

@ -45,12 +45,16 @@ class BackupControllerPage extends HookConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
LinearPercentIndicator( Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
lineHeight: 5.0, child: LinearPercentIndicator(
percent: backupState.serverInfo.diskUsagePercentage / 100.0, padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
backgroundColor: Colors.grey, barRadius: const Radius.circular(2),
progressColor: Theme.of(context).primaryColor, lineHeight: 6.0,
percent: backupState.serverInfo.diskUsagePercentage / 100.0,
backgroundColor: Colors.grey,
progressColor: Theme.of(context).primaryColor,
),
), ),
Padding( Padding(
padding: const EdgeInsets.only(top: 12.0), padding: const EdgeInsets.only(top: 12.0),

View file

@ -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()));

View file

@ -1,7 +1,11 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.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/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/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.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/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'dart:math';
class ProfileDrawer extends HookConsumerWidget { class ProfileDrawer extends HookConsumerWidget {
const ProfileDrawer({Key? key}) : super(key: key); const ProfileDrawer({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
String endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
AuthenticationState _authState = ref.watch(authenticationProvider); AuthenticationState _authState = ref.watch(authenticationProvider);
ServerInfoState _serverInfoState = ref.watch(serverInfoProvider); ServerInfoState _serverInfoState = ref.watch(serverInfoProvider);
final uploadProfileImageStatus = ref.watch(uploadProfileImageProvider).status;
final appInfo = useState({}); final appInfo = useState({});
var dummmy = Random().nextInt(1024);
_getPackageInfo() async { _getPackageInfo() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform(); 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(() { useEffect(() {
_getPackageInfo(); _getPackageInfo();
_buildUserProfileImage();
return null; return null;
}, []); }, []);
return Drawer( return Drawer(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topRight: Radius.circular(5),
bottomRight: Radius.circular(5),
),
),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@ -51,22 +114,60 @@ class ProfileDrawer extends HookConsumerWidget {
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
children: [ children: [
DrawerHeader( DrawerHeader(
decoration: BoxDecoration( decoration: const BoxDecoration(
color: Colors.grey[200], gradient: LinearGradient(
colors: [Color.fromARGB(255, 216, 219, 238), Color.fromARGB(255, 226, 230, 231)],
begin: Alignment.centerRight,
end: Alignment.centerLeft,
),
), ),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Image( Stack(
image: AssetImage('assets/immich-logo-no-outline.png'), clipBehavior: Clip.none,
width: 50, children: [
filterQuality: FilterQuality.high, _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)), const Padding(padding: EdgeInsets.all(8)),
Text( Text(
_authState.userEmail, "${_authState.firstName} ${_authState.lastName}",
style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold), 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(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Card( child: Card(
elevation: 0,
color: Colors.grey[100], 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( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
child: Column( child: Column(

View file

@ -8,6 +8,11 @@ class AuthenticationState {
final String userId; final String userId;
final String userEmail; final String userEmail;
final bool isAuthenticated; final bool isAuthenticated;
final String firstName;
final String lastName;
final bool isAdmin;
final bool isFirstLogin;
final String profileImagePath;
final DeviceInfoRemote deviceInfo; final DeviceInfoRemote deviceInfo;
AuthenticationState({ AuthenticationState({
@ -16,6 +21,11 @@ class AuthenticationState {
required this.userId, required this.userId,
required this.userEmail, required this.userEmail,
required this.isAuthenticated, required this.isAuthenticated,
required this.firstName,
required this.lastName,
required this.isAdmin,
required this.isFirstLogin,
required this.profileImagePath,
required this.deviceInfo, required this.deviceInfo,
}); });
@ -25,6 +35,11 @@ class AuthenticationState {
String? userId, String? userId,
String? userEmail, String? userEmail,
bool? isAuthenticated, bool? isAuthenticated,
String? firstName,
String? lastName,
bool? isAdmin,
bool? isFirstLoggedIn,
String? profileImagePath,
DeviceInfoRemote? deviceInfo, DeviceInfoRemote? deviceInfo,
}) { }) {
return AuthenticationState( return AuthenticationState(
@ -33,24 +48,36 @@ class AuthenticationState {
userId: userId ?? this.userId, userId: userId ?? this.userId,
userEmail: userEmail ?? this.userEmail, userEmail: userEmail ?? this.userEmail,
isAuthenticated: isAuthenticated ?? this.isAuthenticated, 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, deviceInfo: deviceInfo ?? this.deviceInfo,
); );
} }
@override @override
String toString() { 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() { Map<String, dynamic> toMap() {
return { final result = <String, dynamic>{};
'deviceId': deviceId,
'deviceType': deviceType, result.addAll({'deviceId': deviceId});
'userId': userId, result.addAll({'deviceType': deviceType});
'userEmail': userEmail, result.addAll({'userId': userId});
'isAuthenticated': isAuthenticated, result.addAll({'userEmail': userEmail});
'deviceInfo': deviceInfo.toMap(), 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) { factory AuthenticationState.fromMap(Map<String, dynamic> map) {
@ -60,6 +87,11 @@ class AuthenticationState {
userId: map['userId'] ?? '', userId: map['userId'] ?? '',
userEmail: map['userEmail'] ?? '', userEmail: map['userEmail'] ?? '',
isAuthenticated: map['isAuthenticated'] ?? false, 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']), deviceInfo: DeviceInfoRemote.fromMap(map['deviceInfo']),
); );
} }
@ -78,6 +110,11 @@ class AuthenticationState {
other.userId == userId && other.userId == userId &&
other.userEmail == userEmail && other.userEmail == userEmail &&
other.isAuthenticated == isAuthenticated && other.isAuthenticated == isAuthenticated &&
other.firstName == firstName &&
other.lastName == lastName &&
other.isAdmin == isAdmin &&
other.isFirstLogin == isFirstLogin &&
other.profileImagePath == profileImagePath &&
other.deviceInfo == deviceInfo; other.deviceInfo == deviceInfo;
} }
@ -88,6 +125,11 @@ class AuthenticationState {
userId.hashCode ^ userId.hashCode ^
userEmail.hashCode ^ userEmail.hashCode ^
isAuthenticated.hashCode ^ isAuthenticated.hashCode ^
firstName.hashCode ^
lastName.hashCode ^
isAdmin.hashCode ^
isFirstLogin.hashCode ^
profileImagePath.hashCode ^
deviceInfo.hashCode; deviceInfo.hashCode;
} }
} }

View file

@ -4,31 +4,58 @@ class LogInReponse {
final String accessToken; final String accessToken;
final String userId; final String userId;
final String userEmail; final String userEmail;
final String firstName;
final String lastName;
final String profileImagePath;
final bool isAdmin;
final bool isFirstLogin;
LogInReponse({ LogInReponse({
required this.accessToken, required this.accessToken,
required this.userId, required this.userId,
required this.userEmail, required this.userEmail,
required this.firstName,
required this.lastName,
required this.profileImagePath,
required this.isAdmin,
required this.isFirstLogin,
}); });
LogInReponse copyWith({ LogInReponse copyWith({
String? accessToken, String? accessToken,
String? userId, String? userId,
String? userEmail, String? userEmail,
String? firstName,
String? lastName,
String? profileImagePath,
bool? isAdmin,
bool? isFirstLogin,
}) { }) {
return LogInReponse( return LogInReponse(
accessToken: accessToken ?? this.accessToken, accessToken: accessToken ?? this.accessToken,
userId: userId ?? this.userId, userId: userId ?? this.userId,
userEmail: userEmail ?? this.userEmail, 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() { Map<String, dynamic> toMap() {
return { final result = <String, dynamic>{};
'accessToken': accessToken,
'userId': userId, result.addAll({'accessToken': accessToken});
'userEmail': userEmail, 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) { factory LogInReponse.fromMap(Map<String, dynamic> map) {
@ -36,6 +63,11 @@ class LogInReponse {
accessToken: map['accessToken'] ?? '', accessToken: map['accessToken'] ?? '',
userId: map['userId'] ?? '', userId: map['userId'] ?? '',
userEmail: map['userEmail'] ?? '', 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)); factory LogInReponse.fromJson(String source) => LogInReponse.fromMap(json.decode(source));
@override @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 @override
bool operator ==(Object other) { bool operator ==(Object other) {
@ -53,9 +87,23 @@ class LogInReponse {
return other is LogInReponse && return other is LogInReponse &&
other.accessToken == accessToken && other.accessToken == accessToken &&
other.userId == userId && 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 @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;
}
} }

View file

@ -17,9 +17,14 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
AuthenticationState( AuthenticationState(
deviceId: "", deviceId: "",
deviceType: "", deviceType: "",
isAuthenticated: false,
userId: "", userId: "",
userEmail: "", userEmail: "",
firstName: '',
lastName: '',
profileImagePath: '',
isAdmin: false,
isFirstLogin: false,
isAuthenticated: false,
deviceInfo: DeviceInfoRemote( deviceInfo: DeviceInfoRemote(
id: 0, id: 0,
userId: "", userId: "",
@ -76,6 +81,11 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
isAuthenticated: true, isAuthenticated: true,
userId: payload.userId, userId: payload.userId,
userEmail: payload.userEmail, userEmail: payload.userEmail,
firstName: payload.firstName,
lastName: payload.lastName,
profileImagePath: payload.profileImagePath,
isAdmin: payload.isAdmin,
isFirstLoggedIn: payload.isFirstLogin,
); );
if (isSavedLoginInfo) { if (isSavedLoginInfo) {
@ -114,9 +124,14 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
state = AuthenticationState( state = AuthenticationState(
deviceId: "", deviceId: "",
deviceType: "", deviceType: "",
isAuthenticated: false,
userId: "", userId: "",
userEmail: "", userEmail: "",
firstName: '',
lastName: '',
profileImagePath: '',
isFirstLogin: false,
isAuthenticated: false,
isAdmin: false,
deviceInfo: DeviceInfoRemote( deviceInfo: DeviceInfoRemote(
id: 0, id: 0,
userId: "", userId: "",
@ -139,6 +154,10 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
DeviceInfoRemote deviceInfoRemote = await _backupService.setAutoBackup(backupState, deviceId, deviceType); DeviceInfoRemote deviceInfoRemote = await _backupService.setAutoBackup(backupState, deviceId, deviceType);
state = state.copyWith(deviceInfo: deviceInfoRemote); state = state.copyWith(deviceInfo: deviceInfoRemote);
} }
updateUserProfileImagePath(String path) {
state = state.copyWith(profileImagePath: path);
}
} }
final authenticationProvider = StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) { final authenticationProvider = StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) {

View file

@ -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;
}

View file

@ -2,8 +2,15 @@ import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/material.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/models/user_info.model.dart';
import 'package:immich_mobile/shared/services/network.service.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 { class UserService {
final NetworkService _networkService = NetworkService(); final NetworkService _networkService = NetworkService();
@ -21,4 +28,39 @@ class UserService {
return []; 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;
}
}
} }

View file

@ -42,14 +42,14 @@ packages:
name: auto_route name: auto_route
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.2.4" version: "4.0.1"
auto_route_generator: auto_route_generator:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: auto_route_generator name: auto_route_generator
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.2.3" version: "4.0.0"
badges: badges:
dependency: "direct main" dependency: "direct main"
description: description:
@ -126,7 +126,7 @@ packages:
name: cached_network_image name: cached_network_image
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.2.0" version: "3.2.1"
cached_network_image_platform_interface: cached_network_image_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -197,6 +197,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.1" 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: crypto:
dependency: transitive dependency: transitive
description: description:
@ -321,6 +328,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.14.0" 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: flutter_riverpod:
dependency: transitive dependency: transitive
description: description:
@ -393,7 +407,7 @@ packages:
name: hive name: hive
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.0" version: "2.2.1"
hive_flutter: hive_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
@ -450,6 +464,41 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.1.3" 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: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@ -673,7 +722,7 @@ packages:
name: percent_indicator name: percent_indicator
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.4.0" version: "4.2.2"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:

View file

@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: "none" publish_to: "none"
version: 1.9.1+14 version: 1.10.0+15
environment: environment:
sdk: ">=2.15.1 <3.0.0" sdk: ">=2.15.1 <3.0.0"
@ -14,13 +14,13 @@ dependencies:
photo_manager: ^2.0.6 photo_manager: ^2.0.6
flutter_hooks: ^0.18.0 flutter_hooks: ^0.18.0
hooks_riverpod: ^2.0.0-dev.0 hooks_riverpod: ^2.0.0-dev.0
hive: hive: ^2.2.1
hive_flutter: hive_flutter: ^1.1.0
dio: ^4.0.4 dio: ^4.0.4
cached_network_image: ^3.2.0 cached_network_image: ^3.2.1
percent_indicator: ^3.4.0 percent_indicator: ^4.2.2
intl: ^0.17.0 intl: ^0.17.0
auto_route: ^3.2.2 auto_route: ^4.0.1
exif: ^3.1.1 exif: ^3.1.1
transparent_image: ^2.0.0 transparent_image: ^2.0.0
visibility_detector: ^0.2.2 visibility_detector: ^0.2.2
@ -38,6 +38,7 @@ dependencies:
flutter_spinkit: ^5.1.0 flutter_spinkit: ^5.1.0
flutter_swipe_detector: ^2.0.0 flutter_swipe_detector: ^2.0.0
equatable: ^2.0.3 equatable: ^2.0.3
image_picker: ^0.8.5+3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@ -45,7 +46,7 @@ dev_dependencies:
flutter_lints: ^1.0.0 flutter_lints: ^1.0.0
hive_generator: ^1.1.2 hive_generator: ^1.1.2
build_runner: ^2.1.7 build_runner: ^2.1.7
auto_route_generator: ^3.2.1 auto_route_generator: ^4.0.0
flutter: flutter:
uses-material-design: true uses-material-design: true

View file

@ -7,7 +7,7 @@ import { UpdateUserDto } from './dto/update-user.dto';
import { UserEntity } from './entities/user.entity'; import { UserEntity } from './entities/user.entity';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import sharp from 'sharp'; import sharp from 'sharp';
import { createReadStream } from 'fs'; import { createReadStream, unlink, unlinkSync } from 'fs';
import { Response as Res } from 'express'; import { Response as Res } from 'express';
@Injectable() @Injectable()
@ -129,25 +129,14 @@ export class UserService {
async createProfileImage(authUser: AuthUserDto, fileInfo: Express.Multer.File) { async createProfileImage(authUser: AuthUserDto, fileInfo: Express.Multer.File) {
try { try {
// Convert file to jpeg await this.userRepository.update(authUser.id, {
let filePath = '' profileImagePath: fileInfo.path
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
})
}
return { return {
userId: authUser.id, userId: authUser.id,
profileImagePath: filePath profileImagePath: fileInfo.path
}; };
} catch (e) { } catch (e) {
Logger.error(e, 'Create User Profile Image'); Logger.error(e, 'Create User Profile Image');
@ -156,10 +145,22 @@ export class UserService {
} }
async getUserProfileImage(userId: string, res: Res) { async getUserProfileImage(userId: string, res: Res) {
const user = await this.userRepository.findOne({ id: userId }) try {
res.set({ const user = await this.userRepository.findOne({ id: userId })
'Content-Type': 'image/webp', if (!user.profileImagePath) {
}); console.log("empty return")
return new StreamableFile(createReadStream(user.profileImagePath)); 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")
}
} }
} }

View file

@ -19,6 +19,7 @@ export const profileImageUploadOption: MulterOptions = {
destination: (req: Request, file: Express.Multer.File, cb: any) => { destination: (req: Request, file: Express.Multer.File, cb: any) => {
const basePath = APP_UPLOAD_LOCATION; const basePath = APP_UPLOAD_LOCATION;
const profileImageLocation = `${basePath}/${req.user['id']}/profile`; const profileImageLocation = `${basePath}/${req.user['id']}/profile`;
if (!existsSync(profileImageLocation)) { if (!existsSync(profileImageLocation)) {
mkdirSync(profileImageLocation, { recursive: true }); mkdirSync(profileImageLocation, { recursive: true });
} }
@ -28,9 +29,10 @@ export const profileImageUploadOption: MulterOptions = {
}, },
filename: (req: Request, file: Express.Multer.File, cb: any) => { filename: (req: Request, file: Express.Multer.File, cb: any) => {
const userId = req.user['id']; const userId = req.user['id'];
cb(null, `${userId}`); cb(null, `${userId}${extname(file.originalname)}`);
}, },
}), }),
}; };

View file

@ -1,12 +1,20 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { ImmichUser } from '$lib/models/immich-user'; import type { ImmichUser } from '$lib/models/immich-user';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { serverEndpoint } from '../../constants';
export let user: ImmichUser; export let user: ImmichUser;
let shouldShowAccountInfo = false; 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) => { const getFirstLetter = (text?: string) => {
return text?.charAt(0).toUpperCase(); return text?.charAt(0).toUpperCase();
}; };
@ -39,9 +47,17 @@
on:mouseleave={() => (shouldShowAccountInfo = false)} on:mouseleave={() => (shouldShowAccountInfo = false)}
> >
<button <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> </button>
{#if shouldShowAccountInfo} {#if shouldShowAccountInfo}