diff --git a/package-lock.json b/package-lock.json
index b4e9cb1c..721388cc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,7 +15,7 @@
"@react-native-async-storage/async-storage": "^1.24.0",
"@react-native-clipboard/clipboard": "^1.14.2",
"@react-native-community/cli": "^13.6.9",
- "@react-native-community/datetimepicker": "github:react-native-community/datetimepicker",
+ "@react-native-community/datetimepicker": "8.2.0",
"@react-native-community/netinfo": "^11.3.2",
"@react-native-firebase/analytics": "^21.0.0",
"@react-native-firebase/app": "^21.5.0",
@@ -4824,14 +4824,15 @@
}
},
"node_modules/@react-native-community/datetimepicker": {
- "version": "9.1.0",
- "resolved": "git+ssh://git@github.com/react-native-community/datetimepicker.git#81b90986646a3b43515b523367aea435730b04fe",
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-8.2.0.tgz",
+ "integrity": "sha512-qrUPhiBvKGuG9Y+vOqsc56RPFcHa1SU2qbAMT0hfGkoFIj3FodE0VuPVrEa8fgy7kcD5NQmkZIKgHOBLV0+hWg==",
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4"
},
"peerDependencies": {
- "expo": ">=52.0.0",
+ "expo": ">=50.0.0",
"react": "*",
"react-native": "*",
"react-native-windows": "*"
diff --git a/package.json b/package.json
index 1b25ed74..49f8adad 100644
--- a/package.json
+++ b/package.json
@@ -37,7 +37,7 @@
"@react-native-async-storage/async-storage": "^1.24.0",
"@react-native-clipboard/clipboard": "^1.14.2",
"@react-native-community/cli": "^13.6.9",
- "@react-native-community/datetimepicker": "github:react-native-community/datetimepicker",
+ "@react-native-community/datetimepicker": "8.2.0",
"@react-native-community/netinfo": "^11.3.2",
"@react-native-firebase/analytics": "^21.0.0",
"@react-native-firebase/app": "^21.5.0",
diff --git a/src/Routes/Public/DashboardStack.js b/src/Routes/Public/DashboardStack.js
index 23196f2c..693d5ee5 100644
--- a/src/Routes/Public/DashboardStack.js
+++ b/src/Routes/Public/DashboardStack.js
@@ -4,6 +4,7 @@ import Courses from '../../screens/Dashboard/Courses/Courses';
import ViewAllContent from '../../screens/Dashboard/ViewAllContent';
import CourseContentList from '../../screens/Dashboard/Courses/CourseContentList';
import UnitList from '../../screens/Dashboard/Courses/UnitList';
+import CompleteProfileFormScreen from '../../screens/Profile/CompleteProfileFormScreen';
const Stack = createNativeStackNavigator();
@@ -28,6 +29,11 @@ const DashboardStack = ({ CopilotStopped, customProp = null }) => {
component={ViewAllContent}
options={{ lazy: true }} // Lazily load LoadingScreen
/>
+
);
};
diff --git a/src/Routes/SCPUser/SCPUserStack.js b/src/Routes/SCPUser/SCPUserStack.js
index cd9f7d45..31388d54 100644
--- a/src/Routes/SCPUser/SCPUserStack.js
+++ b/src/Routes/SCPUser/SCPUserStack.js
@@ -8,6 +8,7 @@ import FullAttendance from '../../screens/Dashboard/Calendar/FullAttendance';
import TimeTable from '../../screens/Dashboard/Calendar/TimeTable';
import PreviousClassMaterialFullView from '../../screens/Dashboard/Preference/SCPDashboard/PreviousClassMaterialFullView';
import SubjectDetails from '../../screens/Dashboard/Preference/SCPDashboard/SubjectDetails';
+import CompleteProfileFormScreen from '../../screens/Profile/CompleteProfileFormScreen';
//for deep link
import CourseContentList from '@src/screens/Dashboard/Courses/CourseContentList';
@@ -58,6 +59,11 @@ const SCPUserStack = () => {
component={SubjectDetails}
options={{ lazy: true }} // Lazily load LoadingScreen
/>
+
{/* //for deep link course */}
{
component={UnitList}
options={{ lazy: true }} // Lazily load LoadingScreen
/>
+
);
};
diff --git a/src/components/CompleteProfileBanner/CompleteProfileBanner.js b/src/components/CompleteProfileBanner/CompleteProfileBanner.js
new file mode 100644
index 00000000..5e10c1cc
--- /dev/null
+++ b/src/components/CompleteProfileBanner/CompleteProfileBanner.js
@@ -0,0 +1,110 @@
+import React, { useCallback, useState } from 'react';
+import { StyleSheet, TouchableOpacity, View } from 'react-native';
+import { useFocusEffect, useNavigation } from '@react-navigation/native';
+import GlobalText from '@components/GlobalText/GlobalText';
+import { useTranslation } from '@context/LanguageContext';
+import globalStyles from '../../utils/Helper/Style';
+import {
+ buildUserDetailsObject,
+ getDataFromStorage,
+ getMergedProfileSchema,
+ getMissingProfileFields,
+ getProfileCompletionSchema,
+} from '../../utils/JsHelper/Helper';
+
+const CompleteProfileBanner = () => {
+ const { t } = useTranslation();
+ const navigation = useNavigation();
+ const [banner, setBanner] = useState({
+ visible: false,
+ tenantId: null,
+ missingFields: [],
+ });
+
+ const checkProfileCompletion = useCallback(async () => {
+ try {
+ const tenantData = JSON.parse((await getDataFromStorage('tenantData')) || 'null');
+ const tenantId = tenantData?.[0]?.tenantId;
+ if (!tenantId) {
+ setBanner({ visible: false, tenantId: null, missingFields: [] });
+ return;
+ }
+
+ // Completeness is judged only on the current program's required fields
+ // (see getProfileCompletionSchema). The user's saved values are still read
+ // against the full merged schema so common-form labels resolve correctly.
+ const mergedSchema = await getMergedProfileSchema(tenantId);
+ const completionSchema = await getProfileCompletionSchema(tenantId);
+ const profileData = JSON.parse((await getDataFromStorage('profileData')) || 'null');
+ const userDetails = buildUserDetailsObject(profileData, mergedSchema);
+ const { missingFields, isComplete } = getMissingProfileFields(
+ completionSchema,
+ userDetails
+ );
+
+ setBanner({ visible: !isComplete, tenantId, missingFields });
+ } catch {
+ // Fail closed - don't nag the user if we couldn't determine completeness.
+ setBanner({ visible: false, tenantId: null, missingFields: [] });
+ }
+ }, []);
+
+ useFocusEffect(
+ useCallback(() => {
+ checkProfileCompletion();
+ }, [checkProfileCompletion])
+ );
+
+ if (!banner.visible) {
+ return null;
+ }
+
+ return (
+
+
+ {t('complete_profile_banner_message')}
+
+
+ navigation.navigate('CompleteProfileForm', {
+ tenantId: banner.tenantId,
+ missingFields: banner.missingFields,
+ })
+ }
+ >
+ {t('complete_profile_button')}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ backgroundColor: '#FCE7B8',
+ borderRadius: 16,
+ marginVertical: 15,
+ padding: 15,
+ },
+ message: {
+ marginBottom: 10,
+ },
+ button: {
+ alignSelf: 'center',
+ borderRadius: 20,
+ backgroundColor: '#F0A809',
+ paddingHorizontal: 14,
+ height: 36,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ buttonText: {
+ textAlign: 'center',
+ fontWeight: '700',
+ fontFamily: 'Roboto-Black',
+ fontSize: 13,
+ color: '#1F1B13',
+ },
+});
+
+export default CompleteProfileBanner;
diff --git a/src/components/CustomRadioCard/RadioButton.js b/src/components/CustomRadioCard/RadioButton.js
index 1350e7ed..bf74a1ef 100644
--- a/src/components/CustomRadioCard/RadioButton.js
+++ b/src/components/CustomRadioCard/RadioButton.js
@@ -4,7 +4,6 @@ import {
StyleSheet,
Text,
View,
- ScrollView,
TouchableOpacity,
Image,
Dimensions,
@@ -39,7 +38,7 @@ const RadioButton = ({ field, formData, handleValue, errors }) => {
-
+
{
{errors[field.name] && (
{errors[field.name]}
)}
-
+
>
);
@@ -88,13 +87,13 @@ const RadioButton = ({ field, formData, handleValue, errors }) => {
const styles = StyleSheet.create({
card: {
- backgroundColor: '#fff',
- padding: 20,
- marginVertical: 10,
- marginHorizontal: 10,
+ backgroundColor: '#b51212',
+ padding:10,
+ marginVertical: 8,
+ marginHorizontal: 8,
borderRadius: 8,
elevation: 3,
- width: '44%',
+ //width: '44%',
},
radioContainer: {
flexDirection: 'row',
diff --git a/src/components/CustomTextField/CustomTextField.js b/src/components/CustomTextField/CustomTextField.js
index 2c2ddfbc..adfc7e80 100644
--- a/src/components/CustomTextField/CustomTextField.js
+++ b/src/components/CustomTextField/CustomTextField.js
@@ -33,52 +33,51 @@ const CustomTextField = ({
};
return (
- handleValue(field.name, text.trim())}
- secureTextEntry={secureTextEntry}
- autoCapitalize={autoCapitalize} // Disable auto-capitalization
- keyboardType={keyboardType} // Opens numeric keyboard by default
- editable={editable}
- />
- {text && (
- handleCopyLink(formData[field.name])}
- >
-
-
- )}
-
-
+ {t(field.label.toLowerCase())}
+ {!field?.isRequired &&
+ !['guardian_name', 'guardian_relation'].includes(field.name) &&
+ `(${t('optional')})`}
+
+
+
- {t(field.label.toLowerCase())}
- {!field?.isRequired &&
- !['guardian_name', 'guardian_relation'].includes(field.name) &&
- `(${t('optional')})`}
-
+ value={formData[field.name] || ''}
+ onChangeText={(text) => handleValue(field.name, text.trim())}
+ secureTextEntry={secureTextEntry}
+ autoCapitalize={autoCapitalize} // Disable auto-capitalization
+ keyboardType={keyboardType} // Opens numeric keyboard by default
+ editable={editable}
+ />
+ {text && (
+ handleCopyLink(formData[field.name])}
+ >
+
+
+ )}
{errors[field.name] && (
@@ -86,8 +85,7 @@ const CustomTextField = ({
style={{
color: 'red',
alignSelf: 'flex-start',
- marginBottom: 10,
- marginTop: -20,
+ marginTop: 4,
fontFamily: 'Poppins-Regular',
}}
>
@@ -131,17 +129,14 @@ const styles = StyleSheet.create({
fontSize: 16,
fontFamily: 'Poppins-Regular',
},
- overlap: {
- top: -62,
- left: 13,
- // top: -76,
- // left: -120,
- backgroundColor: 'white',
+ inputRow: {
+ width: '100%',
},
text: {
color: '#4D4639',
paddingLeft: 2,
- fontFamily: 'Poppins-Regular',
paddingRight: 2,
+ marginBottom: 6,
+ fontFamily: 'Poppins-Regular',
},
});
diff --git a/src/components/DropdownSelect/DropdownSelect.js b/src/components/DropdownSelect/DropdownSelect.js
index fbb060c3..b2e202a7 100644
--- a/src/components/DropdownSelect/DropdownSelect.js
+++ b/src/components/DropdownSelect/DropdownSelect.js
@@ -13,12 +13,19 @@ import { useTranslation } from '../../context/LanguageContext';
import GlobalText from '@components/GlobalText/GlobalText';
-const DropdownSelect = ({ field, errors, options, formData, handleValue }) => {
+const DropdownSelect = ({
+ field,
+ errors,
+ options,
+ formData,
+ handleValue,
+ editable = true,
+}) => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { t } = useTranslation();
const toggleDropdown = () => {
- if (options && options.length > 0) {
+ if (editable && options && options.length > 0) {
setIsDropdownOpen(!isDropdownOpen);
}
};
@@ -61,22 +68,24 @@ const DropdownSelect = ({ field, errors, options, formData, handleValue }) => {
onPress={toggleDropdown}
style={[
styles.dropdownButton,
- { borderColor: errors[field.name] ? 'red' : '#DADADA' },
+ {
+ borderColor: errors[field.name] ? 'red' : '#DADADA',
+ backgroundColor: editable ? 'white' : '#F5F5F5',
+ },
]}
>
{labelArray.includes(field.label) ? (
-
+
{t(formData[field.name]?.label)}
) : (
-
+
{t(formData[field.name]?.label?.toLowerCase())}
)}
- {/*
- {t(formData[field.name]?.label?.toLowerCase())}
- */}
-
+ {editable && (
+
+ )}
{isDropdownOpen && (
@@ -115,7 +124,6 @@ const styles = StyleSheet.create({
marginBottom: 10,
width: '95%',
alignSelf: 'center',
- top: -10,
},
dropdownButton: {
flexDirection: 'row',
@@ -127,12 +135,9 @@ const styles = StyleSheet.create({
borderRadius: 5,
},
label: {
- // position: 'absolute',
- top: 15,
- left: 15,
backgroundColor: 'white',
paddingHorizontal: 5,
- zIndex: 1,
+ marginBottom: 6,
alignSelf: 'flex-start', // Allow the label to adjust to its content width
},
selectedValue: {
diff --git a/src/context/locales/en.json b/src/context/locales/en.json
index 66d02b50..c6bed2a8 100644
--- a/src/context/locales/en.json
+++ b/src/context/locales/en.json
@@ -299,6 +299,10 @@
"skilling_center_near_you": "Skilling Center Near You",
"open_on_maps": "Open on Maps",
"edit_profile": "Edit Profile",
+ "complete_profile_banner_message": "Complete your profile so we can guide you better on your learning journey",
+ "complete_profile_button": "Complete Profile",
+ "complete_profile_title": "Complete Your Profile",
+ "complete_profile_intro": "Help us with your background & other details",
"change_username": "Change Username",
"change_password": "Change Password",
"completed_courses_certificates": "Completed Courses & Certificates",
diff --git a/src/screens/Dashboard/Courses/Courses.js b/src/screens/Dashboard/Courses/Courses.js
index 7bb725d4..9790f16f 100644
--- a/src/screens/Dashboard/Courses/Courses.js
+++ b/src/screens/Dashboard/Courses/Courses.js
@@ -48,6 +48,7 @@ import globalStyles from '../../../utils/Helper/Style';
import GlobalText from '@components/GlobalText/GlobalText';
import AppUpdatePopup from '../../../components/AppUpdate/AppUpdatePopup';
import AttemptAssessmentButton from '../../../components/AttemptAssessmentButton/AttemptAssessmentButton';
+import CompleteProfileBanner from '../../../components/CompleteProfileBanner/CompleteProfileBanner';
import PrimaryButton from '../../../components/PrimaryButton/PrimaryButton';
import InterestModal from './InterestModal';
import InterestModalError from './InterestModalError';
@@ -441,6 +442,7 @@ const Courses = ({ route, CopilotStopped, customProp = null }) => {
) : (
<>
+
diff --git a/src/screens/Dashboard/Preference/SCPDashboard/SCPDashboard.js b/src/screens/Dashboard/Preference/SCPDashboard/SCPDashboard.js
index c7f3a259..21c2fbfa 100644
--- a/src/screens/Dashboard/Preference/SCPDashboard/SCPDashboard.js
+++ b/src/screens/Dashboard/Preference/SCPDashboard/SCPDashboard.js
@@ -36,6 +36,7 @@ import {
} from '../../../../utils/API/AuthService';
import ActiveLoading from '../../../LoadingScreen/ActiveLoading';
import BackButtonHandler from '../../../../components/BackNavigation/BackButtonHandler';
+import CompleteProfileBanner from '../../../../components/CompleteProfileBanner/CompleteProfileBanner';
import GlobalText from '@components/GlobalText/GlobalText';
import AppUpdatePopup from '../../../../components/AppUpdate/AppUpdatePopup';
@@ -232,6 +233,7 @@ const SCPDashboard = (props) => {
}
style={styles.view2}
>
+
diff --git a/src/screens/Profile/CompleteProfileFormScreen.js b/src/screens/Profile/CompleteProfileFormScreen.js
new file mode 100644
index 00000000..f5e09531
--- /dev/null
+++ b/src/screens/Profile/CompleteProfileFormScreen.js
@@ -0,0 +1,327 @@
+import React, { useEffect, useState } from 'react';
+import {
+ Image,
+ KeyboardAvoidingView,
+ Modal,
+ Platform,
+ ScrollView,
+ StyleSheet,
+ TouchableOpacity,
+ View,
+} from 'react-native';
+import { useNavigation, useRoute } from '@react-navigation/native';
+import Ionicons from 'react-native-vector-icons/Ionicons';
+import SecondaryHeader from '../../components/Layout/SecondaryHeader';
+import GlobalText from '@components/GlobalText/GlobalText';
+import PrimaryButton from '../../components/PrimaryButton/PrimaryButton';
+import globalStyles from '../../utils/Helper/Style';
+import { useTranslation } from '@context/LanguageContext';
+import { useInternet } from '@context/NetworkContext';
+import ActiveLoading from '../LoadingScreen/ActiveLoading';
+import lightning from '../../assets/images/png/lightning.png';
+import {
+ buildUserDetailsObject,
+ getDataFromStorage,
+ getMergedProfileSchema,
+ getMissingProfileFields,
+ getProfileCompletionSchema,
+ logEventFunction,
+ setDataInStorage,
+} from '../../utils/JsHelper/Helper';
+import {
+ getGeoLocation,
+ getProfileDetails,
+ updateUser,
+} from '../../utils/API/AuthService';
+import { transformPayload } from './TransformPayload';
+import {
+ renderProfileField,
+ reorderFamilyFieldsAfterSelector,
+ validateProfileFields,
+} from './ProfileFormShared';
+
+const FAMILY_NAME_FIELDS = ['father_name', 'mother_name', 'spouse_name'];
+
+const CompleteProfileFormScreen = () => {
+ const { t } = useTranslation();
+ const navigation = useNavigation();
+ const route = useRoute();
+ const { isConnected } = useInternet();
+
+ const [loading, setLoading] = useState(true);
+ const [filteredSchema, setFilteredSchema] = useState([]);
+ const [formData, setFormData] = useState({});
+ const [errors, setErrors] = useState({});
+ const [modal, setModal] = useState(false);
+ const [err, setErr] = useState();
+ const [stateData, setStateData] = useState([]);
+ const [districtData, setDistrictData] = useState([]);
+ const [blockData, setBlockData] = useState([]);
+ const [villageData, setVillageData] = useState([]);
+
+ useEffect(() => {
+ const loadSchema = async () => {
+ const tenantData = JSON.parse((await getDataFromStorage('tenantData')) || 'null');
+ const tenantId = route.params?.tenantId || tenantData?.[0]?.tenantId;
+ // `schema` is the full merged set - needed so we can still render a field
+ // like father_name that is not itself part of the completion scope.
+ // `completionSchema` (program form, required only) is what decides which
+ // fields count as missing, matching the web client.
+ const schema = await getMergedProfileSchema(tenantId);
+ const profileData = JSON.parse((await getDataFromStorage('profileData')) || 'null');
+ const userDetails = buildUserDetailsObject(profileData, schema);
+
+ let missingFields = route.params?.missingFields;
+ if (!missingFields) {
+ const completionSchema = await getProfileCompletionSchema(tenantId);
+ missingFields = getMissingProfileFields(
+ completionSchema,
+ userDetails
+ ).missingFields;
+ }
+
+ const fieldsToShow = new Set(missingFields);
+ if (fieldsToShow.has('family_member_details')) {
+ // The relation is still unknown, so keep all three name fields in the
+ // schema - once the user picks one, isFieldVisible() shows only that one.
+ FAMILY_NAME_FIELDS.forEach((name) => fieldsToShow.add(name));
+ }
+
+ setFilteredSchema(schema.filter((field) => fieldsToShow.has(field.name)));
+ // Seed with the user's already-saved values (not just the missing ones) so
+ // conditional visibility that depends on a field NOT being edited here -
+ // e.g. mobile/guardian fields depending on a dob already on file - still
+ // resolves correctly.
+ setFormData(userDetails);
+ setLoading(false);
+ };
+
+ loadSchema();
+
+ const logOpen = async () => {
+ await logEventFunction({
+ eventName: 'complete_profile_form_view',
+ method: 'on-view',
+ screenName: 'CompleteProfileForm',
+ });
+ };
+ logOpen();
+ }, []);
+
+ useEffect(() => {
+ if (filteredSchema.some((field) => field.name === 'state') && stateData.length === 0) {
+ getGeoLocation({ payload: { offset: 0, fieldName: 'state' } }).then((data) =>
+ setStateData(data?.values || [])
+ );
+ }
+ }, [filteredSchema]);
+
+ useEffect(() => {
+ if (!filteredSchema.some((field) => field.name === 'district')) {
+ return;
+ }
+ const stateValue = formData?.state?.value;
+ if (!stateValue) {
+ return;
+ }
+ getGeoLocation({
+ payload: { offset: 0, fieldName: 'district', controllingfieldfk: [stateValue] },
+ }).then((data) => setDistrictData(data?.values || []));
+ }, [formData?.state]);
+
+ useEffect(() => {
+ if (!filteredSchema.some((field) => field.name === 'block')) {
+ return;
+ }
+ const districtValue = formData?.district?.value;
+ if (!districtValue) {
+ return;
+ }
+ getGeoLocation({
+ payload: { offset: 0, fieldName: 'block', controllingfieldfk: [districtValue] },
+ }).then((data) => setBlockData(data?.values || []));
+ }, [formData?.district]);
+
+ useEffect(() => {
+ if (!filteredSchema.some((field) => field.name === 'village')) {
+ return;
+ }
+ const blockValue = formData?.block?.value;
+ if (!blockValue) {
+ return;
+ }
+ getGeoLocation({
+ payload: { offset: 0, fieldName: 'village', controllingfieldfk: [blockValue] },
+ }).then((data) => setVillageData(data?.values || []));
+ }, [formData?.block]);
+
+ const handleInputChange = (name, value) => {
+ setFormData((prev) => ({ ...prev, [name]: value }));
+ setErrors((prev) => ({ ...prev, [name]: '' }));
+ };
+
+ const handleSubmit = async () => {
+ const familyType = (() => {
+ const raw = formData?.family_member_details;
+ return raw && typeof raw === 'object' ? raw.value : raw;
+ })();
+
+ const dataToValidate = { ...formData };
+ if (familyType) {
+ ['father_name', 'mother_name', 'spouse_name']
+ .filter((name) => name !== `${familyType}_name`)
+ .forEach((name) => {
+ dataToValidate[name] = '';
+ });
+ }
+
+ const newErrors = validateProfileFields(filteredSchema, dataToValidate, t);
+ setErrors(newErrors);
+ if (Object.keys(newErrors).length > 0) {
+ return;
+ }
+
+ setLoading(true);
+ const payload = await transformPayload(dataToValidate);
+ const user_id = await getDataFromStorage('userId');
+ const register = await updateUser({ payload, user_id });
+
+ if (!isConnected) {
+ setLoading(false);
+ } else if (register?.params?.status === 'failed') {
+ setLoading(false);
+ setModal(true);
+ setErr(register?.params?.err);
+ } else {
+ const profileData = await getProfileDetails({ userId: user_id });
+ await setDataInStorage('profileData', JSON.stringify(profileData));
+ navigation.goBack();
+ }
+ };
+
+ if (loading) {
+ return ;
+ }
+
+ const orderedSchema = reorderFamilyFieldsAfterSelector(filteredSchema);
+ const geoOptions = { stateData, districtData, blockData, villageData };
+
+ return (
+ <>
+
+
+ navigation.goBack()} style={styles.backButton}>
+
+
+
+ {t('complete_profile_title')}
+
+
+
+
+
+
+ 🧐📝 {t('complete_profile_intro')}
+
+
+ {orderedSchema.map((field) => {
+ const content = renderProfileField(field, {
+ formData,
+ errors,
+ handleInputChange,
+ geoOptions,
+ });
+ if (!content) {
+ return null;
+ }
+ return (
+
+ {content}
+
+ );
+ })}
+
+
+
+
+
+ {modal && (
+
+
+ {err && (
+
+
+
+ Error: {err}
+
+ setModal(false)} />
+
+ )}
+
+
+ )}
+ >
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: 'white',
+ },
+ titleRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 20,
+ paddingVertical: 15,
+ },
+ backButton: {
+ marginRight: 10,
+ },
+ title: {
+ flex: 1,
+ textAlign: 'center',
+ marginRight: 34,
+ },
+ card: {
+ margin: 20,
+ padding: 20,
+ borderRadius: 20,
+ backgroundColor: 'white',
+ borderWidth: 1,
+ borderColor: '#EEE6DA',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.08,
+ shadowRadius: 8,
+ elevation: 3,
+ },
+ intro: {
+ marginBottom: 15,
+ },
+ fieldList: {
+ marginBottom: 20,
+ },
+ fieldContainer: {
+ width: '100%',
+ marginBottom: 10,
+ },
+ modalContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
+ },
+ alertBox: {
+ width: 350,
+ backgroundColor: 'white',
+ borderRadius: 20,
+ alignItems: 'center',
+ padding: 10,
+ },
+});
+
+export default CompleteProfileFormScreen;
diff --git a/src/screens/Profile/ProfileFormShared.js b/src/screens/Profile/ProfileFormShared.js
new file mode 100644
index 00000000..ad2a857a
--- /dev/null
+++ b/src/screens/Profile/ProfileFormShared.js
@@ -0,0 +1,254 @@
+import React from 'react';
+import CustomTextField from '../../components/CustomTextField/CustomTextField';
+import CustomCards from '@components/CustomCard/CustomCard';
+import RadioButton from '@components/CustomRadioCard/RadioButton';
+import DropdownSelect from '@components/DropdownSelect/DropdownSelect';
+import CustomPasswordTextField from '@components/CustomPasswordComponent/CustomPasswordComponent';
+import DateTimePicker from '@components/DateTimePicker/DateTimePicker';
+import {
+ calculateAge,
+ isProfileFieldVisible,
+ PROFILE_NON_EDITABLE_FIELDS,
+} from '../../utils/JsHelper/Helper';
+
+const FAMILY_NAME_FIELDS = ['father_name', 'mother_name', 'spouse_name'];
+
+const getFamilyType = (formData) => {
+ const raw = formData?.family_member_details;
+ return raw && typeof raw === 'object' ? raw.value : raw;
+};
+
+const getPhoneType = (formData) => {
+ const raw = formData?.phone_type_accessible;
+ return raw && typeof raw === 'object' ? raw.value : raw;
+};
+
+// Re-exported from Helper so the forms, the banner's completeness check, and the
+// mini-form all decide field visibility from one implementation.
+export const isFieldVisible = isProfileFieldVisible;
+
+// Reorders `schema` so father_name/mother_name/spouse_name render immediately
+// after family_member_details, regardless of their original position.
+export const reorderFamilyFieldsAfterSelector = (schema) => {
+ const familyDetailsIndex = schema.findIndex(
+ (f) => f.name === 'family_member_details'
+ );
+ if (familyDetailsIndex === -1) {
+ return schema;
+ }
+
+ const nameFields = schema.filter((f) => FAMILY_NAME_FIELDS.includes(f.name));
+ let orderedSchema = schema.filter((f) => !FAMILY_NAME_FIELDS.includes(f.name));
+ const insertAt =
+ orderedSchema.findIndex((f) => f.name === 'family_member_details') + 1;
+ orderedSchema.splice(insertAt, 0, ...nameFields);
+
+ return orderedSchema;
+};
+
+// Renders the single input component for `field` (no wrapping container - callers
+// own their own layout), or null if the field is currently hidden per
+// isFieldVisible(). Shared between ProfileUpdateForm and CompleteProfileFormScreen
+// so both screens use identical field components and behavior.
+export const renderProfileField = (
+ field,
+ { formData, errors, handleInputChange, geoOptions = {} }
+) => {
+ if (!isFieldVisible(field, formData)) {
+ return null;
+ }
+
+ switch (field.type) {
+ case 'text':
+ return (
+
+ );
+ case 'email':
+ return (
+
+ );
+ case 'numeric':
+ return (
+
+ );
+ case 'radio':
+ return (
+
+ );
+ case 'select':
+ return (
+
+ );
+ case 'drop_down':
+ return (
+
+ );
+ case 'password':
+ case 'confirm_password':
+ return (
+
+ );
+ case 'date':
+ return (
+
+ );
+ default:
+ return null;
+ }
+};
+
+// Validates `fieldNames` (defaults to every field in `schema`) against `formData`,
+// applying the same required/minLength/maxLength/pattern rules - and the same
+// conditional skips (age, family member selection, phone ownership) - as the full
+// Edit Profile form. Returns an { [fieldName]: message } errors object.
+export const validateProfileFields = (schema, formData, t, fieldNames) => {
+ const targetFields = fieldNames || schema?.map((f) => f.name) || [];
+ const newErrors = {};
+ const age = calculateAge(formData?.dob || '');
+ const ageValue = age ? parseInt(age, 10) : null;
+ const familyType = getFamilyType(formData);
+ const phoneType = getPhoneType(formData);
+
+ targetFields.forEach((fieldName) => {
+ const field = schema?.find((f) => f.name === fieldName);
+ if (!field) {
+ return;
+ }
+
+ const fieldValue = formData[field.name];
+ let value = '';
+ if (fieldValue !== null && fieldValue !== undefined) {
+ if (typeof fieldValue === 'object' && !Array.isArray(fieldValue)) {
+ value = fieldValue.value !== undefined ? String(fieldValue.value) : '';
+ } else if (Array.isArray(fieldValue)) {
+ value =
+ fieldValue.length > 0
+ ? String(fieldValue[0]?.value || fieldValue[0] || '')
+ : '';
+ } else {
+ value = String(fieldValue);
+ }
+ }
+
+ if (
+ [
+ 'confirm_password',
+ 'password',
+ 'program',
+ 'username',
+ 'is_volunteer',
+ 'family_member_details',
+ ].includes(field.name)
+ ) {
+ return;
+ }
+ if (
+ ['guardian_name', 'guardian_relation', 'parent_phone'].includes(field.name) &&
+ ageValue !== null &&
+ ageValue >= 18
+ ) {
+ return;
+ }
+ if (familyType === 'mother' && ['father_name', 'spouse_name'].includes(field.name)) {
+ return;
+ }
+ if (familyType === 'father' && ['mother_name', 'spouse_name'].includes(field.name)) {
+ return;
+ }
+ if (familyType === 'spouse' && ['father_name', 'mother_name'].includes(field.name)) {
+ return;
+ }
+ if (!familyType && FAMILY_NAME_FIELDS.includes(field.name)) {
+ return;
+ }
+ if (field.name === 'mobile' && ageValue !== null && ageValue < 18) {
+ return;
+ }
+ if (field.name === 'own_phone_check' && phoneType === 'nophone') {
+ return;
+ }
+
+ if (field.isRequired && !value) {
+ newErrors[field.name] = `${t(field.name)} ${t('is_required')}`;
+ } else if (field.minLength && value.length < field.minLength && value) {
+ newErrors[field.name] = `${t('min_validation')
+ .replace('{field}', t(field.name))
+ .replace('{length}', field.minLength)}`;
+ } else if (field.maxLength && value.length > field.maxLength && value) {
+ newErrors[field.name] = `${t('max_validation')
+ .replace('{field}', t(field.name))
+ .replace('{length}', field.maxLength)}`;
+ } else if (
+ field.pattern &&
+ value &&
+ !new RegExp(field.pattern.replace(/^\/|\/$/g, '')).test(value)
+ ) {
+ newErrors[field.name] = `${t(field.name)} ${t('is_invalid')}.`;
+ }
+ });
+
+ return newErrors;
+};
diff --git a/src/screens/Profile/ProfileUpdateForm.js b/src/screens/Profile/ProfileUpdateForm.js
index 3c3ec0c4..184458a8 100644
--- a/src/screens/Profile/ProfileUpdateForm.js
+++ b/src/screens/Profile/ProfileUpdateForm.js
@@ -8,8 +8,6 @@ import {
Modal,
Image,
} from 'react-native';
-import CustomTextField from '../../components/CustomTextField/CustomTextField';
-import CustomCards from '@components/CustomCard/CustomCard';
import { logEventFunction } from '@src/utils/JsHelper/Helper';
import { useTranslation } from '@context/LanguageContext';
import { useInternet } from '@context/NetworkContext';
@@ -23,7 +21,6 @@ import globalStyles from '../../utils/Helper/Style';
import GlobalText from '@components/GlobalText/GlobalText';
import lightning from '../../assets/images/png/lightning.png';
import {
- calculateAge,
createNewObject,
getDataFromStorage,
setDataInStorage,
@@ -34,10 +31,11 @@ import {
updateUser,
} from '../../utils/API/AuthService';
import { useNavigation } from '@react-navigation/native';
-import RadioButton from '@components/CustomRadioCard/RadioButton';
-import DropdownSelect from '@components/DropdownSelect/DropdownSelect';
-import CustomPasswordTextField from '@components/CustomPasswordComponent/CustomPasswordComponent';
-import DateTimePicker from '@components/DateTimePicker/DateTimePicker';
+import {
+ renderProfileField,
+ reorderFamilyFieldsAfterSelector,
+ validateProfileFields,
+} from './ProfileFormShared';
const ProfileUpdateForm = ({ fields }) => {
const { t } = useTranslation();
@@ -331,108 +329,7 @@ const ProfileUpdateForm = ({ fields }) => {
const validateFields = () => {
const pageFields = pages[currentPage];
- const newErrors = {};
-
- pageFields.forEach((fieldName) => {
- const field = schema?.find((f) => f.name === fieldName);
- const age = calculateAge(formData?.dob || '');
-
- if (field) {
- // Extract value - handle both object and string formats
- const fieldValue = formData[field.name];
- let value = '';
-
- if (fieldValue !== null && fieldValue !== undefined) {
- if (typeof fieldValue === 'object' && !Array.isArray(fieldValue)) {
- // If it's an object, extract the value property
- value = fieldValue.value !== undefined ? String(fieldValue.value) : '';
- } else if (Array.isArray(fieldValue)) {
- // If it's an array, join the values or use first value
- value = fieldValue.length > 0 ? String(fieldValue[0]?.value || fieldValue[0] || '') : '';
- } else {
- // If it's a string or number, convert to string
- value = String(fieldValue);
- }
- }
-
- if (
- [
- 'confirm_password',
- 'password',
- 'program',
- 'username',
- 'is_volunteer',
- 'family_member_details',
- ].includes(field.name)
- ) {
- return; // Skip validation for these fields
- }
- if (
- ['guardian_name', 'guardian_relation', 'parent_phone'].includes(
- field.name
- ) &&
- age &&
- parseInt(age, 10) >= 18
- ) {
- return; // Skip validation for these fields
- }
-
- // Skip validation for family member fields that are hidden
- const rawFamilyType1 = formData?.family_member_details;
- const familyType = (rawFamilyType1 && typeof rawFamilyType1 === 'object') ? rawFamilyType1.value : rawFamilyType1;
- if (familyType === 'mother' && field.name === 'father_name') {
- return; // Skip validation for hidden fields
- }
- if (familyType === 'mother' && field.name === 'spouse_name') {
- return; // Skip validation for hidden fields
- }
- if (familyType === 'father' && field.name === 'mother_name') {
- return; // Skip validation for hidden fields
- }
- if (familyType === 'father' && field.name === 'spouse_name') {
- return; // Skip validation for hidden fields
- }
- if (familyType === 'spouse' && field.name === 'father_name') {
- return; // Skip validation for hidden fields
- }
- if (familyType === 'spouse' && field.name === 'mother_name') {
- return; // Skip validation for hidden fields
- }
- if (!familyType && ['father_name', 'mother_name', 'spouse_name'].includes(field.name)) {
- return; // Skip validation for hidden fields
- }
-
- // Skip validation for mobile field when age is below 18
- const ageValue = age ? parseInt(age, 10) : null;
- if (field.name === 'mobile' && ageValue !== null && ageValue < 18) {
- return; // Skip validation for hidden mobile field
- }
-
- // Skip own_phone_check when phone_type_accessible is nophone (field is hidden)
- const phoneTypeValue = formData?.phone_type_accessible?.value || formData?.phone_type_accessible;
- if (field.name === 'own_phone_check' && phoneTypeValue === 'nophone') {
- return;
- }
-
- if (field.isRequired && !value) {
- newErrors[field.name] = `${t(field.name)} ${t('is_required')}`;
- } else if (field.minLength && value.length < field.minLength && value) {
- newErrors[field.name] = `${t('min_validation')
- .replace('{field}', t(field.name))
- .replace('{length}', field.minLength)}`;
- } else if (field.maxLength && value.length > field.maxLength && value) {
- newErrors[field.name] = `${t('max_validation')
- .replace('{field}', t(field.name))
- .replace('{length}', field.maxLength)}`;
- } else if (
- field.pattern &&
- value &&
- !new RegExp(field.pattern.replace(/^\/|\/$/g, '')).test(value)
- ) {
- newErrors[field.name] = `${t(field.name)} ${t('is_invalid')}.`;
- }
- }
- });
+ const newErrors = validateProfileFields(schema, formData, t, pageFields);
setErrors(newErrors);
console.log('ProfileUpdateForm validation errors:', JSON.stringify(newErrors));
@@ -440,237 +337,28 @@ const ProfileUpdateForm = ({ fields }) => {
};
const renderField = (field) => {
- const dob = formData?.dob || '';
- let age = null;
- let ageValue = null;
-
- // Calculate age if DOB exists
- if (dob) {
- try {
- age = calculateAge(dob);
- ageValue = age !== null && age !== undefined && !isNaN(age) ? parseInt(age, 10) : null;
- } catch (error) {
- console.log('Error calculating age:', error);
- ageValue = null;
- }
- }
-
- const isAge18OrAbove = ageValue !== null && ageValue >= 18;
- const isAgeBelow18 = ageValue !== null && ageValue < 18;
-
- // Debug logging for mobile field visibility
- if (field.name === 'mobile' || field.name === 'phone_num' || field.name === 'phone_number') {
- console.log(`Field: ${field.name}, DOB: ${dob}, Age: ${ageValue}, isAgeBelow18: ${isAgeBelow18}`);
- }
-
- // Hide guardian fields for users 18 or above
- if (
- (field.name === 'guardian_relation' ||
- field.name === 'guardian_name' ||
- field.name === 'parent_phone') &&
- isAge18OrAbove
- ) {
- return null;
- }
-
- // Hide phone/mobile number field when age is below 18
- // Show mobile for users 18 or above, hide it when age is below 18
- if (
- (field.name === 'phone_num' ||
- field.name === 'phone_number' ||
- field.name === 'mobile') &&
- isAgeBelow18
- ) {
- return null;
- }
-
- // Family member details conditional logic
- const rawFamilyType2 = formData?.family_member_details;
- const familyType = (rawFamilyType2 && typeof rawFamilyType2 === 'object') ? rawFamilyType2.value : rawFamilyType2;
- console.log('familyType:', familyType, 'field.name:', field.name, 'formData.family_member_details:', formData?.family_member_details);
-
- // If no family_member_details is selected, hide all family name fields
- if (
- !familyType &&
- ['father_name', 'mother_name', 'spouse_name'].includes(field.name)
- ) {
- console.log('Hiding field (no familyType):', field.name);
- return null;
- }
-
- // If spouse is selected, hide father_name and mother_name
- if (familyType === 'spouse' && (field.name === 'father_name' || field.name === 'mother_name')) {
- console.log('Hiding field (spouse selected):', field.name);
- return null;
- }
-
- // If father is selected, hide spouse_name and mother_name
- if (familyType === 'father' && (field.name === 'spouse_name' || field.name === 'mother_name')) {
- console.log('Hiding field (father selected):', field.name);
- return null;
- }
-
- // If mother is selected, hide father_name and spouse_name
- if (familyType === 'mother' && (field.name === 'father_name' || field.name === 'spouse_name')) {
- console.log('Hiding field (mother selected):', field.name);
- return null;
- }
-
- // Hide "Does this phone belong to you?" when "No Phone" is selected.
- const phoneTypeValue = formData?.phone_type_accessible?.value || formData?.phone_type_accessible;
- if (field.name === 'own_phone_check' && phoneTypeValue === 'nophone') {
- return null;
- }
+ const geoOptions = { stateData, districtData, blockData, villageData };
+ const content = renderProfileField(field, {
+ formData,
+ errors,
+ handleInputChange,
+ geoOptions,
+ });
- // if (field.name && !field?.isEditable) {
- // return null;
- // }
- if (
- [
- 'username',
- 'password',
- 'confirm_password',
- 'is_volunteer',
- // 'state',
- // 'district',
- // 'block',
- // 'village',
- ].includes(field.name)
- ) {
+ if (!content) {
return null;
}
- switch (field.type) {
- case 'text':
- return (
-
-
-
- );
- case 'email':
- return (
-
-
-
- );
- case 'numeric':
- return (
-
-
-
- );
-
- case 'radio':
- return (
-
-
-
- );
- case 'select':
- return (
-
-
-
- );
- case 'drop_down':
- return (
-
-
-
- );
- case 'password':
- case 'confirm_password':
- return (
-
-
-
- );
- case 'date':
- return (
-
-
-
- );
- default:
- return null;
- }
+ return (
+
+ {content}
+
+ );
};
const renderPage = () => {
const pageFields = pages[currentPage];
- const familyNameFields = ['father_name', 'mother_name', 'spouse_name'];
-
- // Move family name fields to appear immediately after family_member_details
- const familyDetailsIndex = schema.findIndex(
- (f) => f.name === 'family_member_details'
- );
- let orderedSchema = [...schema];
- if (familyDetailsIndex !== -1) {
- const nameFields = orderedSchema.filter((f) =>
- familyNameFields.includes(f.name)
- );
- orderedSchema = orderedSchema.filter(
- (f) => !familyNameFields.includes(f.name)
- );
- const insertAt =
- orderedSchema.findIndex((f) => f.name === 'family_member_details') + 1;
- orderedSchema.splice(insertAt, 0, ...nameFields);
- }
+ const orderedSchema = reorderFamilyFieldsAfterSelector(schema);
return orderedSchema
.filter((field) => pageFields?.includes(field.name))
diff --git a/src/screens/Profile/ProfileUpdateScreen.js b/src/screens/Profile/ProfileUpdateScreen.js
index 20502168..3f4ea302 100644
--- a/src/screens/Profile/ProfileUpdateScreen.js
+++ b/src/screens/Profile/ProfileUpdateScreen.js
@@ -2,10 +2,9 @@ import React, { useEffect, useState } from 'react';
import { SafeAreaView, StyleSheet } from 'react-native';
import ProfileUpdateForm from './ProfileUpdateForm';
import NetworkAlert from '../../components/NetworkError/NetworkAlert';
-import { getStudentForm } from '../../utils/API/AuthService';
import {
getDataFromStorage,
- setDataInStorage,
+ getMergedProfileSchema,
} from '../../utils/JsHelper/Helper';
import ActiveLoading from '../LoadingScreen/ActiveLoading';
// import Geolocation from 'react-native-geolocation-service'; //GeoLocation Comment
@@ -24,24 +23,17 @@ const ProfileUpdateScreen = () => {
});
};
const fetchData = async () => {
- const data = await getStudentForm();
const tenantData = JSON.parse(await getDataFromStorage('tenantData'));
const tenantId = tenantData?.[0]?.tenantId;
- const programForm = await getStudentForm(tenantId);
- setDataInStorage('studentProgramForm', JSON.stringify(programForm?.fields));
- const newSchema = [...data.fields, ...programForm.fields];
- const filteredSchema = newSchema.filter(
- (field) => field.name !== 'center' && field.name !== 'batch'
- );
- const updatedSchema = updateOrder(filteredSchema);
- console.log("updatedSchema",updatedSchema);
- if (data?.error) {
- setNetworkError(true);
- } else {
- // const states = await fetchstates();
- setDataInStorage('studentForm', JSON.stringify(data?.fields));
+
+ try {
+ const filteredSchema = await getMergedProfileSchema(tenantId);
+ const updatedSchema = updateOrder(filteredSchema);
+ console.log("updatedSchema",updatedSchema);
setMainSchema(updatedSchema);
setNetworkError(false);
+ } catch {
+ setNetworkError(true);
}
setLoading(false);
diff --git a/src/screens/YouthNet/L1Courses.js b/src/screens/YouthNet/L1Courses.js
index 188a19d8..bada8868 100644
--- a/src/screens/YouthNet/L1Courses.js
+++ b/src/screens/YouthNet/L1Courses.js
@@ -42,6 +42,7 @@ import globalStyles from '@src/utils/Helper/Style';
import GlobalText from '@components/GlobalText/GlobalText';
import AppUpdatePopup from '@src/components/AppUpdate/AppUpdatePopup';
+import CompleteProfileBanner from '@src/components/CompleteProfileBanner/CompleteProfileBanner';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import AntDesign from 'react-native-vector-icons/AntDesign';
@@ -237,6 +238,7 @@ const L1Courses = () => {
// translucent={true}
backgroundColor="transparent"
/>
+
diff --git a/src/utils/JsHelper/Helper.js b/src/utils/JsHelper/Helper.js
index 8a4aac1c..3b8beb83 100644
--- a/src/utils/JsHelper/Helper.js
+++ b/src/utils/JsHelper/Helper.js
@@ -1,6 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { BackHandler, PermissionsAndroid } from 'react-native';
-import { getAccessToken } from '../API/AuthService';
+import { getAccessToken, getStudentForm } from '../API/AuthService';
import analytics from '@react-native-firebase/analytics';
import RNFS from 'react-native-fs';
import messaging from '@react-native-firebase/messaging';
@@ -603,6 +603,241 @@ export const createNewObjectTarget = (customFields, labels, profileView) => {
return result;
};
+// Fetches the two Form Read responses that make up a learner's profile:
+// the base ("common") form collected at registration, and the current program's
+// own form. Both are cached to storage because TransformPayload reads them back
+// when building the update payload.
+export const getProfileFormSchemas = async (tenantId) => {
+ const base = await getStudentForm();
+ const program = await getStudentForm(tenantId);
+ const commonFields = base?.fields || [];
+ const programFields = program?.fields || [];
+
+ await setDataInStorage('studentForm', JSON.stringify(commonFields));
+ await setDataInStorage('studentProgramForm', JSON.stringify(programFields));
+
+ return { commonFields, programFields };
+};
+
+// Everything a learner can edit: common form + current program's form. Used by
+// the full Edit Profile screen, and by the Complete Profile mini-form when it
+// needs to render a field (e.g. a family member's name) that is not itself part
+// of the completion scope below.
+export const getMergedProfileSchema = async (tenantId) => {
+ const { commonFields, programFields } = await getProfileFormSchemas(tenantId);
+
+ return [...commonFields, ...programFields].filter(
+ (field) => field.name !== 'center' && field.name !== 'batch'
+ );
+};
+
+// The fields that determine whether a profile counts as "complete" for the
+// current program - deliberately narrower than getMergedProfileSchema.
+//
+// Scope is the PROGRAM form only, matching the web client: common-form fields
+// (name, dob, gender, state/district/block/village, preferred language) are
+// collected during registration, so they must never hold this banner open.
+// Both required and optional program fields count - Camp to Club's two fields
+// are optional and web still asks for them.
+//
+// `center`/`batch` are dropped here (admin-assigned, not user-supplied); the
+// remaining exclusions - unsupported types like `consent_file`, and the
+// name-based list in PROFILE_COMPLETENESS_OPTIONAL_FIELDS - are applied by
+// getMissingProfileFields.
+export const getProfileCompletionSchema = async (tenantId) => {
+ const { programFields } = await getProfileFormSchemas(tenantId);
+
+ return programFields.filter(
+ (field) => field.name !== 'center' && field.name !== 'batch'
+ );
+};
+
+// Builds the same flattened { fieldName: value } object ProfileUpdateForm builds
+// from cached profileData + the merged schema's labels, for reuse by anything that
+// needs to inspect the user's saved values (e.g. the profile-completeness check).
+export const buildUserDetailsObject = (profileData, schema) => {
+ const finalResult = profileData?.getUserDetails?.[0];
+ if (!finalResult) {
+ return {};
+ }
+
+ const keysToRemove = [
+ 'customFields',
+ 'total_count',
+ 'status',
+ 'updatedAt',
+ 'createdAt',
+ 'updatedBy',
+ 'createdBy',
+ 'username',
+ ];
+ const filteredResult = Object.keys(finalResult)
+ .filter((key) => !keysToRemove.includes(key))
+ .reduce((obj, key) => {
+ obj[key] = finalResult[key];
+ return obj;
+ }, {});
+
+ const requiredLabels = schema?.map((item) => ({
+ label: item?.label,
+ name: item?.name,
+ }));
+ const userDetails = createNewObject(finalResult?.customFields, requiredLabels);
+
+ return { ...userDetails, ...filteredResult };
+};
+
+const extractProfileFieldValue = (fieldValue) => {
+ if (fieldValue === null || fieldValue === undefined) {
+ return '';
+ }
+ if (Array.isArray(fieldValue)) {
+ return fieldValue.length > 0
+ ? String(fieldValue[0]?.value ?? fieldValue[0] ?? '').trim()
+ : '';
+ }
+ if (typeof fieldValue === 'object') {
+ return fieldValue.value !== undefined ? String(fieldValue.value).trim() : '';
+ }
+ return String(fieldValue).trim();
+};
+
+// Field types the profile forms know how to render (see renderProfileField in
+// screens/Profile/ProfileFormShared.js). A field of any other type cannot be
+// filled in through the app, so it must never be counted as "missing" - doing so
+// would show a Complete Profile banner that opens a form with nothing in it.
+export const PROFILE_SUPPORTED_FIELD_TYPES = [
+ 'text',
+ 'email',
+ 'numeric',
+ 'radio',
+ 'select',
+ 'drop_down',
+ 'password',
+ 'confirm_password',
+ 'date',
+];
+
+// Rendered, but read-only - set at registration or derived elsewhere. The user
+// cannot change these here, so they must never be counted as "missing" either.
+export const PROFILE_NON_EDITABLE_FIELDS = [
+ 'first_name',
+ 'firstName',
+ 'last_name',
+ 'lastName',
+ 'state',
+ 'district',
+ 'block',
+ 'village',
+];
+
+// Never rendered at all - internal/system fields.
+export const PROFILE_ALWAYS_HIDDEN_FIELDS = [
+ 'username',
+ 'password',
+ 'confirm_password',
+ 'is_volunteer',
+];
+
+// Fully editable in the forms, but genuinely optional - a user may legitimately
+// have no value for these, so leaving them blank must not keep the Complete
+// Profile banner up forever.
+export const PROFILE_COMPLETENESS_OPTIONAL_FIELDS = [
+ 'middle_name',
+ 'middleName',
+];
+
+const ALWAYS_EXCLUDED_PROFILE_FIELDS = [
+ ...PROFILE_ALWAYS_HIDDEN_FIELDS,
+ ...PROFILE_NON_EDITABLE_FIELDS,
+ ...PROFILE_COMPLETENESS_OPTIONAL_FIELDS,
+ 'center',
+ 'batch',
+ 'program',
+];
+
+// Whether `field` should be rendered at all given the rest of the form's current
+// values. Single source of truth shared by the Edit Profile form, the Complete
+// Profile mini-form, and the banner's completeness check, so the three can't
+// drift apart. Note "visible" != "editable": first_name/state/etc. are visible
+// but read-only (see PROFILE_NON_EDITABLE_FIELDS).
+export const isProfileFieldVisible = (field, formData) => {
+ if (PROFILE_ALWAYS_HIDDEN_FIELDS.includes(field?.name)) {
+ return false;
+ }
+
+ const dob = formData?.dob || '';
+ let age = null;
+ if (dob) {
+ try {
+ const parsed = calculateAge(dob);
+ age =
+ parsed !== null && parsed !== undefined && !isNaN(parsed)
+ ? parseInt(parsed, 10)
+ : null;
+ } catch {
+ age = null;
+ }
+ }
+
+ if (
+ ['guardian_relation', 'guardian_name', 'parent_phone'].includes(field?.name) &&
+ age !== null &&
+ age >= 18
+ ) {
+ return false;
+ }
+ if (
+ ['mobile', 'phone_num', 'phone_number'].includes(field?.name) &&
+ age !== null &&
+ age < 18
+ ) {
+ return false;
+ }
+
+ const rawFamilyType = formData?.family_member_details;
+ const familyType =
+ rawFamilyType && typeof rawFamilyType === 'object'
+ ? rawFamilyType.value
+ : rawFamilyType;
+ if (['father_name', 'mother_name', 'spouse_name'].includes(field?.name)) {
+ if (!familyType) {
+ return false;
+ }
+ return field.name === `${familyType}_name`;
+ }
+
+ const rawPhoneType = formData?.phone_type_accessible;
+ const phoneType =
+ rawPhoneType && typeof rawPhoneType === 'object'
+ ? rawPhoneType.value
+ : rawPhoneType;
+ if (field?.name === 'own_phone_check' && phoneType === 'nophone') {
+ return false;
+ }
+
+ return true;
+};
+
+// Given the merged schema (§getMergedProfileSchema) and the user's saved profile
+// data (§buildUserDetailsObject), returns which schema fields have no value yet.
+//
+// A field only counts as "missing" if the user can actually fill it in through
+// the Complete Profile form: it must render (supported type + currently visible)
+// and be editable. Anything else would produce a banner the user can never clear,
+// because the form it opens would have no usable input for that field.
+export const getMissingProfileFields = (schema, userDetails) => {
+ const missingFields = (schema || [])
+ .filter((field) => field?.name)
+ .filter((field) => !ALWAYS_EXCLUDED_PROFILE_FIELDS.includes(field.name))
+ .filter((field) => PROFILE_SUPPORTED_FIELD_TYPES.includes(field.type))
+ .filter((field) => isProfileFieldVisible(field, userDetails))
+ .filter((field) => !extractProfileFieldValue(userDetails?.[field.name]))
+ .map((field) => field.name);
+
+ return { missingFields, isComplete: missingFields.length === 0 };
+};
+
export const categorizeEvents = async (events) => {
const plannedSessions = [];
const extraSessions = [];