From 0d3869be0bf05ddea0d705f5cef74dd58e4d9aed Mon Sep 17 00:00:00 2001 From: munezeromicha Date: Wed, 20 Nov 2024 20:21:45 +0200 Subject: [PATCH] ft(#99):improving-trainee-profile (#121) --- app.json | 8 +- app/dashboard/_layout.tsx | 15 ++- app/dashboard/trainee/profile/edit.tsx | 139 +++++++++++++++++--- app/dashboard/trainee/profile/index.tsx | 111 +++++++++++++--- components/Login/OrgLogin.tsx | 5 +- components/ProfileAvatar.tsx | 23 ++-- constants/countries.ts | 82 +++++++++++- graphql/mutations/updateAvatar.mutation.tsx | 10 ++ internationalization/locales/en.json | 9 +- internationalization/locales/fr.json | 9 +- internationalization/locales/kin.json | 9 +- package-lock.json | 22 ++++ package.json | 3 +- 13 files changed, 383 insertions(+), 62 deletions(-) create mode 100644 graphql/mutations/updateAvatar.mutation.tsx diff --git a/app.json b/app.json index 951846f..d0182ae 100644 --- a/app.json +++ b/app.json @@ -40,7 +40,13 @@ } ] }, - "plugins": ["expo-router", "expo-font", "expo-localization"], + "plugins": ["expo-router", "expo-font", "expo-localization", [ + "expo-image-picker", + { + "photosPermission": "The app needs access to your photos to let you upload a profile picture." + } + ] + ], "experiments": { "typedRoutes": true }, diff --git a/app/dashboard/_layout.tsx b/app/dashboard/_layout.tsx index f569133..74cc539 100644 --- a/app/dashboard/_layout.tsx +++ b/app/dashboard/_layout.tsx @@ -70,10 +70,21 @@ export default function DashboardLayout() { const colorScheme = useColorScheme(); const [authToken, setAuthToken] = useState(null); - const { data: profileData } = useQuery(GET_PROFILE, { - context: { headers: { Authorization: `Bearer ${authToken}` } }, + const { data } = useQuery(GET_PROFILE, { + context: { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }, skip: !authToken, }); + + useEffect(() => { + if (data) { + setProfile(data.getProfile); + } + }, [data]); + useEffect(() => { (async () => { const cachedToken = await AsyncStorage.getItem('authToken'); diff --git a/app/dashboard/trainee/profile/edit.tsx b/app/dashboard/trainee/profile/edit.tsx index bfb989e..e61cf14 100644 --- a/app/dashboard/trainee/profile/edit.tsx +++ b/app/dashboard/trainee/profile/edit.tsx @@ -11,17 +11,19 @@ import { router } from 'expo-router'; import { useFormik } from 'formik'; import { useEffect, useState } from 'react'; import { - ActivityIndicator, - Text, - TextInput, - TouchableOpacity, - useColorScheme, - View, + ActivityIndicator, + Text, + TextInput, + TouchableOpacity, + useColorScheme, + View, } from 'react-native'; import DropDownPicker from 'react-native-dropdown-picker'; import { useToast } from 'react-native-toast-notifications'; import { CoverImage } from '.'; import { useTranslation } from 'react-i18next'; +import * as ImagePicker from 'expo-image-picker'; +import { UPDATE_AVATAR } from '@/graphql/mutations/updateAvatar.mutation'; type FormValues = { firstName: string; @@ -60,7 +62,11 @@ const EditProfile = () => { if (token) { setUserToken(token); } else { - toast.show(t('toasts.dashboard.tokenNotFound'), { type: 'danger', placement: 'top', duration: 3000 }); + toast.show(t('toasts.dashboard.tokenNotFound'), { + type: 'danger', + placement: 'top', + duration: 3000, + }); } } catch (error) { toast.show(t('toasts.dashboard.failedToken'), { @@ -73,7 +79,7 @@ const EditProfile = () => { fetchToken(); }, []); - const { data, loading, error } = useQuery(GET_PROFILE, { + const { data, loading, error, refetch } = useQuery(GET_PROFILE, { context: { headers: { Authorization: `Bearer ${userToken}`, @@ -83,7 +89,11 @@ const EditProfile = () => { }); useEffect(() => { if (error) { - toast.show(t('toasts.dashboard.profileErr'), { type: 'danger', placement: 'top', duration: 3000 }); + toast.show(t('toasts.dashboard.profileErr'), { + type: 'danger', + placement: 'top', + duration: 3000, + }); } }, [error]); @@ -173,6 +183,70 @@ const EditProfile = () => { ); } + const [updateAvatar] = useMutation(UPDATE_AVATAR, { + context: { + headers: { + Authorization: `Bearer ${userToken}`, + }, + }, + onCompleted: (data) => { + if (data.updateAvatar) { + toast.show(t('toasts.dashboard.avatarUpdated'), { + type: 'success', + placement: 'top', + duration: 3000, + }); + refetch(); + } + }, + onError: (error) => { + toast.show(error.message || t('toasts.dashboard.avatarError'), { + type: 'danger', + placement: 'top', + duration: 3000, + }); + }, + }); + + const handleImagePicker = async () => { + try { + const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); + + if (!permissionResult.granted) { + toast.show(t('toasts.dashboard.permissionDenied'), { + type: 'danger', + placement: 'top', + duration: 3000, + }); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: true, + aspect: [1, 1], + quality: 0.8, + base64: true, + }); + + if (!result.canceled && result.assets[0].base64) { + const base64Image = `data:image/jpeg;base64,${result.assets[0].base64}`; + + await updateAvatar({ + variables: { + avatar: base64Image, + }, + }); + } + } catch (error) { + toast.show(t('toasts.dashboard.imagePickerError'), { + type: 'danger', + placement: 'top', + duration: 3000, + }); + } + }; + return ( @@ -180,9 +254,14 @@ const EditProfile = () => { - - - {t('editProfile.edit')} + + + + {t('editProfile.photo')} + @@ -210,7 +289,7 @@ const EditProfile = () => { - + router.push('/dashboard/trainee/profile')} @@ -223,7 +302,7 @@ const EditProfile = () => { {tab === 0 && ( <> - + {t('userRegister.firstName')} { - + {t('editProfile.country')} - { setOpen={setCountrySelectOpen} setValue={setCountry} theme={colorScheme === 'dark' ? 'DARK' : 'LIGHT'} - placeholder="Select Gender" + placeholder="Select country" multiple={false} style={{ borderColor: 'transparent', backgroundColor: 'transparent' }} + dropDownContainerStyle={{ + maxHeight: 200, + position: 'relative', + top: 0, + }} + listMode="SCROLLVIEW" + scrollViewProps={{ + nestedScrollEnabled: true, + persistentScrollbar: true, + }} + modalProps={{ + animationType: 'fade', + }} + containerStyle={{ + width: '100%', + }} + modalContentContainerStyle={{ + height: '80%', + }} + searchable={true} + searchPlaceholder="Search for a country..." + zIndex={3000} + zIndexInverse={1000} /> @@ -384,7 +485,9 @@ const EditProfile = () => { {Loading ? ( ) : ( - {t('editProfile.updateProfile')} + + {t('editProfile.updateProfile')} + )} diff --git a/app/dashboard/trainee/profile/index.tsx b/app/dashboard/trainee/profile/index.tsx index 365b49f..5e4ec6a 100644 --- a/app/dashboard/trainee/profile/index.tsx +++ b/app/dashboard/trainee/profile/index.tsx @@ -2,19 +2,20 @@ import { Text, View } from '@/components/Themed'; import { useEffect, useState } from 'react'; import { Image, Pressable, ScrollView, TouchableOpacity, useColorScheme } from 'react-native'; import { SvgXml } from 'react-native-svg'; - +import * as ImagePicker from 'expo-image-picker'; import { editBG } from '@/assets/Icons/dashboard/Icons'; import ProfileAvatar from '@/components/ProfileAvatar'; import AboutTrainee from '@/components/trainee/About'; import ProfileAccountTab from '@/components/trainee/Account'; import TraineeOrg from '@/components/trainee/Organisation'; import { GET_PROFILE, GET_TRAINEE_PROFILE } from '@/graphql/queries/user'; -import { useQuery } from '@apollo/client'; +import { useMutation, useQuery } from '@apollo/client'; import { Ionicons } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { router, useLocalSearchParams, useRouter } from 'expo-router'; import { useToast } from 'react-native-toast-notifications'; import { useTranslation } from 'react-i18next'; +import { UPDATE_AVATAR } from '@/graphql/mutations/updateAvatar.mutation'; type TabKey = 'About' | 'Organisation' | 'Account'; @@ -49,7 +50,7 @@ export default function Profile() { })(); }, []); - const { data, error } = useQuery(GET_PROFILE, { + const { data, error, refetch } = useQuery(GET_PROFILE, { context: { headers: { Authorization: `Bearer ${userToken}`, @@ -77,11 +78,19 @@ export default function Profile() { if (orgToken) { setOrgToken(orgToken); } else { - toast.show(t('toasts.dashboard.tokenNotFound'), { type: 'danger', placement: 'top', duration: 3000 }); + toast.show(t('toasts.dashboard.tokenNotFound'), { + type: 'danger', + placement: 'top', + duration: 3000, + }); } } catch (error) { - toast.show(t('toasts.dashboard.failedToken'), { type: 'danger', placement: 'top', duration: 3000 }); - } + toast.show(t('toasts.dashboard.failedToken'), { + type: 'danger', + placement: 'top', + duration: 3000, + }); + } }; fetchOrgToken(); }, []); @@ -98,11 +107,15 @@ export default function Profile() { }, }); -useEffect(() => { - if (err) { - toast.show(t('toasts.dashboard.profileErr') , { type: 'danger', placement: 'top', duration: 3000 }); - } -}, [err]); + useEffect(() => { + if (err) { + toast.show(t('toasts.dashboard.profileErr'), { + type: 'danger', + placement: 'top', + duration: 3000, + }); + } + }, [err]); useEffect(() => { if (traineedata) { @@ -110,6 +123,70 @@ useEffect(() => { } }, [traineedata]); + const [updateAvatar] = useMutation(UPDATE_AVATAR, { + context: { + headers: { + Authorization: `Bearer ${userToken}`, + }, + }, + onCompleted: (data) => { + if (data.updateAvatar) { + toast.show(t('toasts.dashboard.avatarUpdated'), { + type: 'success', + placement: 'top', + duration: 3000, + }); + refetch(); + } + }, + onError: (error) => { + toast.show(error.message || t('toasts.dashboard.avatarError'), { + type: 'danger', + placement: 'top', + duration: 3000, + }); + }, + }); + + const handleImagePicker = async () => { + try { + const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); + + if (!permissionResult.granted) { + toast.show(t('toasts.dashboard.permissionDenied'), { + type: 'danger', + placement: 'top', + duration: 3000, + }); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: true, + aspect: [1, 1], + quality: 0.8, + base64: true, + }); + + if (!result.canceled && result.assets[0].base64) { + const base64Image = `data:image/jpeg;base64,${result.assets[0].base64}`; + + await updateAvatar({ + variables: { + avatar: base64Image, + }, + }); + } + } catch (error) { + toast.show(t('toasts.dashboard.imagePickerError'), { + type: 'danger', + placement: 'top', + duration: 3000, + }); + } + }; + return ( @@ -123,13 +200,15 @@ useEffect(() => { - + router.push('/dashboard/trainee/profile/edit')} - className="absolute left-24 bottom-8 pl-3 pr-4 py-2.5 bg-action-500 rounded-lg flex flex-row items-center w-32 h-13" + onPress={handleImagePicker} + className="absolute items-center justify-center left-24 bottom-8 pl-3 pr-4 py-2.5 bg-action-500 rounded-lg flex flex-row items-center w-32 h-13" > - - {t('editProfile.edit')} + + + {t('editProfile.photo')} + diff --git a/components/Login/OrgLogin.tsx b/components/Login/OrgLogin.tsx index 2e81201..27decdb 100644 --- a/components/Login/OrgLogin.tsx +++ b/components/Login/OrgLogin.tsx @@ -72,7 +72,9 @@ export default function OrgLogin({ onSubmit }: OrgLoginProps) { {formik.touched.organization && formik.errors.organization && ( - {formik.errors.organization} + + {formik.errors.organization} + )} @@ -93,7 +95,6 @@ export default function OrgLogin({ onSubmit }: OrgLoginProps) { - diff --git a/components/ProfileAvatar.tsx b/components/ProfileAvatar.tsx index 6e03781..f5b40ce 100644 --- a/components/ProfileAvatar.tsx +++ b/components/ProfileAvatar.tsx @@ -28,18 +28,25 @@ export default function ProfileAvatar({ name, size = 'md', src }: AvatarProps) { return ; } + const getInitials = (name?: string) => { + if (!name) return 'UN'; + return name + .trim() + .toUpperCase() + .split(' ') + .filter((word) => word.length > 0) + .slice(0, 2) + .map((word) => word[0]) + .join(''); + }; + return ( - - {name - ?.toUpperCase() - .split(' ') - .filter((_, i) => i < 2) - .map((w) => w[0]) - .join('') || 'UN'} - + + {getInitials(name)} + ); } diff --git a/constants/countries.ts b/constants/countries.ts index d93bb9a..7be4ad7 100644 --- a/constants/countries.ts +++ b/constants/countries.ts @@ -48,6 +48,10 @@ export const COUNTRIES: SelectMenuOption[] = [ value: 'AU', label: 'Australia', }, + { + value: 'AT', + label: 'Austria', + }, { value: 'AZ', label: 'Azerbaijan', @@ -166,7 +170,7 @@ export const COUNTRIES: SelectMenuOption[] = [ }, { value: 'CO', - label: 'Columbia', + label: 'Colombia', }, { value: 'KM', @@ -236,6 +240,10 @@ export const COUNTRIES: SelectMenuOption[] = [ value: 'SV', label: 'El Salvador', }, + { + value: 'GQ', + label: 'Equatorial Guinea', + }, { value: 'ER', label: 'Eritrea', @@ -244,6 +252,14 @@ export const COUNTRIES: SelectMenuOption[] = [ value: 'EE', label: 'Estonia', }, + { + value: 'ET', + label: 'Ethiopia', + }, + { + value: 'FK', + label: 'Falkland Islands', + }, { value: 'FO', label: 'Faroe Islands', @@ -284,6 +300,10 @@ export const COUNTRIES: SelectMenuOption[] = [ value: 'GH', label: 'Ghana', }, + { + value: 'GI', + label: 'Gibraltar', + }, { value: 'GR', label: 'Greece', @@ -433,7 +453,7 @@ export const COUNTRIES: SelectMenuOption[] = [ label: 'Lesotho', }, { - value: 'LB', + value: 'LR', label: 'Liberia', }, { @@ -524,6 +544,10 @@ export const COUNTRIES: SelectMenuOption[] = [ value: 'ME', label: 'Montenegro', }, + { + value: 'MS', + label: 'Montserrat', + }, { value: 'MA', label: 'Morocco', @@ -540,6 +564,10 @@ export const COUNTRIES: SelectMenuOption[] = [ value: 'NA', label: 'Namibia', }, + { + value: 'NR', + label: 'Nauru', + }, { value: 'NP', label: 'Nepal', @@ -548,10 +576,6 @@ export const COUNTRIES: SelectMenuOption[] = [ value: 'NL', label: 'Netherlands', }, - { - value: 'AN', - label: 'Netherlands Antilles', - }, { value: 'NC', label: 'New Caledonia', @@ -572,6 +596,14 @@ export const COUNTRIES: SelectMenuOption[] = [ value: 'NG', label: 'Nigeria', }, + { + value: 'NU', + label: 'Niue', + }, + { + value: 'NF', + label: 'Norfolk Island', + }, { value: 'KP', label: 'North Korea', @@ -592,6 +624,10 @@ export const COUNTRIES: SelectMenuOption[] = [ value: 'PK', label: 'Pakistan', }, + { + value: 'PW', + label: 'Palau', + }, { value: 'PS', label: 'Palestine', @@ -616,6 +652,10 @@ export const COUNTRIES: SelectMenuOption[] = [ value: 'PH', label: 'Philippines', }, + { + value: 'PN', + label: 'Pitcairn Islands', + }, { value: 'PL', label: 'Poland', @@ -652,6 +692,14 @@ export const COUNTRIES: SelectMenuOption[] = [ value: 'RW', label: 'Rwanda', }, + { + value: 'BL', + label: 'Saint Barthélemy', + }, + { + value: 'SH', + label: 'Saint Helena', + }, { value: 'KN', label: 'Saint Kitts and Nevis', @@ -732,6 +780,10 @@ export const COUNTRIES: SelectMenuOption[] = [ value: 'ZA', label: 'South Africa', }, + { + value: 'GS', + label: 'South Georgia and the South Sandwich Islands', + }, { value: 'KR', label: 'South Korea', @@ -756,6 +808,10 @@ export const COUNTRIES: SelectMenuOption[] = [ value: 'SR', label: 'Suriname', }, + { + value: 'SJ', + label: 'Svalbard and Jan Mayen', + }, { value: 'SZ', label: 'Swaziland', @@ -792,6 +848,10 @@ export const COUNTRIES: SelectMenuOption[] = [ value: 'TG', label: 'Togo', }, + { + value: 'TK', + label: 'Tokelau', + }, { value: 'TO', label: 'Tonga', @@ -856,6 +916,10 @@ export const COUNTRIES: SelectMenuOption[] = [ value: 'VU', label: 'Vanuatu', }, + { + value: 'VA', + label: 'Vatican City', + }, { value: 'VE', label: 'Venezuela', @@ -864,6 +928,10 @@ export const COUNTRIES: SelectMenuOption[] = [ value: 'VN', label: 'Vietnam', }, + { + value: 'WF', + label: 'Wallis and Futuna', + }, { value: 'EH', label: 'Western Sahara', @@ -880,4 +948,4 @@ export const COUNTRIES: SelectMenuOption[] = [ value: 'ZW', label: 'Zimbabwe', }, -]; +]; \ No newline at end of file diff --git a/graphql/mutations/updateAvatar.mutation.tsx b/graphql/mutations/updateAvatar.mutation.tsx new file mode 100644 index 0000000..45e34b1 --- /dev/null +++ b/graphql/mutations/updateAvatar.mutation.tsx @@ -0,0 +1,10 @@ +import { gql } from '@apollo/client'; + +export const UPDATE_AVATAR = gql` + mutation UpdateAvatar($avatar: String) { + updateAvatar(avatar: $avatar) { + avatar + } + } +`; + diff --git a/internationalization/locales/en.json b/internationalization/locales/en.json index 6643c6f..c788473 100644 --- a/internationalization/locales/en.json +++ b/internationalization/locales/en.json @@ -204,7 +204,8 @@ "uploadLink" : "Upload resume from external link", "chooseFile" : "Choose File" , "upload" : "Upload", - "edit": "Edit" + "edit": "Edit", + "photo": "Photo" }, "settings":{ "title" : "Settings", @@ -254,7 +255,11 @@ "logoutErr" : "Error logging out", "navigationErr" : "Failed to navigate", "updatePassSuccess": "Password updated successfully", - "updatePassFail": "Oops, something went wrong" + "updatePassFail": "Oops, something went wrong", + "permissionDenied": "Permission to access photos was denied", + "avatarUpdated": "Profile picture updated successfully", + "avatarError": "Failed to update profile picture", + "imagePickerError": "Error selecting image" } }, "notifications":{ diff --git a/internationalization/locales/fr.json b/internationalization/locales/fr.json index 11e24be..e3f3cc6 100644 --- a/internationalization/locales/fr.json +++ b/internationalization/locales/fr.json @@ -204,7 +204,8 @@ "uploadLink" : "Télécharger le CV à partir d'un lien externe", "chooseFile" : "Choisir le Fichier", "upload" : "Télécharger", - "edit": "Modifier" + "edit": "Modifier", + "photo": "Photo" }, "settings": { "title" : "Paramètres", @@ -255,7 +256,11 @@ "logoutErr": "Erreur de déconnexion", "navigationErr": "Échec de la navigation", "updatePassSuccess": "Mot de passe mis à jour avec succès", - "updatePassFail": "Oups, quelque chose s'est mal passé" + "updatePassFail": "Oups, quelque chose s'est mal passé", + "permissionDenied": "Permission pour accéder aux photos a été refusée", + "avatarUpdated": "Photo de profil mise à jour avec succès", + "avatarError": "Erreur lors de la mise à jour de la photo de profil", + "imagePickerError": "Erreur lors de la sélection de l'image" } }, "notifications": { diff --git a/internationalization/locales/kin.json b/internationalization/locales/kin.json index 4f68b57..e1fdb57 100644 --- a/internationalization/locales/kin.json +++ b/internationalization/locales/kin.json @@ -204,7 +204,8 @@ "uploadLink" : "Shyiraho CV uhereye kumurongo wo hanze", "chooseFile" : "Hitamo Dosiye", "upload" : "Shyiraho", - "edit": "Hindura" + "edit": "Hindura", + "photo": "IFoto" }, "settings": { "title" : "Igenamiterere", @@ -255,7 +256,11 @@ "logoutErr": "Ikosa mu gusohoka", "navigationErr": "Byanze kugera aho ushaka", "updatePassSuccess": "Ijambo ry'ibanga ryongewe neza", - "updatePassFail": "Yooo, habaye ikibazo" + "updatePassFail": "Yooo, habaye ikibazo", + "permissionDenied": "Ibitabwirwa by'amakuru biri kuva kuva", + "avatarUpdated": "Foto ya umwirondoro yashyizweho neza", + "avatarError": "Ikosa mu kuvugurura foto ya umwirondoro", + "imagePickerError": "Ikosa mu kuvugurura foto ya umwirondoro" } }, "notifications": { diff --git a/package-lock.json b/package-lock.json index 08e78a0..36a6a5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "expo-document-picker": "~12.0.2", "expo-font": "~12.0.10", "expo-image": "~1.13.0", + "expo-image-picker": "~15.0.7", "expo-linking": "~6.3.1", "expo-localization": "~15.0.3", "expo-router": "~3.5.24", @@ -11249,6 +11250,27 @@ "expo": "*" } }, + "node_modules/expo-image-loader": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-4.7.0.tgz", + "integrity": "sha512-cx+MxxsAMGl9AiWnQUzrkJMJH4eNOGlu7XkLGnAXSJrRoIiciGaKqzeaD326IyCTV+Z1fXvIliSgNW+DscvD8g==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "15.0.7", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-15.0.7.tgz", + "integrity": "sha512-u8qiPZNfDb+ap6PJ8pq2iTO7JKX+ikAUQ0K0c7gXGliKLxoXgDdDmXxz9/6QdICTshJBJlBvI0MwY5NWu7A/uw==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~4.7.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "13.0.2", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-13.0.2.tgz", diff --git a/package.json b/package.json index ab9c0aa..7158a75 100644 --- a/package.json +++ b/package.json @@ -84,8 +84,7 @@ "react-test-renderer": "^18.2.0", "vector-icons": "^0.1.0", "yup": "^1.4.0", - "react-native-get-random-values": "~1.11.0", - "undefined": "react-native-async-storage/async-storage" + "expo-image-picker": "~15.0.7" }, "devDependencies": { "@babel/core": "^7.25.2",