From 7b82c1cbb87b4c6763b24cd02afff1a2a3f7c6fd Mon Sep 17 00:00:00 2001 From: Rafael <1024481@stud.hs-mannheim.de> Date: Mon, 24 Jun 2024 04:41:17 +0200 Subject: [PATCH] LinkedIn, Facebook and Xing links added in user profile. --- lib/components/external_link_widget.dart | 59 ++++++++++++++++++++++ lib/constants.dart | 3 ++ lib/models/user_profile.dart | 6 +++ lib/pages/edit_profile_page.dart | 39 +++++++++++++++ lib/pages/liked_users_page.dart | 32 ++---------- lib/pages/user_profile_page.dart | 10 ++++ lib/services/user_service.dart | 3 ++ lib/utils/helper.dart | 60 ++++++++++++++++++++++ lib/utils/helper_dialogs.dart | 27 ++++++++++ pubspec.lock | 64 ++++++++++++++++++++++++ pubspec.yaml | 1 + 11 files changed, 276 insertions(+), 28 deletions(-) create mode 100644 lib/components/external_link_widget.dart diff --git a/lib/components/external_link_widget.dart b/lib/components/external_link_widget.dart new file mode 100644 index 0000000..34695f3 --- /dev/null +++ b/lib/components/external_link_widget.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show Clipboard, ClipboardData; +import 'package:url_launcher/url_launcher.dart'; + +import '../utils/helper_dialogs.dart'; + +class ExternalLinkWidget extends StatelessWidget { + final String url; + final BuildContext context; + + const ExternalLinkWidget( + {required this.url, required this.context, super.key}); + + Future _onTap() async { + bool? confirm = await showConfirmationDialog( + context, + 'External Link', + 'You are about to open an external link. ' + 'Please make sure you trust the link before proceeding.\n' + '$url\n' + 'Do you want to open this external link?\n\n' + 'Hint: Long press to copy the link to your clipboard instead of opening it.', + ); + if (confirm == true) { + Uri weblink = Uri.parse(url); + if (!await launchUrl(weblink)) { + if (context.mounted) { + showErrorSnackBar(context, 'Could not launch $weblink'); + } + } + } + } + + void _onLongPress() { + _copyToClipboard(context, url); + } + + void _copyToClipboard(BuildContext context, String text) { + Clipboard.setData(ClipboardData(text: text)).then((_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Copied $text to clipboard')), + ); + }); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _onTap, + onLongPress: _onLongPress, + child: Text( + url, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.blue), + ), + ); + } +} diff --git a/lib/constants.dart b/lib/constants.dart index a43a870..afe5fb2 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -58,6 +58,9 @@ class Constants { static const String dbFieldUsersCommunication = 'communication'; static const String dbFieldUsersRiskTolerance = 'risk_tolerance'; static const String dbFieldUsersSectors = 'sectors_of_interest'; + static const String dbFieldUsersUrlFacebook = 'url_facebook'; + static const String dbFieldUsersUrlLinkedIn = 'url_linkedin'; + static const String dbFieldUsersUrlXing = 'url_xing'; static const String dbStoragePathProfiles = 'profile_images'; diff --git a/lib/models/user_profile.dart b/lib/models/user_profile.dart index 0236972..6c485c9 100644 --- a/lib/models/user_profile.dart +++ b/lib/models/user_profile.dart @@ -10,6 +10,9 @@ class UserProfile { final String firstName; final String lastName; String? profilePictureUrl; + String? urlFacebook; + String? urlLinkedIn; + String? urlXing; String? bio; Gender? gender; int? born; @@ -33,6 +36,9 @@ class UserProfile { required this.firstName, required this.lastName, this.profilePictureUrl, + this.urlFacebook, + this.urlLinkedIn, + this.urlXing, this.bio, this.gender, this.born, diff --git a/lib/pages/edit_profile_page.dart b/lib/pages/edit_profile_page.dart index e44dc4c..ceb5f29 100644 --- a/lib/pages/edit_profile_page.dart +++ b/lib/pages/edit_profile_page.dart @@ -10,6 +10,7 @@ import 'dart:typed_data'; import '../constants.dart'; import '../models/user_profile.dart'; +import '../utils/helper.dart'; class EditProfilePage extends StatefulWidget { final UserProfile userData; @@ -24,6 +25,9 @@ class EditProfilePageState extends State { final _formKey = GlobalKey(); late TextEditingController _nameController; late TextEditingController _bioController; + late TextEditingController _urlFbController; + late TextEditingController _urlLnController; + late TextEditingController _urlXiController; String? profileImageUrl; io.File? _profileImage; // for android/ios Uint8List? _webProfileImage; // for web @@ -33,6 +37,9 @@ class EditProfilePageState extends State { super.initState(); _nameController = TextEditingController(text: widget.userData.name); _bioController = TextEditingController(text: widget.userData.bio); + _urlFbController = TextEditingController(text: widget.userData.urlFacebook); + _urlLnController = TextEditingController(text: widget.userData.urlLinkedIn); + _urlXiController = TextEditingController(text: widget.userData.urlXing); if (widget.userData.profilePictureUrl != null) { profileImageUrl = widget.userData.profilePictureUrl; } @@ -135,6 +142,9 @@ class EditProfilePageState extends State { Constants.dbFieldUsersName: nameTrim, Constants.dbFieldUsersBio: bioTrim, Constants.dbFieldUsersProfilePic: profileImageUrl, + Constants.dbFieldUsersUrlFacebook: _urlFbController.text.trim(), + Constants.dbFieldUsersUrlLinkedIn: _urlLnController.text.trim(), + Constants.dbFieldUsersUrlXing: _urlXiController.text.trim(), }; await FirebaseFirestore.instance @@ -152,6 +162,9 @@ class EditProfilePageState extends State { Constants.dbFieldUsersProfilePic: map[Constants.dbFieldUsersProfilePic], Constants.dbFieldUsersName: map[Constants.dbFieldUsersName], Constants.dbFieldUsersBio: map[Constants.dbFieldUsersBio], + Constants.dbFieldUsersUrlFacebook: map[Constants.dbFieldUsersUrlFacebook], + Constants.dbFieldUsersUrlLinkedIn: map[Constants.dbFieldUsersUrlLinkedIn], + Constants.dbFieldUsersUrlXing: map[Constants.dbFieldUsersUrlXing], }); } @@ -217,6 +230,32 @@ class EditProfilePageState extends State { maxLength: 4096, ), const SizedBox(height: 16), + TextFormField( + controller: _urlFbController, + decoration: + const InputDecoration(labelText: 'Facebook Profile'), + validator: (value) { + return validateUrlFacebook(value); + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _urlLnController, + decoration: + const InputDecoration(labelText: 'LinkedIn Profile'), + validator: (value) { + return validateUrlLinkedIn(value); + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _urlXiController, + decoration: const InputDecoration(labelText: 'Xing Profile'), + validator: (value) { + return validateUrlXing(value); + }, + ), + const SizedBox(height: 16), ElevatedButton( onPressed: _saveProfile, child: const Text('Save'), diff --git a/lib/pages/liked_users_page.dart b/lib/pages/liked_users_page.dart index 8fa0849..54b1bfa 100644 --- a/lib/pages/liked_users_page.dart +++ b/lib/pages/liked_users_page.dart @@ -104,31 +104,6 @@ class LikedUsersPageState extends State { } } - Future _showConfirmationDialog(String userId, String userName) async { - bool? confirm = await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('Confirm Removal'), - content: - Text('Are you sure you want to remove $userName from your list?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Confirm'), - ), - ], - ); - }, - ); - - return confirm; - } - List> _getSortedLikedUsersWithSwipes() { List> likedOnlyUsers = []; List> matchedUsers = []; @@ -320,9 +295,10 @@ class LikedUsersPageState extends State { user.data() as Map; bool hasName = userMap.containsKey(Constants.dbFieldUsersName); - bool? confirm = await _showConfirmationDialog( - user.id, - (hasName ? user[Constants.dbFieldUsersName] : 'Name: n/a'), + bool? confirm = await showConfirmationDialog( + context, + 'Confirm Removal', + 'Are you sure you want to remove ${(hasName ? user[Constants.dbFieldUsersName] : 'Name: n/a')} from your list?', ); if (confirm == true) { diff --git a/lib/pages/user_profile_page.dart b/lib/pages/user_profile_page.dart index e55f4f2..2abb633 100644 --- a/lib/pages/user_profile_page.dart +++ b/lib/pages/user_profile_page.dart @@ -2,6 +2,7 @@ import 'package:expandable_text/expandable_text.dart'; import 'package:flutter/material.dart'; import 'package:firebase_auth/firebase_auth.dart'; +import '../components/external_link_widget.dart'; import '../components/language_list.dart'; import '../constants.dart'; import '../enumerations.dart'; @@ -73,6 +74,9 @@ class _UserProfilePageState extends State { updatedUserData[Constants.dbFieldUsersProfilePic]; myData.name = updatedUserData[Constants.dbFieldUsersName]; myData.bio = updatedUserData[Constants.dbFieldUsersBio]; + myData.urlFacebook = updatedUserData[Constants.dbFieldUsersUrlFacebook]; + myData.urlLinkedIn = updatedUserData[Constants.dbFieldUsersUrlLinkedIn]; + myData.urlXing = updatedUserData[Constants.dbFieldUsersUrlXing]; }); } } @@ -318,6 +322,12 @@ class _UserProfilePageState extends State { ], ), if (isOwner) Text(myData.email, style: const TextStyle(fontSize: 16)), + if (myData.urlXing != null && myData.urlXing!.isNotEmpty) + ExternalLinkWidget(url: myData.urlXing!, context: context), + if (myData.urlLinkedIn != null && myData.urlLinkedIn!.isNotEmpty) + ExternalLinkWidget(url: myData.urlLinkedIn!, context: context), + if (myData.urlFacebook != null && myData.urlFacebook!.isNotEmpty) + ExternalLinkWidget(url: myData.urlFacebook!, context: context), const SizedBox(height: 32), Align( alignment: Alignment.centerLeft, diff --git a/lib/services/user_service.dart b/lib/services/user_service.dart index 30fa462..753ecd7 100644 --- a/lib/services/user_service.dart +++ b/lib/services/user_service.dart @@ -206,6 +206,9 @@ class UserService { communication: communication, workValues: works, profilePictureUrl: data[Constants.dbFieldUsersProfilePic], + urlFacebook: data[Constants.dbFieldUsersUrlFacebook], + urlLinkedIn: data[Constants.dbFieldUsersUrlLinkedIn], + urlXing: data[Constants.dbFieldUsersUrlXing], bio: data[Constants.dbFieldUsersBio], gender: Gender.values[data[Constants.dbFieldUsersGender] ?? 0], born: data[Constants.dbFieldUsersYearBorn], diff --git a/lib/utils/helper.dart b/lib/utils/helper.dart index cc88bb8..bb6e5d1 100644 --- a/lib/utils/helper.dart +++ b/lib/utils/helper.dart @@ -100,3 +100,63 @@ String getDisplayText(dynamic option) { // Fallback to default toString if not an enum return option.toString().split('.').last; } + +/// Returns null if the given [value] is a valid Facebook Url, +/// else an error message is returned. +String? validateUrlFacebook(String? value) { + if (value == null || value.trim().isEmpty) { + return null; + } + if (!value.startsWith('http:') && !value.startsWith('https:')) { + return 'Link does not start with http(s):'; + } + RegExp regex1 = RegExp( + r'^(?:https?://)?(?:www\.)?facebook.com/(?:profile\.php\?id=)?[0-9]+$'); + RegExp regex2 = RegExp( + r'^(?:https?://)?(?:www\.)?(?:facebook|fb)\.com/(?![A-z]+\.php)(?!marketplace|gaming|watch|me|messages|help|search|groups)[A-z0-9_\-.]+/?$'); + if (!regex1.hasMatch(value) && !regex2.hasMatch(value)) { + return 'Unknown or invalid Facebook URL format.'; + } + return null; +} + +/// Returns null if the given [value] is a valid LinkedIn Url, +/// else an error message is returned. +String? validateUrlLinkedIn(String? value) { + if (value == null || value.trim().isEmpty) { + return null; // ok, nothing to validate + } + if (!value.startsWith('http:') && !value.startsWith('https:')) { + return 'Link does not start with http(s):'; + } + // RegEx source https://github.com/lorey/social-media-profiles-regexs?tab=readme-ov-file#linkedin + RegExp regex1 = RegExp( + r'^(?:https?://)?(?:\w+\.)?linkedin\.com/(?:(company)|(school))/[A-z0-9-À-ÿ.]+/?$'); + RegExp regex2 = RegExp( + r'^(?:https?://)?(?:\w+\.)?linkedin\.com/feed/update/urn:li:activity:[0-9]+/?$'); + RegExp regex3 = + RegExp(r'^(?:https?://)?(?:\w+\.)?linkedin\.com/in/[\w\-_À-ÿ%]+/?$'); + if (!regex1.hasMatch(value) && + !regex2.hasMatch(value) && + !regex3.hasMatch(value)) { + return 'Unknown or invalid LinkedIn URL format.'; + } + return null; +} + +/// Returns null if the given [value] is a valid Xing Url, +/// else an error message is returned. +String? validateUrlXing(String? value) { + if (value == null || value.trim().isEmpty) { + return null; + } + if (!value.startsWith('http:') && !value.startsWith('https:')) { + return 'Link does not start with http(s):'; + } + RegExp regex = + RegExp(r'^(?:https?://)?(?:www\.)?xing.com/profile/[A-z0-9-_]+$'); + if (!regex.hasMatch(value)) { + return 'Unknown or invalid Xing URL format.'; + } + return null; +} diff --git a/lib/utils/helper_dialogs.dart b/lib/utils/helper_dialogs.dart index 3e5965a..3327cc5 100644 --- a/lib/utils/helper_dialogs.dart +++ b/lib/utils/helper_dialogs.dart @@ -41,3 +41,30 @@ void showErrorSnackBar(BuildContext context, String message) { ), ); } + +/// Show a confirmation dialog and return [true] on confirm, +/// [false] on cancel, or null otherwise. +Future showConfirmationDialog( + BuildContext context, String title, String content) async { + bool? confirm = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(title), + content: Text(content), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Confirm'), + ), + ], + ); + }, + ); + + return confirm; +} diff --git a/pubspec.lock b/pubspec.lock index bdc8697..372c186 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -829,6 +829,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: ceb2625f0c24ade6ef6778d1de0b2e44f2db71fded235eb52295247feba8c5cf + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7068716403343f6ba4969b4173cbf3b84fc768042124bc2c011e5d782b24fe89" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 + url: "https://pub.dev" + source: hosted + version: "3.1.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + url: "https://pub.dev" + source: hosted + version: "3.1.1" uuid: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 954dab3..3437c6c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: expandable_text: ^2.3.0 shared_preferences: ^2.2.3 flutter_launcher_icons: ^0.13.1 + url_launcher: ^6.3.0 dev_dependencies: flutter_test: