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:
parent
bdf38e7668
commit
d476b15312
17 changed files with 576 additions and 85 deletions
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
Binary file not shown.
|
@ -45,13 +45,17 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
LinearPercentIndicator(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
lineHeight: 5.0,
|
||||
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),
|
||||
child: Text('${backupState.serverInfo.diskUse} of ${backupState.serverInfo.diskSize} used'),
|
||||
|
|
|
@ -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()));
|
|
@ -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.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: Theme.of(context).primaryColor, fontWeight: FontWeight.bold),
|
||||
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(
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
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/webp',
|
||||
'Content-Type': 'image/jpeg',
|
||||
});
|
||||
return new StreamableFile(createReadStream(user.profileImagePath));
|
||||
|
||||
const fileStream = createReadStream(user.profileImagePath)
|
||||
return new StreamableFile(fileStream);
|
||||
} catch (e) {
|
||||
console.log("error getting user profile")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)}`);
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
{#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}
|
||||
|
|
Loading…
Reference in a new issue